前文再续,书接上一回,这篇主要总结TCP编程中那些重要选项的含义解析,毕竟没有研究过linux的网络这块的源码,大都是从现象来分析,如有错漏,请大家指正,linux内核版本为4.4.0,系统为ubuntu16.04,macos版本为10.12。
1 SO_REUSEADDR选项解析
SO_REUSEADDR选项大家不会陌生,在编程中会经常看到有设置为1。至于为什么要设置这个选项,要从之前提到的TCP关闭连接的四次握手说起,我们知道,在TCP关闭连接的时候主动关闭的一方会有2MSL(Maximum Segment Life,报文最大生存时间)的时间处于TIME_WAIT状态,并不会马上关闭。TCP连接在2MSL等待期内,定义这个连接的插口四元组(客户端IP, 客户端端口,服务端IP,服务端端口)不能再被使用。而且在大多数的TCP实现中,要求更为严格,在2MSL内的等待期内,插口中使用的本地端口在默认情况下也不能再次使用。那如果要使用,就需要用到SO_REUSEADDR选项了。
对于SO_REUSEADDR选项,一些文章中有点语焉不详,这里来细细探究一下。对于这个选项的解释,大部分书籍是以Unix为例子的,也就是说只适用于Unix系列的系统,比如MacOS,BSD等。
先看看Linux下面这个选项的实际作用。注意说的是TIME_WAIT状态的端口可以再次使用,如果端口只是在监听中,并不是TIME_WAIT状态,是不能再次使用的。还是用之前的代码,服务端的设置了SO_REUSEADDR选项,并加了一个host参数,如下:
#server.py
import socket
def start_server(ip, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((ip, port))
sock.listen(1)
while True:
conn, cliaddr = sock.accept()
print 'server connect from: ', cliaddr
while True:
data = conn.recv(1024)
if not data:
print 'client closed:', cliaddr
break
conn.send(data.upper())
conn.close()
except Exception, ex:
print 'exception occured:', ex
finally:
sock.close()
if __name__ == "__main__":
import sys
host = '127.0.0.1'
if len(sys.argv) >= 2:
host = sys.argv[1]
print 'host:%s' % host
start_server(host, 7777)
#client.py
from socket import *
import sys
def start_client(ip, port):
try:
sock = socket(AF_INET, SOCK_STREAM, 0)
sock.connect((ip, port))
print 'connected'
while True:
data = sys.stdin.readline().strip()
print 'input data:', data
if not data: break
sock.send(data)
result = sock.recv(1024)
if not result:
print 'other side has closed'
else:
print 'response from server:%s' % result
sock.close()
except Exception, ex:
print ex
if __name__ == "__main__":
start_client('127.0.0.1', 7777)
第一个实验
先在一个终端运行python server.py
,这个时候你再开一个终端运行python server.py
或者python server.py 0.0.0.0
都是不行的,因为端口已经在使用了,前一个是因为是IP和端口都相同不行,而后一个是因为0.0.0.0
是指代本机所有的IP,linux下面是不能让0.0.0.0
和其他ip一起在同一个端口监听的,而macos是可以的。由于之前占用端口的连接不处于TIME_WAIT状态,即便你设置了SO_REUSEADDR选项也不能再次使用,当然有个例外就是,如果你指定本机的ip10.0.2.15
是可以同时监听的。
第二个实验
先在一个终端运行python server.py
,再开一个终端运行python client.py
,接着CTRL+C
终止第一个终端脚本,这个时候服务端这个连接会处于FIN_WAIT2状态,而客户端连接则是CLOSE_WAIT状态。然后CTRL+C
关掉第二个终端脚本,这时服务端的连接处于TIME_WAIT状态,此时我们另外开启一个终端,运行python server.py
或者python server.py 0.0.0.0
发现,是可以正常运行的,因为设置了SO_REUSEADDR标记,所以可以将TIME_WAIT状态的端口进行再次使用。这一点linux和macos表现一致。
2 backlog的含义
backlog参数的解释在网上查资料很是混乱,各种解释都有。当然大部分可能还是依照的《Unix网络编程》上面的说明,实际上根据我在macos和Linux上面测试的情况,是有所不同的。backlog队列的设计一般而言是有两种方式的:
1)第一种方式是只用一个队列,队列大小是listen函数里面的backlog大小。当服务端接收到一个SYN包的时候,则回复一个SYN-ACK包并将这个连接加入到队列中,此时连接状态为SYN-RECV。当接收到ACK包的时候,则将该连接的状态从SYN-RECV改为ESTABLISHED,此时这个连接才可以移交给上层应用。也就是说,这个队列中的连接有两种状态,SYN-RECV和ESTABLISHED,只有处于ESTABLISHED的连接才会通过accept函数调用提交给上层应用处理。
2)第二种方式是用两个队列。一个是syn queue,另一个是accept queue。处于SYN-RECV状态的连接会被加入到syn queue,等到ACK包到达完成三次握手了会将其状态改为ESTABLISHED,并将连接移到accept queue。正如队列名字中所示,accept函数调用就是从accept queue中取连接即可。
大多数的BSD系列系统应该是采用的第一种方式,即只用一个队列。但是在《Unix网络编程》中提到的是一些BSD系列系统也是用的两个队列,但是两个队列大小之和不超过backlog大小,也就是说从表现上来看,跟第一种方式是类似的,比如macos就是类似第一种方式。比如指定backlog为1,则accept queue的大小为1,也就是说除了accept已经处理的那个连接,accept queue中还可以有最多1个连接,一共最多可以有2个连接同时处于ESTABLISHED状态。这时,如果有其他连接进来,macos里面会忽略SYN包,不做任何处理,其他的那个连接会在重试75秒左右(先是每隔一秒尝试5次,然后是隔2,4,8,16,32秒等)放弃。
而Linux从2.2之后,是采用的第二种方式,而且accept queue的大小是backlog+1和系统配置文件/etc/sysctl.conf
中的somaxconn+1的较小值(也就是说如果你指定一个很大的backlog,但是somaxconn小于backlog的话,最终的backlog会被设置为somaxconn),syn queue的大小是backlog和tcp_max_syn_backlog
的较小值(即syn队列大小不会超过tcp_max_syn_backlog大小)。如果backlog设置为0,则Linux会自动设置为一个默认大小,我的系统里面默认大小是1。而前面之所以要加1,是因为Linux里面默认是从0开始计数队列大小,也就是保证accept队列里面至少可以有1个连接,那么我们如果指定backlog=1,而somaxconn使用默认的128的话,那么最终的accept queue大小为2,也就是说除了已经被accept处理的那个连接,accept queue里面还可以有2个连接,结果是最终最多有3个连接同时处于ESTABLISHED状态(1个已经被accept处理+2个在accept queue中,当然通常情况下我们accept后正在处理的并不止一个连接)。
2.1 Linux环境下测试
首先有几个配置要说明下,Linux的sysctl.conf里面的几个相关配置如下,macos的配置保持默认。
net.ipv4.tcp_max_syn_backlog = 128
net.ipv4.tcp_synack_retries = 5
net.ipv4.tcp_syncookies = 1
net.core.somaxconn = 128
net.ipv4.tcp_abort_on_overflow = 0
测试代码还是用第一节中的,先开一个终端运行python server.py
,然后开启3个终端,运行python client.py
,这个时候通过命令netstat -alnp|grep 7777
可以看到3个客户端和服务端的连接都建立成功(双向连接一共6个,还有1个处于监听状态的服务端连接),处于ESTABLISHED状态如下:
root@ssj-VirtualBox:/home/ssj/network# netstat -alpn|grep 7777
tcp 0 0 127.0.0.1:7777 0.0.0.0:* LISTEN 24027/python
tcp 0 0 127.0.0.1:45786 127.0.0.1:7777 ESTABLISHED 24049/python
tcp 0 0 127.0.0.1:7777 127.0.0.1:45786 ESTABLISHED -
tcp 0 0 127.0.0.1:7777 127.0.0.1:45784 ESTABLISHED 24027/python
tcp 0 0 127.0.0.1:45784 127.0.0.1:7777 ESTABLISHED 24040/python
tcp 0 0 127.0.0.1:7777 127.0.0.1:45788 ESTABLISHED -
tcp 0 0 127.0.0.1:45788 127.0.0.1:7777 ESTABLISHED 24062/python
通过ss -l|grep 7777
命令也可以看到当前Recv-Q为2,也就是accept queue大小为2,而Send-Q显示的是backlog值的大小。这跟我们的预期是一致的。也就是说,这个时候,有1个连接被accept函数调用返回,另外2个连接处于ESTABLISHED状态,在accept queue中。
root@ssj-VirtualBox:/home/ssj/network# ss -l|grep 7777
tcp LISTEN 2 1 127.0.0.1:7777 *:*
接下来,我们继续开启新的终端,运行python client.py
,可以看到,虽然客户端那边新的连接是处于ESTABLISHED状态的,服务端这边新的连接会处于SYN_RECV状态,也就是在syn queue中,如果前面的连接没有关闭的,那么这个连接会一直处于SYN_RECV状态中,而此时服务端会重发SYN-ACK包5次(重试次数由net.ipv4.tcp_synack_retries参数决定)后,如果还是建立不了连接,会将该连接从syn queue中移除,这个重试时间分别间隔1,2,4,8,16秒,最后还要等32秒才移除,所以一共耗时1+2+4+8+16+32=63秒。
#处于SYN_RECV状态的连接
tcp 0 0 127.0.0.1:7777 0.0.0.0:* LISTEN 24027/python
tcp 0 0 127.0.0.1:45804 127.0.0.1:7777 ESTABLISHED 29314/python
tcp 0 0 127.0.0.1:45786 127.0.0.1:7777 ESTABLISHED 24049/python
tcp 0 0 127.0.0.1:7777 127.0.0.1:45786 ESTABLISHED -
tcp 0 0 127.0.0.1:7777 127.0.0.1:45784 ESTABLISHED 24027/python
tcp 0 0 127.0.0.1:7777 127.0.0.1:45804 SYN_RECV -
tcp 0 0 127.0.0.1:45784 127.0.0.1:7777 ESTABLISHED 24040/python
tcp 0 0 127.0.0.1:7777 127.0.0.1:45788 ESTABLISHED -
tcp 0 0 127.0.0.1:45788 127.0.0.1:7777 ESTABLISHED 24062/python
另外一个有意思的问题是,如果在上面这种情况下,再起一个客户端会是什么结果呢?实验之后发现,再起一个客户端,新的连接在客户端看来开始会处于ESTABLISHED状态,也就是说服务端会完成三次握手,但是从服务端看来并没有建立连接,都没有放入到syn queue中,因为syn queue和accept queue都满了。所以等到客户端再输入数据 haha
的时候,服务端会返回一个RST关闭该连接(如果之前的那个SYN_RECV的连接还在重试没有关闭,这个客户端连接则也会重试多次最后才会被服务端发送RST关闭)。
注意到,这里的实验室是以net.ipv4.tcp_abort_on_overflow = 0
为前提的,这个设置关乎在accept queue满了的情况下,TCP对新来的连接如何处理,为0表示不做任何处理,也就是放入syn queue中,等重试超时再关闭连接。如果设置为net.ipv4.tcp_abort_on_overflow=1
,并执行命令sysctl -p
让配置生效,则在accept queue已经满的情况下(前面3个客户端连接成功),那么再运行新的客户端,这个时候服务端会马上返回RST,而不会将连接加入到syn queue中,也就是服务端不会有SYN_RECV状态的连接出现。
backlog设置为其他值时效果类比即可,通常来说,只要somaxconn值够大,处于 accept queue + 正在处理的连接
一共是backlog+2个,处于SYN_RECV的连接数目暂时没有发现设置规律。另外要强调的一点是,连接建立这个是TCP协议来完成的,比如Linux中就是内核协议栈完成的,并不依赖accept这个函数是否返回。比如我的实验中accept函数其实是阻塞在第一个连接的,并不妨碍其他连接建立。
2.2 Macos环境下测试
代码与Linux下面保持一样,先也是运行服务端,然后运行2个客户端,这个时候可以发现2个客户端可以正常连接处于ESTABLISHED状态,而当运行第三个客户端的时候可以发现,客户端连接一直处于SYN_SENT状态,并不断重试,直到一定次数后超时关闭(超时时间为75秒)。
ssj@ssj-mbp ~/Prog/network $ date; python client.py; date
2016年 8月29日 星期一 15时51分50秒 CST
[Errno 60] Operation timed out
2016年 8月29日 星期一 15时53分06秒 CST
这也就验证了Macos是采用类似第一种方案的队列设计,backlog为1则队列总大小为1,在队列中已经有一个连接的时候,其他的连接会不做任何处理,除非队列中的第一个连接已经处理并移除。
2.3 backlog总结
backlog参数在Linux和Macos中的含义并不一致,这一点需要区分。另外,backlog参数设置会影响服务器性能,要谨慎设置,apache和redis的网络部分的backlog的默认大小都是设置的511,对于一般的站点应该是足够的。
3 其他几个小点
如果客户端连接一个不可达的IP地址,在macos或Linux里面都会重试一定次数后提示timeout,注意如果你在客户端代码中设置了超时时间,如sock.settimeout(5)
,则超时时间以设置的为准,否则是系统默认超时时间。Linux的系统默认的超时约为为126秒,macos约为75秒。如修改client.py中连接的IP为123.0.0.1,即start_client('123.0.0.1', 7777)
,运行结果如下:
ssj@ssj-mbp ~/Prog/network $ date; python client2.py; date
2016年 8月29日 星期一 16时28分06秒 CST
[Errno 60] Operation timed out
2016年 8月29日 星期一 16时29分22秒 CST
而如果连接一个可达的IP地址,但是服务端对应的端口并没有处于监听状态,那么并不会重试,而是直接返回拒绝连接的错误。这两点在Linux和Macos上基本表现一致,除了超时时间略有不同外。
ssj@ssj-mbp ~/Prog/network $ date; python client2.py; date
2016年 8月29日 星期一 16时38分03秒 CST
[Errno 61] Connection refused
2016年 8月29日 星期一 16时38分04秒 CST
关于timeout再多说几句,服务端可以设置每个连接的timeout,如果在指定的timeout时间内没有收到数据,则像python语言会抛出timeout异常,这种情况下服务端就可以关闭客户端连接了。而客户端也可以设置timeout,这样在connect或者读取服务端数据的时候,如果超过指定时间没有连接成功或者读取到数据,也会抛出timeout异常。
4 总结
这篇笔记主要总结了一些常用的选项含义,并做了一些实验进行验证。第三篇准备总结TCP一些常见算法,第四篇为多进程网络编程和epoll等相关内容。
5 参考资料
- how-tcp-backlog-works-in-linux
- 耗子哥的TCP那些事儿
- TCP/IP协议详解卷一部分章节