5-2 Socket API与TCP连接/断开过程剖析
网络连接(TCP/UDP)和断开的过程较为复杂,涉及了许多状态,在5-1章中介绍了有关socket函数库后,我们需要思考socket的API函数:socket()、bind()、listen()、connect()、accept()、send()、recv() 和网络连接(TCP/UDP)和断开的过程如何联系起来?
#1. Socket编程与TCP3次握手连接过程剖析 先上图:socket api执行各个流程对应的TCP建立连接时3次握手的过程
1.1 进行网络连接前的准备工作——客户端
如图所示,客户端在进行网络连接的准备工作较为简单,只是调用socket()函数生成用于网络连接的套接字。
注意:为了进行TCP连接,在调用socket()时指定type参数为SOCK_STREAM、protocol参数为IPPROTO_TCP。
1.2 进行网络连接前的准备工作——服务器
同样的,在进行网络连接前,服务器也需要调用socket()函数生成用于网络连接的套接字。此外,服务器为了接受客户端的连接请求,需要额外进行以下两步:
-
- 调用bind()函数将服务器所在的地址(ip地址和端口"addr:port")绑定到socket上;
-
- 调用listen()来监听bind()函数处理后的套接字,处于监听状态下的socket便可以接受客户端的连接、断开请求。
绑定了地址和端口的套接字就有了源地址和源端口(对服务器自身来说是源),再加上通过配置文件中指定的协议类型,socket建立连接需要的五元组中就有了其中3个要素。即:{protocal,src_addr,src_port}
1.3 TCP连接过程与socket函数
在建立TCP连接之前的socket都处于CLOSE状态。一般的TCP连接的过程具体如下:
- 1.listen()函数就是监听已经通过bind()绑定了ip_addr+port的套接字的。监听之后,套接字就从CLOSE状态转变为LISTEN状态。
- 2.TCP连接的发起是由客户端调用connect()函数开始的,connect()函数的作用是向某个已监听的套接字发起连接请求,也就是发起TCP的三次握手中第一次携带SYN标志的请求。调用connect()后,客户端处于SYN_SENT状态。
- 3.服务端处于LISTEN状态的socket在接收到客户端发送的SYN信号后,回复SYN+ACK,随后进入SYN_RECV状态。此时,连接被放置到连接未完成队列中,下文详述。
- 4.客户端处于SYN_SEND状态的socket在接收到服务端回复的SYN+ACK后,再次回复ACK,随后进入ESTABLISH状态。
- 5.服务端在接收到客户端回复的ACK后,进入ESTABLISH状态,连接放置到连接已完成队列中,下文详述。
1.4 连接未完成队列(syn queue)
客户端发起connect()到处于LISTEN状态的服务器后,进入TCP三次握手过程。 服务端通常使用select()、poll()或者epoll()进行I/O复用管理,在服务端的线程监听的过程时,它等待到有数据(连接请求,SYN信息)写入到它所监听的socket缓冲区中(recv buffer),此时内核被唤醒(注意不是app进程被唤醒,因为TCP三次握手和四次挥手是在内核空间由内核完成的,不涉及用户空间);内核唤醒后将SYN数据拷贝到某一处内核缓冲区(kernel buffer)中进行处理(比如判断SYN是否合理),再准备SYN+ACK数据(数据需要从kernel buffer中拷入send buffer中,再拷入网卡传送出去)。此时会在连接未完成队列(syn queue)中为这个连接创建一个新项目,并设置为SYN_RECV状态。
1.5 连接完成队列(established queue)
当客户端回复的ACK到达服务器后,内核再次被唤醒,内核判断这次写入的数据是ACK信息(第三次握手),表示是某个客户端对服务端内核发送的SYN的回应,于是将数据(ACK数据)拷入到kernel buffer中进行处理后,把连接未完成队列中对应的项目移入连接已完成队列(established queue),并设置为ESTABLISHED状态。
如果这次接收的不是ACK,则肯定是SYN,也就是新的连接请求,于是和上面的处理过程一样,放入连接未完成队列。
对于已经放入已完成队列中的连接,将等待内核通过accept()函数进行消费(由用户空间进程发起accept()系统调用,由内核完成消费操作),只要经过accept()过的连接,连接将从已完成队列中移除,也就表示TCP已经建立完成了,两端的用户空间进程可以通过这个连接进行真正的数据传输了,直到使用close()或shutdown()关闭连接时的4次挥手,中间再也不需要内核的参与。
1.6 accept()函数
accpet()函数的作用是读取已完成连接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续连接的套接字描述符,假设使用connfd来表示。有了新的连接套接字,进程/线程就可以通过这个连接套接字和客户端进行数据传输。 accept()工作的两种模式:
- 1、prefork模式 每个子进程既是监听者,又是工作者,每个客户端发起连接请求时,子进程在监听时将它接收进来,并释放对监听套接字的监听,使得其他子进程可以去监听这个套接字。多个来回后,终于是通过accpet()函数生成了新的连接套接字,于是这个子进程就可以通过这个套接字专心地和客户端建立交互,当然,中途可能会因为各种io等待而多次被阻塞或睡眠。prefork模式效率是低的,仅仅考虑从子进程收到SYN消息开始到最后生成新的连接套接字这几个阶段,这个子进程一次又一次地被阻塞。当然,可以将监听套接字设置为非阻塞IO模式,只是即使是非阻塞模式,它也要不断地去检查状态。
- 2、worker/event模式 每个子进程中都使用了一个专门的监听线程和N个工作线程,也可以理解为生产者-消费者模型,生产者负责监听,将连接成功的socket放入到消费者线程的处理队列中;消费者线程则处理连接成功后的数据发送和接收工作。这样监听者和工作者就分开了,在监听的过程中,工作者可以仍然可以自由地工作。worker/event模式比prefork模式性能高的,主要是因为prefork模式处理监听、连接和网络收发业务的耦合性太大,一旦某个环节出现了阻塞则直接影响其它业务的运行。worker/event模式将监听和工作分离,解耦合使得各个业务之间影响不大,当然多线程也有帮助提高效率,但不是关键。
当监听者发起accept()系统调用的时候,如果已完成连接队列中没有任何数据,那么监听者会被阻塞。当然,可将套接字设置为非阻塞模式,这时accept()在得不到数据时会返回EWOULDBLOCK或EAGAIN的错误。可以使用select()或poll()或epoll来等待已完成连接队列的可读事件。
1.7 TCP连接双方:send buffer、recv buffer
**每个tcp连接的两端都会关联一个套接字,该套接字又作为文件描述符指向的一块内核空间(视为文件)。**当服务端收到了ack消息后,就表示三次握手完成,和客户端的这个tcp连接已经建立好。连接建立好的socket会放在listen()打开的已完成队列中等待accept()的消费。这时tcp连接在服务端所关联的套接字是处于监听状态的listen套接字。
当已完成队列中的tcp连接被accept()消费后,这个tcp连接就会关联accept()所指定的套接字,并分配一个新的文件描述符。也就是说,经过accept()之后,这个连接和listen套接字已经没有任何关系了,并且accept()会分配新的套接字关联TCP连接
从客户端调用connect()到服务端调用accept()函数,客户端与服务端建立的TCP连接为双方都分配了套接字,套接字可视为文件描述符,它对应了内核空间中的一段内存buffer;由于TCP协议是面向字节流的传输协议,要通过TCP连接发送出去的数据都先拷贝到send buffer,可能是从用户空间进程的app buffer拷入的,也可能是从内核的kernel buffer拷入的,拷入的过程是通过send()函数完成的。
最终数据是通过网卡流出去的,所以send buffer中的数据需要拷贝到网卡中。由于一端是内存,一端是网卡设备,可以直接使用DMA(Direct Memory Access,直接存储器访问,外部设备不通过CPU而直接与系统内存交换数据的接口)的方式进行拷贝,无需CPU的参与。也就是说,send buffer中的数据通过DMA的方式拷贝
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
<p> C++工程师面试真题解析! </p> <p> 邀请头部大厂创作者<a href="https://www.nowcoder.com/profile/73627192" target="_blank">@Evila</a> 及牛客教研共同打磨 </p> <p> 助力程序员的求职! </p>