上节简单的介绍了http报文封装和dns请求获取目标IP
本节将介绍http报文在协议栈中如何进一步处理并发送到网络中。这里说的协议栈是指TCP/IP协议栈。
对于TCP传输而言,请求的发送和接收需要通过连接进行;而UDP传输则不用;本节会先介绍TCP传输的过程再介绍UDP传输。
在此之前,需要先介绍一些相关概念
* 协议栈
协议栈是由多个具有层级的协议模块组合而成的一个软件程序。每个协议模块通常都要和上下层的两个其他协议模块通信。最低级的协议总是描述与物理硬件交互,为每个高级的层次增加更多的特性。
协议是计算机在网络通信时需要遵循的规范和约定,而协议栈则是这种规范和约定的实现,也就是具体的代码和函数以供上层调用。每一层协议模块都是为上一层协议模块服务,本层的协议模块完成某个操作只需要调用下层模块的方法而无需关注其具体怎么实现。
* 套接字与连接
对于TCP传输而言,请求的发送和接收需要通过连接进行。我们可以把连接看做是一条端到端之间的管道,这条管道的两端都既可以是发送方也可以是接收方,数据在这条管道是双向流动的。而建立这条管道的出入口我们称之为套接字,分别在客户端和服务端两方,两方需要先创建套接字才能建立连接。连接建立后,请求和响应会通过套接字发出并通过这个管道进行传输。
当然管道只是一个比喻,现实中是不存在这么一个管道。
对于UDP传输,虽然不需要建立连接,但是依旧要创建套接字,并通过套接字发送和接收包。
* 什么是套接字
套接字本质是存放诸如IP地址、端口号、通信操作状态这样的通信控制信息的内存空间里的数据。协议栈在执行操作时会用到这些控制信息,如封装报文时需要知道通信对象的IP和端口,如协议栈需要知道数据发送后经过了多长时间仍未返回以便重发,如比对序号是否正确,如保存窗口大小从而将窗口大小带到TCP头部通知通信的另一方,这都要查询套接字的控制信息得知。
协议栈是根据套接字中记录的控制信息来工作的。
在windows下执行netstat可以显示套接字内容。如下图所示,每一行就是一个套接字:
第一列:协议,通常为TCP或UDP,表示该套接字是为TCP传输还是UDP传输而创建的。
第二列:本地端的ip和端口;如果安装了多块网卡则会显示不同的IP;0.0.0.0表示不绑定IP地址
第三列:远程端的IP和端口;0.0.0.0表示还未开始通信,没有绑定IP和端口;此外,UDP协议中的套接字不绑定对方地址和端口因此显示*:*
第四列:通信状态;LISTENING 等待对方连接;ESTABLISHED 完成连接正在通信
第五列:使用该套接字程序的进程ID
回到正题
当客户端程序(浏览器)拿到目标IP地址后,会经历:创建套接字、连接、发送数据、接收数据、断开连接、删除套接字。如下图所示
应用程序(浏览器)调用socket()方法,然后工作会切到操作系统的协议栈进行。协议栈会先分配一个用于存放套接字的内存空间,并向其写入初始状态,协议栈会返回一个代表套接字的描述符(一个整型)给浏览器。
之后当浏览器需要收发数据时(connect/read/write)就需要向协议栈提供这个描述符。协议栈会根据这个描述符找到对应的套接字进行操作。
创建套接字后,应用程序(浏览器)会调用connect方法,工作会切换到操作系统的协议栈,由协议栈负责将本地的套接字和服务器的套接字进行连接。
连接的本质是通信双方交换控制信息并记录到双方套接字中,之后的每一次数据收发时数据包都要带上套接字中的控制信息。例如在本地套接字中记录下服务器的ip和端口,并告知服务器请求方的ip和端口,让服务端把客户端的ip端口记录到服务端套接字中,此外还有其他的控制信息。
另外,连接操作过程中,通信双方还会分配一块用于临时存放收发数据的内存空间。
* TCP控制信息(TCP头部)
上面所说的控制信息会写入到TCP报文的头部发送到服务端,如下所示:
上面这些字段都是保存在套接字中,并且在需要构建头部的时候从套接字中取出这些信息。再通信双方的不断通信的过程中会更新到套接字中(如窗口和序号)。
* TCP传输的数据包
无论是连接、收发数据和断开连接,通信双方都需要通过发送数据包来进行。
对于连接和断开连接而言,无需传递应用程序(浏览器)数据,因此数据包只用包含控制信息(也就是头部信息)。而收发数据时的包会包含控制信息和数据块内容。应用程序的数据可能会很大,因此协议栈会将数据切分为一个个数据块,每个数据块都添加上头部控制信息做成包再发送。
如下所示:
下面我们正式介绍连接的过程:
到此为止,客户端和服务端的套接字都保存了对方的信息,控制流程从操作系统回到了应用程序,之后可以开始真正发送客户端的http消息了。在执行close之前,连接会一直保持。
上述双方交换3次数据包的过程也是大家熟悉的“三次握手”过程。
3. http消息封装成网络包发送
连接完成以后,应用程序(浏览器)会调用socket库的write()方法将数据(封装好的http消息)传递给协议栈,由协议栈发送给服务端。
在讲数据收发流程之前,需要先介绍一些数据收发的细节和重点:
* 网络包
包是网络数据在网络层(如IP包和以太网包)的称呼。
包是由记录了控制信息的头部和包的内容组成。IP头部和MAC头部是在网络层添加的,因此都能被称为包:
TCP模块负责将http消息加上TCP头部控制信息,并将这些数据传递给下一层的IP模块,由IP模块封装IP头部和MAC头部形成网络包,其中TCP头部记录着通信双方的端口号,IP头部记录着通信双方的IP。无论是连接阶段的数据还是数据收发的数据都会经过IP模块的封装和发送。
IP头部
PS:IP地址不是分配给计算机的而是分配给网卡的。因此如果计算机内包含多块网卡就可以拥有多个IP地址。如果客户端和服务端主机有多个IP地址,那么在IP头部中填写哪个IP地址取决于协议栈想让哪块网卡发送数据包,并把数据包发送到服务器的哪块网卡上。
MAC头部
下章节再对IP头部和MAC头部进行介绍。这里我们只需要知道添加了这两个头部之后数据就变成网络包,再经由IP模块传递给网卡,传递给网卡的时候,网络包才算正式离开了协议栈。
其他层的网络数据称呼
帧:数据在数据链路层的单位
包:数据在网络层的单位
段:数据在传输层的单位
消息:数据在应用层的单位
* 数据发送时机
协议栈不一定会在一收到数据就马上发出去,也可能是先将数据存到内部的发送缓冲区,等到积累到一定程度后再发送。
这样做的原因是应用程序每次传递多少数据给协议栈是不定的,有些应用程序是逐字或逐行节传递给协议栈,有些是一次性传递整个请求的所有数据给协议栈,如果应用程序每次传递给协议栈的数据很少,且协议栈每接收到一次数据就马上发送就会发送大量的小包导致网络效率下降。
每次传递多少数据给协议栈是由应用程序决定的。
是否让协议栈一接收到应用程序的数据就马上发送也是由应用程序进行系统调用时的指定的一些选项决定的。
譬如对于浏览器这种会话型应用程序而言,它会指定让协议栈马上发送数据以避免延迟。
如果应用程序决定让协议栈先缓存数据再发送的话,那么协议栈会根据两个要素决定发送的时机:一个是网络包能容纳的最大数据长度,一个是应用程序传递数据的频率(每次传递数据的时间间隔)
网络包能容纳的最大数据长度: 协议栈规定一个网络包能容纳的最大长度是MTU(在以太网中是1500字节),即IP头部+TCP头部+真实数据 <= 1500。除去头部,真实数据的最大长度是MSS = 1500 - 20 - 20 = 1460。当协议栈收到的数据接近或超过MSS的长度时再发出去就可以避免发送大量小包的问题。
应用程序传递数据的时间间隔:
当应用程序传递消息的频率不高的时候,如果每次都要等到长度接近MSS就会造成较大的发送延迟。协议栈中有一个计时器,当经过一定时间后即便缓冲区数据没有达到MSS也会将其封装为包发送出去。
* 大数据拆分
一般GET请求方式的HTTP消息不会很长,一个网络包(这里的包是指IP模块发出去的包)就能装下。但如果要传递表单甚至上传文件就可能超过一个包容纳的数据量(MSS),此时发送缓冲区的数据量会大于MSS。这时协议栈的TCP模块会将发送缓冲区中的数据(也就是http消息)以MSS为单位进行拆分为多个数据块,每个块被单独添加TCP头部,单独封装为网络包传输。
在拆分数据时,TCP模块会计算好每一块数据的第一个字节是整体数据的第几个字节,以此作为每块数据的TCP头部中的序号(序号是应用程序的数据序号而不是网络包的长度序号)。接收方可以根据接收到每个网络包的长度减去头部长度得到每个数据块的长度。然后通过当前最新序号+数据块长度得知下一个接收到的包的序号应该是多少。接收方返回的应答包的ACK号应该是当前最新序号+数据块长度+1,下一次发送方应该发送的序号也应该是接收方上一次回包的ACK号。
例如:当前序号为A,包的长度是1460。那么响应包ACK号应该是A+1460+1 。接收方应该接收到的下一个包的序号应该是A+1460+1 = A + 1461。如果接收方收到的下个包的序号比A+1461大,就说明中间有包遗漏。
序号告诉接收方当前发送方已经发送了多少字节的数据,ACK告诉发送方当前接收方接收了多少字节的数据(所以同理,发送方能通过ACK号判断接收方的回包是否有丢包)。
每次发送方发出的网络包都会得到接收方包含ACK号的回包,这种机制成为确认应答机制
PS:实际通信中,序号不是从1开始,而是用随机数计算出来的一个初始值。在连接阶段,双方设置SYN为1的时候,初始的序号也会被设置好并由通信双方互相发送给对方(同理初始ACK号也在连接阶段通知给对方)。
也就是说,通信的时候序号是有两个,一个是客户端生成的序号,一个是服务端生成的序号,因为TCP数据的收发是双向的(例如服务端会接收到客户端的包时回包,服务端也会在返回响应数据时主动发送包[这里不是指ACK包,而是返回http响应消息的网络包],所以客户端不只是发送方,服务端不只是接收方,应该是客户端和服务端都既能是发送方也能是接收方)。同理ACK号也有两个。如下:
序号和ACK号的出现是为了告诉通信双方每次发包发送了多少数据和接收了多少数据从而判断是否有丢包。丢包会引发TCP的重传机制,重传是TCP模块独有的错误补偿机制,网卡、路由器、集线器一旦检测到错误会直接丢弃包而不会重传。
* 重传机制之ACK号等待时间
当发送方发送包后会等待接收方返回带有ACK号的回包(在这个等待过程中发送方不会什么都不做,这里涉及到后面要介绍的滑动窗口,这里先不提),这段等待的时间叫做ACK号的等待时间,如果超过了这段时间都没有接收到回包,发送方会认为这个包在网络中丢失,因而重新发送这个包。
PS:发送方丢包 或者 发送方的包到达接收方后接收方的回包丢包 或者 接收方的回包因为网络拥塞而返回缓慢都可能造成接收方等待超过ACK号的超时时间而引起重传。
TCP模块怎么设置一个合适的ACK等待时间?
由于不同服务器的距离不同,或者不同网络环境下网络拥塞的情况不同(如局域网内可能几毫秒能返回ACK号,而在互联网中遇到拥塞可能几百毫秒才能返回),ACK等待时间不是一个固定的时间,而是动态变化的时间。TCP模块会持续测量多次ACK号的返回时间,如果前几次ACK号返回变慢就会延长等待时间,如果前几次ACK号能马上返回则缩短等待时间。
每次因为超过等待时间导致的重传会延长这个超时时间,如果多次重传后仍未收到确认包则会断开连接。
* 重传机制之快速重传
快速重传是比超时重传更有效率的重传方式,当接收方接收到乱序的报文(如 1-3-2,或者1-3-4)时,会立刻不延迟的返回重复ACK的回包给发送方,发送方重复接收到3次相同的ACK号之后就会重发丢失的包。如下所示:
图中发送方第一个包到达,接收方返回ACK号为ACK2的回包;但发送方的第二个报文丢失,之后第3~5个包到达接收方,接收方通过确认包的序号得知第二个包没有发送过来,因此对第3~5的包返回重复的ACK号(ACK2)。
接收方接收到多次重复的ACK号为ACK2的响应包之后得知第2个包没有发送到接收方,就会重新发送第2个包(序号为ACK2的包),并且重发第3~5个包(因为发送方得到的最新ACK号是ACK2,因此会重发序号为ACK2以后的所有包)。接收方接收到第2~5个包后,统一回复了一个ACK号为ACK6的包
如果使用了SACK选项,发送方只会重发第2个包,而不会重发3~5。
* 简述滑动窗口
如果TCP模块发送一个包并等待回包的过程中什么都不做会显得十分浪费。为了减少这样的浪费,TCP模块使用滑动窗口的方式发送包,也就是说它不会非得等到一个包回包之后才发下一个包,而是连续发送多个包并连续接收多个回包。如下所示:
接收方在接收到包会先存放到接收缓冲区,TCP模块会从缓冲区中获取包并计算包的ACK号,将多个包的数据块组装起来还原为原本的数据并传递给接收方的应用程序。
如果包到达的速度要比数据处理并传递给应用程序的速度快,那么缓冲区中数据就会越积越多最后溢出,溢出之后,接收方就无法接受后面的包。
如下图所示,当接收方收到包之后,会马上从接收缓冲区中取出包并处理,缓冲区的空间会得到释放,然后在返回包的TCP头部注明接收缓冲区的剩余空间(也是窗口大小),这样发送方收到回包后就知道下次发送的数据量不要超过这个窗口的大小的数据量。
在接收方不断发送数据的过程中,它会自动计算自己用掉了多少窗口大小,当计算到窗口大小为0时会暂停发送包。在这个过程中如果接收方回包,发送方会根据回包中的TCP头部窗口大小来更新自己套接字内的窗口大小(自动计算过程为4380->2920->1460,此时接收方返回ACK包并告知窗口大小剩余2920,发送方会更新当前窗口大小为2920,再从2920继续自动计算,2920->1460->0,此时暂停,直到接收方的ACK包又到达发送方,发送方再更新窗口大小,又开始继续发包)。这就是滑动窗口的基本思路。
这张图是为了讲解方便,故意体现一种接收方来不及处理收到的包,导致缓冲区被填满的情况。实际上,接收方在收到数据之后马上就会开始进行处理,如果接收方的性能高,处理速度比包的到达速率还快,缓冲区马上就会被清空,并通过窗口字段告知发送方。
还有,图中只显示了从右往左发送数据的操作,实际上和序号、ACK号一样,发送操作也是双向进行的。
另外需要注意的是:接收方不会对发送方的每个包都发送回包,因为接收方发送回包其实是为了通知发送方ACK号和窗口大小(也就是告诉发送方我收到了多少包以及我还能接收你多少包),所以接收方在一段时间内连续收到发送方多个包后可能只会发送一个回包里面记录着当前最新的ACK号(即最后一个来包对应的ACK号)和最新的窗口大小,中间的ACK号会省略,这样减少了包的数量提升了网络通信的效率。
当服务端接收完客户端某个HTTP消息的所有包并还原为http消息时,服务端的应用程序就对这个http请求进行处理,再响应数据封装为http响应消息,经过协议栈封装成一个个包返回给客户端。这时服务端变成了发送方,而客户端变成接收方。
4. 接收http响应消息
当协议栈将http消息以多个包的形式发送完毕之后,工作流程回到应用程序(浏览器),应用程序会调用read()接收响应消息。工作流程再次转移到协议栈。
响应消息到达客户端主机后会先暂存在接收缓冲区,协议栈会尝试从缓冲区获取数据,如果缓冲区中没有数据,则协议栈会暂停接收工作去做其他事情。直到消息到达才会将数据传递给应用程序,即把数据复制到应用程序指定的内存地址(数据量大的情况下不会一次性全传递给应用程序,而是会分多次传递)。
在这一步中,还略过了很多的细节,如协议栈会检查收到的数据块和TCP头部,判断是否有数据丢失;向服务器方更新窗口大小;将多块数据按照序号拼接为原始的数据,等等。这些过程和客户端发送发送方时的过程类似。
服务器断开连接并删除套接字
发送完数据的一方会主动发起断开连接的操作。为Web通信中,服务器一方是最后发送数据的一方因此会主动关闭连接。
以上过程就是大家熟知的“四次挥手”过程。
服务端不马上不关闭连接而是进入time-wait状态是为了防止误操作。例如当客户端是主动发起断开连接的一端(假定客户端的套接字是绑定54305这个端口),那么最后发送ACK包的会是客户端,如果这个ACK包丢包,服务端等待一段时间没有收到这个ACK包会重新发FIN包给客户端。假如客户端没有time-wait状态而是在发送ACK包后直接关闭连接(即删除套接字,释放54305这个端口),那么客户端可能会为其他连接生成一个新的套接字绑定54305这个端口。服务端重发的FIN包到达客户端后,客户端根据包头部的接收方端口找到了新的套接字上就会导致该新套接字上的连接关闭。
time-wait状态一般会持续几分钟。
附:使用UDP协议收发包
相比于TCP协议,使用UDP协议发送包无需建立连接(通信双方服务交换控制消息),因此数据的收发相比于TCP减小了开销和发送延迟更加高效。但是UDP协议没有TCP协议的安全传输机制如确认应答机制,重传和窗口等。
UDP头部的控制信息
UDP适用于以下场景
短数据
如果可以仅用一个包就将所有数据发送给对端,那么就无需建立连接以保存双方的IP和端口以及其他信息到套接字中。而且数据少意味着传输的包数量少,丢包的可能性就会减小,也就无需像TCP那样对包的送达状态进行监控。如果丢包,协议栈收不到对方的回复是不会自行重发数据包的,应用程序可以自行组织重发数据(需要开发应用程序的人编写重试逻辑)。
视频和音频数据
音频和视频数据必须在规定的时间内送达,一旦送达晚了,就会错过播放时机,导致声音和图像卡顿。如果像TCP一样通过接收确认应答来检查错误并重发,重发的过程需要消耗一定的时间,因此重发的数据很可能已经错过了播放的时机。
此外,音频和视频数据中缺少了某些包并不会产生严重的问题,只是会产生一些失真或者卡顿而已,一般都是可以接受的。使用UDP发送数据的效率会更高。
上面的操作都是围绕着传输层TCP模块介绍数据的收发。其实TCP报文还需要在网络层的IP模块中经过添加IP头部和MAC头部封装成包再委托给网卡发送出去。
UDP套接字编程
客户端代码:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 使用IPv4和UDP运输协议
addr = ("127.0.0.1", 8081)
while True:
data = input("enter something: ")
if not data or data == "quit":
break
s.sendto(data.encode(), addr) # 直接消息,无需建立连接,由于没有和服务端交换控制信息,所以每次发送消息都要告诉协议栈目标IP和端口
msg, _ = s.recvfrom(2048)
print(msg.decode())
s.close()
服务端代码:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 使用IPv4和UDP运输协议
s.bind(("127.0.0.1", 8081))
while True:
msg, addr = s.recvfrom(2048) # 无需接收连接,直接接收消息
print("addr: %s:%s; msg: %s" % (addr[0], addr[1], msg.decode()))
s.sendto("OK".encode(), addr)
s.close()