python网络编程详解

最近在看《UNIX网络编程 卷1》和《FREEBSD操做系统设计与实现》这两本书,我重点关注了TCP协议相关的内容,结合本身后台开发的经验,写下这篇文章,一方面是为了帮助有须要的人,更重要的是方便本身整理思路,加深理解。python

理论基础

OSI网络模型

OSI七层网络模型

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经过三次分节创建链接,四次分节关闭链接,心跳检查判断链接是否正常,所以须要记录链接的状态,TCP一共定义了11种不一样的状态。编程

TCP状态扭转

经过netstat命令能够查看全部的tcp状态。windows

TCP状态查看

三路握手

TCP三路握手

在三路握手以前,服务器必须准备好接收外来的链接。这一般经过调用bindlisten完成被动打开,此时服务进程有一个套接字处于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参数,具体能够参考协议详细规定。

终止链接

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。

TCP链接分组交换

在某些状况下,第二和第三分节可能会合并发送。调用close可能会触发主动关闭,当进程正常或者非正常退出时,内核会将该进程所使用的文件描述符对应的打开次数执行减一操做,当某个文件打开次数为0时,也就是说全部的进程都没有使用此文件时,也会触发TCP的主动关闭操做。

TIME_WAIT状态

在终止链接的过程当中,主动关闭方套接字最终的状态是TIME_WAIT,在通过2MSL(maximun segment lifetime,每一个IP数据报都包含一个跳限的字段,代表数据报能通过的路由最大个数,所以默认每一个数据报在因特网中有一个最大存活时间)时间后状态才变为CLOSED,为何这样设计呢?

这样的设计出于两个考虑:

  1. 可靠地实现TCP全双工链接的终止。上图的四次分节关闭链接是在正常流程,实际状况中,任何一次分节均可能出现发送失败的状况。主动关闭方最后的一个ACK分节可能会由于路由问题发送失败,为了保证可靠性,须要从新发送保证另外一方正确关闭套接字,所以此时的状态不能为CLOSED。
  2. 容许老的重复分界在网络中消失。加入10.10.89.9的3400端口和206.168.12.12的80端口创建了一个TCP链接,此链接中断后,以前发送的TCP分节可能由于路由循环的问题还在因特网中游荡,而此时这两个机器相同的端口再创建起新的链接后,原来在网络中游荡的分解会对新的链接形成干扰。为了不这种状况,设置一个2MSL的超时时间,保证以前还在网络中游荡的数据包彻底消失。

套接字编程

下图是C语言的套接字函数,考虑Python的socket库只是底层C库的简单封装,接口参数大同小异,并且Python方便上手调试,语法上也更通俗易懂,因此本文使用Python的socket库做为讲解实例。

套接字函数

socket

socket是python套接字类,经过构造函数生成套接字对象,构造函数签名以下

socket函数签名

其中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的组合都是有效的,下表给出了一些有效的组合和对应的协议,其中标的项也是有效的,可是没有找到便捷的缩略词,而空白项是无效组合。

family和type组合

connect

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对应的错误码。

connect端口无服务

  • 若是发出的SYN在中间的吗某个路由器上引起了目的地不可达错误,客户端会等待一段时间后从新发送,直到等待时间超过上限(和第一种状况相似),此时会抛出ENETUNREACH或者EHOSTUNREACH错误。下图为关闭本机网络后,用python调用connect,因为网络不可达,异常的错误码为101,errno中查找正是ENETUNREACH错误码。

connect网络不可达

bind

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链接。

bind地址使用错误

listen

当建立一个套接字时,默认为主动套接字,也就是说,是一个将调用connect发起链接的客户套接字。listen方法把一个未链接的套接字转换为一个被动套接字,指示内核应接受指向该套接字的状态请求。根据TCP状态转换图,调用listen致使套接字从CLOSED状态转换到LISTEN状态。此方法参数规定了内核应该为相应套接字排队的最大链接个数,在bind以后,并在accept以前调用。

listen方法签名

为了理解backlog参数,咱们必须认识到内核为其中任何一个给定的监听套接字维护两个队列:

  1. 未完成链接队列,每一个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程,这些套接字处于SYN_RCVD状态。
  2. 已完成链接队列,每一个已完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态。

三路握手和两个队列

RTT指的是未链接队列中的任何一项在队列中的存活时间。linux下的backlog指的是已完成链接队列的容量,若是服务器长时间未调用accept今后队列中取走数据,当新的客户端经过三路握手从新创建链接时,服务器不会处理收到的SYN分节,而客户端会一直等待并不断重试直到超时。在服务器负载很大的状况下,就会形成客户端链接时间长,因此须要合理设置backlog大小。

accept

accept用于从已完成链接队列头返回下一个已完成链接,若是已完成链接队列为空,那么进程会被投入睡眠(套接字为阻塞方式)。

accept方法签名

accept会自动生成一个全新的文件描述符,表明与所返回客户的TCP链接。须要注意的是,此处有两个套接字对象,一个是监听套接字,一个返回的已链接套接字。区分这两个套接字很重要,一个服务器一般仅仅建立一个监听套接字,它在该服务器的生命周期内一直存在,内核为每一个由服务器进程接受的客户链接建立一个已链接套接字(也就是说TCP三路握手已经完成),当服务器完成对某个给定客户的服务时,相应的已链接套接字会被关闭。

close

close方法用来关闭套接字,方法签名以下:

close方法签名

须要注意的是,close方法并不必定会触发TCP的四分组链接终止序列,当一个已链接套接字被多个进程打开时,关闭套接字只会致使此进程相应描述符的计数值减1,只有全部进程都将该套接字关闭后,套接字的引用计数值小于1之后,系统内核才会开始终止链接操做,这一点在多进程开发过程当中须要格外注意。若是确实想在某个TCP链接上发送FIN触发主动关闭,能够调用shutdown方法。

send

send方法用于TCP发送数据,方法签名以下:

send方法签名

每个TCP套接字都有一个发送缓冲区,默认大小经过socket.SO_SNDBUF查看,当某个进程调用send时,内核从该应用进程的缓冲区复制全部数据到所写套接字的发送缓冲区,若是该套接字的发送缓冲区容不下该应用进程的全部数据(或是应用进程的缓冲区大小大于套接字的发送缓冲区,或是套接字的发送缓冲区已有其余数据),该应用进程将被投入睡眠(套接字阻塞的状况),内核将不从系统调用返回,直到应用进程缓冲区的全部数据都复制到套接字发送缓存区。当对端确认收到数据后,会发送ACK分节,随着对端ACK的不断到达,本端TCP才能从套接字发送缓存区中丢弃已确认的数据。

send缓存区

在相似于HTTP的应用层协议中,客户端在发送完请求数据以后,能够调用s.shutdown(socket.SHUT_WR)告诉服务端全部的数据已经发送完成,服务端经过recv会读取到空字符串,以后就能够处理请求数据了。

recv

recv方法用于TCP接收数据,方法签名以下:

recv方法签名

每个TCP套接字也都有一个接受缓存区,默认大小经过socket.SO_RCVBUF查看。当某个进程调用recv并且缓存区没有数据时,该进程会被投入睡眠(套接字阻塞的状况),内核将不从系统调用返回。

在《Unix网络编程》中,全部C语言调用acceptreadwrite函数都会检查errno是否等于EINTR,这是由于进程在执行这些系统调用的时候可能会被信号打断,致使系统调用返回。而我本身用python2.7尝试的时候发现并无此问题,猜想是python针对系统调用被信号打断的状况,自动从新执行系统调用,stackoverflow上也证明了这一点: http://stackoverflow.com/questions/16094618/python-socket-recv-and-signals

IO多路复用

在作服务器开发的时候,常常会碰处处理多个套接字的情形,此时能够经过多进程或这多线程的模型解决此问题。用一个主进程或者主线程负责监听套接字,其它每一个进程或线程负责一个已链接套接字,这样还能够利用操做系统的线程切换实现多并发,提升机器利用率。可是机器资源有限,不可能无限制的生成新线程或进程,IO多路复用应运而生。当内核一旦发现进程指定的一个或者多个IO条件就绪,它就通知进程。

IO模型

Unix下有5中IO模型:

  1. 阻塞式IO
  2. 非阻塞式IO
  3. IO复用
  4. 信号驱动IO
  5. 异步IO

已读取数据为例,讲解这物种IO模型的区别。每次读取数据包括如下两个阶段,而这五种模型的不一样之处也体如今这两个阶段不一样的处理。

  1. 等待数据准备好
  2. 从内核想进程复制数据

阻塞式IO

socket套接字默认就是阻塞式IO。以recvfrom为例,用户进程经过系统调用获取TCP数据,若是套接字缓存区没有数据,系统调用不会返回,形成用户进程一直阻塞。直到缓存区有可用数据,内核将缓存区数据拷贝至用户进程空间,系统调用才会返回。

阻塞式IO模型

非阻塞式IO

python能够经过调用s.setblocking(False)或者s.settimeout(0.0)将一个套接字设置为非阻塞式IO。以recvfrom为例,当没有可用的数据时,用户进程不会阻塞,而是立刻抛出EWOULDBLOCK错误(或者EAGAIN,对应的errno错误码都是11),只有当数据复制到内核空间后,才会正确返回数据。

非阻塞式IO

IO多路复用

在有多个IO操做时,先阻塞于select调用,等待数据报套接字变为可读,而后再经过recvfrom把缓存区数据复制到用户进程空间。和阻塞是IO相比,当处理的套接字个数较少的时候,多路复其实没有性能上的优点,它的优点在于能够方便操做不少套接字。

IO多路复用模型

信号驱动式IO

经过信号处理的方式读取数据。

信号驱动式IO模型

异步IO

当数据包被复制到用户进程后,用户经过callback的方式获取数据。

异步IO模型

模型对比

模型比较

能够发现,前四种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大体匹配。

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%)

benchmark

横坐标是链接个数,纵坐标是qps,括号内的数字表示错误率。在链接数较少的状况下,使用libev管理socket和多线程性能相差不大,在链接数超过200后,libev模型的请求耗时会增长,致使qps增长的并很少,可是线程模型在链接数不少的状况下,会致使部分请求一直得不处处理,在链接个数350的时候就会出现部分请求超时,而libev模型在450的时候才会出现。

相关文章
相关标签/搜索