Tornado TCP

参考资料

  • http://c.biancheng.net/view/2125.html

TCP

TCP协议是一个面向连接的可靠性交付协议。

  • 由于是面向连接的,所以在服务器上需要为其分配内存来存储客户端连接,同样客户端也需要存储服务器的。
  • 由于保证可靠性,所以引入了很多保证可靠性的机制,比如定时重传机制,如SYN/ACK机制等。

TCP传输控制协议是一种面向连接的、可靠的、基于字节流的通信协议,数据在传输前需要建立连接,传输完毕后需要断开连接。客户端在收发数据前要使用connect()函数和服务器建立连接,建立连接的目的是保证IP地址、端口、物理链路等正确无误,为数据的传输开辟通道。

TCP与UDP的异同点是什么呢?

TCP是面向连接的传输协议,建立连接时需要经过三次握手,断开连接时需要经过四次挥手,中间传输数据时也需要回复ACK包确认。多种机制保证了数据能够正确到达,不会丢失或出错。UDP是非连接的传输协议,没有连接和断开连接的过程,只是简单地把数据丢到网络中,也不需要ACK包确认。

UDP传输数据好像邮寄包裹,邮寄前需要填好寄件人和收件人的地址,之后送到快递公司即可。但包裹是否正确到达,是否损坏是无法得知也无法保证的。UDP协议只管将数据包发送到网络,然后就不管了。如果数据包丢失或损坏,发送端是无法知道的,当然也不会重发。

既然如此,TCP应该是更加优质的传输协议吗?

如果只是考虑可靠性,TCP确实比UDP要好,但是UDP在结构上比TCP更加简洁。因为不会发送ACK的应答消息也不会给数据包分配SEQ序号,所以UDP的传输效率又是会比TCP高出很多,另外编程中实现UDP也比TCP简单。

UDP的可靠性虽然不及TCP,但也不会像想象中那样频繁地发生数据损毁,在更加重视传输效率而非可靠性的情况下。UDP是一种很好的选择,比如视频通信或音频通信,就非常适合采用UDP协议。通信时数据必须高效传输才不会出现卡顿现象,用户体验才能更加流畅,如果丢失几个数据包,视频画面可能会出现雪花点,音频可能会夹杂一些杂音,这些都是无妨的。

与UDP相比,TCP的生命在于流控制,这保证了数据传输的正确性。

最后需要说明的是TCP的速度无法超越UDP,但是在收发某些类型的数据时有可能会接近UDP。例如,每次交换的数据越大,TCP的传输效率就接近于UDP。

TCP数据报结构

Tornado TCP_第1张图片
TCP数据报结构

带阴影的重点字段

  1. SEQ(Sequence Number) 序号,序号占32位,用来标识从计算机A发送到计算机B的数据包的序号,计算机发送数据时对此进行标记。
  2. ACK(Acknowledge Number) 确认号,确认号占32位,客户端和服务器都可以发送,ACK = SEQ + 1。
  3. 标志位:每个标志位占1Bit,共有6个分别是
  • URG 紧急指针(Urgent Pointer)有效
  • ACK 确认序号有效
  • PSH 接收方应尽快将这个报文交给应用层
  • RST 重置连接
  • SYN 建立一个新连接
  • FIN 断开一个连接

TCP通信过程

  1. 三次握手:建立TCP连接通道
  2. 数据传输
  3. 四次挥手:断开TCP连接通道
Tornado TCP_第2张图片
TCP通信过程

1. 三次握手

TCP建立连接时需要传输三个数据包,简称三次握手(Three-way Handshaking)。使用connect()建立连接时,客户端和服务器会相互发送三个数据包。

Tornado TCP_第3张图片
三次握手

当客户端调用socket()函数创建套接字后,因为没有建立连接,所以套接字处于CLOSED状态。服务器调用listen()函数后,套接字进入LISTEN状态,并开始监听客户端请求。

此时客户端开始发起请求

  1. 当客户端调用connect()函数后,TCP协议会组建一个数据包,被设置SYN标志位,表示该数据包是用来建立同步连接的。
  • 客户端同时会生成一个随机数1000,填充SEQ序号字段,表示该数据包的序号。
  • 当完成这些工作后开始向服务器发送数据包,客户端就进入了SYN-SEND状态。
  1. 服务器接收到数据包,检测到已经设置了SYN标志位,知道这是客户端发来建立连接的“请求包”。
  • 服务器会组建一个数据包,并设置SYNACK标志位,SYN表示该数据包是用来建立连接的,ACK表示用来确认收到了刚才客户端发送的数据包。
  • 服务器会生成一个随机数2000,填充SEQ序列字段,2000和客户端数据包没有关系。
  • 服务器将客户端数据包序号1000加1后得到1001,并使用这个数字填充ACK确认号字段。
  • 服务器将数据包发出并进入SYN-RECV状态
  1. 客户端接收到数据包,检测到已经设置了SYNACK标志位,就知道这是服务器发过来的确认包。
  • 客户端会检测确认号ACK字段看它是否为1000+1,如果是就说明连接建立成功。
  • 客户端会继续组件数据包并设置ACK标志位,表示客户端正确接受了服务器发过来的确认包。
  • 客户端同时将刚才服务发送过来的数据包序号2000加1得到2001,并使用2001填充ACK确认号字段。
  • 客户端将数据包发出并进入ESTABLISHED状态,表示连接成功建立。
  1. 服务器接收到数据包,检测到已经设置了ACK标志位,就知道是客户端发过来的确认包。
  • 服务器会检测ACK确认号,看它是否等于2000+1,如果是就说明连接建立成功。
  • 服务器则进入ESTABLISHED状态。

到此为止,客户端和服务器都进入了ESTABLISHED状态,表示连接通道建立成功,接下来就可以收发数据了。

三次握手的关键是要确认双方收到了都收到了自己的数据包,这个目标是通过ACK确认号字段实现的,计算机会记录下自己发送的数据包序号SEQ,待收到双方的数据包后会检测ACK确认号,当看到ACK = SEQ + 1成立后则说明对方正确的接收到了自己的数据包。

2. 数据传输

当TCP建立连接后两台主机就可以相互传输数据了。

Tornado TCP_第4张图片
数据传输

例如:主机A分2次,也就是分2个数据包向主机B传递200字节数据的过程。

首先,主机A通过1个数据包发送100个字节的数据,数据包的SEQ序列号设置为1200,主机B为了确认这一点,先主机A发送了ACK确认序列有效的数据包,并将ACK号设置为1301。

为了保证数据能够准确到达,目标机器在接收到数据包,包括SYN包、FIN包、普通数据包等。必须立即回传ACK包,这样发送方才能确认数据传输成功。

此时ACK号为1301而不是1201,原因在于ACK号的增量为传输的数据字节数,假设每次ACK号不添加传输的字节数,这样虽然可以确认数据包的传输,但是却无法明确100字节全部正确传输还是丢失了一部分,比如说只传输了80字节,因此按ACK号 = SEQ号 + 传递的字节数 + 1的公式确认ACK号。与三次握手协议相同的是,最后加1是为了告诉对方要传输的SEQ号。

TCP套接字传输过程中发生错误,在数据传输过程中数据丢失的情况。

Tornado TCP_第5张图片
数据传输过程中数据包丢失的情况

例如:通过SEQ序号1301数据包向主机B传递了100字节的数据,但是中间却发生了错误,主机B未收到。经过一段时间后,主机A仍未收到对于SEQ 1301的ACK确认,因此会尝试重传数据。为了完成数据包的重传,TCP套接字每次发送数据包时都会启动定时器。如果在一段时间内没有收到目标机器传回的ACK包,数据包会重传。当然,也会有ACK包丢失的情况,一样会重传。

重传超时时间(RTO, Retransmission Time Out)

重传超时时间RTO的值如果设置过大会导致不必要的等待,如果太小则会导致不必要的重传,理论上最好是网络往返时间RTT(Round-Trip Time)时间,但又受制于网络距离和瞬态时延变化,所以实际上使用自适应的动态算法来确定超时时间。

网络往返时间RTT(Round-Trip Time)表示从发送数据开始,到发送端接收到来自接收端的ACK确认包(接收端接收到数据后便会立即确认),总共经历的时延。

重传次数

TCP数据包重传次数会根据系统设置的不同而有所区别,有些系统一个数据包只会被重传三次,如果重传三次之后还未接收到该数据包的ACK确认,就不再会尝试重传。但有些要求很高的业务系统,会不断地重传丢失的数据包,以尽最大可能保证业务数据的正常交互。

最后需要说明的是,发送端只有在接收到对方的ACK确认包之后,才会清空输出缓冲区中的数据。

3. 四次挥手

TCP四次挥手断开连接的过程中,首先需要理解建立连接是非常重要的,它是数据正确传输的前提。断开连接同样重要,它让计算机释放不再使用的资源。如果连接不能正常断开,不仅会造成数据传输错误,还会导致套接字不能关闭,持续占用资源,如果并发量比较高的话,服务器压力堪忧。

Tornado TCP_第6张图片
四次挥手

当连接建立后,客户端和服务器都处于ESTABLISHED状态,此时客户端发起断开连接的请求。

  1. 客户端调用close()函数后,向服务器发送FIN数据包,并进入FIN_WAIT_1状态。FINFinish的缩写表示完成任务需要断开连接。
  2. 服务器接收到数据包后,检测到设置了FIN标志位,知道要断开连接。于是向客户端发送确认包并进入CLOSE_WAIT状态。需要注意的是,服务器接收到请求后并不是立即断开连接,而是先向客户端发送确认包,告诉它我知道了,我需要准备一下才能断开连接。
  3. 客户端接收到确认包后进入FIN_WAIT_2状态,并等待服务器准备完毕后再次发送数据包。
  4. 客户端等待片刻后,服务器准备完毕,可以断开连接。于是服务器再主动向客户端发送FIN包,并告诉它我准备好了,断开连接吧。然后进入LAST_ACK状态。
  5. 客户端接收到服务器的FIN包后再次向服务器发送ACK包,并告诉它你断开连接吧,然后进入TIME_WAIT状态。
  6. 服务器接收到客户端的ACK包后立即断开连接并关闭套接字进入CLOSED状态。

TIME_WAIT状态

客户端最后一次发送ACK包后进入TIME_WAIT状态而不是直接进入CLOSED状态关闭连接,这是为什么呢?

TCP是面向连接的传输方式,必须保证数据能够正确地到达目标机器,不能丢失或出错,但网络是不稳定的,随时可能会损坏数据,所以机器A每次先机器B发送数据包后,都会要求机器B确认回传ACK包,告诉机器A我收到了,这样机器A才能知道数据传送成功了。如果机器B没有回传ACK包,机器A会重新发送直到机器B回传ACK包。

客户端最后一次向服务器回传ACK包时,有可能会因为网络问题导致服务器接收不到,服务器会再次发送FIN包,如果此时客户端完全关闭了连接,那么服务器无论如何也接收不到ACK包,所以客户端需要等待片刻,确认对方接收到ACK包之后才能进入CLOSED状态。那么需要等待多久呢?

数据在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。这成为报文最大生存时间MSL, Maximum Segment LifetimeTIME_WAIT要等待2MSL才会进入CLOSED状态。ACK包到达服务器需要MSL时间,服务器重传FIN也需要MSL时间,2MSL是数据包往返的最大时间,如果2MSL后还未收到服务器重传的FIN包就说明服务器已经收到了ACK包。

TCP状态转换

TCP三次握手建立连接通道和四次挥手断开连接通道过程中状态变迁以及数据传输的过程,根据TCP状态转换图可分为上下两段:上半部分是TCP三次握手过程的状态变迁,下半部分是TCP四次挥手过程的状态变迁。

Tornado TCP_第7张图片
TCP状态转化图

1. TCP三次握手过程的状态变迁

  • CLOSED 起始点
    在超时或连接关闭时进入此状态,这并不是一个真正的状态,而是状态图的假想七点和重点。
  • LISTEN 服务器等待连接的状态
    服务器经过socketbindlisten函数之后进入此状态,开始监听客户端发过来的连接请求,又称为应用程序被动打开,等待客户端连接请求。
  • SYN_SENT 第一次握手阶段客户端发起连接
    客户端调用connect发送SYN给服务器,然后进入SYN_SENT状态等待服务器确定(三次握手中的第二个报文)。如果服务器不能连接则之直接进入CLOSED状态。
  • SYN_RCVD 第二次握手发生阶段
    SYN_RCVD阶段与SYN_SEND阶段对应,这里是服务器接收到了客户端的SYN,此时服务器由LISTEN状态进入SYN_RCVD状态,同时服务器回应一个ACK然后再发送一个SYNSYN+ACK给客户端。状态图中还描述了这样一种情况,当客户端在发送SYN的同时也接收到服务器的SYN请求,即两个同时发起连接请求,那么客户端就会从SYN_SEND转为SYN_REVD状态。
  • ESTABLISHED 第三次握手发生阶段
    客户端接收到服务器的ACK包(ACKSYN)之后 ,会发送一个ACK确认包,客户端进入 ESTABLISHED状态,表明客户端端这边已经准备好,但TCP需要两端都在准备好才可以进行数据传输。服务器接收到客户端的ACK之后会从SYN_RCVD状态转移到ESTABLISHED状态,表明服务器也准备好进入ESTABLISHED,也就是说是一个数据传送状态。

以上就是TCP三次握手过程的状态变迁,结合三次握手过程图,从报文的角度看状态变迁:

  • SYN_SENT状态表示客户端已经发送了SYN报文
  • SYN_RCVD状态表示服务器已经接收到了SYN报文

2. TCP四次挥手过程的状态变迁

  • FIN_WAIT_1 第一次挥手
    主动关闭的一方(执行主动关闭的一方既可以是客户端也可以是服务器,这里以客户端执行主动关闭为例),终止连接时发送FIN给对方,然后等待对方返回ACK。调用close()方法第一挥手就进入此状态。
  • CLOSE_WAIT 接收到FIN之后,被动关闭一方进入此状态。
    具体动作是接收到FIN同时发送ACK,之所以叫CLOSE_WAIT可以理解为被动关闭的一方此时正在等待上层应用程序发出关闭连接指令。TCP关闭是全双工过程,这里客户端执行了主动关闭,被动方服务器接收到FIN后也需要调用close()方法进行关闭,这个CLOSE_WAIT就是处于这个状态,等待发送FIN,发送FIN后则进入LAST_ACK状态。
  • FIN_WAIT_2
    主动关闭方(这里是客户端)先执行主动关闭发送FIN,然后接收到被动关闭方返回的ACK后进入此状态。
  • LAST_ACK
    被动关闭方(这个是服务器)发起关闭请求,由CLOSE_WAIT进入此状态,具体动作是发送FIN给对方,同时在接收到ACK时进入CLOSED状态。
  • CLOSING
    双方同时发送关闭请求时,即主动关闭方发送FIN等待被动关闭方返回ACK,同时被动关闭方也发送了FIN,主动关闭方接收到了FIN之后发送ACK给被动方,主动关闭方由FIN_WAIT_1进入此状态等待被动关闭方返回ACK
  • TIME_WAIT
    从状态变迁图中看到,四次挥手操作最后都会经过这样一个状态TIME_WAIT然后进入CLOSED状态,共有三个状态会进入该状态TIME_WAIT
    (1) 由CLOSING进入TIME_WAIT
    同时发起关闭情况下,当主动关闭方接收到ACK后进入TIME_WAIT状态,实际上这里同时发生的是这样的情况:客户端发起关闭请求,发送FIN之后等待服务器回应ACK,但此时服务器同时也发起关闭请求,也发送了FIN,并且被客户端先于ACK接收到。
    (2)由FIN_WAIT_1进入TIME_WAIT
    当发起关闭后发送了FIN等待ACK的时候,正好被动关闭方(服务器)也发起关闭请求,发送了FIN,此时客户端接收到了先前ACK也接收到了对方的FIN然后发送ACK(给对方FIN的回应),与CLOSING进入的状态不同的是接收到FINACK的先后顺序。
    (3)由FIN_WAIT_2进入TIME_WAIT
    这是不同时的情况,主动方在完成自身发起的主动关闭请求后,接收到对方发送过来的FIN然后回应ACK

从上面进入TIME_WAIT状态的三个状态动作来看,都是主动方最后回一个ACKCLOSING实际上前面的哪个FIN_WAIT_1状态就已经回应了ACK。

先考虑这样一种情况:加入这个最后 回应的ACK丢失了,也就是服务器接收不到这个ACK,那么服务器将继续发送它最终的那个FIN,因此客户端必须维持状态信息TIME_WAIT允许它重发最后的那个ACK。如果没有这个TIME_WAIT状态,客户端处于CLOSED状态(CLOSED状态实际并不存在只是为了方便描述假想的),那么客户端将响应RST,服务器接收到后会将该RST分节解释成一个错误,也就不能实现最后的全双工关闭了(可能是主动方单方的关闭)。所以要实现TCP全双工连接的正常终止(两方都关闭连接),必须处理终止过程中四个分节任何一个分节的丢失情况,那么主动关闭连接的主动端必须维持TIME_WAIT状态,最后一个回应ACK的是主动执行关闭的那一端。从变迁图可以看出,如果没有TIME_WAIT状态将没有任何机制来保证最后一个ACK能够正常到达。前面的FINACK正常到达均由相应的状态对应。

这里还有一种情况:如果目前的通信双方都已经调用了close(),都到达了CLOSED状态,没有TIME_WAIT状态时,会出现这样一种情况,现在有一个新的连接被建立起来,使用的IP地址和端口和这个先前到达了CLOSED状态的完全相同,假定原来的连接中还有是数据报残存在网络之中,这样新的连接建立之后创数的数据极有可能就是原先的连接的数据报,为了防止这一点,TCP不允许从处于TIME_WAIT状态的Socket建立一个连接。处于TIME_WAIT状态的Socket在等待了两倍的MSL时间后将会转变为CLOSED状态。这里TIME_WAIT状态维持的时间是2MSLMSL是任何IP数据报能够在Internet中存活的最长时间),足以让这两个方向上的数据包被丢弃(最长是2MSL)。通过实施这个规则,九能保证每成功建立一个TCP连接时,来自该连接先前化生的老的重复分组都已经在网络中消逝了。

综合来看,TIME_WAIT存在的理由是

  • 可靠地实现TCP全双工连接的终止
  • 允许老的重复分节(数据报)在网络中消逝

Socket

4933701-5240e680db2b57a8.png
Socket

在UNIX/Linux系统中为了统一对各种硬件的操作以简化接口,不同的硬件设备都被视为一个文件。对这些文件的操作等同于对磁盘上普通文件的操作。所以,UNIX/Linux中一切都是文件。

为了表示和区分已经打开的文件,UNIX/Linux会给每个文件分配一个整型ID,这个整数的ID被称为文件描述符(fd, File Descriptor)。例如

  • 通常使用0表示标准输入文件stdin,它对应的硬件设备是键盘。
  • 通常使用1表示标准输出文件stdout,它对应的硬件设备是显示器。

UNIX/Linux程序在执行任何形式的I/O操作时都是在读取或写入一个文件描述符fd,一个文件描述符fd只是一个和打开的文件相关联的整数,它的背后可能是一个硬盘上的普通文件、FIFO、管道、终端、键盘、显示器,甚至是一个网络连接。

Socket是应用层与TCP/IP协议簇通信的中间软件抽象层,是一组接口。

Tornado TCP_第8张图片
Socket位置

TCP/IP只是 一个协议栈,就像操作系统的运行机制一样,它必须要有具体实现,同时还要提供对外的操作接口。就像操作系统会提供标准的编程接口,比如Win32编程接口有一样,TCP/IP也必须对外提供编程接口,这就是Socket编程接口。

在Socket编程接口中设计者提出了一个很重要的概念,就是Socket。这个Socket和文件句柄很相似,实际上在BSD系统中就是跟文件句柄一样存放在进程句柄表中。这个Socket其实就是一个序号,表示在句柄表中的位置。操作系统中句柄分很多种,比如文件句柄、窗口句柄等。这些句柄其实代表系统中某些特定的对象,用于在各种函数中作为参数传入,以对特定的对象进行操作 - 这其实是C语言的问题。在C++中这些句柄其实就是this对象指针。

Socket跟TCP/IP并没有必然的联系,Socket编程接口在设计的时候希望能够适应其他的网络协议,所以Socket的出现只是为了更加方便地使用TCP/IP协议栈而已,通过对TCP/IP进行抽象形成了几个最基本的函数接口 ,如createlistenacceptconnectreadwrite等。

Tornado TCP_第9张图片
Socket通信

服务器先初始化Socket,然后与指定地址的端口进行绑定,接着对端口进行监听,最后调用accept阻塞,等待客户端连接。此时如果有客户端初始化一个Socket后连接服务器,如果连接成功,客户端与服务器的连接就会建立成功。客户端发送数据请求 ,服务器接收请求并处理,然后将回应数据发送给客户端,客户端读取数据,最后关闭连接,完成一次交互过程。


Socket类型

Socket套接字有很多种类型,比如DARPA Internet地址(Internet套接字)、本地节点的路径名(UNIX套接字)、CCITT X.25地址(X.25套接字)等。这里我们所讨论的是Internet套接字,它是最具代表也是最经典最常用的。

根据传输方式,可以将Internet套接字分为两种类型:流格式套接字SOCK_STREAM、数据报格式套接字SOCK_DGRAM

流格式套接字SOCK_STREAM

流格式套接字Stream Sockets也叫做面向连接的套接字,代码中使用SOCK_STREAM表示。流格式套接字是一个可靠地、双向的通信数据流,数据可以准确无误的到达另一台计算机,如果损坏或丢失可以重新发送。流格式套接字有自己的纠错机制。

流格式套接字具有以下几个特征:数据在传输过程中不会消失、数据是按顺序传输的、数据的发送和接收不是同步的(不存在数据边界)

可以将流格式套接字比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失。同时较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按顺序传递的。

Tornado TCP_第10张图片
流格式套接字

为什么流格式套接字可以达到高质量的数据传输呢?

这是因为它使用了TCP传输控制协议,TCP协议会控制你的数据按照顺序到达并且保证没有错误。对于TCP/IP协议族而言,TCP是用来确保数据的正确性,IP网络协议是用来控制数据如何从源头到达目的地也就是常说的路由。

TCP是如何解决数据收发不同步的问题的呢?

假设传送带传送的是水果,接收者需要凑齐100个后才能装箱,但是传送带可能会把100个水果分批传送,比如第一批传送20个,第二批50个,第三批30个。接收者不需要和传送带保持同步,只需要根据自己的节奏来装箱即可,不管传送带传送多少批,也不用没到一批就装箱依次,可以等到凑够100个在装箱。

流格式套接字的内部有一个字符数组的缓冲区,通过Socket传输的数据将保存在这个缓冲区中。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。也就是说,不管数据分几次传送,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送段有自己的节奏,接收端也有自己的节奏,它们是不一致的。

流格式套接字有什么实际的应用场景吗?浏览器使用的HTTP协议是基于面向连接的套接字,因此必须确保数据准确无误,否则加载的HTML将无法解析。

数据报格式套接字SOCK_DGRAM

数据报格式套接字(Datagram Sockets)也叫做无连接的套接字,在代码中使用SOCK_DGRAM表示。

由于计算机只管传输数据不做数据校验,如果数据在传输过程中损坏,或者没有达到另一台计算机,是没有办法补救的。也就是说,数据错误就错了无法重传。

由于数据报套接字所做的校验工作少所以在传输效率方面比流式套接字效率要高。

可以将数据报格式套接字比率成高速移动的摩托车快递,它具有以下特征:强调快速传输而非传输顺序、传输的数据可能丢失也可能损毁、限制每次传输数据的大小、数据的发生和接收是同步的(存在数据边界)。

总所周知,速度是快递行业的生命,使用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交付给客户即可。这种方式存在损坏或丢失的风险,而且包裹大小有一定的限制。因此,想要传递大量包裹,就得分批发送。另外,使用两辆摩托车分别发送两件包裹,接收者也需要分两次接收,所以“数据的发送和接收是同步的”,换句话说接收次数应该和发送次数相同。总之数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。

数据报套接字使用IP协议作路由,但不使用TCP,而是使用UDP用户传输协议。例如QQ视频聊天和语音聊天就是使用数据报套接字,因为首先要保证通信的效率,尽量减少延迟,而数据的正确性是次要的,即使丢失很小一部分的数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质上的影响。


面向连接套接字 VS 无连接套接字

流式套接字SOCK_STREAM是基于TCP协议面向连接的套接字,数据报格式套接字SOCK_DGRAM是基于UDP协议无连接的套接字。面向连接是可靠的通信,无连接的通信是不可靠的通信,实际情况是这样的吗?

首先,不管是哪一种数据传输方式,都必须通过整个Internet网络的物理线路将数据传输过去,从这个层面 上来看,所有的Socket都是有物理连接的,那么为什么还会有无连接的Socket呢?

从字面上看,面向连接好像有一条管道,它连接发送和接收端,数据报都是通过这条管道来传输,当然,两台计算机在通信之前必须先搭建好管道。而无连接好像是无头苍蝇乱撞,数据报从发送端到接收端并没有固定的路线,爱怎么走就怎么走,只要能到达就行。每个数据包都比较自私,不会和别人分享自己的线路,但是最终都能殊途同归,到达接收端。

无连接的套接字

对于无连接的套接字,每个数据报可以选择不同的路径。每个数据包之间都是独立的,各走各的的路,谁也不影响谁,除了迷路或发生以外的数据报,最后都到达目的地。只是到达的顺序是不确定的。对于无连接的套接字,数据包在传输过程中会发生各种不测,也会发生各种奇迹。无连接套接字遵循的是“尽最大努力交付”的原则,就是尽力而为,实在做不到了也没有办法,无连接套接字提供的是没有质量保证的服务。

Tornado TCP_第11张图片
无连接的套接字

面向连接的套接字

面向连接的套接字在正式通信之前需要先确定一条路径,没有特殊情况的话,以后就会固定地使用这条路线来传递数据包。当然,如果路径被破坏的话,比如某个路由器断电了,那么会重新建立路线。固定线路是由路由器维护的,路径上所有的路由器都要存储路径的信息,实际上只需要存储上游和下游两个路由器的位置即可。所以路由器是有开销的。

Tornado TCP_第12张图片
面向连接的套接字

面向连接的套接字建立的固定路径,又被称为虚电路,也就是一条虚拟的通信电路。为了保证数据包准确、顺序地到达,发送端在发送数据包以后,必须得到接收端的确认后才会发送下一个数据包。如果数据包发送出去后一端时间仍没有得到接收端的回应,发送端会重新再发送一次,直到得到接收端的回应。这样一来,发送端发送的数据包都能到达接收端,并且是按照顺序到达的。

发送端发送一个数据包,是如何得到接收端的确认呢?

这个很简单,为每个数据包分配一个ID,接收端接收到数据包以后,再给发送端返回一个数据包,告诉发送端接收到的ID即可。

面向连接的套接字比无连接的套接字多出很多数据包,因为发送端每发送一个数据包,接收端会要返回一个数据包,此外建立连接和断开连接的过程也会传递很多数据包。因此不但是数量多了,每个数据包也会变大。除了源端口和目标端口,面向连接的套接字还包括序号、确认信息、数据偏移、控制标志(如URG、ACK、PSH、RST、SYN、FIN)、窗口、校验和、紧急指针、选项等信息。无连接的套接字则只包含长度和校验信息。

总的来说,两种套接字的传输方式各有优缺

  • 无连接套接字传输效率高,但不可靠有丢失数据包、捣乱数据的风险。
  • 有连接套接字非常可靠万无一失,但传输效率低消耗资源多。

Socket缓冲区

每个Socket被创建后都会分配两个I/O缓冲区:输入缓冲区、输出缓冲区,I/O缓冲区的默认大小一般是8K。

I/O缓冲区的特性是:I/O缓冲在每个TCP套接字中单独存在、I/O缓冲区在创建套接字时自动生成、即使关闭套接字也会继续传送输出缓冲区中遗留的数据、关闭套接字将会丢失输入缓冲区中的数据

TCP协议独立于write()send()函数,数据有可能刚被写入缓冲区就发送到网络,也有可能在缓冲区中 不断积累,多次写入的数据被一次性发送到网络,这取决于当时的网络情况以及当前线程是否 空闲等诸多因素,这不是由程序员能控制的。read()recv()函数也是如何,也从输入缓冲区中读取数据,而不是直接从网络中读取数据。

write()send()函数并不会立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。

Tornado TCP_第13张图片
TCP套接字的I/O缓冲区

Socket的阻塞模式

所谓阻塞就是上一步动作还没有完成,下一步动作将暂停,直到上一步动作完成后才能继续,以保持同步性。TCP套接字默认情况下是阻塞模式的。

对于TCP套接字在默认情况下,当使用write()send()函数发送数据时

  1. 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据长度,那么write()send()函数将会被阻塞也就时暂停执行,直到缓冲区中的数据被发送到目标机器,腾出足够的空间才唤醒write()send()函数继续写入数据。
  2. 如果TCP协议正在向网络中发送数据,那么输出缓冲区会被锁定,不允许写入。write()send()函数也会被阻塞,直到数据发送完毕后缓冲区解锁,write()send()才会被唤醒。
  3. 如果写入的数据大于缓冲区的最大长度将会分批写入
  4. 直到所有数据被写入缓冲区write()send()函数才会返回

对于TCP套接字在默认情况下,当使用read()recv()函数读取数据时

  1. 首先会检查缓冲区,如果缓冲区中有数据则直接读取,否则函数会被阻塞直到网络上有数据到来。
  2. 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积累直到有read()recv()函数再次读取。
  3. 直到读取到数据后read()recv()函数才会返回否则会一致被阻塞

UNIX BSD Socket

使用TCP/IP协议的应用程序通常采用应用编程接口UNIX BSD的Socket来实现网络进程之间的通信

在讨论网络中进程通信之前需要解决的问题是如何唯一标识一个进程呢?在本地可以通过进程PID来唯一标识一个进程,但在网络中是行不通的。TCP/IP协议簇已经帮助我们解决了这个问题,网络层的“IP地址”可以唯一标识网络中的主机,传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用“三元组 = IP地址 + 协议 + 端口”,就可以标识网络中的进程,网络中的进程通信就可以利用这个三元组标志与其它进程进行交互。

网络中的进程是通过Socket来通信的,那什么是Socket呢,Socket其起源于UNIX,UNIX/Linux基本哲学之一是“一一切皆文件”,文件都可以使用“打开-读写-关闭”模式来操作。而Socket其实是一种特殊的文件,Socket函数其实就是对其进行的操作。

TCP服务器编程的基本经典三段式:创建一个监听socket,然后将其绑定到指定地址的端口上并开始监听,然后循环不断的accept客户端请求。

例如:使用C语言实现的TCP服务器

// 创建监听的socket
int sfd = socket(AF_INET, SOCK_STREAM, 0);// 返回服务端的文件描述符
// 绑定socket到指定地址的端口并开始监听
bind(sfd, (struct sockaddr *)(&s_addr), sizeof(struct sockaddr)) && listen(sfd, 10);
// 循环accept客户端请求
while(1){
  cfd = accept(sfd, (struct sockaddr *)(&cli_addr), &addr_size);//返回客户端文件描述符
}

socket

int socket(int domain, int type, int protocol);

socket()函数对应于普通文件的打开操作,普通文件的打开返回文件描述符fdsocket()函数用于创建一个Socket描述符sd, socket descriptor,它能唯一标识一个Socket。这个Socket描述符跟文件描述符fd一样,在后续的操作中都会使用到,会将其作为参数通过它来进行一些列的读写操作。

创建Socket的时候,可以指定不同的参数创建不同的Socket描述符,socket()函数有三个参数分别是:

  • domain 协议域又称为协议族AF, address family,常用的协议族有AF_INETAF_INET6AF_LOCAL(又称为AF_UNIX即UNIX域的Socket)、AF_ROUTE等。协议族决定了Socket的地址类型,在通信中必须采用对应的地址,例如AF_INET决定了要使用32位的IPv4地址与16位的端口号的组合,AF_UNIX决定了要使用一个绝对路径名作为地址。
  • type指定Socket类型,常用的Socket类型包括SOCK_STREAMSOCK_DGRAMSOCK_RAWSOCK_PACKETSOCK_SEQPACKET等。
  • protocol指定协议,常用的协议包括TCP传输协议IPPROTO_TCP、UDP传输协议IPPROTO_UDP、STCP传输协议IPPROTO_STCP、TIPC传输协议IPPROTO_TIPC等。

需要注意的是typeprotocol并非可以随意组合的,比如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时会自动选择type类型对应的默认协议。

当调用socket()函数创建一个Socket时返回的Socket描述符存在于协议族Address Family, AF_XXX空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数否则调用connect()listen()函数时系统会自动随机分配一个端口。

bind

bind()函数会将一个地址簇中的特定地址赋给Socket,例如对应AF_INETAF_INET6会将一个IPv4或IPv6的地址和端口赋给Socket。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind()函数的三个参数分别是

  • int sockfd 表示Socket描述字,它是通过socket()函数创建用于唯一标识一个Socket,bind()函数就是将这个描述字绑定一个名字。
  • const struct sockaddr *addr 表示一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址,这个地址结构根据地址创建Socket时的地址协议族的不同而不同。
  • socklen_t addlen表示对应的地址的长度

通常服务器在启动时都会绑定一个总所周知的地址,比如IP地址+端口号,用来提供服务,客户端通过这个地址来连接服务器,客户端自身是不用指定的因为有系统会自动分配一个端口号和自身的IP地址组合。这就是为什么畅通服务器在监听listen前会调用bind()函数,而客户端不用调用直接使用connect()时会由系统随机生成一个。

listen

int listen(int sockfd, int backlog);

listen()函数的第一个参数int sockfd是需要监听的Socket的描述字,第二个参数int backlog为相应Socket可以排队的最大连接个数。socket()函数创建的Socket默认是一个主动类型的,listen()函数将Socket转变为被动类型并等待客户端的连接请求。

connect

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect()函数的第一个参数为客户端Socket描述符,第二个参数是服务器的Socket地址,第三个参数是Socket地址的长度。客户端通过调用connect()函数建立与TCP服务器的连接。

accept

TCP服务器一次调用socket()bind()listen()之后会监听指定Socket地址,TCP客户端依次调用socket()connect()之后会向TCP服务器发送一个连接请求。TCP服务器监听到这个请求后会调用accept()函数获取并接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,类似普通文件的读写I/O操作一样。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)

accept()函数的第一个参数int sockfd是服务器的Socket描述字,第二个参数为指向struct sockaddr *的指针用于返回客户端的协议地址,第三个参数socklen_t *addrlen为协议地址的长度。如果accept成功返回值是由内核自动生成的一个全新的描述符,代表与返回客户的TCP连接。

需要注意的是accept()函数的第一个参数是服务器的Socket描述符,是服务器开始调用socket()函数创建生成的,又被称为监听Socket描述符。而accept()函数返回的是已连接的Socket描述符。一个服务器通常仅仅只创建一个监听Socket描述符,它在该服务器的生命周期内一直会存在。系统内核会为每个由服务器进程接收的客户连接创建一个已经连接的Socket描述符,当服务器完成了对某个客户端的服务时相应的已连接Socket描述符就会被关闭。

close

int close(int fd);

close()函数表示TCP Socket的缺省行为时把该Socket描述符标记为关闭,然后立即返回到调用进程。Socket描述符不能再由调用进程使用,也就是说不能再作为readwrite的第一个参数使用。需要注意的是close()操作只是使相应Socket描述符的引用计数减一,只有当引用计数为0的时候才会触发TCP客户端向服务器发送终止连接请求。


TCP粘包

TCP协议的粘包问题实际上是针对数据无边界性提出的,为什么这么说呢?

Socket缓冲区和数据传输过程中,可以发现数据的接收和发送是无关的,read()recv()读函数不管数据发送多少次都回尽可能多的接收数据,都会尽可能多的接收数据,也就是说read()recv()读函数和write()send()写函数的执行次数可能不同。

例如:write()send()写函数重复执行三次,每次都发送字符串abc,那么目标机器上的read()recv()读函数可能分成三次接收,每次都接收abc。也有可能分成两次接收,第一次接收abcab,第二次接收cabc。也有可能一次就接收所有的字符串abcabcabc

假设希望客户端每次发送一位学生的学号,就让服务器返回学生的姓名、地址、成绩等信息,此时就可能出现问题,服务器是不能够区分学生的学号的。例如第一次发送1,第二次发送3,服务器可能当成13来处理,返回的信息显然是错误的。

这就是数据的粘包问题,客户端发送的多个数据包被当作一个数据包接收,也称为数据的无边界性。read()recv()读函数不知道数据包的开始或结束标志,实际上也没有任何开始或结束标志,只是把它们当作连续的数据流来处理。


Tornado TCPServer

Tornado有了tornado.iolooptornado.iostream两个模块的帮助可以实现异步Web服务器,tornado.httpserver是Tornado的Web服务器模块,该模块中实现了HTTPServer - 一个单线程HTTP服务器,其实现是基于tornado.tcpserver模块的TCPServer

TCPServer是一个非阻塞单线程的TCP服务器,负责处理TCP协议部分的内容,并预留handle_stream抽象接口方法针对相应的应用层协议编写服务器。所以,在分析Tornado的HTTP服务器实现之前,需要先理解tornado.tcpserver.TCPServer的实现。

tornado.tcpserver模块中只定义了一个TCPServer类,由于其实现不涉及到具体的应用层协议,加上有IOLoopIOStream的支持,其实现相对简单。

#! /usr/bin/env python3
# -*- coding=utf-8 -*-

from tornado.tcpserver import TCPServer
from tornado.ioloop import IOLoop
from tornado.options import define, options

define("port", type=int, default=8000)

# Tornado实现TCPServer需子类实例化TCPServer
class Server(TCPServer):
    def handle_stream(self, sockfd, client_addr):
        sockfd.read_until_close(self.handle_recv)
    def handl_recv(self, data):
        print(data)

if __name__=="__main__":
    options.parse_command_line()
    server = Server()
    server.listen(options.port, address="127.0.0.1")
    IOLoop.instance().start()

Tornado的TCPServer类定义在tcpserver.py文件中

from tornado.tcpserver import TCPServer

Tornado的TCPServer两种用法分别是bind + startlisten

  • 使用bind + start绑定开启的方式用于多进程
  • 使用listen监听的方式用于单进程
def listen(self, port, address=""):
  sockets = bind_sockets(port, address=address)
  self.add_sockets(sockets)

listen方法会接收两个参数分别是端口port和 主机地址address,进入后首先会使用bind_sockets方法接收地址和端口来创建sockets列表并绑定地址端口并监听,也就完成了TCP三部曲的前两步。然后,使用add_sockets在这些sockets上注册read/timeout事件。

由于Tornado采用的是单进程单线程异步IO的网络模型 ,所以可以看作是单线程事件驱动模式的服务器,TCP三部曲中的第三步accept就被分隔到了事件回调中,因此要在所有的文件描述符fd上监听事件。当完成上述操作后就可以安心的调用ioloop单例的start方法开始循环监听事件。

简单来说,基于事件驱动的服务器需要做的就是:创建socket,绑定bind到指定地址的端口上并监听listen,然后注册事件和对应的回调,最后在和回调中accept客户端的最新请求。


Tornado的HTTPServer是派生自TCPServer的,从协议上讲是再自然不过的。从TCPServer的实现上看,它是一个通用的Server架构,基本是按照BSD Socket的思想设计的,因此create-bind-listen三段式一个都不少。

Tornado中TCPServer类的实现代码位于tornado/tcpserver.py文件中,TCPServer是一个非阻塞(non-blocking)单线程(single-threaded)的TCPServer,关于这一点如何理解呢?

首先是非阻塞non-blocking表示服务器没有使用阻塞式API,为什么是阻塞式设计呢?例如在BSD Socket中recv函数默认是阻塞式的。当使用recv读取客户端数据时,如果客户端并未发送数据,此时这个API就会一直阻塞在那里不返回,这样服务器的设计就不得不使用多线程或多进程的方式,避免因为一个API的阻塞导致服务器没有去做其他的事情。

阻塞式的API是非常常见的,可以认为阻塞式设计是:“不管有没有是数据,服务器都会派API去读,如果读不到API就不会回来交差”。而非阻塞式的对于recv来说,区别在于当没有数据可读时服务器不会在那里死等,会直接返回。由于服务器无法预知到有没有数据可读,因此不得不反复派recv函数去读,这样不会浪费大量的CPU资源吗?

Tornado的非阻塞设计的要高级很多,基本上是另一种思路:服务器并不主动的读取数据,它和操作系统合作实现了一种监视器(Linux上Epoll的IO网络模型,UNIX的kqueue),TCP连接也就是监视器的监视对象。当某个连接上有数据到来时,操作系统会按照事先约定通知服务器:“xxx号客户端连接上有数据到来了,你去处理于一下”。服务器此时才会派API去取数据。因此,服务器不用创建大量线程来阻塞式的处理每个连接,也不用不停地派API去检查连接上是否有数据,它只需要坐在那里等待操作系统的通知,这也保证了recv函数这个API一旦出手就不会落空。

Tornado另一个被强调的特性的单线程single-threaded,由于与操作系统何实现的监视器非常高效,因此可以在一个线程中监视成千上万个连接的状态,基本上不需要再动用线程来分流。实测表明,单线程比阻塞式多进程或多进程设计更加高效,当然这也依赖于操作系统的大力配合。不过,现代主流操作系统都提供了非常高效的监视器机制。


handle_stream

TCPServer是一个非阻塞的单线程TCP服务器,它提供了一个抽象接口方法handle_stream供子类实现,同时支持多进程的运行方式。

TCPServer类一般不直接被实例化,而是由它派生出子类,再用子类实例化。为了强化这个设计思想,TCPServer定义了一个未直接实现的接口handle_stream()。这个技巧就是强制让子类覆盖此方法,否则给报错。

def handle_stream(self, stream, address):
  raise NotImplementedError()

例如:使用Tornado实现TCPServer聊天功能

实现聊天服务器,当客户端连接服务器后发出消息,服务器将该消息推送到当前连接服务器的每个客户端上。

$ vim server.py
#! /usr/bin/env python3
#encoding=utf-8

from tornado.tcpserver import TCPServer
from tornado.ioloop import IOLoop
# 连接类
class Connection(object):
        # 声明一个空集合clients用来存储所有连接到服务器的客户端对象
    clients = set()
    def __init__(self, stream, address):
        Connection.clients.add(self)
                # _stream可以抽象成一座架在服务器与客户端之间的桥梁,在其上进行数据传输操作。
        self._stream = stream
                #_address是客户端地址和端口,是一个元组对象
        self._address = address
                # EOF用来作为客户端发送消息完毕的标识
                self.EOF = b"\n"
                # set_clolse_callback()函数用于注册一个回调函数,在Stream关闭时会被激活。
        self._stream.set_close_callback(self.on_close)
                # read_message()负责读取客户端发送的消息,是连接类的核心方法。
        self.read_message()
        print("client entered ", address)
        # 广播消息
    def broadcast_messages(self, data):
        print("broadcast message: ", data[:-1],  self._address)
        for conn in Connection.clients:
            conn.send_message(data)
        self.read_message()
    # 负责读取客户端发送过来的消息
    def read_message(self):
        print("read message")
                # 从缓冲区读取数据当遇到EOF时读取完成并激活回调函数
        # tornado.iostream.BaseIOStream类的read_until(delimiter,  callback)方法
        # 将会从缓冲区中直到读到截止标记时会产生一次回调
        # 如果没有截止标记缓冲区就继续积攒数据,直到截止标记出现,才会生成回调。
        # 缓冲区默认最大尺寸max_buffer_size = 102857600
        self._stream.read_until(self.EOF, self.broadcast_messages)
    def send_message(self, data):
        print("send message:", data)
        self._stream.write(data)
    def on_close(self):
        print("client close ", self._address)
        Connection.clients.remove(self)

class Server(TCPServer):
    def handle_stream(self, stream, address):
        print("client connection ", address, stream)
        Connection(stream, address)
        print("client connection num is ", len(Connection.clients))

if __name__=="__main__":
    print("server start")
    server = Server()
    server.listen(8000)
    IOLoop.instance().start()
#! /usr/bin/env python3
# -*- coding=utf-8 -*-

from tornado.tcpserver import TCPServer
from tornado.ioloop import IOLoop
from tornado.options import define, options

define("host", type=str, default="127.0.0.1")
define("port", type=int, default=8000)

# 服务器连接类
class Connection:
    # 声明空集合clients用于存储所有连接到服务器的客户端对象
    clients = set()
    # 初始化方法
    def __init__(self, stream, address):
        print("client connect", address)
        # 添加客户端连接
        Connection.clients.add(self)
        # _stream可抽象成一座架在客户端和服务器之间的桥梁,在其上进行数据传输等操作。
        self._stream = stream
        # _address是客户端地址和端口,是一个元素对象
        self._address = address
        # EOF用来作为客户端发送消息完毕的 标识
        self.EOF = b'\n'
        # set_close_callback方法用于注册一个回调函数,当stream关闭时会被激活。
        self._stream.set_close_callback(self.on_close)
        # read_message用于读取客户端发送过来的消息,是服务器连接类的核心方法。
        self.read_message()
    # 读取客户端发送过来的消息
    def read_message(self):
        print("read client message")
        # read_until方法负责从缓冲区读取数据,当遇到EOF标识后读取完成并激活结束回调函数
        self._stream.read_until(self.EOF, self.broadcast_message)
    # 将客户端发送的消息广播给每个已经连接的客户端
    def broadcast_message(self, data):
        print("broadcast clients message:", data)
        try:
            data = tornado.escape.to_unicode(data)
            # 遍历Connection.clients所有客户端连接并保持监听每个客户端发送的消息
            for conn in Connection.clients:
                conn.send_message(data)
            self.read_message()
        except StreamClosedError as e:
            # 出现异常pass断开stream时的报错
            pass
    # 客户端发送消息
    def send_message(self, data):
        print("send message")
        # 将数据转换为bytes字节类型后通过stream_write方法写入缓冲区
        data = str(self._address) + ":" + data
        self._stream.write(bytes(data.encode("utf-8")))
    # 当客户端断开连接时将其从客户端集合中删除
    def on_close(self):
        print("client close")
        Connection.clients.remove(self)
# 服务器类
# Tornado实现TCPServer需继承tornado.tcpserver.TCPServer并重写handle_stream()方法
class Server(TCPServer):
    # 重写TCPServer的handle_stream方法
    def handle_stream(self, sockfd, client_addr):
        # 实例化服务器连接类
        Connection(sockfd, client_addr)

# 入口方法,运行服务器。
if __name__=="__main__":
    options.parse_command_line()
    # 创建服务器实例
    server = Server()
    # 监听指定地址的端口
    server.listen(options.port, options.host)
    # 运行IOLoop实例并开启事件循环
    IOLoop.instance().start()
$ vim client.py
#! /usr/bin/python3
#encoding=utf-8

import socket
import time

HOST = "127.0.0.1"
PORT = 8000

sfd = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sfd.connect((HOST, PORT))
print("client connect")

sfd.sendall(bytes("hello\n", "utf-8"))
time.sleep(3)
sfd.sendall(bytes("world\n", "utf-8"))
print("client send message")


data = sfd.recv(1024)
print("client recieve: ", repr(data))

time.sleep(60)
sfd.close()
#! /usr/bin/python3
#encoding=utf-8

import socket,threading
from tornado.iostream import IOStream
from tornado.ioloop import IOLoop

# 定义客户端类
class Client:
    # 初始化
    def __init__(self, host, port):
        self._host = host
        self._port = port 
        # 创建客户端时还未与服务器建立连接,所以_stream初始值位None。
        self._stream = None
        # EOF设置为消息的结尾,当读取到这个标识的时候标识一条消息输入完毕
        self.EOF = b'\n'
    # 建立连接
    def connect(self):
        # 建立流
        self.get_stream()
        # 指定地址和端口连接服务器,并注册回调函数为开始客户端运行的函数。
        self._stream.connect((self._host, self._port), self.start)
    # 获取Socket,通过tornado.iostream.IOStream创建_stream
    def get_stream(self):
        # 创建Socket描述符,AF_INET表示协议族为IPv4,SOCK_STREAM表示连接类型为流式基于TCP,0表示系统根据情况决定协议类型。
        sockfd = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
        # 创建流
        self._stream = IOStream(sockfd)
        # 设置关闭流时的回调函数
        self._stream.set_close_callback(self.on_close)
    # 开始并运行客户端
    def start(self):
        # 使用多线程同时通知收发消息
        # 使用多线程时如果退出程序就必须要结束线程否则会抛出异常,程序何时结束取决于用户。
        t1 = threading.Thread(target = self.read_msg)
        t2 = threading.Thread(target = self.send_msg)
        # 为解决这个问题,将线程设置为daemon守护线程,daemon线程可也i也在主程序结束时自动结束。
        t1.daemon, t2.daemon = True, True
        # 开启线程
        t1.start()
        t2.start()
    # 读取消息
    def read_msg(self):
        self._stream.read_until(self.EOF, self.show_msg)
    # 屏幕打印读取的消息
    def show_msg(self, data):
        print(to_unicode(data))
        self.read_msg()
    # 发送消息
    def send_msg(self):
        # 使用while循环保持输入状态
        while True:
            # 读取客户端输入
            data = input()
            # 当输入完毕后将消息转换为字节byte型并拼接结束标识后发送
            self._stream.write(bytes(data) + self.EOF)
    # 用户退出时关闭_stream会激活_close函数
    def on_close(self):
        print("exit")
        quit()

if __name__ == "__main__":
    client = Client("127.0.0.1", 8000)
    client.connect()
    IOLoop.instance().start()

运行测试

$ python3 server.py
server start
client connection  ('127.0.0.1', 52088) 
read message
client entered  ('127.0.0.1', 52088)
client connection num is  1
WARNING:tornado.general:error on read: '>=' not supported between instances of 'int' and 'method'
client close  ('127.0.0.1', 52088)
$ python3 client.py
client connect
client send message
client recieve:  b''

服务器出现警告信息

WARNING:tornado.general:error on read: '>=' not supported between instances of 'int' and 'method'
client close  ('127.0.0.1', 52088)

TCPServer SSL

TCPServer的构造函数中会对非Nonessl_options选项参数进行检查,要求必须包括certfilekeyfile选项并且选线需要指定文件保存路径,但不会检查文件内容。至于文件内容得检查,会推迟到客户端连接建立时。ssl_options是一个字典dictionary类型,在Python3.2+之后可以使用ssl.SSLContext实例来代替。

TCPServer是支持SSL的,由于强大的Python支持SSL非常简单,因此只要启动一个支持SSL的TCPServer时告诉它你的certfilekeyfile即可。

TCPServer(
  ssl_options = {
    "certfile" : os.path.join(datadir, "domain.crt"'), 
    "kefile" : os.path.join(datadir, "domain.key")
  }
)

TCPServer初始化

  1. 使用listen的单进程形式

通过使用TCPServer的listen方法,程序将以单进程的方式运行服务器实例。

import tornado.tcpserver from TCPServer
import tornado.ioloop from IOLoop

server = TCPServer()
server.listen(8000)
IOLoop.current().start()

TCPServer提供的listen方法可以立即启动在指定地址端口上进行监听,并将相应的Socket加入到IOLoop事件循环中。listen方法可以多次调用,即同时监听多个端口。由于需要IOLoop事件循环来驱动,所以必须确保相应的IOLoop实例已经启动。

  1. 使用bind + start的多进程方式

通过使用TCPServer的bindstart方法,程序可以以多进程的方式运行服务器实例。

import tornado.tcpserver from TCPServer
import tornado.ioloop from IOLoop

server = TCPServer()
server.bind(8000)
server.starat(0) # fork派生创建多个子进程
IOLoop.current().start()

bind方法可以将服务器绑定到指定地址,并通过start方法启动多个子进程,以达到多进程运行的模式。

start方法通过参数num_processes来指定以单进程或多进程方式运行服务器,num_processes参数的默认值为1,即以单进程方式运行。当设置为None或小于0时将尝试使用与CPU核心数量相同的子进程运行。当设置为大于1时将以该值指定的子进程数量运行。不过,如果是以单进程方式运行服务器的话,一般都会直接使用listen方式。

在多进程模式启动时,不能将IOLoop对象传递给TCPServer的构造函数,如果这样做将会导致TCPServer直接按单进程方式启动。

  1. 高级多进程形式

TCPServer的bindstart方法内部实际封装的是绑定监听端口和启动子进程的业务逻辑,你也可以不使用这两个方式,而是执行调用绑定函数bind_socketsfork进程来达到多进程运行服务器实例的目的。

sockets = bind_sockets(8000)

tornado.process.fork_processes(0)

server = TCPServer()
server.add_sockets(socktes)

IOLoop.current().starat()

bind_sockets函数定义在tornado.netutil模块中,pork_processes函数定义在tornado.process模块中。

通过调用bind_sockets函数可以创建一个或多个鉴定指定端口的Socket,注意一个域名hostname可能会绑定到多个IO地址上。

通过调用fork_processes方法可以fork创建出多个子进程,其中主进程调用负责监听子进程的状态而不会返回,子进程会继续执行后续代码。

实际上TCPServer的bindstart方法内部也是通过调用bind_socketsfork_processes函数实现的。

高级多进程形式的主要优点是tornado.process.fork_processes(0)为进程的创建提供多的灵活性。


TCPServer类

__init__

TCPServer类的初始化方法__init__可以接收一个io_loop参数,实际上io_loop对TCPServer来说并不是可有可无的,它是必须的。不过TCPServer提供了多种渠道来与一个io_loop绑定,初始化参数只是其中一种绑定方式而已。

listen

在创建服务器实例后第一个被调用的是listen方法,TCPServer类的listen函数是开始接受指定地址端口上的连接。注意,这个listen与BSD Socket中的listen并不等价,它做的事比BSD socket() + bind() + listen()还要多。

listen函数注释中提到的一句话是这样的:你可以在一个Server的实例中多次调用listen以实现一个Server监听多个端口。这个应该怎么来理解呢?

在BSD Socket架构中是不可能在一个Socket上同时监听多个端口的,反推不难想到,TCPServer的listen函数内部一定是执行了全套的BSD Socket三段式create->bind->listen,使得每调用一次listen实际上是创建一个新的Socket。

def listen(self, port, address=""):
  # 创建Socket
  sockets = bind_sockets(port,  address=address)
  # 将创建的Socket添加到监听队列中
  self.add_sockets(sockets)

bind_sockets

bind_sockets函数的主要作用是创建Socket并绑定Socket到指定地址的端口,并开启监听。

bind_sockets函数并不是TCPServer的成员,它定义在netutil.py文件中,原型为:

def bind_sockets(
  port, 
  address=None, 
  family=socket.AF_UNSPEC, 
  backlog=128, 
  flags=None
):

端口列表

  • port 端口
  • address 地址
    address地址可以是IP地址,也可以是域名hostname,如果是localhost则可以监听域名对应的所有IP。如果address是空字符串""None则会监听主机上的所有端口。
  • family 网络层协议类型
    family网络层协议类型可选AF_INETAF_INET6,默认情况下两则都会被启用。此参数是在BSD Sockett创建时的sockaddr_in.sin_family参数。
  • backlog 监听队列的长度
    backlog指的是其实时BSD listen(n)中的n值。
  • flag 位标志
    flag位标志是用来传递给socket.getaddrinfo()函数的,比如socket.AI_PASSIVE等。

当在IPV4和IPV6混用的情况下,bind_socket函数的返回值是一个Socket列表,此时一个address地址参数可能对应一个IPV4和一个IPV6地址,但是它们的Socket是不通的,会各自独立创建。

# bind_socket的参数赋值流程
sockets = []
if address == "":
  address = None
if not socket.has_ipv6 and family == socket.AF_UNSPEC:
  family = socket.AF_INET
if flags is None:
  flags = socket.AI_PASSIVE

接下来是一个循环,之所以使用循环是因为IPv4和IPv6混用情况下getaddrinfo方法会返回多个地址的信息。

for res in set(socket.getaddrinfo(address, port, family, socket.SOCK_STREAM, 0, flags)):

socket.getaddrinfo()方法是Python标准库中的函数,其作用是将所接收的参数重组为一个结构resres类型将可以直接作为socket.socket()的参数,跟BSD Socket中的getaddrinfo函数相似。

循环体内是针对单个地址,会直接获取getaddrinfo方法的返回值来创建Socket。

af, socktype, proto, canonname, sockaddr=res
try:
  sock = socket.socket(af, socktype, proto)
except socket.error as e:
  if e.args[0] == errno.EAFNOSUPPORT:
    continue
raise

首先会从res这个元组tuple中拆分出5个参数,然后根据需要来创建Socket。

接下来会设置进程退出时对sock的操作

set_close_exec(sock.fileno())

例如:使用Tornado实现的TCPServer和TCPClient

服务器

  1. 创建一个继承于TCPServer类的实例,监听端口后开启服务 ,启动消息循环处理客户端请求,服务器开始运行。
from tornado.tcpserver import TCPServer
class Server(TCPServer):
server = Server()
server.listen(9000)
server.start()
IOLoop.current().start()
  1. 如果有客户端连接过来,Tornado会创建一个iostream,然后调用handle_stream方法,调用时传入两个参数iostreamclient的地址。
handle_stream(self, stream, address)
  1. 服务器每收到一段20字符以内的内容就将其反序回传,如果收到over则表示断开连接。
msg = yield stream.read_bytes(20, partial = True)
yield stream.write(msg[::-1])
if msg == "over":
  stream.close()
  1. 断开连接不使用yield调用,无论是谁主动断开连接,连接双方都会各自触发一个StreamClosedError错误。
except iostream.StreamClosedError:
  pass

运行服务器

$ vim server.py
#! /usr/bin/python3
# encoding=utf-8

from tornado import iostream, gen
from tornado.tcpserver import TCPServer
from tornado.ioloop import IOLoop

class Server(TCPServer):
    @gen.coroutine
    def handle_stream(self, stream, address):
        try:
            while True:
                msg = yield stream.read_bytes(20, partial = True)
                print(msg, "from", address)
                yield stream.write(msg[::-1])
                if msg == "over":
                    stream.close()
        except iostream.StreamClosedError:
            pass

if __name__ == "__main__":
    server = Server()
    server.listen(9000)
    server.start()
    IOLoop.current().start()
$ python3 server.py

客户端

$ vim client.py
#! /usr/bin/python3
# encoding=utf-8

from tornado import gen, iostream
from tornado.tcpclient import TCPClient
from tornado.ioloop import IOLoop

@gen.coroutine
def Trans():
    stream = yield TCPClient().connect("192.168.56.103", 9000)
    try:
        while True:
            data = input("Enter: ")
            back = yield stream.read_bytes(20, partial = True)
            msg = yield stream.read_bytes(20,  partial = True)
            print(back, msg)
            if data == "over":
                break
    except iostream.StreamClosedError:
        pass

if __name__ == "__main__":
    IOLoop.current().run_sync(Trans)
$ python3 client.py

使用TCPClientconnect方法连接到服务器,此时会返回iostream对象,向服务器发送一些字符串,它都会反序发回。最后发一个over则让服务器断开连接。

未完待续...

你可能感兴趣的:(Tornado TCP)