最近在看《UNIX网络编程 卷1》和《FREEBSD操作系统设计与实现》这两本书,我重点关注了TCP协议相关的内容,结合自己后台开发的经验,写下这篇文章,一方面是为了帮助有需要的人,更重要的是方便自己整理思路,加深理解。
理论基础
OSI网络模型
OSI模型是一个七层模型,实际工程中,层次的划分没有这么细致。一般来说,物理层和数据层对应着硬件和设备驱动程序,例如网卡和网卡驱动。传输层和网络层由操作系统内核实现,当用户进程需要通过网络传输数据,通过系统调用的方式让内核将数据封装为相应的协议格式,进而调用网卡驱动传输数据。顶上三层对应具体的网络应用协议:FTP、HTTP等,这些应用层协议不需要知道具体的通信细节。
传输层
在实际工程中,我们常用的应用层服务(例如:HTTP服务、数据库服务、缓存服务)通信的直接底层就是传输层,下图是一些常用命令涉及的通信协议。
IPv4(Internet Protocol version 4)全称是网际协议版本4,它使用32地址,平时常说的IP协议就是指IPv4,类似于192.168.99.100
的地址可以看成4位256进制数据,也就是32网络地址。但随着网络设备爆炸式增长,32地址面临这用完的风险,IPv6(Internet Protocol version 6)应运而生。IPv6使用128位地址,但IPv4地址耗尽的问题有了新的解决方案,目前普遍使用的还是IPv4,IPv6全面取代IPv4还有很长的距离。
UDP (User Datagram Protocol),全称用户数据报协议。UDP提供面向无连接的服务,客户端和服务端不存在任何长期的关系。UDP不提供可靠的通信,它不保证数据报一定送达,也不保证数据包送达的先后顺序,也不保证每份数据报只送达一次。虽然UDP可靠性差,但是消耗资源少,适用在网络环境较好的局域网中,例如不需要精确统计的监控服务(eg: Statsd)。由于使用了UDP,客户端每次打点统计只需要一次发送UDP数据报的IO开销,服务性能损失很小,而且在内网环境数据包一般都能正常到达服务端,也能保证较高的可行度。
TCP(Transmission Control Protocl),全称传输控制协议。和UDP相反,TCP提供了面向连接的服务,而且提供了可靠性保障。平常我们使用的应用层协议,例如HTTP,FTP等,几乎都是建立在TCP协议之上,深入了解TCP的细节对于开发高质量的后台开发和客户端开发都有很好的借鉴意义。下面开始重点介绍TCP协议的细节。
TCP协议
状态转换
为了提供可靠的通信服务,TCP通过三次分节建立连接,四次分节关闭连接,心跳检查判断连接是否正常,因此需要记录连接的状态,TCP一共定义了11种不同的状态。
通过netstat
命令可以查看所有的tcp状态。
三路握手
在三路握手之前,服务器必须准备好接收外来的连接。这通常通过调用bind
和listen
完成被动打开,此时服务进程有一个套接字处于LISTEN状态。在客户端发通过调用connect
送一个SYN分节后,服务进程必须确认(ACK)此分节,同时也发送一个SYN分节,这两步在同一分节中完成,通过上面的转台扭转图,可以知道服务进程中会生成一个处于SYN_RCVD状态的套接字。当再次收到客户端的ACK分节后,服务端的套接字状态转变为ESTABLISHED。
客户端通过connect函数发起主动打开,在此之前客户端套接字状态为CLOSED。调用connect导致客户TCP发送一个SYN分节,此时套接字状态有CLOSED变为SYN_SENT,在收到服务器的SYN和ACK后,客户端socket再发送ACK分节,套接字状态变为ESTABLISHED,此时connect返回。
备注:SYN分节中除了有序列号之外,还会有最大分节大小、窗口规模选项、时间戳等TCP参数,具体可以参考协议详细规定。
终止连接
上图展示了客户端执行主动关闭的情形,实际上无论客户端还是服务器,都可以执行主动关闭。一般情况下客户端执行主动关闭较多,所以使用客户端主动关闭为例讲解。
客户端调用close
,执行主动关闭时,发送FIN分节,此时客户端套接字状态由ESTABLISED变为FIN_WAIT_1。服务器收到这个FIN,会执行被动关闭,并向客户端发送ACK,FIN的接受也作为一个文件结束符传递给服务进程,如果此时服务进程调用套接字的方法,无论缓存区是否有数据都会返回EOF,服务端套接字状态由ESTABLISED变为为CLOSE_WAIT。客户端接收到ACK后,客户端套接字状态由FIN_WAIT_1变为FIN_WAIT_2。
一段时间后,当服务进程调用close
或者shutdown
时,也会发生送FIN分节,服务端套接字状态由CLOSE_WAIT变为LAST_ACK。客户端在接收到FIN分节后,发送ACK分节,客户端套接字状态由FIN_WAIT_2变为TIME_WAIT。服务器段接收到客户端的ACK分节,状态变成CLOSED。
在某些情况下,第二和第三分节可能会合并发送。调用close
可能会触发主动关闭,当进程正常或者非正常退出时,内核会将该进程所使用的文件描述符对应的打开次数执行减一操作,当某个文件打开次数为0时,也就是说所有的进程都没有使用此文件时,也会触发TCP的主动关闭操作。
TIME_WAIT状态
在终止连接的过程中,主动关闭方套接字最终的状态是TIME_WAIT,在经过2MSL(maximun segment lifetime,每个IP数据报都包含一个跳限的字段,表明数据报能经过的路由最大个数,因此默认每个数据报在因特网中有一个最大存活时间)时间后状态才变为CLOSED,为什么这样设计呢?
这样的设计出于两个考虑:
- 可靠地实现TCP全双工连接的终止。上图的四次分节关闭连接是在正常流程,实际情况中,任何一次分节都可能出现发送失败的情况。主动关闭方最后的一个ACK分节可能会因为路由问题发送失败,为了保证可靠性,需要重新发送保证另一方正确关闭套接字,因此此时的状态不能为CLOSED。
- 允许老的重复分界在网络中消失。加入10.10.89.9的3400端口和206.168.12.12的80端口建立了一个TCP连接,此连接中断后,之前发送的TCP分节可能因为路由循环的问题还在因特网中游荡,而此时这两个机器相同的端口再建立起新的连接后,原来在网络中游荡的分解会对新的连接造成干扰。为了避免这种情况,设置一个2MSL的超时时间,保证之前还在网络中游荡的数据包完全消失。
套接字编程
下图是C语言的套接字函数,考虑Python的socket库只是底层C库的简单封装,接口参数大同小异,而且Python方便上手调试,语法上也更通俗易懂,所以本文使用Python的socket库作为讲解实例。
socket
socket
是python套接字类,通过构造函数生成套接字对象,构造函数签名如下
其中family参数指协议族;type参数指套接字类型;protocol值协议类型,或者设置为0,以选择所给定family和type组合的系统默认值;fileno指文件描述符(我从来没用过)。
family | 说明 |
---|---|
AF_INET | IPv4协议 |
AF_INET6 | IPv6协议 |
AF_LOCAL | Unix域协议 |
AF_ROUTE | 路由套接字 |
AF_KEY | 密钥套接字 |
type | 说明 |
---|---|
SOCK_STREAM | 字节流套接字 |
SOCK_DGRAM | 数据包套接字 |
SOCK_SEQPACKET | 有序分组套接字 |
SOCK_RAW | 原始套接字 |
protocol | 说明 |
---|---|
IPPROTO_TCP | TCP传输协议 |
IPPROTO_UDP | UDP传输协议 |
并非所有套接字family和type的组合都是有效的,下表给出了一些有效的组合和对应的协议,其中标是
的项也是有效的,但是没有找到便捷的缩略词,而空白项是无效组合。
connect
connect
用于客户端和服务器建立连接,函数签名如下:
客户端在调用connect
之前不必非得调用bind
函数,内核会确定源IP地址,并选择一个临时端口作为源端口。如果使用TCP协议,connect
将激发TCP的三路握手过程,TCP状态由CLOSED变为SYN_SENT,最终变为ESTABLISHED,在三路握手的过程中,可能会出现下面几种情况导致connect
报错。connect
失败则套接字不可用,必须关闭,不能对这样的套接字再次调connect
函数。
- TCP客户端没有是收到SYN分节响应,一般发生在服务端backlog队列已满的情况下,服务器会对收到的SYN分节不做任何处理。客户端等待一段时间后会重新发送SYN分节,直到等待时间超过上限,才会抛出
ETIMEDOUT
错误(对应的python异常是TimeoutError
)。 - 对客户端SYN的响应是RST,表明服务端在指定的端口上没有进程在等待与之连接,客户端马上会抛出
ECONNRFUSED
错误。下图是用python连接一个未使用的端口,抛出异常ConnectionRefusedError
,该异常错误号码111,errno中查找正是ECONNRFUSED
对应的错误码。
- 如果发出的SYN在中间的吗某个路由器上引发了目的地不可达错误,客户端会等待一段时间后重新发送,直到等待时间超过上限(和第一种情况类似),此时会抛出
ENETUNREACH
或者EHOSTUNREACH
错误。下图为关闭本机网络后,用python调用connect
,由于网络不可达,异常的错误码为101,errno中查找正是ENETUNREACH
错误码。
bind
bind
方法把一个本地协议地址赋予给一个套接字,方法签名如下:
在不调用bind
的情况下,内核会确定IP地址,并分配临时端口,这种情况很适合客户端,因此客户端在调用connect
之前不调用bind
方法。而服务端需要一个确定的ip和端口,因此需要调用bind
指定地址和端口。一般情况下,服务器都有多个ip地址,除了环路地址127.0.0.1
外,还有局域网和公网地址,如果bind
绑定的是环路地址127.0.0.1
,则只有本机通过环路地址才能访问,如果需要通过任一ip地址都能访问到,可以绑定通配地址0.0.0.0
。当指定的端口为0时,内核会分配一个临时端口。
如果端口已经在使用,会抛出EADDRINUSE(errno对应错误码是98)异常,可以通过设置SO_REUSEADDR和SO_REUSEPORT这两个套接字参数让多个进程使用同一个TCP连接。
listen
当创建一个套接字时,默认为主动套接字,也就是说,是一个将调用connect发起连接的客户套接字。listen方法把一个未连接的套接字转换为一个被动套接字,指示内核应接受指向该套接字的状态请求。根据TCP状态转换图,调用listen
导致套接字从CLOSED状态转换到LISTEN状态。此方法参数规定了内核应该为相应套接字排队的最大连接个数,在bind
之后,并在accept
之前调用。
为了理解backlog参数,我们必须认识到内核为其中任何一个给定的监听套接字维护两个队列:
- 未完成连接队列,每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程,这些套接字处于SYN_RCVD状态。
- 已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态。
RTT指的是未连接队列中的任何一项在队列中的存活时间。linux下的backlog指的是已完成连接队列的容量,如果服务器长时间未调用accept
从此队列中取走数据,当新的客户端通过三路握手重新建立连接时,服务器不会处理收到的SYN分节,而客户端会一直等待并不断重试直到超时。在服务器负载很大的情况下,就会造成客户端连接时间长,所以需要合理设置backlog大小。
accept
accept
用于从已完成连接队列头返回下一个已完成连接,如果已完成连接队列为空,那么进程会被投入睡眠(套接字为阻塞方式)。
accept
会自动生成一个全新的文件描述符,代表与所返回客户的TCP连接。需要注意的是,此处有两个套接字对象,一个是监听套接字,一个返回的已连接套接字。区分这两个套接字很重要,一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命周期内一直存在,内核为每个由服务器进程接受的客户连接创建一个已连接套接字(也就是说TCP三路握手已经完成),当服务器完成对某个给定客户的服务时,相应的已连接套接字会被关闭。
close
close
方法用来关闭套接字,方法签名如下:
需要注意的是,close
方法并不一定会触发TCP的四分组连接终止序列,当一个已连接套接字被多个进程打开时,关闭套接字只会导致此进程相应描述符的计数值减1,只有所有进程都将该套接字关闭后,套接字的引用计数值小于1以后,系统内核才会开始终止连接操作,这一点在多进程开发过程中需要格外注意。如果确实想在某个TCP连接上发送FIN触发主动关闭,可以调用shutdown
方法。
send
send
方法用于TCP发送数据,方法签名如下:
每一个TCP套接字都有一个发送缓冲区,默认大小通过socket.SO_SNDBUF
查看,当某个进程调用send
时,内核从该应用进程的缓冲区复制所有数据到所写套接字的发送缓冲区,如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大小大于套接字的发送缓冲区,或是套接字的发送缓冲区已有其他数据),该应用进程将被投入睡眠(套接字阻塞的情况),内核将不从系统调用返回,直到应用进程缓冲区的所有数据都复制到套接字发送缓存区。当对端确认收到数据后,会发送ACK分节,随着对端ACK的不断到达,本端TCP才能从套接字发送缓存区中丢弃已确认的数据。
在类似于HTTP的应用层协议中,客户端在发送完请求数据之后,可以调用s.shutdown(socket.SHUT_WR)
告诉服务端所有的数据已经发送完成,服务端通过recv
会读取到空字符串,之后就可以处理请求数据了。
recv
recv
方法用于TCP接收数据,方法签名如下:
每一个TCP套接字也都有一个接受缓存区,默认大小通过socket.SO_RCVBUF
查看。当某个进程调用recv
而且缓存区没有数据时,该进程会被投入睡眠(套接字阻塞的情况),内核将不从系统调用返回。
在《Unix网络编程》中,所有C语言调用accept
,read
, write
函数都会检查errno是否等于EINTR
,这是因为进程在执行这些系统调用的时候可能会被信号打断,导致系统调用返回。而我自己用python2.7尝试的时候发现并没有此问题,猜测是python针对系统调用被信号打断的情况。
IO多路复用
在做服务器开发的时候,经常会碰到处理多个套接字的情形,此时可以通过多进程或这多线程的模型解决此问题。用一个主进程或者主线程负责监听套接字,其它每个进程或线程负责一个已连接套接字,这样还可以利用操作系统的线程切换实现多并发,提高机器利用率。但是机器资源有限,不可能无限制的生成新线程或进程,IO多路复用应运而生。当内核一旦发现进程指定的一个或者多个IO条件就绪,它就通知进程。
IO模型
Unix下有5中IO模型:
- 阻塞式IO
- 非阻塞式IO
- IO复用
- 信号驱动IO
- 异步IO
已读取数据为例,讲解这物种IO模型的区别。每次读取数据包括以下两个阶段,而这五种模型的不同之处也体现在这两个阶段不同的处理。
- 等待数据准备好
- 从内核想进程复制数据
阻塞式IO
socket套接字默认就是阻塞式IO。以recvfrom
为例,用户进程通过系统调用获取TCP数据,如果套接字缓存区没有数据,系统调用不会返回,造成用户进程一直阻塞。直到缓存区有可用数据,内核将缓存区数据拷贝至用户进程空间,系统调用才会返回。
非阻塞式IO
python可以通过调用s.setblocking(False)
或者s.settimeout(0.0)
将一个套接字设置为非阻塞式IO。以recvfrom
为例,当没有可用的数据时,用户进程不会阻塞,而是马上抛出EWOULDBLOCK错误(或者EAGAIN,对应的errno错误码都是11),只有当数据复制到内核空间后,才会正确返回数据。
IO多路复用
在有多个IO操作时,先阻塞于select调用,等待数据报套接字变为可读,然后再通过recvfrom
把缓存区数据复制到用户进程空间。和阻塞是IO相比,当处理的套接字个数较少的时候,多路复其实没有性能上的优势,它的优势在于可以方便操作很多套接字。
信号驱动式IO
通过信号处理的方式读取数据。
异步IO
当数据包被复制到用户进程后,用户通过callback的方式获取数据。
模型对比
可以发现,前四种IO模型——阻塞式IO、非阻塞式IO、IO复用、信号驱动IO都是同步IO模型,因为真正的IO操作(recvfrom
)将阻塞进程,只有异步IO模型才不会导致用户进程阻塞。
python使用
较早的时候使用的多路复用是select函数,但是由于时间复杂度较高,很快就被其他的函数替代:linux下的epoll,unix下的kqueue,windows下的iocp。为了屏蔽不同系统下的不同实现,跨平台的第三方库出现:libuv、libev、libevent等,这些库根据平台的不同,调用不同的底层代码。
如果想直接使用底层的epoll或者select,它们封装在python的select库中;libuv、libev都有相应的python封装,库名叫做pyuv、pyev,通过pip安装后即可使用。
python示例
一般情况下,为了提升服务的承载量,都会采用进程+IO多路复用或者线程+IO多路复用的开发模式。IO多路复用是为了一个并发单位管理多个套接字,而多进程或者多线程是为了充分利用多核。由于GIL的存在,python多线程模型并不能充分多核,因此我们常见的wsgi server,例如:gunicorn、uwsgi、tornado等都是使用的多进程+IO多路服用开发模式。
tornado使用epoll管理多个套接字,gunicorn和uwsgi都可以使用gevent,gevent是一个python网络库,用greenlet做协程切换,每个协程管理一个套接字,主协程通过libevent轮询查找可用的套接字。因为gevent可以通过monkey patch将socket设置为非阻塞模式,因此当服务器有数据库、缓存或者其他网络请求的时候,相比tornado,uwsgi和gunicorn可以充分利用这部分的阻塞时间。和gunicorn相比,uwsgi是c语言实现,直观感觉这三个server的性能应该是:uwsgi > gunicorn > tornado,和网上的benchmark大致匹配。
| |50|100|150|200|250|300|350|400|450|
|---|---|---|---|---|---|---|---|---|---|---|
| libev| 92| 181| 269.9| 355.2| 362.6| 367.1| 373.8| 378.5| 315(3%)|
| thread| 88.9| 180.5| 266.1| 354.8| 428.9| 460.2| 486.5(2%)| 477.9(7%)| 486.5(22%)|
横坐标是连接个数,纵坐标是qps,括号内的数字表示错误率。在连接数较少的情况下,使用libev管理socket和多线程性能相差不大,在连接数超过200后,libev模型的请求耗时会增加,导致qps增加的并不多,但是线程模型在连接数很多的情况下,会导致部分请求一直得不到处理,在连接个数350的时候就会出现部分请求超时,而libev模型在450的时候才会出现。