16. 关于TCP并发连接的几个思考题与试验
附录 D 关于 TCP 并发连接的几个思考题与试验
第一道初级题目
有一台机器,它有一个 IP,上面运行了一个 TCP 服务程序,程序只侦听一个端口,问:从理论上讲(只考虑 TCP/IP 这一层,不考虑 IPv6)这个服务程序可以支持多少并发 TCP 连接?(答 65536 上下的直接出局。) 具体来说,这个问题等价于:有一个 TCP 服务程序的地址是 1.2.3.4:8765,问它从理论上能接受多少个并发连接?
第二道进阶题目
一台被测机器 A,功能同上,同一交换机上还接有一台机器 B,如果允许 B 的程序直接收发以太网 frame,问:让 A 承担 10 万个并发 TCP 连接需要用多少 B 的资源?100 万个呢?
从讨论的结果看,很多人做出了第一道题,而第二道题则几乎无人问津。
一个 TCP 连接要占用多少系统资源?
在现在的 Linux 操作系统上,如果用 socket(2) 或 accept(2) 来创建 TCP 连接,那么每个连接至少要占用一个文件描述符(file descriptor)。为什么说“至少”?因为文件描述符可以复制,比如 dup();也可以被继承,比如 fork();这样可能出现系统中同一个 TCP 连接有多个文件描述符与之对应。据此,很多人给出的第一题答案是:并发连接数受限于系统能同时打开的文件数目的最大值。这个答案在实践中是正确的,却不符合原题意。
如果抛开操作系统层面,只考虑 TCP/IP 层面,建立一个 TCP 连接有哪些开销?理论上最小的开销是多少?考虑两个场景:
- 假设有一个 TCP 服务程序,向这个程序成功发起连接需要做哪些事情?换句话说,如何才能让这个 TCP 服务程序认为有客户连接到了它(让它的 accept(2) 调用正常返回)?
- 假设有一个 TCP 客户端程序,让这个程序成功建立到服务器的连接需要做哪些事情?换句话说,如何才能让这个 TCP 客户端程序认为它自己已经连接到服务器了(让它的 connect(2) 调用正常返回)?
以上这两个问题问的不是如何编程,如何调用 Sockets API,而是问如何让操作系统的 TCP/IP 协议栈认为任务已经成功完成,连接已经成功建立。
学过 TCP/IP 协议,理解三路握手的读者想必明白,TCP 连接是虚拟的连接,不是电路连接。维持 TCP 连接理论上不占用网络资源(会占用两头程序的系统资源)。只要连接的双方认为 TCP 连接存在,并且可以互相发送 IP packet,那么 TCP 连接就一直存在。
对于问题 1,向一个 TCP 服务程序发起一个连接,客户端(为明白起见,以下称为 fake tcp 客户端)只需要做三件事情(三路握手):
- 向 TCP 服务程序发一个 IP packet,包含 SYN 的 TCP segment;
- 等待对方返回一个包含 SYN 和 ACK 的 TCP segment;
- 向对方发送一个包含 ACK 的 segment.
fake tcp 客户端在做完这三件事情之后,TCP 服务器程序会认为连接已建立。而做这三件事情并不占用客户端的资源(为什么?),如果 fake tcp 客户端程序可以绕开操作系统的 TCP/IP 协议栈,自己直接发送并接收 IP packet 或 Ethernet frame 的话。换句话说,fake tcp 客户端可以一直重复做这三件事件,每次用一个不同的 IP:PORT,在服务端创建不计其数的 TCP 连接,而 fake tcp 客户端自己毫发无损。我们很快将看到如何用程序来实现这一点。
对于问题 2,为了让一个 TCP 客户端程序认为连接已建立,fake tcp 服务端也只需要做三件事情:
- 等待客户端发来的 SYN TCP segment;
- 发送一个包含 SYN 和 ACK 的 TCP segment;
- 忽视对方发来的包含 ACK 的 segment.
fake tcp 服务端在做完头两件事情(收一个 SYN、发一个 SYN+ACK)之后,TCP 客户端程序会认为连接已建立。而做这三件事情并不占用 fake tcp 服务端的资源(为什么?)。换句话说,fake tcp 服务端可以一直重复做这三件事,接受不计其数的 TCP 连接,而 fake tcp 服务端自己毫发无损。我们很快将看到如何用程序来实现这一点。
基于对以上两个问题的分析,说明单独谈论“TCP 并发连接数”是没有意义的,因为连接数基本上是要多少有多少。更有意义的性能指标或许是:“每秒收发多少条消息”、“每秒收发多少字节的数据”、“支持多少个活动的并发客户”等等。
fake tcp 的程序实现
为了验证我上面的说法,我写了几个小程序来实现 fake tcp,这几个程序可以发起或接受不计其数的 TCP 并发连接,并且不消耗操作系统资源,连动态内存分配都不会用到。代码见 recipes/fake tcp,可以直接用 make 编译。
我家里有一台运行 Ubuntu Linux 10.04 的 PC,hostname 是 atom,所有的试验都在这上面进行。家里试验环境的网络配置如图 D-1 所示。
我在附录 A 中曾提到“可以用 TUN/TAP 设备在用户态实现一个能与本机点对点通信的 TCP/IP 协议栈”,这次的试验正好可以用上这个办法。试验的网络配置如图 D-2 所示。
具体做法是:在 atom 上通过打开 /dev/net/tun 设备来创建一个 tun0 虚拟网卡,然后把这个网卡的地址设为 192.168.0.1/24,这样 fake tcp 程序就扮演了 192.168.0.0/24 这个网段上的所有机器。atom 发给 192.168.0.2~192.168.0.254 的 IP packet 都会发给 fake tcp 程序,fake tcp 程序可以模拟其中任何一个 IP 给 atom 发 IP packet.
第一步:实现 ICMP echo 协议
这样就能 ping 通 fake tcp 了。代码见 recipes/fake tcp/icmpecho.c. 其中响应 ICMP echo request 的函数是 icmp_input(),位于 recipes/fake tcp/fake tcp.c. 这个函数在后面的程序中也会用到。 运行方法,打开 3 个命令行窗口: 1. 在第 1 个窗口运行 sudo ./icmpecho,程序显示 allocted tunnel interface tun0 2. 在第 2 个窗口运行
1 |
|
- 在第 3 个窗口运行 注意到每个 192.168.0.X 的 IP 都能 ping 通。 ### 第二步:实现拒绝 TCP 连接的功能 即在收到 SYN TCP segment 的时候发送 RST segment。代码见 recipes/fake tcp/rejectall.c. 运行方法,打开 3 个命令行窗口,头两个窗口的操作与前面相同,运行的 fake tcp 程序是 ./rejectall. 在第 3 个窗口运行
1
2
3$ ping 192.168.0.2
$ ping 192.168.0.3
$ ping 192.168.0.234注意到向其中任意一个 IP 发起的 TCP 连接都被拒接了。 ### 第三步:实现接受 TCP 连接的功能 即在收到 SYN TCP segment 的时候发回 SYN+ACK。这个程序同时处理了连接断开的情况,即在收到 FIN segment 的时候发回 FIN+ACK。代码见 recipes/fake tcp/acceptall.c.1
2
3$ nc 192.168.0.2 2000
$ nc 192.168.0.23 3333
$ nc 192.168.0.7 5555
运行方法,打开 3 个命令行窗口,步骤与前面相同,运行的 fake tcp 程序是 ./acceptall. 这次会发现 nc 能和 192.168.0.X 中的每一个 IP 每一个 port 都能连通。
还可以在第 4 个窗口中运行 netstat -tpn,以确认连接确实建立起来了。如果在 nc 中输入数据,数据会堆积在操作系统中,表现为 netstat 显示的发送队列(Send-Q)的长度增加。
第四步:在第三步接受 TCP 连接的基础上,实现接收数据
即在收到包含 payload 数据的 TCP segment 时发回 ACK。代码见 recipes/fake tcp/discardall.c. 运行方法,打开 3 个命令行窗口,步骤与前面相同,运行的 fake tcp 程序是 ./discardall. 这次会发现 nc 能和 192.168.0.X 中的每一个 IP 每一个 port 都能连通,数据也能发出去。
还可以在第 4 个窗口中运行 netstat -tpn,以确认连接确实建立起来了,并且发送队列的长度为 0. 这一步已经解决了前面的问题 2,扮演任意 TCP 服务端。
第五步:解决前面的问题 1,扮演客户端向 atom 发起任意多的连接
代码见 recipes/fake tcp/connectmany.c.
这一步的运行方法与前面不同,打开 4 个命令行窗口: 1. 在第 1 个窗口运行
sudo ./connectmany 192.168.0.1 2007 1000,表示将向 192.168.0.1:2007 发起
1000 个并发连接。程序显示 allocted tunnel interface tun0 press enter key
to start connecting 192.168.0.1:2007 2. 在第 2 个窗口运行 1
2$ sudo ifconfig tun0 192.168.0.1/24
$ sudo tcpdump -i tun0
一个 TCP 连接有两个 endpoints,每个 endpoint 是 (ip, port),题目说其中一个 endpoint 已经固定,那么留下一个 endpoint 的自由度,即 2^48. 客户端 IP 的上限是 2^32 个,每个客户端 IP 发起连接的上限是 2^16,乘到一起得到理论上限。
即便客户端使用 NAT,也不影响这个理论上限。(为什么?)
在真实的 Linux 系统中,可以通过调整内核参数来支持上百万并发连接,具体做法见:
- http://urbanairship.com/blog/2010/09/29/linux-kernel-tuning-for-c500k/
- http://www.metabrew.com/article/a-million-user-comet-application-with-mochiweb-part-3
- http://www.erlang-factory.com/upload/presentations/558/efsf2012-whatsapp-scaling.pdf
在 TCP/IP 协议栈中,一个 TCP 连接是由四个元素定义的:源 IP 地址、源端口号、目的 IP 地址和目的端口号。这四个元素合在一起被称为一个“四元组”(tuple),它唯一地标识了一个 TCP 连接。
对于一个运行在特定 IP 地址和端口上的 TCP 服务程序,例如
1.2.3.4:8765
,它能够接受的并发连接数取决于能够与之形成不同四元组的客户端的数量。理论上,客户端可以来自不同的
IP
地址,每个客户端可以使用不同的端口号来建立连接。因此,理论上可以建立的并发连接数是客户端
IP 地址数量与客户端端口号数量的乘积。
- 客户端 IP 地址数量:IPv4 地址由 32 位组成,因此理论上可以有 (2^{32}) 个不同的 IP 地址。
- 客户端端口号数量:端口号由 16 位组成,因此理论上可以有 (2^{16}) 个不同的端口号。
因此,理论上可以建立的并发连接数为 (2^{32} ^{16} = 2^{48})。
然而,这个理论上的上限在实际中是不可能达到的,原因包括:
- 保留的 IP 地址:并不是所有的 IPv4 地址都是可用的。有些地址段被保留用于特殊用途,例如本地网络、多播等。
- 操作系统限制:操作系统对可以同时打开的文件描述符数量有限制。每个 TCP 连接都需要一个文件描述符来管理,因此操作系统的文件描述符限制会直接影响并发连接数。
- 内存限制:每个 TCP 连接都需要一定的内存来存储连接状态、缓冲区等信息。系统的内存大小会限制可以同时维持的连接数。
- 网络带宽和性能限制:即使理论上可以建立大量的并发连接,实际的网络带宽和服务器的处理能力也会限制并发连接的有效数量。
因此,虽然理论上可以达到 (2^{48}) 个并发连接,但实际上受到多种因素的限制,实际的并发连接数会远远低于这个理论值.
第二道题的核心是理解如何通过机器 B 来支持机器 A 承担大量的并发 TCP 连接,而不需要消耗 B 的大量资源。关键在于如何通过 B 来模拟大量的客户端连接到 A,而不需要 B 本身为每个连接分配大量的资源.
理论分析
在理论上,TCP 连接的建立和维持主要消耗的是操作系统的资源,包括文件描述符、内存等。然而,如果 B 可以直接收发以太网帧,并且能够模拟大量的客户端连接,那么 B 的资源消耗可以被大大减少。具体来说:
文件描述符和内存:通常,每个 TCP 连接都需要一个文件描述符和一定的内存来维护连接状态。但如果 B 可以通过直接操作以太网帧来模拟连接,而不需要为每个连接分配文件描述符和内存,那么 B 的资源消耗可以被最小化。
连接模拟:B 可以通过发送和接收以太网帧来模拟大量的客户端连接。具体来说,B 可以发送包含 SYN、SYN+ACK、ACK 等 TCP 段的以太网帧,从而在 A 上建立大量的 TCP 连接。由于 B 不需要为每个连接维护完整的 TCP 状态,因此资源消耗可以被大大减少.
实现思路
- 使用 TUN/TAP 设备:B 可以使用 TUN/TAP 设备来创建一个虚拟网络接口,通过这个接口直接收发以太网帧。这样,B 可以模拟大量的客户端 IP 地址和端口号,发送 SYN 包到 A,从而在 A 上建立大量的 TCP 连接.
- 最小化资源消耗:由于 B 只需要发送和接收以太网帧,而不需要为每个连接维护完整的 TCP 状态,因此 B 的资源消耗可以被最小化。具体来说,B 可以使用少量的内存和文件描述符来管理大量的连接模拟.
资源消耗估计
- 10 万个并发连接:对于 10 万个并发连接,B 可以通过发送和接收以太网帧来模拟这些连接,而不需要为每个连接分配大量的资源。假设 B 使用少量的内存和文件描述符来管理这些连接模拟,那么 B 的资源消耗可以被控制在较低的水平.
- 100 万个并发连接:对于 100 万个并发连接,情况与 10 万个类似。B 可以通过同样的方法来模拟这些连接,而不需要为每个连接分配大量的资源。由于 B 的资源消耗主要来自于以太网帧的发送和接收,而不是连接状态的维护,因此 B 的资源消耗仍然可以被控制在较低的水平.
结论
通过直接收发以太网帧来模拟大量的客户端连接,B 可以在 A 上建立大量的 TCP 连接,而不需要消耗大量的资源。这种方法充分利用了 TCP 连接的虚拟性,通过模拟连接来实现高并发,而不需要为每个连接分配大量的系统资源.