int main()
{
/*Step 1: 创建服务器端监听socket描述符listen_fd*/
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
/*Step 2: bind绑定服务器端的IP和端口,所有客户端都向这个IP和端口发送和请求数据*/
bind(listen_fd, xxx);
/*Step 3: 服务端开启监听*/
listen(listen_fd, 128);
/*Step 4: 服务器等待客户端的链接,返回值cfd为客户端的socket描述符*/
cfd = accept(listen_fd, xxx);
/*Step 5: 读取客户端发来的数据*/
n = read(cfd, buf, sizeof(buf));
}
int main()
{
/*Step 1: 创建客户端端socket描述符cfd*/
cfd = socket(AF_INET, SOCK_STREAM, 0);
/*Step 2: connect方法,对服务器端的IP和端口号发起连接*/
ret = connect(cfd, xxxx);
/*Step 4: 向服务器端写数据*/
write(cfd, buf, strlen(buf));
}
重新构建连接建立流程的文章:
在下面的动图中,展示了客户端和服务端建立连接时的代码流程。这段伪代码是服务端的简化版本,大家应该对它非常熟悉。需要注意的是,在执行listen()方法后,还会执行accept()方法。通常情况下,当启动服务器时,我们会发现程序最后会阻塞在accept()方法中。此时,服务端已经准备就绪,只等待客户端的连接。现在,让我们来看一下简化的客户端伪代码。
客户端的代码相对简单,创建socket后,直接发起connect方法。此时,服务端会发现之前一直阻塞的accept方法返回结果了。这样,两端成功建立了连接,随后可以愉快地进行读写操作。那么,我们今天的问题是,如果没有accept方法,TCP连接还能建立起来吗?实际上,只要在执行accept()之前加入一个sleep(20)的延迟,并立即执行客户端相关的方法,同时抓包观察,我们就能得出结论。
在不执行accept时的抓包结果显示,即使不执行accept()方法,三次握手仍然会正常进行,并成功建立连接。更有趣的是,在服务端执行accept()之前,如果客户端向服务端发送消息,服务端仍然能够正常回复ACK确认包。而且,当sleep(20)结束后,服务端正常执行accept(),之前客户端发送的消息仍然能够正常接收。通过这个现象,我们可以进一步思考原因,并深入了解三次握手的细节。
TCP的三次握手是一种经典的面试题。服务端的代码中,通过执行bind方法可以绑定监听端口,然后执行listen方法,进入监听(LISTEN)状态。内核会为每个处于LISTEN状态的socket分配两个队列,分别是半连接队列和全连接队列。
每个listen Socket都有一个全连接队列和半连接队列。那么,半连接队列和全连接队列到底是什么呢?
半连接队列和全连接队列是两个不同的队列。全连接队列(icskacceptqueue)实际上是一个链表,而半连接队列(syn_table)则是一个哈希表。
# ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 127.0.0.1:46269 *:*
半连接队列被设计为哈希表的原因是什么呢?
我们先来对比一下全连接队列。全连接队列本质上是一个链表,因为它是线性结构,所以称之为队列是合理的。全连接队列中存放的是已经建立完成的连接,这些连接正在等待被取走。服务端在取走连接时,并不关心具体是哪个连接,只要是连接就可以,所以直接从队列头部取出即可,这个过程的算法复杂度是O(1)。
而半连接队列不同,因为队列中存放的都是不完整的连接,它们正在等待第三次握手的到来。如果半连接队列也是一个链表,那么在取出相应IP端口的连接时,就需要依次遍历,算法复杂度将是O(n)。
然而,如果将半连接队列设计为哈希表,那么查找半连接的算法复杂度将回到O(1)。因此,出于效率考虑,全连接队列被设计为链表,而半连接队列被设计为哈希表。
如何查看两个队列的大小?
通过执行ss -lnt命令,我们可以查看全连接队列的大小。其中,Send-Q表示全连接队列的最大值,可以看到我这里的最大值是128;Recv-Q表示当前全连接队列的使用值,我这里使用了0个,也就是全连接队列为空,所有连接都已被取走。
当Send-Q和Recv-Q的数值非常接近时,说明全连接队列可能已经满了。我们可以通过下面的命令来查看是否发生过队列溢出。
# watch -d 'netstat -s | grep overflowed'
Every 2.0s: netstat -s | grep overflowed Fri Sep 17 09:00:45 2021
4343 times the listen queue of a socket overflowed
上面的结果显示,全连接队列溢出的次数为4343次。这个结果是历史上发生的次数。
如果配合使用watch -d命令,可以每2秒自动执行相同的命令,并以高亮显示变化的数字部分。如果溢出的数字不断增加,说明正在发生队列溢出的情况。
# netstat -nt | grep -i '127.0.0.1:8080' | grep -i 'SYN_RECV' | wc -l
0
# netstat -s | grep -i "SYNs to LISTEN sockets dropped"
26395 SYNs to LISTEN sockets dropped
如何查看半连接队列?
没有直接的命令可以直接查看半连接队列,但是我们可以通过统计处于SYN_RECV状态的连接数量来间接获得半连接队列的长度。需要注意的是,半连接队列和全连接队列都是挂在某个Listen socket上的。在这里,我使用的是127.0.0.1:8080,请根据需要替换为您想要查看的IP端口。
可以看到,我的机器上半连接队列的长度为0,这是正常的情况。正常的连接不会一直待在半连接队列中。当半连接队列中的连接不断增加时,最终也会发生溢出。可以通过下面的命令来查看。
# watch -d 'netstat -s | grep -i "SYNs to LISTEN sockets dropped"'
Every 2.0s: netstat -s | grep -i "SYNs to LISTEN sockets dropped" Fri Sep 17 08:36:38 2021
26395 SYNs to LISTEN sockets dropped
# cat /proc/sys/net/ipv4/tcp_abort_on_overflow
0
可以看到,我的机器上共发生了26395次半连接队列溢出。同样建议配合watch -d命令使用,以自动执行并高亮显示变化的数字部分。如果溢出的数字不断增加,说明正在发生溢出的行为。
全连接队列满了会发生什么?
如果全连接队列已满,服务端收到客户端的第三次握手ACK时,默认会丢弃这个ACK。除了丢弃之外,还会有一些附带行为,这取决于tcpaborton_overflow参数的设置。
当tcpaborton_overflow为0时,发生的现象是服务端会回复一个RST,这与当服务端端口未监听时,客户端尝试连接时的行为是一样的。这两种情况看起来是一样的,所以当客户端收到RST后,无法区分是端口未监听还是全连接队列已满。
当tcpaborton_overflow为1时,半连接队列满了会发生什么?
一般情况下,半连接队列会被丢弃,但这个行为可以通过tcp_syncookies参数进行控制。然而,与其关注这个参数,更重要的是先了解一下为什么半连接队列会被打满。
首先,我们需要明白,半连接的“生存”时间通常很短,只在第一次和第三次握手之间存在。如果半连接队列被打满,说明服务端疯狂地收到第一次握手请求。如果是在线游戏应用,能有这么多请求进来,那说明你可能要发财了。但现实往往比较残酷,你可能遇到了SYN Flood攻击。
所谓的SYN Flood攻击,简单来说,攻击方模拟客户端不断发送第一次握手请求,而在服务端回复第二次握手后,攻击方却不发送第三次握手,这样做可以将服务端的半连接队列填满,导致正常连接无法建立。
那么,如何处理这种情况?有没有一种方法可以绕过半连接队列?
有,这就是之前提到的tcp_syncookies派上用场了。
当tcp_syncookies被设置为1时,服务端在收到客户端的第一次握手SYN时,不会将其放入半连接队列,而是直接生成一个cookies,并将其随第二次握手一起发送回客户端。当客户端发送第三次握手时,会携带这个cookies,服务端验证它是否与之前发送的一致,如果一致,就建立连接并将其放入全连接队列中。可以看出,整个过程不再需要半连接队列的参与。
tcp_syncookies=1时,是否会有一个cookies队列?
生成的cookies保存在哪里呢?是否会有一个队列来保存这些cookies?
我们可以反过来思考,如果有一个cookies队列,那么它最终也会被SYN Flood攻击填满,与半连接队列的情况类似。
实际上,cookies并没有一个专门的队列来保存,它是通过通信双方的IP地址、端口、时间戳、MSS等信息进行实时计算的,并保存在TCP报头的seq字段中。
当服务端收到客户端发送的第三次握手包时,会通过seq字段还原出通信双方的IP地址、端口、时间戳、MSS,并进行验证。验证通过后,建立连接。
为什么不直接用cookies方案取代半连接队列?
目前看来,syn cookies方案节省了半连接队列所需的队列内存,并且可以解决SYN Flood攻击。那么,为什么不直接用syn cookies方案取代半连接队列呢?
事物都有两面性,尽管syn cookies方案可以防止SYN Flood攻击,但也存在一些问题。由于服务端不会保存连接信息,所以如果在传输过程中丢失了数据包,也不会重新发送第二次握手的信息。
此外,编码和解码cookies都会消耗较多的CPU资源。利用这一点,如果攻击者构造大量的ACK包,并带上虚假的cookies信息,服务端在收到ACK包后会尝试解码(消耗CPU),最后才发现不是正常的数据包并丢弃。这种利用ACK包消耗服务端资源的攻击称为ACK攻击,受攻击的服务器可能因为CPU资源耗尽而无法响应正常请求。
没有listen方法,为什么仍然能建立连接?
既然没有accept方法可以建立连接,那么没有listen方法是否也能建立连接呢?是的,之前的一篇文章提到过,客户端可以自己连接自己(TCP自连接),也可以两个客户端同时向对方发起连接请求(TCP同时打开),这两种情况都没有服务端的参与,也就是没有listen方法,仍然能够建立连接。
当时的文章最后留下了一个问题,没有listen方法,为什么仍然能建立连接?
我们知道,执行listen方法时,会创建半连接队列和全连接队列,握手过程中会在这两个队列中暂存连接信息。因此,建立连接的前提是必须有一个地方存储连接信息,以便在握手过程中根据IP地址、端口等信息找到socket信息。
那么,客户端是否有半连接队列呢?
显然没有,因为客户端没有执行listen方法,半连接队列和全连接队列是在执行listen方法时内核自动创建的。
但是,内核还有一个全局哈希表,用于存储sock连接的信息。这个全局哈希表实际上还细分为ehash、bhash和listen_hash等,但是我们可以将其理解为有一个全局哈希表就足够了。
在TCP自连接的情况下,客户端在执行connect方法时,最后会将自己的连接信息放入这个全局哈希表中,然后将信息发送出去。当消息经过回环地址重新回到TCP传输层时,根据IP地址、端口等信息再次从全局哈希表中取出信息。因此,通过一来一回的握手包,最终成功建立连接。
TCP同时打开的情况类似,只是从一个客户端变成了两个客户端而已。
总结:以上是关于握手建立连接流程的重新构建文章。通过对连接建立过程的分析,我们了解了半连接队列和全连接队列的作用,以及它们的内部结构。我们还讨论了半连接队列为什么被设计为哈希表,以及如何查看队列的大小。此外,我们还探讨了全连接队列和半连接队列溢出的情况,以及tcpaborton_overflow参数的影响。最后,我们讨论了syn cookies方案以及TCP自连接和TCP同时打开的情况。希望这篇重新构建的文章对您有所帮助。