之前我们已经使用udp/tcp的相关接口写了一些简单的客户端与服务端代码。也了解了协议是什么,包括自定义协议和知名协议比如http/https和ssh等。现在我们再回到传输层,对udp和tcp这两传输层巨头协议做更深一步的分析。
一.UDP
UDP相关内容很简单,因为它不保证可靠性,面向数据报的特性。常用于直播和游戏等场景,所以我们分析也会简单些。
1.1UDP协议端格式
UDP报文的格式非常简单。16位源/目的端口号,这个不多介绍。16位UDP长度表示整个数据报(UDP 首部+UDP 数据)的最大长度,16位检验和则是确认UDP报文是否正确,如果错误直接丢弃。所以我们说UDP是面向数据报的,本身也不会产生粘包的问题。
1.2UDP的特点
UDP 传输的过程类似于寄信,也就是说,我们自己(应用层)吧信(数据报)寄出去后。邮局派人(UDP)将我们的信封放到我们事先说好的位置(目的端口号)后,如果说信封在邮箱里面别人取错了。原来要收我们信件的人此时就收不到信了。但邮局不管,想要对方收到信我们只能自己重发一次再邮寄出去。
所以我们可以总结UDP如下的几个特点:
- 无连接: 知道对端的 IP 和端口号就直接进行传输, 不需要建立连接;
- 不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方,UDP 协议层也不会给应用层返回任何错误信息;
- 面向数据报: 不能够灵活的控制读写数据的次数和数量;
1.3面向数据报
我们可以这样理解,应用层交给 UDP 多长的报文, UDP 原样发送, 既不会拆分, 也不会合并;用 UDP 传输 100 个字节的数据。也就是说如果发送端调用一次 sendto, 发送 100 个字节, 那么接收端也必须调用对应的一次 recvfrom, 接收 100 个字节; 而不能循环调用 10 次 recvfrom, 每次接收 10 个字节。
1.4UDP的缓冲区
- UDP 没有真正意义上的 发送缓冲区. 调用 sendto 会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
- UDP 具有接收缓冲区. 但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致; 如果缓冲区满了, 再到达的 UDP 数据就会被丢弃;
所以我们此时就明白了为什么UDP是全双工的,因为UDP的socket 既能读,也能写。
1.5UDP使用时的注意事项
我们注意到, UDP 协议首部中有一个 16 位的最大长度. 也就是说一个 UDP 能传输的数据最大长度是 64K(包含 UDP 首部).然而 64K 在当今的互联网环境下, 是一个非常小的数字.如果我们需要传输的数据超过 64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装;
基于UDP知名的应用层协议有:NFS: 网络文件系统,TFTP: 简单文件传输协议,DHCP: 动态主机配置协议,BOOTP: 启动协议(用于无盘设备启动),DNS: 域名解析协议等等。
二.TCP
在分析TCP之前,博主想多提一嘴。我们经常说UDP比TCP快,但博主自己认为这是不太对的。快也是要就场景的不同而言的,有的场景下TCP还比UDP快呢。所以我们不谈UDP和TCP谁快谁慢的问题。归根结底, TCP 和 UDP 都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定。
2.1TCP协议与其协议端格式
TCP 全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制。
- 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去。
- 序号与确认信号:个人认为是TCP中最为重要的部分,我们后面结合场景细致分析。
- 4位TCP首部长度:表示TCP报头长度大小。首先4位bits可以表示0~15的大小。因为我们的每个选项都是4字节的,所以0~15的每一个单位表示4字节大小。也就是说TCP报头实际长度为:4位首部长度*4(字节)。又因为选项最多有10个,最少为0个。所以TCP的4位首部长度取值范围为5~15(20字节大小~60字节大小)。
- 6位保留位:是预留给未来扩展使用的字段,当前协议(RFC 793及后续标准)中并未定义其具体用途。它们的默认值为全0,发送方必须将这些位设置为0,接收方则会忽略其值。
- 6位标志位: URG(确认紧急指针是否有效),ACK(表示当前报文中的确认序号是否有效),PSH(催促对端应用程序尽快把数据从缓冲区读走),RST(表示对方请求重新连接,也就是重新进行三次握手过程),SYN(对端请求建立连接; 我们把携带 SYN 标识的称为同步报文段),FIN(对端请求断开连接,我们称携带 FIN 标识的为结束报文段)。
- 16位窗口大小:表示发送当前报文的一方TCP缓冲区的剩余大小,后面我们还会再提到它。
- 16位校验和:与UDP一样,确认报文的合法性,错误了直接丢弃。由发送端填充, CRC 校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含 TCP 首部, 也包含 TCP 数据部分。
- 16位紧急指针:标识那部分是紧急数据,位置为报头首部地址+紧急指针大小。其实也就是记录了紧急数据位置相对于报头首部地址的偏移量。
- 40字节头部选项:暂时不提。
那么TCP是如何保证其传输的可靠性的呢,有什么优化传输速度的机制吗?我们从下面几个TCP的核心机制来分析。
2.2确认应答机制
TCP的确认应答机制是TCP可靠性保证的基石。因为我们最终的目的是要让对方收到我发出的数据,怎么能确认100%收到对方发送的数据呢。也就是确认应答机制,但这里说的确认是对历史数据的100%确认,我们下面细说:
我们以C->S一个传输方向为例:
(多提一嘴,为什么我们画箭头是斜着画的而不是平直的呢,这是因为报文到达对端需要一定的时间,斜着画能体现出这种时间间隔)。
当客户端发送数据给对端时,对方会发送一个携带有确认应答号的报文(此时报文中的ACK置为1)。那我们此时就能够确定我们历史上发给服务端的1000序号以前的数据都被服务端收到了。那服务端如何保证自己的应应答报文被客户端收到了呢?
客户端也发一个ACK回去吗,显然这又成了一个鸡生蛋,蛋生鸡的问题。所以我们的策略是:不对应答做应答,使用超时重传机制来让服务端确定对端是否收到应答报文。
2.3超时重传机制
我们先说数据丢失的情况:
主机 A 发送数据给 B 之后, 可能因为网络拥堵等原因, 数据无法到达主机 B,如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答, 就会进行重发。
再说我们之前提到的ACK丢失的情况:
因此主机 B 会收到很多重复数据。那么 TCP 协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉,这时候我们可以利用前面提到的序列号,就可以很容易做到去重的效果。
那么这个超时的时间大小如何确定呢?最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回".但是这个时间的长短, 随着网络环境的不同, 是有差异的.如果超时时间设的太长, 会影响整体的重传效率;如果超时时间设的太短, 有可能会频繁发送重复的包;
TCP 为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间。具体的过程如下:
- Linux 中(BSD Unix 和 Windows 也是如此), 超时以 500ms 为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms 的整数倍.
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
- 累计到一定的重传次数, TCP 认为网络或者对端主机出现异常, 强制关闭连接.
2.4连接管理机制
在正常情况下, TCP 要经过三次握手建立连接, 四次挥手断开连接:
由于TCP中通信双方的地位是对等的,所以我们只分析客户端主动向服务端发起连接/断开请求的情况。服务端主动的情况是一样的。
2.4.1三次握手过程
客户端流程:connect()
→ 发送SYN
→ SYN_SENT
→ 收到SYN+ACK
→ 发送ACK
→ ESTABLISHED。
服务端流程:listen()
→ 收到SYN
→ 发送SYN+ACK
→ SYN_RCVD
→ 收到ACK
→ ESTABLISHED
→ accept()
返回confd
。
为什么要进行三次握手,因为通信必须要双方同意才能进行。就比如结婚,一方如果不同意怎么结吗。其次,此过程中双方也进行了数据序号起始位置的交换,同时也以最短的方式验证了双方的全双工能力是否正常。(因为此时双方都能进行正常的接收发送)
其次我们发现ACK其实在传输过程中在双方的报文99%的情况下是置为1的,什么时候不置为1呢,一个是真失效了,还有一个就是主动发起连接的一方首次发送SYN给对端请求建立连接时。
还有几个细节问题,第一个既然要双方同意,不应该是4次握手吗,为什么是三次握手。因为服务端一般对客户端的请求是无条件同意的(这个我们下面说四次挥手时就明白了)。
第二个,TCP为了数据传输的高效性,通常会在应答报文中捎带上我当前端要发的数据。那么这个捎带数据不可能在前两次握手中出现。也就是说前两次握手时,双方发给对端的报文只有报头没有数据。因为此时连接并没有建立,不能发送数据给对方。
2.4.2四次挥手过程
因为双方断开连接,需要双方均同意才会断开连接。当主动断开方发送的FIN请求到达对端时,对端此时进入半关闭状态(如果想要看到这个状态可以把服务端代码中的close(fd)删除即可)CLOSE_WAIT。并发送ACK到主动断开方,主动断开方收到ACK后进入FIN_WAIT_2状态。
过了一段时间,服务端想要与客户端断开连接(调用close(fd)),发送FIN请求到客户端,客户端收到服务端断开连接请求后进入TIME_WAIT状态,并发送ACK报文给对方。客户端等待一段时间后进入CLOSED状态。服务端收到ACK后当前通信文件立马进入CLOSED状态。
TIME_WAIT状态
为什么主动断开连接的一方要等待一段时间呢?因为有的时候,对端在发送完FIN请求后。TIME_WAIT这段时间内有可能收到之前因为网络问题此时才到的对端之前发送的报文(因为我们的报文为了效率是并发的而不是上面那样一个一个发的,下面我们会说这个问题,此处先了解即可)。所以他的第一个作用为处理过期报文。
第二个作用,确保发送的ACK到达对端。因为超时重传机制,对端没有收到我发回的ACK时会重发FIN请求。收到了就不发了。也就是说在TIME_WAIT时间内,只要客户端没有收到消息,我客户端就认为对方收到了我的ACK应答。
唉,这时候就有人要钻牛角尖了。那如果更为极端的一种情况,过了TIME_WAIT时间后,再使用原来的端口号绑定,这个过期报文此时到达了,此时怎么办。
-
需要同时满足以下极不可能的条件:
-
报文在网络中存活超过MSL(违反IP协议规范)
-
新连接的五元组完全匹配旧连接
-
新连接的初始序列号恰好与旧报文匹配
-
-
现代TCP实现会使用随机化初始序列号(ISN),使得这种碰撞概率约为1/4 billion
所以基本上不太可能,有了我们也不考虑。因为工程上不会为了 “理论上可能,但实际几乎不会发生” 的情况增加过多复杂度。
那么TIME_WAIT等待的时间是多长呢,为什么?
TIME_WAIT 的时间是 2MSL,MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同, 在 linux 上默认配置的值是 60s。可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看 msl 的值。
MSL 是 TCP 报文的最大生存时间, 因此 TIME_WAIT 持续存在 2MSL 的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);同时也是在理论上保证最后一个报文可靠到达(假设最后一个 ACK 丢失, 那么服务器会再重发一个 FIN. 这时虽然客户端的进程不在了, 但是 TCP 连接还在, 仍然可以重发 LAST_ACK)。
bind_error的原因
因为主动断开连接的一方会进入TIME_WAIT状态。所以原来绑定的端口号在这段时间内仍然是被占用的。但是1分钟内不允许重新绑定,有些不太合理。比如说即将到来的618,如果在618期间我们就说tb,tb的服务器在此期间发生了服务器崩溃,重启要等上一分钟,但对于人家的服务器来说,分分钟几百万上下啊。所以这种情况下我们就可以在程序内设置TIME_WAIT的时间。
使用 setsockopt()设置 socket 描述符的 选项 SO_REUSEADDR 为 1, 表示允许创建端口号相同但 IP 地址不同的多个socket描述符。此处 opt=1
表示启用 SO_REUSEADDR。

2.5滑动窗口机制
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个 ACK 确认应答. 收到 ACK 后再发送下一个数据段. 这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候。既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
那么每次并行发送的数据量为多少,丢包了怎么办。应答报文丢了怎么办。TCP中的滑窗机制正是解决这些问题的存在。
先来说窗口大小的问题。还记得上面说的TCP报文里面的16位窗口大小吗?唉,那我们滑窗的大小根据对方剩余接收缓冲区大小来动态变化,大的时候我窗口大些发快些,小的时候反之。也就是说,滑窗的大小是由对方的接收缓冲区剩余空间大小决定的。(这里我们为了方便理解暂时这样说,后面说到拥塞机制了再补充)。这其实也正是TCP的流量控制机制。
再来说丢包丢应答的问题:
我们把要发送的数据抽象化为一个一维的数组:
我们一般以滑窗的位置对数据进行划分。start左边的我们称为已经发出并且确认收到的数据。start-end之间的我们称为已经发出但未确认对方收到的数据。end右边的便是未发出的数据。
所以我们先说丢包的情况,可以分为三种:最左端数据包丢失,中间丢失,右端丢失。实际情况一般是上面三种情况的组合。我们对最左端数据包丢失进行讨论,如果最左端数据包丢失,那么我们发出数据包的一段会收到多个相同的确认序号应答。比如以上图为例,会收到多个2001。此时我们客户端就可以确定100%我们最左端的数据包丢失了,此时我们对该位置的数据包重发即可。(我们也称这种机制为快重传)。
那中间,最右端以及混合情况呢。比如在中间,那我们收到报文的确认序号就可以判断中间那个位置100%数据丢失,对其进行重发同时start指针移动到该位置即可。我们发现,最终无论是哪种情况,都会转化为最左端数据包丢失的情况。
同样的我们说应答丢失的情况,如果说最左端,中间应答丢失了,我们不管,为什么?因为最右端的数据发出后的确认应答就可以帮助我们确认前面没有数据丢失,如果后面一连串的应答都丢失了?过了超时重传时间我们客户端就认为数据包丢失了,于是又回到了上面丢包时候的情况了。
传数据时应答序号一直增加,会溢出吗?
当然不会,通过环形数组的思想便可以轻松解决这个问题。每次取收数据时start与end对整个缓冲区大小取模不就可以了。
2.6拥塞控制机制
虽然 TCP 有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的.
TCP 引入慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
此处引入一个概念称为拥塞窗口。发送开始的时候, 定义拥塞窗口大小为 1;每次收到一个 ACK 应答, 拥塞窗口加 1;每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;也就是说,实际上我们的窗口大小是取拥塞窗口和接收端主机的反馈窗口的较小值。
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长:
当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回 1;少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。但这个中折的几乎完美,所以我们说指数增长的方法极为巧妙。
那么线性增长会有上限吗,因为网络稳定时他会一直增长,答案是有的。 线性增长到最后最终由接收方窗口、网络容量(BDP)或算法自身的收敛机制限制。毕竟网络稳定的时候再增长本身这件事情也就失去了意义。
2.7延迟应答机制
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小.
假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口就是 500K;但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费掉了;在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的窗口大小就是 1M;一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么? 肯定也不是;数量限制: 每隔 N 个包就应答一次;时间限制: 超过最大延迟时间就应答一次;具体的数量和超时时间, 依操作系统不同也有差异; 一般 N 取 2, 超时时间取 200ms;
2.8捎带应答机制
在延迟应答的基础上,我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收"的. 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";那么这个时候 ACK 就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户端。
其他的面向字节流,粘包等问题,我们之前编写简答服务器时已经有结合场景介绍过,这里不再过多介绍。
2.9TCP异常情况
- 进程终止: 进程终止会释放文件描述符, 仍然可以发送 FIN. 和正常关闭没有什么区别.
- 机器重启: 和进程终止的情况相同.
- 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行 reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
- 另外, 应用层的某些协议, 也有一些这样的检测机制. 例如 HTTP 长连接中, 也会定期检测对方的状态. 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接.
2.10小结
为什么 TCP 这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能。
可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等)
基于 TCP 应用层的知名协议有HTTP,HTTPS,SSH,Telnet,FTP,SMTP等,有兴趣的读者可以自行下去了解这些协议。