POSIX API与TCP网络编程
- 一、引言
- 二、三次握手
- [三、POSIX API](#三、POSIX API)
- 四、四次挥手
- 五、TCP的粘包问题
- 六、结语
一、引言
这篇文章咱们就来聊聊 TCP 网络编程里那些绕不开的 POSIX API。我会带你捋一捋这些 API 到底是怎么干活的,顺便把开发中容易踩的坑,还有面试官最爱问的那些"经典问题"也都一并讲透。
二、三次握手

这里放上一张TCP首部格式, 以便于理解它三次握手的数据传输过程。

第一次握手,由客户端主动发起,将TCP首部中的SYN标志位置为1,同时携带上自己的初始序列号x,随后进入到SYN_SENT状态。
第二次握手,服务器收到客户端建立连接的请求后,回复SYN同意建立连接,同时携带上自己的初始序列号y,并对客户端进行确认,确认号为x + 1,随后进入到SYN_RECV状态。
第三次握手,客户端收到服务器同意建立连接的报文后,对这个报文进行确认,给服务器发送ACK,确认号为y + 1。
序号:
发送方收到序号为x的报文,意思是,接收方已经成功接收了x之前的数据。
网络环境是很复杂的,先发送的包可能后到,TCP通过序号从小到大排序,就可以保证数据的顺序是正确的。
面试中,可能会被问到:为什么是三次握手,两次不行吗?
结合上面三次握手的过程,如果是两次握手,意味着不用客户端发送ACK,服务器就认为连接已经建立了,在发送完SYN+ACK后直接进入到ESTABLISHED状态。
- TCP是全双工的,在建立连接之前,需要确认双方的收发能力,所以双方都是一发一收,一共就是4次握手,只不过是中间的SYN和ACK这两次握手通过捎带应答机制被合并成了一次,所以就是三次握手。两次握手,并不能够确认双发的收发能力,所以肯定不行。
- 网络环境是复杂的,客户端发送了一个连接请求SYN,但是这个SYN迟迟没有到达服务器。客户端超时,重新发了一个新的SYN,完成了正常的通信并且关闭了连接。这时,那个旧的SYN突然到达服务器。如果是两次握手,服务器直接回复SYN+ACK并建立连接,导致服务器白白的浪费资源。
对于这个问题的答案,一开始我是不赞同第二个说法的。我想,客户端断开连接后,会进入到TIME_WAIT状态,等待2倍的最大报文段生存时间,这个旧的SYN不应该早就消失了吗,为什么还要担心它会突然到达服务器?
后面才发现是我错了。客户端进入TIME_WAIT状态,在这期间,这个旧的SYN有可能还没有消失。当然,如果是客户端结束了TIME_WAIT状态,这个旧的SYN还没到达服务器,那么可以肯定这个旧的SYN已经不存在于网络中了。而且,TIME_WAIT是连接断开的时候才有的状态,针对的是一个已经成功建立并完成数据传输的连接。这个连接关闭后,通过进入TIME_WAIT状态,确保该连接产生的所有报文彻底消失在网络中,不会干扰到相同四元组的新连接,这跟连接建立有什么关系呢?
三、POSIX API
服务端:
socket();
bind();
listen();
accept();
recv();
send();
close();
客户端:
socket();
bind();
connect();
send();
recv();
close();
以上就是客户端和服务器搭建的基本流程了,涉及到的最常用的函数也基本就是这些了。
我们先不讲这些函数的具体作用,先来了解一个关键的数据结构------TCP控制块(TCP Control Block)。
TCB包含的核心内容:
五元组:源IP、源端口、目的IP、目的端口、协议
状态信息:标识当前连接所处的状态(LISTEN、ESTABLISHED、TIME_WAIT等)
传输控制:发送/接收序列号、窗口大小等
缓冲队列:发送缓冲区和接收缓冲区的指针
定时器:重传计时器、保活计时器等
TCB的创建时机取决于你是客户端还是服务端。
当客户端调用socket()时,内核只分配一个fd和一个空的TCP壳子(状态为CLOSED)。真正创建是在调用connect()的时候,填入目的IP和端口,将状态设置为SYN_SENT,并发送SYN包。
服务端的TCB创建分为"监听者"和"连接者"。
- 监听TCB:当服务端调用socket()和bind()后,在调用listen()时,内核会创建一个处于LISTEN状态的TCB。这个TCB不传输数据,专门用来接收新的连接。
- 连接TCB:真正的数据传输TCB,是在收到客户端的SYN包时创建的。
半连接队列和全连接队列是在服务端listen()之后创建的,监听TCB中包含指向这两个队列的指针,但由于没有序列化、缓冲区等,所以监听套接字listen_fd是不能够用来进行数据传输的。
- 半连接队列存储的是还没有完成三次握手的连接。服务端收到客户端的SYN包后,会为这个请求创建一个新的TCB,然后放入半连接队列中,并给客户端回复SYN+ACK。所以半连接队列也叫做SYN队列。
- 全连接队列是存储已经完成三次握手,但是还没有被应用层调用accept()取走的连接。所以全连接队列也叫做Accept队列。当连接完成三次握手后,内核就会从半连接队列中取出对应的TCB,放入全连接队列中。

两边分别是客户端、服务器和网络协议栈,中间则是数据包要经过的复杂网络环境。我们能控制的无非就是客户端或服务器加上网络协议栈,中间的网络我们是控制不了的。
客户端调用send()给服务器发送数据时,实际上就只是把数据从用户空间拷贝到网络协议栈中的TCB对应的发送缓冲区中,所以send()的返回值大于零,并不意味着就发送成功了。服务端调用recv()读取数据时,实际上也是从网络协议栈中的TCB对应的接收缓冲区中把数据拷贝到用户空间。
总的来说,Socket API的本质,就是通过fd找到对应的TCB,然后对TCB里的数据进行读写或状态修改。
最后,我们在来总结一下上面这些POSIX API的作用。
- socket(): 分配fd和一个空的TCB。
- bind(): 将本地IP和端口写入到TCB的对应字段中。
- listen(): 修改TCB的状态,将TCB的状态设置为LISTEN,并初始化半连接队列和全连接队列。
- connect(): 发送SYN包,将TCB的状态设置为SYN_SENT,并等待完成三次握手,在完成三次握手后,将TCB的状态修改为ESTABLISHED在返回(当然,也有可能是超时返回)。
- accept(): 从全连接队列中取出一个TCB,并创建一个新的fd指向它,然后返回给应用层。
- send(): 将用户态的数据拷贝到TCB的发送缓冲区。至于什么时候发、发多少、怎么发,这完全是由网络协议栈来决定的。
- recv(): 从TCB的接收缓冲区中将数据拷贝到用户态。
- close(): 发送FIN包,并最终释放TCB占用的内存。
下面我们聊一聊面试可能会遇到的一些问题。
SYN Flood攻击(SYN泛洪攻击)
攻击者发送海量的伪造SYN包,由于半连接队列(SYN队列)的长度是有限的,所以队列很快会被沾满。导致合法请求的SYN包无法进入半连接队列,从而无法建立连接。
虽然服务器不会永久保留办连接资源,它会在重复发送几次SYN+ACK,仍没有收到客户端的ACK后,释放这条连接对应的资源(TCB)。但是,在SYN泛洪攻击下,释放的速度远远跟不上填充的速度。
如何防御?
- 增大半连接队列的长度?就算半连接队列再怎么长,在海量的SYN包下,被打满只是时间问题。所以这个方法意义不大。
- 减少SYN+ACK的重试次数,加快超时连接的释放速度。但是你再怎么快,也快不过SYN泛洪攻击的速度。
- 启用SYN Cookie:服务器不在是收到SYN包后就给这个连接创建对应的TCB,而是通过SYN包的参数(源IP、源端口、目的IP、目的端口、序列号等)生成一个加密的Cookie值,写入到SYN+ACK包的序列号字段中。当客户端回复ACK时,服务器会验证ACK中的序列号是否包含合法的Cookie。合法客户端的ACK包会携带服务器给的Cookie值,服务器验证通过后才会创建TCB。伪造IP的攻击无法回复ACK,自然也就不会创建TCB,白白浪费资源。
如果建立连接后,一方断电了怎么办?
假设A和B已经建立了TCP连接,但是A突然断电。分一下三种情况:
- 没有数据传输: 这时候就要依赖TCP的保活机制了。TCP默认情况下是不开启保活机制的,如果B没有数据要发送给A,那么它就会认为这条连接时正常的,不会释放这条连接所占用的资源。如果开启了TCP的保活机制,那么B会发送心跳包检测A是否在线。超时后,会重发几次,直到重发次数达到设定的阈值后,仍然没有响应,才会判断A已经失联。然后内核会将该连接对应的TCB中的连接状态字段修改为CLOSED,最终释放掉这个连接所占用的资源。Linux的默认保活机制是很保守的,连接空闲2个小时后,才发送第一个探测包。
- 有数据传输时: 依赖的是超时重传机制。B向A发送数据,因为A已经断点,不会回复ACK。所以B会进行超时重传,如果重传的次数达到设定的阈值后,仍然没有收到ACK的话,内核就会判定这条连接已经失效,并释放这条连接所占用的资源。
- A断电后重启,再次和B通信: 因为TCP的连接控制块(TCB)都是保存在内存中的,所以断电后就全都丢失了。如果此时B还在向A发送数据或者保活探测包,A收到后,不认识这条连接,它就会向B发送一个RST复位报文,告诉B当前的连接并不存在。B收到RST报文后,就会释放该连接对应的资源。
四、四次挥手

第一次挥手:客户端向服务器发送FIN报文,告诉服务器说,我已经不在发送数据了(但是可以接收数据),随后进入到FIN_WAIT_1状态。
第二次挥手:服务器收到客户端的FIN报文后,立即发出确认。随后服务器进入到CLOSE_WAIT状态。但是它仍然可以给客户端发送数据。客户端收到这个ACK后,进入到FIN_WAIT_2状态。
第三次挥手:当服务器发送完所有数据后,给客户端发送FIN报文,告诉客户端我这边的数据处理完了,我也要关闭连接了,随后进入到LAST_ACK状态。
第四次挥手:客户端收到服务器的FIN报文后,立即发出确认,随后进入到TIME_WAIT状态,等待2倍的最大报文段生存时间后,进入CLOSED状态。服务器收到ACK后,立即进入到CLOSED状态。
下面我们聊一聊面试可能会遇到的问题。
为什么要进行四次挥手,三次不行吗?
TCP是全双工,每个方向都必须单独关闭。当客户端发送FIN时,只是说明客户端没有数据要发送了,但是服务器可能还有数据要发送给客户端。所以只能先回复ACK,等数据发送完毕后,才能回FIN关闭连接。这两个动作在时间上是分离的,所以就变成了四次挥手。如果服务器在收到客户端的FIN报文后,没有数据要发送的话,ACK和FIN可以合并发送,这时候就是三次挥手了。
TIME_WAIT状态的作用是什么,为什么要等待2倍的最大报文段生存时间?
第一个原因:要可靠的终止TCP连接。如果客户端的ACK丢失,那么服务器会超时重传FIN报文,处于TIME_WAIT状态下的客户端能重新发送ACK,确保服务器正确的进入CLOSED状态。但是,如果客户端没有TIME_WAIT状态,它在发完ACK后就直接关闭连接、释放资源,那么当它收到重传的FIN包后,它不认识这个连接,然后回复一个RST报文,服务器收到后,会认为是连接发生了错误,而不是优雅的关闭连接。
第二个原因:让旧连接的报文在网络中消失,避免干扰相同四元组的新连接。连接A关闭后,系统立刻使用相同的四元组建立新的连接B。如果连接A的某个数据包因为网络拥堵,在连接关闭后才姗姗来迟,到达目的地。由于这个迟到的旧数据包的四元组和连接B的完全相同,接收方会误以为这是连接B的数据,从而导致数据错乱。
为什么要等待2MSL?
MSL(Maximum Segment Lifetime)是一个报文在网络中可以存活的最长时间,超过这个时间就会被丢弃。
第一个MSL,确保客户端发出的最后一个ACK报文能够到达服务器,或者因为超时被丢弃。
第二个MSL,确保服务器在没有收到ACK后,重传的FIN报文能够到达客户端,以便于客户端能够再次响应ACK。
为什么CLOSE_WAIT状态过多,如何解决?
如果服务器上出现大量的CLOSE_WAIT状态,意味着recv()返回0后,到调用close()的这段时间间隔比较长。可能的原因是在这期间进行了比较耗时的数据处理。如果把数据处理交给另外一个线程处理,当前线程在调用recv()后,立马就往下执行调用close()就不会出现大量的CLOSE_WAIT状态。
五、TCP的粘包问题

可以看到,TCP首部字段中是没有数据长度字段的。也就是说TCP对消息的边界不敏感,发送多条消失的时候,就会出现粘包问题。
粘包问题的解决方案有以下三种,下面我们探讨一下用哪种方案解决粘包问题比较合适。
- 固定消息长度。这种方法,接收方在读取时,也按照固定的长度来读取即可。但是它不灵活,消息长了不够用,消息短了浪费空间。
- 使用特殊字符来进行分割。你可能会说,如果发送的消息就包含特殊字符呢?这种情况其实还是比较好处理的,比如我们的消息内容可能包含\r\n,那分割符采用2个\r\n就可以区分。或者使用更加特殊的字符来作为分隔符。这种方法它的难点在于,接收方调用一次recv()从协议栈的接收缓冲区中读取数据,需要在应用层遍历读到的这些数据中,是否包含特殊字符。如果包含,就需要进行分割,保存特殊字符后面的部分,因为这部分数据属于下一条消息,后面还要进行拼接。总的来说,虽然可以解决问题,但是整个过程是比较麻烦的。
- 应用层增加消息长度字段。在消息的前面,加上一个4字节的消息长度字段。这样,接收方在接收到数据时,先读取4字节的长度字段,在根据消息的长度读取后面完整的一条消息。相对与第二种解决方案来说,它读两次就可以搞定了,没有那么麻烦。所以,增加消息长度字段的这种方法是比较推荐的。
六、结语
欢迎批评指正!