POSIX API与TCP网络编程

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状态。

  1. TCP是全双工的,在建立连接之前,需要确认双方的收发能力,所以双方都是一发一收,一共就是4次握手,只不过是中间的SYN和ACK这两次握手通过捎带应答机制被合并成了一次,所以就是三次握手。两次握手,并不能够确认双发的收发能力,所以肯定不行。
  2. 网络环境是复杂的,客户端发送了一个连接请求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字节的长度字段,在根据消息的长度读取后面完整的一条消息。相对与第二种解决方案来说,它读两次就可以搞定了,没有那么麻烦。所以,增加消息长度字段的这种方法是比较推荐的。

六、结语

欢迎批评指正!

相关推荐
UrSpecial2 天前
基于C语言与Epoll的Reactor模型
c语言·网络编程·reactor·epoll
学会去珍惜3 天前
学会C语言可以做什么
c语言·网络编程·游戏开发·嵌入式系统·系统编程
Bytenerd_03 天前
【中级软件设计师】传输层协议 —— TCP/UDP (附软考真题)
udp·tcp·传输层协议·中级软件设计师·软考真题
hhl_483841045 天前
上海域格4G模块信号说明
linux·功能测试·物联网·信号处理·tcp
追兮兮9 天前
基于 GD32 与 LwIP 的 TCP OTA 固件升级实现
网络·网络协议·tcp/ip·tcp·gd32·ota
大熊背12 天前
Serial over TCP实现原理
网络·tcp·isppipeline
2401_8414956413 天前
Linux C++ TCP 服务端经典的监听骨架
linux·网络·c++·网络编程·ip·tcp·服务端
呆呆在发呆.19 天前
JavaEE初阶
java·jvm·网络协议·学习·udp·java-ee·tcp
洛水水24 天前
高性能网络编程:io_uring vs epoll、QPS测试工具实现与10道网络面试题解析
c++·udp·tcp·io_uring