套接字 socket 和 tcp 连接过程
一、socket 和 fd(file descriptor)是什么?
Unix/Linux 基本哲学之一就是"一切皆文件",即一切都可以用 "open -> read/write -> close" 来操作,socket 也可以理解成是一种特殊的文件。
fd(file descriptor):文件描述符,非负整数,是内核为了高效的管理已经被打开的文件所创建的索引,内核(kernel)利用文件描述符来访问文件。
需要明确的是,每个 tcp 连接的两端都会关联一个套接字和该套接字指向的文件描述符。
二、tcp 连接过程
要通过 TCP 连接发送出去的数据都先拷贝到 send buffer,可能是从用户空间进程的 app buffer 拷入的,也可能是从内核的 kernel buffer 拷入的,拷入的过程是通过 send() 函数完成的,由于也可以使用 write() 函数写入数据,所以也把这个过程称为写数据,相应的 send buffer 也就有了别称 write buffer。
最终数据是通过网卡流出去的,所以 send buffer 中的数据需要拷贝到网卡中。由于一端是内存,一端是网卡设备,可以直接使用 DMA 的方式进行拷贝,无需 CPU 的参与。也就是说,send buffer 中的数据通过 DMA 的方式拷贝到网卡中并通过网络传输给 TCP 连接的另一端。
当通过 TCP 连接接收数据时,数据肯定是先通过网卡流入的,然后同样通过 DMA 的方式拷贝到 recv buffer 中,再通过 recv() 函数将数据从 recv buffer 拷入到用户空间进程的 app buffer 中。
三、tcp 连接细节
a. 进程创建一个 socket ----> int s = socket(AF_INET, SOCK_STREAM, 0); //返回句柄 fd
b. 绑定端口 ----> bind(s, ...);
c. 设置监听端口 ----> listen(s, ...);
d. 接收客户端连接,阻塞 ----> int c = accept(s, ...) //返回句柄 fd
f. 接收客户端数据,阻塞 ----> recv(c, ...)
e. 关闭客户端连接 ----> close()
服务端处理客户端连接,大抵经历了以上几个步骤,下面我们要逐一对这些步骤进行解释。
1. socket() 函数
socket() 函数的作用就是生成一个用于通信的套接字文件描述符 sockfd(socket() creates an endpoint for communication and returns a descriptor),这个文件描述符可以作为稍后 bind() 函数的绑定对象。
2. bind() 函数
服务程序通过分析配置文件,从中解析出想要监听的地址和端口,再加上可以通过 socket() 函数生成的套接字 sockfd,就可以使用 bind() 函数将这个套接字绑定到要监听的地址和端口组合 "addr:port" 上,绑定了端口的套接字可以作为 listen() 函数的监听对象。
3. listen() 函数
listen() 函数就是监听已经通过 bind() 绑定了 "addr+port" 的套接字的。监听之后,套接字就从 CLOSE 状态转变为 LISTEN 状态,于是这个套接字就可以对外提供 TCP 连接的窗口了。
listen() 函数维护了两个队列:连接未完成队列(syn queue)和连接已完成队列(accept queue),用来配合内核完成 TCP 三次握手和四次挥手过程(注意,这时还不涉及用户线程),当监听的 sockfd 接收到某个客户端发来的 SYN 并回复了 SYN+ACK 之后,就会在连接未完成队列(syn queue)的尾部创建一个关于这个客户端的条目,并设置它的状态为 SYN_RECV,显然,这个条目中必须包含客户端的地址和端口相关信息,当监听的该条目再次收到这个客户端发送的 ACK 信息之后,就会把这个条目移入到连接已完成队列(accept queue),并设置它的状态为 ESTABLISHED 。
Linux 内核参数 /proc/sys/net/ipv4/tcp_max_syn_backlog
用来设置连接未完成队列(syn queue)的最大长度;/proc/sys/net/core/somaxconn
用来设置连接已完成队列(accept queue)的最大长度;
这里有个连接已完成队列(accept queue)的溢出事故,可以参读一下:懂得三境界-使用dubbo时请求超过问题
4. connect() 函数
connect() 函数是用于向某个已监听的套接字发起连接请求,也就是发起 TCP 的三次握手过程。可以看出,连接请求方(如客户端)才会使用 connect() 函数,当然,在发起 connect() 之前,连接发起方也需要生成一个 sockfd,且使用的很可能是绑定了随机端口的套接字。既然 connect() 函数是向某个套接字发起连接的,自然在使用 connect() 函数时需要带上连接的目的地,即目标地址和目标端口,这正是服务端的监听套接字上绑定的地址和端口。同时,它还要带上自己的地址和端口,对于服务端来说,这就是连接请求的源地址和源端口。于是,TCP 连接的两端的套接字都已经成了五元组的完整格式。
5. accept() 函数
listen() 函数的连接已完成队列(accept queue)中维护着已经完成三次握手的连接,accpet() 函数的作用是读取已完成连接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续连接的套接字描述符(姑且用 connfd 来表示),有了新的连接套接字,用户进程/线程(称其为工作者)就可以通过这个连接套接字和客户端进行数据传输,而前文所说的监听套接字(sockfd)则仍然被监听者监听。
accept() 函数是由用户空间进程发起,由内核空间消费操作,只要经过 accept() 过的连接,连接将从已完成队列(accept queue)中移除,也就表示 TCP 已经建立完成了,两端的用户空间进程可以通过这个连接进行真正的数据传输了,直到使用 close() 或 shutdown() 关闭连接时的四次挥手,中间再也不需要内核的参与。
经过 accept() 函数后,tcp 连接的套接字从 sockfd 变成了 connfd ,也就是说,经过 accept() 之后,这个连接和 sockfd 套接字已经没有任何关系了。
6. send() 和 recv() 函数
send() 函数是将数据从 app buffer 复制到 send buffer 中(当然,也可能直接从内核的 kernel buffer 中复制),recv() 函数则是将 recv buffer 中的数据复制到 app buffer 中。当然,对于 tcp 套接字来说,更多的是使用 write() 和 read() 函数来发送、读取 socket buffer 数据,这里使用 send()/recv() 来说明仅仅只是它们的名称针对性更强而已。
这两个函数都涉及到了 socket buffer,但是在调用 send() 或 recv() 时,复制的源 buffer 中是否有数据、复制的目标 buffer 中是否已满而导致不可写是需要考虑的问题。不管哪一方,只要不满足条件,调用 send()/recv() 时进程/线程会被阻塞(假设套接字设置为阻塞式IO模型)。当然,可以将套接字设置为非阻塞 IO 模型,这时在 buffer 不满足条件时调用 send()/recv() 函数,调用函数的进程/线程将返回错误状态信息 EWOULDBLOCK 或 EAGAIN ;buffer中是否有数据、是否已满而导致不可写,其实可以使用 select()/poll()/epoll 去监控对应的文件描述符(对应socket buffer则监控该socket描述符),当满足条件时,再去调用 send()/recv() 就可以正常操作了;还可以将套接字设置为信号驱动 IO 或异步 IO 模型,这样数据准备好、复制好之前就不用再做无用功去调用
send()/recv() 了。(BIO、NIO、AIO 简单介绍)
7. close()、shutdown() 函数
通用的 close() 函数可以关闭一个文件描述符,当然也包括面向连接的网络套接字描述符。当调用 close() 时,将会尝试发送 send buffer 中的所有数据。但是 close() 函数只是将这个套接字引用计数减 1,就像 rm 一样,删除一个文件时只是移除一个硬链接数,只有这个套接字的所有引用计数都被删除,套接字描述符才会真的被关闭,才会开始后续的四次挥手过程。对于父子进程共享套接字的并发服务程序,调用 close() 关闭子进程的套接字并不会真的关闭套接字,因为父进程的套接字还处于打开状态,如果父进程一直不调用 close() 函数,那么这个套接字将一直处于打开状态,将一直进入不了四次挥手过程。
而 shutdown() 函数专门用于关闭网络套接字的连接,和 close() 对引用计数减 1 不同的是,它直接掐断套接字的所有连接,从而引发四次挥手的过程。可以指定3种关闭方式:
- 关闭写。此时将无法向 send buffer 中再写数据,send buffer 中已有的数据会一直发送直到完毕。
- 关闭读。此时将无法从 recv buffer 中再读数据,recv buffer 中已有的数据只能被丢弃。
- 关闭读和写。此时无法读、无法写,send buffer 中已有的数据会发送直到完毕,但 recv buffer 中已有的数据将被丢弃。
无论是 shutdown() 还是 close(),每次调用它们,在真正进入四次挥手的过程中,它们都会发送一个 FIN。
参考文章:不可不知的socket和TCP连接过程