最近在看《UNIX网络编程 卷1》和《FREEBSD操做系统设计与实现》这两本书,我重点关注了TCP协议相关的内容,结合本身后台开发的经验,写下这篇文章,一方面是为了帮助有须要的人,更重要的是方便本身整理思路,加深理解。python
OSI模型是一个七层模型,实际工程中,层次的划分没有这么细致。通常来讲,物理层和数据层对应着硬件和设备驱动程序,例如网卡和网卡驱动。传输层和网络层由操做系统内核实现,当用户进程须要经过网络传输数据,经过系统调用的方式让内核将数据封装为相应的协议格式,进而调用网卡驱动传输数据。顶上三层对应具体的网络应用协议:FTP、HTTP等,这些应用层协议不须要知道具体的通讯细节。linux
在实际工程中,咱们经常使用的应用层服务(例如:HTTP服务、数据库服务、缓存服务)通讯的直接底层就是传输层,下图是一些经常使用命令涉及的通讯协议。git
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还有很长的距离。github
UDP (User Datagram Protocol),全称用户数据报协议。UDP提供面向无链接的服务,客户端和服务端不存在任何长期的关系。UDP不提供可靠的通讯,它不保证数据报必定送达,也不保证数据包送达的前后顺序,也不保证每份数据报只送达一次。虽然UDP可靠性差,可是消耗资源少,适用在网络环境较好的局域网中,例如不须要精确统计的监控服务(eg: Statsd)。因为使用了UDP,客户端每次打点统计只须要一次发送UDP数据报的IO开销,服务性能损失很小,并且在内网环境数据包通常都能正常到达服务端,也能保证较高的可行度。数据库
TCP(Transmission Control Protocl),全称传输控制协议。和UDP相反,TCP提供了面向链接的服务,并且提供了可靠性保障。日常咱们使用的应用层协议,例如HTTP,FTP等,几乎都是创建在TCP协议之上,深刻了解TCP的细节对于开发高质量的后台开发和客户端开发都有很好的借鉴意义。下面开始重点介绍TCP协议的细节。django
为了提供可靠的通讯服务,TCP经过三次分节创建链接,四次分节关闭链接,心跳检查判断链接是否正常,所以须要记录链接的状态,TCP一共定义了11种不一样的状态。编程
经过netstat
命令能够查看全部的tcp状态。windows
在三路握手以前,服务器必须准备好接收外来的链接。这一般经过调用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,在通过2MSL(maximun segment lifetime,每一个IP数据报都包含一个跳限的字段,代表数据报能通过的路由最大个数,所以默认每一个数据报在因特网中有一个最大存活时间)时间后状态才变为CLOSED,为何这样设计呢?
这样的设计出于两个考虑:
下图是C语言的套接字函数,考虑Python的socket库只是底层C库的简单封装,接口参数大同小异,并且Python方便上手调试,语法上也更通俗易懂,因此本文使用Python的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
以前没必要非得调用bind
函数,内核会肯定源IP地址,并选择一个临时端口做为源端口。若是使用TCP协议,connect
将激发TCP的三路握手过程,TCP状态由CLOSED变为SYN_SENT,最终变为ESTABLISHED,在三路握手的过程当中,可能会出现下面几种状况致使connect
报错。connect
失败则套接字不可用,必须关闭,不能对这样的套接字再次调connect
函数。
ETIMEDOUT
错误(对应的python异常是TimeoutError
)。ECONNRFUSED
错误。下图是用python链接一个未使用的端口,抛出异常ConnectionRefusedError
,该异常错误号码111,errno中查找正是ECONNRFUSED
对应的错误码。ENETUNREACH
或者EHOSTUNREACH
错误。下图为关闭本机网络后,用python调用connect
,因为网络不可达,异常的错误码为101,errno中查找正是ENETUNREACH
错误码。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链接。
当建立一个套接字时,默认为主动套接字,也就是说,是一个将调用connect发起链接的客户套接字。listen方法把一个未链接的套接字转换为一个被动套接字,指示内核应接受指向该套接字的状态请求。根据TCP状态转换图,调用listen
致使套接字从CLOSED状态转换到LISTEN状态。此方法参数规定了内核应该为相应套接字排队的最大链接个数,在bind
以后,并在accept
以前调用。
为了理解backlog参数,咱们必须认识到内核为其中任何一个给定的监听套接字维护两个队列:
RTT指的是未链接队列中的任何一项在队列中的存活时间。linux下的backlog指的是已完成链接队列的容量,若是服务器长时间未调用accept
今后队列中取走数据,当新的客户端经过三路握手从新创建链接时,服务器不会处理收到的SYN分节,而客户端会一直等待并不断重试直到超时。在服务器负载很大的状况下,就会形成客户端链接时间长,因此须要合理设置backlog大小。
accept
用于从已完成链接队列头返回下一个已完成链接,若是已完成链接队列为空,那么进程会被投入睡眠(套接字为阻塞方式)。
accept
会自动生成一个全新的文件描述符,表明与所返回客户的TCP链接。须要注意的是,此处有两个套接字对象,一个是监听套接字,一个返回的已链接套接字。区分这两个套接字很重要,一个服务器一般仅仅建立一个监听套接字,它在该服务器的生命周期内一直存在,内核为每一个由服务器进程接受的客户链接建立一个已链接套接字(也就是说TCP三路握手已经完成),当服务器完成对某个给定客户的服务时,相应的已链接套接字会被关闭。
close
方法用来关闭套接字,方法签名以下:
须要注意的是,close
方法并不必定会触发TCP的四分组链接终止序列,当一个已链接套接字被多个进程打开时,关闭套接字只会致使此进程相应描述符的计数值减1,只有全部进程都将该套接字关闭后,套接字的引用计数值小于1之后,系统内核才会开始终止链接操做,这一点在多进程开发过程当中须要格外注意。若是确实想在某个TCP链接上发送FIN触发主动关闭,能够调用shutdown
方法。
send
方法用于TCP发送数据,方法签名以下:
每个TCP套接字都有一个发送缓冲区,默认大小经过socket.SO_SNDBUF
查看,当某个进程调用send
时,内核从该应用进程的缓冲区复制全部数据到所写套接字的发送缓冲区,若是该套接字的发送缓冲区容不下该应用进程的全部数据(或是应用进程的缓冲区大小大于套接字的发送缓冲区,或是套接字的发送缓冲区已有其余数据),该应用进程将被投入睡眠(套接字阻塞的状况),内核将不从系统调用返回,直到应用进程缓冲区的全部数据都复制到套接字发送缓存区。当对端确认收到数据后,会发送ACK分节,随着对端ACK的不断到达,本端TCP才能从套接字发送缓存区中丢弃已确认的数据。
在相似于HTTP的应用层协议中,客户端在发送完请求数据以后,能够调用s.shutdown(socket.SHUT_WR)
告诉服务端全部的数据已经发送完成,服务端经过recv
会读取到空字符串,以后就能够处理请求数据了。
recv
方法用于TCP接收数据,方法签名以下:
每个TCP套接字也都有一个接受缓存区,默认大小经过socket.SO_RCVBUF
查看。当某个进程调用recv
并且缓存区没有数据时,该进程会被投入睡眠(套接字阻塞的状况),内核将不从系统调用返回。
在《Unix网络编程》中,全部C语言调用accept
,read
, write
函数都会检查errno是否等于EINTR
,这是由于进程在执行这些系统调用的时候可能会被信号打断,致使系统调用返回。而我本身用python2.7尝试的时候发现并无此问题,猜想是python针对系统调用被信号打断的状况,自动从新执行系统调用,stackoverflow上也证明了这一点: http://stackoverflow.com/questions/16094618/python-socket-recv-and-signals。
在作服务器开发的时候,常常会碰处处理多个套接字的情形,此时能够经过多进程或这多线程的模型解决此问题。用一个主进程或者主线程负责监听套接字,其它每一个进程或线程负责一个已链接套接字,这样还能够利用操做系统的线程切换实现多并发,提升机器利用率。可是机器资源有限,不可能无限制的生成新线程或进程,IO多路复用应运而生。当内核一旦发现进程指定的一个或者多个IO条件就绪,它就通知进程。
Unix下有5中IO模型:
已读取数据为例,讲解这物种IO模型的区别。每次读取数据包括如下两个阶段,而这五种模型的不一样之处也体如今这两个阶段不一样的处理。
socket套接字默认就是阻塞式IO。以recvfrom
为例,用户进程经过系统调用获取TCP数据,若是套接字缓存区没有数据,系统调用不会返回,形成用户进程一直阻塞。直到缓存区有可用数据,内核将缓存区数据拷贝至用户进程空间,系统调用才会返回。
python能够经过调用s.setblocking(False)
或者s.settimeout(0.0)
将一个套接字设置为非阻塞式IO。以recvfrom
为例,当没有可用的数据时,用户进程不会阻塞,而是立刻抛出EWOULDBLOCK错误(或者EAGAIN,对应的errno错误码都是11),只有当数据复制到内核空间后,才会正确返回数据。
在有多个IO操做时,先阻塞于select调用,等待数据报套接字变为可读,而后再经过recvfrom
把缓存区数据复制到用户进程空间。和阻塞是IO相比,当处理的套接字个数较少的时候,多路复其实没有性能上的优点,它的优点在于能够方便操做不少套接字。
经过信号处理的方式读取数据。
当数据包被复制到用户进程后,用户经过callback的方式获取数据。
能够发现,前四种IO模型——阻塞式IO、非阻塞式IO、IO复用、信号驱动IO都是同步IO模型,由于真正的IO操做(recvfrom
)将阻塞进程,只有异步IO模型才不会致使用户进程阻塞。
较早的时候使用的多路复用是select函数,可是因为时间复杂度较高,很快就被其余的函数替代:linux下的epoll,unix下的kqueue,windows下的iocp。为了屏蔽不一样系统下的不一样实现,跨平台的第三方库出现:libuv、libev、libevent等,这些库根据平台的不一样,调用不一样的底层代码。
若是想直接使用底层的epoll或者select,它们封装在python的select库中;libuv、libev都有相应的python封装,库名叫作pyuv、pyev,经过pip安装后便可使用。
通常状况下,为了提高服务的承载量,都会采用进程+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大体匹配。
django的做者在github上实现了一个wsgi server,项目地址: https://github.com/jonashaag/bjoern,使用C语言实现,代码量不多,性能听说比uwsgi还好,十分适合网络开发进阶学习。参考这份代码,我用python实现了一个thrift server,项目地址:https://github.com/LiuRoy/dracula,和thriftpy的TThreadedServer作了一个简单的性能对比。
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的时候才会出现。