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的方式拷贝到网卡中并通过网络传输给TCP连接的另一端:接收端。
当通过TCP连接接收数据时,数据肯定是先通过网卡流入的,然后同样通过DMA的方式拷贝到recv buffer中,再通过recv()函数将数据从recv buffer拷入到用户空间进程的app buffer中。
send buffer和recv buffer由操作系统内核进行管理,因此当我们从用户空间发送数据时,需要经历数据从用户空间写入到内核空间;但是系统也存在从内核态发送数据的情况,例如回复ACK报文时,这时数据从内核的某个buffer写入到send buffer中。
1.8 send()和recv()函数
send()函数是将数据从app buffer复制到send buffer中(当然,也可能直接从内核的kernel buffer中复制),recv()函数则是将recv buffer中的数据复制到app buffer中。
这两个函数都涉及到了socket buffer,但是在调用send()或recv()时,发送端的buffer中是否有数据、接收端的buffer中是否已满而导致不可写是需要考虑的问题。不管哪一方,只要不满足条件,调用send()/recv()时进程/线程会被阻塞(假设套接字设置为阻塞式IO模型)。当然,可以将套接字设置为非阻塞IO模型,这时在buffer不满足条件时调用send()/recv()函数,调用函数的进程/线程将返回错误状态信息EWOULDBLOCK或EAGAIN。
buffer中是否有数据、是否已满而导致不可写,可以使用select()/poll()/epoll去监控对应的文件描述符(对应socket buffer则监控该socket描述符),当满足条件时,再去调用send()/recv()就可以正常操作了。
2. Socket编程与TCP4次挥手过程剖析
2.1 TCP断开连接的过程
TCP3次握手建立连接后的双方处于ESTABLISED状态,以客户端主动断开为例,TCP4次挥手过程如下:
- 与断开连接相关的socket api函数有close()和shutdown()函数,二者都可用来关闭连接。以客户端主动断开为例,当客户端调用 close() / shutdown()函数后,向服务器发送 FIN 数据包,进入FIN_WAIT_1状态。
FIN 是Finish的缩写,表示完成任务需要断开连接。
- 与断开连接相关的socket api函数有close()和shutdown()函数,二者都可用来关闭连接。以客户端主动断开为例,当客户端调用 close() / shutdown()函数后,向服务器发送 FIN 数据包,进入FIN_WAIT_1状态。
- 服务器收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送“确认包”,进入CLOSE_WAIT状态。
注意:服务器收到请求后并不是立即断开连接,而是先向客户端发送“确认包”,告诉它我知道了,我需要做完未完成的事情才可以断开。
- 服务器收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送“确认包”,进入CLOSE_WAIT状态。
- 客户端收到“确认包”后进入FIN_WAIT_2状态,等待服务器准备完毕后再次发送数据包。
- 等待片刻后,服务器准备完毕,可以断开连接,于是再主动向客户端发送 FIN 包,告诉它我准备好了,断开连接吧。然后进入LAST_ACK状态。
- 客户端收到服务器的 FIN 包后,再向服务器发送 ACK 包,告诉它你断开连接吧。然后进入TIME_WAIT状态。
- 服务器收到客户端的 ACK 包后,就断开连接,关闭套接字,进入CLOSED状态。
2.2 socket关闭后的异常情况
TCP是全双工的通信机制已经概述过了,全双工连接分为读通道和写通道,如果这两个通道都关闭了,则这个连接不能继续通信。
(1)若服务器和客户端建立连接后,客户端调用了close(),关闭了自身的socket文件描述符, 此时会发送FIN报文给服务器,客户端不能再通过socket发送和接收数据。当服务器接收到FIN后,根据4次挥手的过程,服务端还可以向客户端发送数据(send())。send()调用只负责把数据交给TCP发送缓冲区就可以成功返回了,所以不会出错。
(2)此时,由于客户端已经调用close()关闭了socket,因此在收到数据后应答一个RST段,表示客户端已经不能接收数据,RST会将连接重置,服务端收到RST报文后无法立刻通知应用层,只把这个状态保存在TCP协议层。
(3)如果服务端再次调用send()发数据给客户端,由于TCP协议层已经处于RST状态了,因此不会将数据发出,而是发一个SIGPIPE信号给应用层,SIGPIPE信号的缺省处理动作是终止程序。
2.3 close()与shutdown()函数的区别
2.3.1 close()函数
#include <unistd.h> int close(int fd);
实际上,close()函数减少本进程对socket的引用次数,当引用次数为0时,socket才被真正的关闭;若引用次数不为0时,连接还是未断开的,用这个socket的其它进程还能读或写这个socket。
在多进程的并发场景,假设客户端有两个进程:父进程和子进程,子进程是在父进程和服务器建立连接之后fork出来的,因此客户端的socket的引用次数+1。我们期望实现这样的功能:
(1)子进程将数据写入套接字后close,并退出。
(2)服务端接收完数据,直到检测到EOF(接收到FIN标志),关闭连接并退出。
(3)父进程读取完服务端响应的数据,也退出。
如果子进程使用close的话,并不会发生4次挥手的过程,只是引用计数减1,服务端是接收不到EOF的(客户端是不会发送FIN的),也就不会关闭连接执行到我们规定的第三步。因此,close()函数并非优雅的关闭socket方法。
2.3.2 shutdown()函数
#include <sys/socket.h> int shutdown(int sockfd, int how);
**shutdown 可以选择关闭某个方向或者同时关闭两个方向:
- shutdown how = 0 or how = 1 or how = 2 (SHUT_RD or SHUT_WR or SHUT_RDWR),后两者可以保证对等方接收到一个EOF字符(即发送了一个FIN段),不管其他进程是否已经打开了这个套接字。而close不能保证,只有当某个sockfd的引用计数为0,close 才会发送FIN段,否则只是将引用计数减1而已。
所以,如果是调用shutdown how = 1 ,则意味着往一个已经发送出FIN的套接字中写是允许的,接收到FIN段仅代表对方不再发送数据,但对方还是可以读取数据的,可以让对方可以继续读取缓冲区剩余的数据。
shutdown()和close()的主要区别:
- 对应的系统调用不同
- shutdown()只能用于套接字文件,close()可以用于所有文件类型
- shutdown()只是关闭连接,并没有释放文件描述符,close()可以
- shutdown()不能用于TCP_CLOSE状态的套接字,否则会返回ENOTCONN错误
- shutdown()可以选择关闭读通道或写通道,close()不能。
3. 面试热点
3.1 TCP服务器端口数和客户端的最大连接数之间的关系
【出现频度】★★★
【难度】☆☆☆☆☆
【参考答案】
关于TCP服务器最大并发连接数有一种误解就是“因为端口号上限为65535,所以TCP服务器理论上的可承载的最大并发连接数也是65535”。
先说结论:对于TCP服务端进程来说,他可以同时连接的客户端数量并不受限于可用端口号。并发连接数受限于linux可打开文件数,这个数是可以配置的,可以非常大,所以实际上受限于系统性能。
TCP在建立连接之前,服务端首先会创建一个socket,并绑定一个端口port和主机IP。客户端在连接之前,也会创建一个socket,并由系统随机分配一个端口号port,并绑定了主机IP。于是双方三次握手结束后,服务端的已完成队列(established queue)存储了三次握手成功的客户端socket,等待服务端调用accept()消费。
于是,一个困惑的问题就产生了。当服务端accept一个请求后,生成的新的socket到底使用的是什么端口呢(系统会默认给其分配一个空闲的端口号?)如果是一个空闲的端口,那一定不是服务端socket绑定的端口了。
再回想计算机网络的协议架构,TCP和UDP同属于传输层,共同架设在IP层(网络层)之上。而IP层主要负责的是在节点之间(End to End)的数据包传送,这里的节点是一台网络设备,比如计算机。因为IP层只负责把数据送到节点,而不能区分上面的不同应用,所以TCP和UDP协议在其基础上加入了端口的信息,端口于是标识的是一个节点上的一个应用。TCP协议还加入了更加复杂的传输控制,比如滑动的数据发送窗口(Slice Window),以及接收确认和重发机制,以达到数据的可靠传送。
因此,如果一个程序创建了一个socket,并让其监听80端口,其实是向TCP/IP协议栈声明了其对80端口的占有。以后,所有目标是80端口的TCP数据包都会转发给该程序(这里的应用程序,因为使用的是Socket编程接口,所以首先由Socket层来处理)。所谓accept函数,其实抽象的是TCP的连接建立过程。accept函数返回的新socket其实指代的是本次创建的连接,而一个连接是包括两部分信息的,一个是源IP和源端口,另一个是目标IP和目标端口。所以,accept可以产生多个不同的socket,而这些socket里包含的目标IP和目标端口是不变的,变化的只是源IP和源端口。这些socket目标端口就可以都是80,而Socket层还是能根据源/目标对来准确地分辨出IP数据包和socket的归属关系,从而完成对TCP/IP协议的操作封装!
3.2 介绍TCP:传输控制协议
【出现频度】★★★★★
【难度】☆☆☆
【参考答案】
网络中数据交换需要按照一定的规则,而这种规则就是协议。只有按照约定的规则,双方之间才能正确地进行数据交换。TCP就是这些协议的一种,它提供一种面向连接的,可靠的字节流服务。
面向连接:两个使用TCP的应用在交换数据之前必须先建立一个TCP连接
可靠的:TCP有很多机制来尽可能的保证数据不丢失。例如:滑动窗口协议、拥塞控制算法等
字节流:不区分是ASCII字符还是二进制数据,数据解释交给应用层
在这里补充一些计算机网络的基础知识:
3.3 TCP建立连接为什么要三次握手
【出现频度】★★★★★
【难度】☆☆☆
【参考答案】
这几乎是面试中必问的一个问题。一个TCP连接是全双工的,即数据在两个方向上能同时传输。因此,建立连接的过程也就必须确认双方的收发能力都是正常的。
四次握手是否可以呢?完全可以!但是没有必要!在服务端收到SYN之后,它可以先回ACK,再发送SYN,但是这两个信息可以一起发送出去,因此没有必要。
两次握手是否可以呢?想象这样一种情况,客户端发起了一个连接请求在网络中滞留了很长时间,以至于在连接建立好且断开连接后,它才到达服务端,此时如果采用两次握手,那么服务端就会认为这个报文是新的连接请求,于是建立连接,等待客户端发送数据,但是实际上客户端根本没有发出建立请求,也不会理睬服务端,因此导致服务端空等而浪费资源。
为什么服务器会认为这个迟到的报文是新的连接请求?因为如果采用两次握手机制,那么服务端无法通过SYN来判断这是一个迟到或者重复的报文,还是正常到达的报文,但是对于三次握手,即便出现这样的情况,也不会在服务端建立起真正的连接。
3.4 关于TIME_WAIT状态的说明
【出现频度】★★★★★
【难度】☆☆☆
【参考答案】
客户端最后一次发送 ACK包后进入 TIME_WAIT 状态,而不是直接进入 CLOSED 状态关闭连接,这是为什么呢?
TCP 是面向连接的传输方式,必须保证数据能够正确到达目标机器,不能丢失或出错,而网络是不稳定的,随时可能会毁坏数据,所以机器A每次向机器B发送数据包后,都要求机器B”确认“,回传ACK包,告诉机器A我收到了,这样机器A才能知道数据传送成功了。如果机器B没有回传ACK包,机器A会重新发送,直到机器B回传ACK包。
客户端最后一次向服务器回传ACK包时,有可能会因为网络问题导致服务器收不到,服务器会再次发送 FIN 包,如果这时客户端完全关闭了连接,那么服务器无论如何也收不到ACK包了,所以客户端需要等待片刻、确认对方收到ACK包后才能进入CLOSED状态。那么,要等待多久呢?
数据包在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。这称为报文最大生存时间(MSL,Maximum Segment Lifetime)。TIME_WAIT 要等待 2MSL 才会进入 CLOSED 状态。ACK 包到达服务器需要 MSL 时间,服务器重传 FIN 包也需要 MSL 时间,2MSL 是数据包往返的最大时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就说明服务器已经收到了 ACK 包。