前几天作者在新浪微博上出了两道有关TCP的思考题,引发了一场讨论(http://weibo.com/1701018393/eCuxDrtaONn)。
第一道初级题目是:有一台机器,它有一个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连接有哪些开销?理论上最小的开销是多少?考虑两个场景:
1.假设有一个TCP服务程序,向这个程序成功发起连接需要做哪些事情?换句话说,如何才能让这个TCP服务程序认为有客户连接到了它(让它的accept(2)调用正常返回)?
2.假设有一个TCP客户端程序,让这个程序成功建立到服务器的连接需要做哪些事情?换句话说,如何才能让这个TCP客户端程序认为它自己已经连接到服务器了(让它的connect(2)调用正常返回)?
以上这两个问题问的不是如何编程,如何调用Sockets API,而是问如何让操作系统的TCP/IP协议栈认为任务已经成功完成,连接已经成功建立。
学过TCP/IP协议,理解三路握手的读者想必明白,TCP连接是虚拟的连接,不是电路连接。维持TCP连接理论上不占用网络资源(会占用两头程序的系统资源)。只要连接的双方认为TCP连接存在,并且可以互相发送IP packet,那么TCP连接就一直存在。
对于问题1,向一个TCP服务程序发起一个连接,客户端(为明白起见,以下称为faketcp客户端)只需要做三件事情(三路握手):
1a.向TCP服务程序发一个IP packet,包含SYN的TCP segment;
1b.等待对方返回一个包含SYN和ACK的TCPsegment;
1c.向对方发送一个包含ACK的segment。
faketcp客户端在做完这三件事情之后,TCP服务器程序会认为连接已建立。而做这三件事情并不占用客户端的资源,如果faketcp客户端程序可以绕开操作系统的TCP/IP协议栈,自己直接发送并接收IP packet或Ethernet frame的话。换句话说,faketcp客户端可以一直重复做这三件事情,每次用一个不同的IP:PORT,在服务端创建不计其数的TCP连接,而faketcp客户端自己毫发无损。我们很快将看到如何用程序来实现这一点。
对于问题2,为了让一个TCP客户端程序认为连接已建立,faketcp服务端也只需要做三件事情:
2a.等待客户端发来的SYN TCP segment;
2b.发送一个包含SYN和ACK的TCP segment;
2c.忽视对方发来的包含ACK的segment。
faketcp服务端在做完头两件事情(收一个SYN、发一个SYN+ACK)之后,TCP客户端程序会认为连接已建立。而做这三件事情并不占用faketcp服务端的资源。换句话说,faketcp服务端可以一直重复做这三件事,接受不计其数的TCP连接,而faketcp服务端自己毫发无损。我们很快将看到如何用程序来实现这一点。
基于对以上两个问题的分析,说明单独谈论“TCP并发连接数”是没有意义的,因为连接数基本上是要多少有多少。更有意义的性能指标或许是:“每秒收发多少条消息”、“每秒收发多少字节的数据”、“支持多少个活动的并发客户”等等。
faketcp的程序实现
为了验证上面的说法,作者写了几个小程序来实现faketcp,这几个程序可以发起或接受不计其数的TCP并发连接,并且不消耗操作系统资源,连动态内存分配都不会用到。代码见recipes/faketcp,可以直接用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,这样faketcp程序就扮演了192.168.0.0/24这个网段上的所有机器。atom发给192.168.0.2~192.168.0.254的IP packet都会发给faketcp程序,faketcp程序可以模拟其中任何一个IP给atom发IP packet。
程序分成几步来实现。
第一步:实现ICMP echo协议,这样就能ping通faketcp了。代码见recipes/faketcp/icmpecho.cc。
其中响应ICMP echo request的函数是icmp_input(),位于recipes/faketcp/faketcp.cc。这个函数在后面的程序中也会用到。
运行方法,打开3个命令行窗口:
1.在第1个窗口运行sudo ./icmpecho,程序显示
2.在第2个窗口运行
3.在第3个窗口运行:
注意到每个192.168.0.X的IP都能ping通。
第二步:实现拒绝TCP连接的功能,即在收到SYN TCP segment的时候发送RST segment。代码见recipes/faketcp/rejectall.cc。
运行方法,打开3个命令行窗口,头两个窗口的操作与前面相同,运行的faketcp程序是./rejectall。在第3个窗口运行
注意到向其中任意一个IP发起的TCP连接都被拒绝了。
第三步:实现接受TCP连接的功能,即在收到SYN TCP segment的时候发回SYN+ACK。这个程序同时处理了连接断开的情况,即在收到FIN segment的时候发回FIN+ACK。代码见recipes/faketcp/acceptall.cc。
运行方法,打开3个命令行窗口,步骤与前面相同,运行的faketcp程序是./acceptall。这次会发现nc能和192.168.0.X中的每一个IP每一个port都能联通。还可以在第4个窗口中运行netstat -tpn,以确认连接确实建立起来了。如果在nc中输入数据,数据会堆积在操作系统中,表现为netstat显示的发送队列(Send-Q)的长度增加。
第四步:在第三步接受TCP连接的基础上,实现接收数据,即在收到包含payload数据的TCP sengment时发回ACK。代码见recipes/faketcp/discardall.cc。
运行方法,打开3个命令行窗口,步骤与前面相同,运行的faketcp程序是discardall。这次会发现nc能和192.168.0.X中的每一个IP每一个port都能连通,数据也能发出去。还可以在第4个窗口中运行netstat -tpn,以确认连接确实建立起来了,并且发送队列的长度为0。
这一步已经解决了前面的问题2,扮演任意TCP服务端。
第五步:解决前面的问题1,扮演客户端向atom发起任意多的连接。代码见recipes/faketcp/connectmany.cc。
这一步的运行方法与前面不同,打开4个命令行窗口:
1.在第1个窗口运行sudo ./connectmany 192.168.0.1 2007 1000,表示将向192.168.0.1:2007发起1000个并发连接。程序显示
2.在第2个窗口运行
3.在第3个窗口运行一个能接收并发TCP连接的服务程序,可以是httpd,也可以是muduo的echo或discard示例,程序应listen 2007端口。
4.在第1个窗口中按回车键,再在第4个窗口中用netstat -tpn命令来观察并发连接。
有兴趣的话,还可以继续扩展,做更多的有关TCP的试验,以进一步加深理解,验证操作系统的TCP/IP协议栈面对不同输入的行为。甚至可以按作者在附录A中提议的那样,实现完整的TCP状态机,做出一个简单地mini tcp stack。
第一道题的答案:
在只考虑IPv4的情况下,并发输的理论上限是2 48 ^{48} 48。考虑某些IP段被保留了,这个上界可适当缩小,但数量级不变。实际的限制是操作系统全局文件描述符的数量,以及内存大小。
一个TCP连接有两个end points,每个end point是{ip, port},题目说其中一个end point已经固定,那么留下一个end point的自由度,即2 48 ^{48} 48。客户端IP的上限是2 32 ^{32} 32个,每个客户端IP发起连接的上限是2 16 ^{16} 16,乘到一起得到理论上限。
即便客户端使用NAT,也不影响这个理论上限。
在真实的Linux系统中,可以通过调整内核参数来支持上百万并发连接,具体做法见:
1.http://www.metabrew.com/article/a-million-user-comet-application-with-mochiweb-part-3。
2.http://www.erlang-factory.com/upload/presentations/558/efsf2012-whatsapp-scaling.pdf。