深入理解 Socket 的原理与使用
最开始的 IO 模型是 阻塞 IO,应用做 recv 系统调用。
连接的建立和关闭过程
服务器是如何开始服务的?
-
socket
系统调用用于创建和初始化 socket.int socket(int domain, int type, int protocol);
-
domain:通信域,也称地址簇,例如
AF_INET
-
type 类型,例如非阻塞
-
protocol
协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。 -
返回一个
fd
-
-
bind
系统调用用于将socket
关联到ip:port
。int bind(int sock, struct sockaddr *addr, socklen_t addrlen)
-
sockfd:socket 的返回值
-
addr:地址结构体。一般用特化的
sockaddr_in
和sockaddr_in6
,适用于 IPv4/IPv6 地址。 -
addrlen:addr 大小,sizeof 可得。
-
-
listen
调用让套接字进入被动监听状态。int listen(int sock, int backlog);
-
backlog 为请求队列的最大长度。
当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。
-
客户端是如何发起连接的?
-
socket
调用创建 socket -
connect
调用连接到给定地址。参数和 bind 一样。三次握手(在内核中进行)开始。-
客户端发送 SYN with ISN(c)(客户端的初始序列号,动态随机)
-
服务端应答 SYN with ISN(s) with ACK=ISN(c)+1 (并将客户端加入半连接队列,状态 SYN_RECV)
-
客户端应答 ACK=ISN(s)+1,并可携带数据通信。
-
之后正常通信,服务端将连接加入已连接队列。
-
为什么 TCP 采用三次握手,二次握手可以吗?
我们可以从几个方面来解释:
(一)确认双方的收发能力
TCP 建立连接之前,需要确认客户端与服务器双方的收包和发包的能力。
1. 第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。
2. 第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。不过此时服务器并不能确认客户端的接收能力是否正常。
3. 第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力正常,服务器自己的发送、接收能力也正常。
所以,只有三次握手才能确认双方的接收与发送能力是否正常。
(二)序列号可靠同步
如果是两次握手,服务端无法确定客户端是否已经接收到了自己发送的初始序列号,如果第二次握手报文丢失,那么客户端就无法知道服务端的初始序列号,那 TCP 的可靠性就无从谈起。
(三)阻止重复历史连接的初始化
客户端由于某种原因发送了两个不同序号的 SYN
包,我们知道网络环境是复杂的,旧的数据包有可能先到达服务器。如果是两次握手,服务器收到旧的 SYN
就会立刻建立连接,那么会造成网络异常。
如果是三次握手,服务器需要回复 SYN+ACK
包,客户端会对比应答的序号,如果发现是旧的报文,就会给服务器发 RST
报文,直到正常的 SYN
到达服务器后才正常建立连接。
所以三次握手才有足够的上下文信息来判断当前连接是否是历史连接。
(四)安全问题
我们知道 TCP 新建连接时,内核会为连接分配一系列的内存资源,如果采用两次握手,就建立连接,那会放大 DDOS 攻击的。
TCP 作为一种可靠传输控制协议,其核心思想:既要保证数据可靠传输,又要提高传输的效率,而三次握手恰好可以满足以上两方面的需求!
什么是半连接队列?
答:服务器第一次收到客户端的 SYN
之后,就会处于 SYN_RCVD
状态,此时双方还没有完全建立连接。服务器会把这种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。
当然还有一个_全连接队列_,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。
三次握手过程中,可以携带数据吗?
答:第一次、第二次握手不可以携带数据,而第三次握手是可以携带数据的。
我们可以思考一个问题,假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据,疯狂着重复发 SYN 报文,这会让服务器花费大量的内存空间来缓存这些报文,这样服务器就更容易被攻击了。
对于第三次握手,此时客户端已经处于连接状态,他已经知道服务器的接收、发送能力是正常的了,所以可以携带数据是情理之中。
服务器是如何处理连接请求的?
三次握手在内核中完成,Listener 阻塞在 select() 或 poll() 上,网卡设备收到数据帧,触发中断,由内核中的驱动负责中断处理。接着 AF_PACKET 数据包交给 TCP/IP 协议栈处理。内核设置连接处于 处于 SYN_RCVD
状态。第二次收到 ACK=ISN(s)+1 时连接变成 ESTAB 状态,然后就可以交换实际数据了。
数据传输的过程
操作系统涉及到的主要是应用层,传输层、网络层。
应用层
阻塞模式下 recv/send 进行通信,负责在内核缓冲和用户内存间拷贝数据。
非阻塞模式下有 select,poll,epoll 三种方式。
-
select 方式:监视 writefds、readfds、和 exceptfds 三类 fdset。以读为例,调用 select 后,内核会遍历检查各个 fd 是否可读。若有可读或者超时,则将 fds 集合拷贝到用户内存,并唤醒用户进程。
- 问题:
-
每次调用select,都需要把被监控的fds集合从用户态空间拷贝到内核态空间
-
只能监听有限个 fd
-
用户每次都只能挨个遍历每个socket来收集可读事件
-
- 问题:
-
poll 方式:采用 pollfd 描述 fd 集合,解决了 fdset 的大小限制。但 1,3 问题没有解决。
-
epoll 方式:采用 epoll 对象管理 fd。按需得到 events,解决低效问题。提供三个函数:
-
epoll_create:创建一个epoll句柄
-
epoll_ctl:向 epoll 对象中添加/修改/删除要管理的连接
-
epoll_wait:等待其管理的连接上的 IO 事件
-
按需拷贝:fd 首次调用 epoll_ctl 拷贝,每次调用 epoll_wait 不拷贝
-
无限大小:其内部有 wq(等待队列链表)、rbr(红黑树,索引 socket)、rdllist(就绪 fd 链表)
-
回调通知:不需要遍历所有 fd
-
-
epoll 水平触发和边缘触发
-
LT:只要内核缓冲区有数据就一直通知
-
ET:只有状态发生变化才通知,只有当socket由不可写到可写或由不可读到可读,才会返回其sockfd
-
-
select | poll | epoll | |
---|---|---|---|
性能 | 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 | 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 | 随着连接数的增加,性能基本没有变化 |
连接数 | 一般1024 | 无限制 | 无限制 |
内存拷贝 | 每次调用select拷贝 | 每次调用poll拷贝 | fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
数据结构 | bitmap | 数组 | 红黑树 |
内在处理机制 | 线性轮询 | 线性轮询 | FD挂在红黑树,通过事件回调callback |
时间复杂度 | O(n) | O(n) | O(1) |
传输层
TCP 是流式的数据传输,消息没有边界,需要应用层自己去定义消息边界。因此 TCP 的数据单元称为 Segment,隐含了无边界的意思。
UDP 是数据报传输,所以协议保证了一次只能接收一个数据报。因此 UDP 的数据单元称为 Datagram。
传输层头部主要是端口号。提供对伪首部、首部以及数据报文三部分一起校验。
网络层
单元是 Packet,TCP Segment 和 UDP Datagram 都会被打包进 IP Packet。
网络层头部主要是 IP 地址(源地址和目标地址),协议字段用于区分 TCP/UDP/ICMP/IGMP/OSPF 等协议。只校验包头。提供分片重装服务。
MAC 子层
解决信道分配问题。负责增加 MAC 地址,成帧。IEEE 802 提供前导码(用于时钟同步),填充和校验。传输单元是 MAC PDU。
数据链路层
工作在以太网的数据链路层提供无确认、无连接的服务。通过拆分比特流成帧。提供纠错。
拆分比特流成帧的具体方法:字符计数(首字段记录长度)、字节填充(用开始结束标志 SOH,EOT 来标记,用 ESC 0x1B 转义)、比特填充。单元为 Frame。
连接关闭的过程
socket
-
当用户调用
socket
函数时sys_socketcall
分路器会将调用传送到sys_socket
函数。在Linux内核中只有一个系统调用
sys_socketcall
完成用户程序对所有套接字操作的调用,它以套接字API函数的索引号来选择需要调用的实际函数。此函数做两件事:
-
首先调用
sock_create
函数完成通用套接字的创建、初始化任务(struct socket *sock;
)- 然后调用
sock_map_fd
将 sock 与 fd 关联。 然后在调用特定协议族的套接字创建函数。
- 然后调用
而 sock_create
是 __sock_create
的封装。它负责完成 socket 创建和初始化过程。
-
在
sock_alloc
中:-
new_inode
:先分配一个inode
并初始化(vfs_inode
) -
然后通过
SOCKET_I(inode)
得到socket
-
-
在
pf->create
中:实际上是inet_create
的指针(就 TCP 协议栈而言)-
创建
struct sock * sk
实例 -
与
socket
的关联,配置 sk -
使用具体协议初始化
socket
,初始化收发缓冲区。
-
每个 socket 数据结构都有一个 sock 数据结构成员(由于 socket 结构体是 inode 的一部分,不宜过大,所以才有了 sock,存放和通信相关的部分)
当创建一个TCP socket对象的时候会有一个发送缓冲区和一个接收缓冲区,位于内核空间。
send
-
阻塞模式下, send 将用户 buf 复制到内核 sndbuf,发送并得到确认后再返回. 问题在于 buf 和 sndbuf 的大小不同。
-
如果 buf < sndbuf ,那么 send 调用立即返回,然后向网络中发送数据
-
否则,即用户 buf 过大,则等待确认,以便腾出空间存放新的待发数据。
-
说白了就是 sndbuf 满就分批发送。返回值是对最后一批的确认。
-
-
非阻塞模式下:
- send 只将 buf 复制到内核 sndbuf 返回成功拷贝的大小。如缓存区可用空间为 0,则返回 -1,同时设置 errno 为 EAGAIN.
换句话说,在应用层调用send()返回之时,数据不一定会发送到对端去(和write写文件有点类似),send()仅仅是把应用层buffer的数据拷贝进socket的内核发送buffer中。
参考
深入浅出理解select、poll、epoll的实现 - 知乎 (zhihu.com)
Linux 数据包的接收与发送过程 (morven.life)
浅析 Linux 如何接收网络帧 | Shall We Code? (waynerv.com)
Socket与系统调用深度分析 - luoyang712 - 博客园 (cnblogs.com)
Linux协议栈–套接字的实现 (cxd2014.github.io)