【从0开始学习计算机网络】| 从“发一条等一条“到“流水线发货“:TCP 是怎么保证数据不丢的?

🌈个人主页:一条泥憨鱼(欢迎各位大佬莅临)

🎬精选专栏传送门:

❄️《数据结构》 ❄️《AI与Agent那些事》

❄️《从0开始学计算机网络》 ❄️《后端开发》

你有没有想过一个问题:你在手机上发一条微信消息,对方几乎立刻就能收到。中间的网线、路由器、Wi-Fi 信号、海底光缆……这么多环节,凭什么数据就不会丢?

答案是:它确实会丢

网络本身并不保证数据一定能送到。数据包可能在某个路由器上被丢弃,可能走了不同的路导致到达顺序乱了,甚至可能因为重传而出现两份一模一样的数据。但你在用微信、刷网页的时候,几乎感觉不到这些问题。这背后的功臣,就是 TCP 的可靠性机制

这篇文章,我们就从最朴素的想法开始,一步步看看 TCP 是怎么做到"数据不丢、不乱、不重复"的。

先搞清楚:网络为什么会"丢东西"?

先打个比方。你要给朋友寄快递,有几种寄法:

- 最靠谱但最慢:你亲自送过去,当面交到朋友手上。

- 比较靠谱:发顺丰,能查到物流,丢了可以赔。

- 不太靠谱:把东西扔进一个公共传送带,能不能到全看运气。

互联网的底层传输,更像是第三种。数据被切成一个个数据包(packet),每个包独立地在网络中寻找路径。这就带来三个问题:

1. 丢包:某个路由器太忙了,直接把包扔了。

2. 乱序:包 A 和包 B 走了不同的路,包 B 先到了。

3. 重复:发送方以为包丢了,重发了一份,结果原来的包其实也到了。

所以,"不可靠"是网络的默认状态。而 TCP 的任务,就是在这样一条不靠谱的路上,硬生生地搭出一条"看起来靠谱"的通道。

那怎么让发送方知道对方收到了呢?最朴素的想法是:你收到了就回我一声。这个"回一声"就是 ACK(Acknowledgment,确认应答)

有了 ACK,就有了最原始的可靠性协议——停止等待协议

停止等待:最笨但最可靠的起点

停止等待协议的逻辑,用一句话概括就是:发一个包,等对方确认,确认到了再发下一个。

发送方的逻辑大概长这样:

# 停止等待协议 —— 发送方伪代码

seq = 0  # 当前发送的序号



while 还有数据要发:

    send_packet(seq, data)     # 发送当前包

    start_timer()              # 启动超时定时器



    while True:

        ack = wait_for_ack()   # 等待对方的确认



        if ack is None:        # 超时了,没等到

            send_packet(seq, data)  # 重传同一个包

            start_timer()

        elif ack == seq:       # 收到正确的确认

            stop_timer()

            seq = 1 - seq      # 序号在 0 和 1 之间翻转

            break              # 可以发下一个包了

        elif ack != seq:       # 收到重复/过期的 ACK,忽略

            continue

```

这里有个细节值得说一下:序号为什么只在 0 和 1 之间翻转?

因为停止等待协议一次只发一个包,接收方只需要区分"这是新包还是旧包的重传"。用 1 个 bit 就够了——0 代表这一轮,1 代表下一轮。接收方收到序号 0 的包,回 ACK 0;如果又收到一个序号 0 的包,说明是重传,丢弃就好,但 ACK 还得再回一次(因为对方可能没收到上次的 ACK)。

看起来挺完美对吧?可靠、简单、逻辑清晰。

但它有个致命的问题:太慢了

我们来算一笔账。假设:

- 发送方到接收方的往返时间(RTT,Round-Trip Time)是 100ms

- 每个数据包大小是 1KB(约 8000 bit)

在停止等待协议下,发送方发一个包,必须等 100ms 收到 ACK 后,才能发下一个包。那么每秒能发多少个包?

1 秒 = 1000ms

每 100ms 发 1 个包

每秒发送:1000 / 100 = 10 个包

每秒数据量:10 × 1KB = 10KB = 80Kbps

```

80Kbps。而今天随便一条家庭宽带都是 100Mbps 起步。也就是说,带宽利用率连千分之一都不到。

问题出在哪?发送方在等 ACK 的那 100ms 里,完全闲着没事干。信道是空的,但数据没在传。

能不能一次多发几个包?——这就是连续 ARQ要解决的问题。

连续 ARQ 与滑动窗口:让数据"流动"起来

连续 ARQ(Automatic Repeat reQuest,自动重传请求)的核心思想很简单:别等 ACK 了,连着发好几个包。

发送方一口气把包 1、2、3、4、5 全发出去,然后等着收 ACK。这样一来,RTT 那 100ms 里,信道一直在传数据,效率一下就上来了。

但"连续发"会带来新问题:

- 包 2 的 ACK 丢了,发送方怎么知道包 2 到底收到没有?

- 接收方收到了包 1、2、4,唯独缺了包 3,它该怎么告诉发送方?

这时候就需要一个更聪明的机制——滑动窗口

窗口是什么?

你可以把"窗口"想象成发送方手里的一张待办清单。这张清单上列出了"我现在可以发、但还没收到确认"的那些包。

- 窗口的左边界:最早一个还没被确认的包。

- 窗口的右边界:当前允许发送的最远的一个包。

- 窗口的大小:决定了发送方在等 ACK 的同时,最多能发出去多少个包。

每收到一个 ACK,窗口就向右"滑动"一格,露出一个新的可发包。

举个例子。假设窗口大小是 5,初始时窗口覆盖序号 1~5:

```

初始状态:

[1 2 3 4 5] 6 7 8 9 10 ...

 ↑         ↑

左边界    右边界

```

发送方把 1~5 全发出去。过了一会儿,收到了 ACK 3(意思是"3 及之前的我都收到了")。窗口就向右滑动,覆盖 4~8:

```

收到 ACK 3 后:

1 2 3 [4 5 6 7 8] 9 10 ...

       ↑         ↑

     左边界    右边界

```

注意这里用的是累计确认(Cumulative ACK):ACK 3 不是说"我只收到了 3",而是说"3 以及 3 之前的所有包,我都收到了"。

累计确认的好处是:即使中间某个 ACK 丢了,后面的 ACK 也能"顺带"确认前面的。比如 ACK 2 丢了,但 ACK 3 到了,发送方一样知道 1、2、3 都收到了。

但它也有缺点。假设发送方发了 1、2、3、4、5,接收方收到了 1、2、4、5,唯独 3 丢了。接收方会怎么回 ACK?

它会一直回 ACK 2。因为 3 没到,它不能确认 3 之后的任何数据。发送方收到一堆 ACK 2,就知道 3 丢了,于是从 3 开始全部重传——哪怕 4 和 5 其实已经躺在接收方的缓冲区里了。

这就是累计确认的代价:一个包丢了,可能引发一批包的重传。实际实现中(比如 TCP 的快速重传),会用一些优化手段来缓解这个问题,但基本原理是这样的。

流量控制:别把接收方"撑死"

到这里,发送方的效率问题解决了。但新的问题又来了:发送方发得太快,接收方扛不住怎么办?

想象一下:发送方是台高性能服务器,接收方是部老旧手机。服务器哔哔哔地把数据灌过来,手机的接收缓冲区(一块临时存数据的内存)很快就满了。满了之后,再来数据就只能丢弃。一丢弃,发送方又得重传,陷入恶性循环。

所以接收方需要一种方式告诉发送方:"你慢点,我这边快装不下了"。

这个机制就是流量控制,靠的是 rwnd(Receive Window,接收窗口)。

rwnd 是怎么工作的?

每次接收方回 ACK 的时候,会顺便在 TCP 头部里带一个字段:`rwnd`。它的意思是:"我当前的接收缓冲区还能装多少字节"。

发送方收到这个值后,会把自己的发送窗口大小调整为 `min(拥塞窗口, rwnd)`——也就是说,发送方最多只能发出接收方还能装下的数据量。

我们用一段接收方的伪代码来感受一下:

# 接收方 —— 流量控制逻辑

BUFFER_SIZE = 4096  # 接收缓冲区总大小,单位字节



buffer = b""        # 当前已缓存但还没被上层应用读走的数据



def on_receive(data, seq):

    global buffer

    buffer += data



    # 计算还能接收多少

    rwnd = BUFFER_SIZE - len(buffer)



    # 回 ACK 时带上 rwnd

    send_ack(seq, rwnd)



def on_app_read(n):

    """上层应用从缓冲区读走 n 字节"""

    global buffer

    buffer = buffer[n:]

    # 缓冲区空出来了,需要通知发送方可以继续发了

    send_ack(current_seq, BUFFER_SIZE - len(buffer))

```

零窗口和坚持计时器

有个特别有意思的场景:接收方缓冲区满了,rwnd = 0。

发送方收到 rwnd = 0,就停止发送,等着。等接收方的应用把数据读走,缓冲区空出来了,接收方会主动发一个 ACK,告诉发送方"我现在能收了,rwnd = 2048"。

但如果这个"窗口更新"的通知丢了呢?

发送方在等通知,接收方以为通知已经发出去了,双方就这么干瞪眼——死锁了。

为了解决这个问题,TCP 设计了一个坚持计时器(Persist Timer)。当发送方收到零窗口通知后,会启动这个计时器。时间一到,就发一个窗口探测包(只带 1 字节数据或纯探测),接收方收到后必须回应自己当前的 rwnd。这样即使窗口更新通知丢了,也能通过不断探测来恢复通信。

这个细节特别能体现 TCP 的设计哲学:任何可能丢的东西,都要有兜底机制。

写在最后

回头看,TCP 的可靠性不是靠某一个"银弹"实现的,而是一堆机制互相配合的结果:

- 序号:让接收方知道数据的顺序,能识别重复。

- ACK:让发送方知道数据到了没有。

- 超时重传:ACK 没来就再发一次,兜底。

- 滑动窗口:让数据连续流动,提高效率。

- 累计确认:简化确认逻辑,但可能引发批量重传。

- 流量控制(rwnd):保护接收方不被撑死。

- 坚持计时器:防止零窗口死锁。

这些机制各自解决一个问题,合在一起才构成了"可靠传输"。

如果你刚开始学网络,我的建议是:先理解"为什么需要这个机制",再去记"它怎么实现"。比如你先想明白"发送方不等 ACK 会有什么问题",自然就能理解滑动窗口存在的意义,而不是死记硬背窗口的滑动规则。

最后,强烈建议你动手抓一次包。装个 Wireshark,随便打开一个网页,过滤 `tcp`,看看真实的 TCP 报文里那些字段——序号、ACK 号、Window Size——它们就是你刚学的这些概念在现实中的样子

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值