CS144计算机网络
Lab0
root@LAPTOP-NNMIV17L:~/cs144_lab# telnet cs144.keithw.org http
Trying 104.196.238.229...
Connected to cs144.keithw.org.
Escape character is '^]'.
GET /hello HTTP/1.1
Host: cs144.keithw.org
Connection: close
HTTP/1.1 200 OK
Date: Tue, 25 Nov 2025 10:13:34 GMT
Server: Apache
Last-Modified: Thu, 13 Dec 2018 15:45:29 GMT
ETag: "e-57ce93446cb64"
Accept-Ranges: bytes
Content-Length: 14
Connection: close
Content-Type: text/plain
Hello, CS144!
Connection closed by foreign host.
root@LAPTOP-NNMIV17L:~/cs144_lab#Socket是什么
对应用程序来说:
Socket 就是一个文件描述符(File Descriptor)。它看起来和普通文件一模一样。你可以对它进行 write()(写入数据)和 read()(读取数据)操作。- 当你往普通文件的描述符里写数据,数据被存到了硬盘上。
- 当你往 Socket 的描述符里写数据,数据被交给了网卡,发向了世界另一端。
在普通的编程中(比如写 Python 爬虫),操作系统(OS)帮你管理 Socket,你只需要调用它。
但在 CS144 中,你的角色变了:你要自己写一个“用户态的 TCP 协议栈”。
也就是说,你现在要编写那个管理 Socket 内部逻辑的代码
比喻:Socket 就像是房子墙上的插座。
作为用户(Application),你只管把插头插进去用电(发数据),不管电是怎么来的。
现在,你是电力工程师。你需要去铺设墙里的线路,保证电流(数据)能稳定传输
网络就是在不同机器之间传递字节流吗
应用层的视角):
是的。应用程序(如 HTTP、SSH)认为网络就是一个连续的、可靠的、按顺序的字节流。我发 "ABC",对方一定收到 "ABC"。网络的真实面目:
绝对不是。互联网的最底层(IP层)传递的是数据包(Datagrams)。它们就像明信片。
不可靠:可能会丢。
乱序:你先发 A,再发 B,对方可能先收到 B,再收到 A。
受限:每个包大小有限制(MTU)。
CS144 的核心任务就是:
在不可靠的“明信片”(IP Packet)服务之上,营造出一个可靠的“字节流”(TCP Stream)假象。
TCP 协议就是那个魔术师,它在底层疯狂地重发丢失的包、把乱序的包排好队,然后告诉上层的应用程序:“嘿,这是一个完美的字节流,拿去用吧。”
Lab0 的工作
你的电脑 (Sender) 对方电脑 (Receiver)
+---------------------------+ +---------------------------+
| 应用程序 (Web) | | 应用程序 (Web) |
+-------------+-------------+ +-------------^-------------+
| write("hello") | read() -> "hello"
v |
+-------------+-------------+ +-------------+-------------+
| Lab 0: ByteStream | <---关键--->| Lab 0: ByteStream |
| (暂存数据的蓄水池) | | (暂存数据的蓄水池) |
+-------------+-------------+ +-------------^-------------+
| pop() | push()
v |
+-------------+-------------+ +-------------+-------------+
| Lab 3: TCP Sender | | Lab 2: TCP Receiver |
| (负责把流切成包发送) | | (负责把包拼回成流) |
+-------------+-------------+ +-------------^-------------+
| |
v (不可靠的互联网 - IP层) |
[Packet 1] ---> [Packet 3] ---> [Packet 2] --------/没有这个 ByteStream, TCP 协议就没法处理“网速”和“程序读写速度”不匹配的问题 —— 流量控制 Flow Control
Lab1 reassambler
为什么要写这个?因为互联网是不可靠的。
核心概念:分组交换 (Packet Switching)
互联网(IP 层)是基于“尽力而为”(Best-effort)交付的。当你发一张图片给朋友,图片被切成了几千个小包(Packet)。在网络中:
- 乱序 (Reordering):第 10 个包可能比第 1 个包先到(走了不同的路由)。
- 丢失 (Loss):第 5 个包可能丢了(路由器堵车扔掉了)。
- 重复 (Duplication):第 3 个包可能收到了两次(发送端以为丢了重发了)。
Reassembler 的角色:TCP 的接收端
在现实世界中(Linux 内核、Windows 网络栈),当你正在看这个网页时:
- 光缆/WiFi 传来的是物理信号。
- 网卡 将其转为以太网帧。
- IP 层 识别出这是发给你的包。
- TCP 层 (你的 Lab 1):
- 操作系统内核里有一个极其复杂、高度优化的 Reassembler。
- 它看着进来的乱序数据包(Sequence Numbers),把你刚才请求的 HTML 文字拼凑好。
- 如果中间缺了一块(比如 index 1000-1400 丢了),TCP 就等着,不把后面的数据给浏览器,直到重传的数据填补上这个坑。
- 应用层 (浏览器):调用 read() (对应 Lab 0 的 reader().pop()),拿到的就是完美的 HTML 代码。
具体实现
传过来的数据包是乱序的 有重叠的 而我要提供给bytestream 的字节流一定要是按照顺序的(这一层组件默认字节流是有序的 他只管处理缓冲区的读入和写出)
所以需要进行剪切 暂存 拼接
流水线处理
由于只有一个主要的方法receive
我们应该按照流水线设计这个方法 能够更好地理清思路
- 首先进行溢出检查
传入的idx很可能是会溢出的 保证安全是第一位 - 进行头部尾部裁剪
头部(早于我期望的idx的数据不要)
尾部(会导致bytestream溢出的数据不要) - 查找与合并
使用std::map<idx, std::string>维护所有pending的字节流片段 如果当前可以合并的话 就合并 - 如果拿到了期待的idx 那么就写入流 如果没有待写入的了就关闭流(收到了eof且pending bytes == 0)
合并两个字符串
- 初始状态:试图通过 if-else 穷举所有可能的重叠情况(左边重叠、右边重叠、包含、被包含...)。这导致代码臃肿、难以维护且容易漏掉边界情况。
- 现在的领悟:区间并集(Union of Intervals)。
- 不论两个区间怎么重叠,合并后的新区间永远是
[min(start1, start2), max(end1, end2)]。 - 收获:在处理复杂逻辑时,先寻找数学上的不变量(Invariant或通用公式,而不是陷入具体的 Case study。这能极大地简化代码
具体操作
- 不论两个区间怎么重叠,合并后的新区间永远是
- 通过min max获得新的字节流idx边界
- 如果1在前面 那么就1拼上2后面可能多出的
- vice versa
无符号整数溢出
使用UINT64_MAX - start > size可以判断是否溢出
Lab2
acknowledgment means, “What’s the index of the next byte that the receiver needs so it can reassemble more of the ByteStream?” This tells the sender what bytes it needs to send or resend. Flow control means, “What range of indices is the receiver interested and willing to receive?” (a function of its available capacity). This tells the sender how much it’s allowed to send
这个饰演的内容就是制作一个TCP接收器 他的作用
- 转换TCP头部的seqno变为ByteStream中的index(从32位变为64位)
- 为了实现这个 新增了一个
wrap32类 用来wrap和unwrap- wrap是receiver发送信息时候使用 把期望得到的idx变为seqno
- unwrap是receiver与bytestream交互的时候使用 得到从0开始的idx
- 为了实现这个 新增了一个
- 向sender发送相应信息 主要是两个
- window size 我可以接受多大范围内的字节数 如果超出了 就可能会溢出ByteStream
- ackno 我期望的下一个字节是什么 可以通过底层的ByteStream中存放的expected_idx经过wrap获得
内部通过一个map 暂时存储所有不能放入byte stream的数据(因为对于上层而言 必须保证字节流是有序完整的)
收到一个数据包以后 先尝试与map中的片段合并 然后看看最早的是不是想要的index 如果是 就推入字节流 如果不是就暂存
Lab3 TCP sender
主要是实现重传和流量控制
主要方法
- push
在windowsize允许的情况下(window通过receiver的信息更新 窗口大小为0时设置为1 这样可以通过发送一个小数据来不断询问)发送字节流 如果这里没有要发送的了就发送时加上FIN
注意这里发送的所有包要暂存 因为不能保证送到了 并且更新bytes in flight -> 这会减少可用窗口 - receive
收到receiver的回应 根据ackseqno更新需要的seqno 并且将本地暂存的比这个ackno更早的message删掉(之前的都被收到了)并且更新window size 这个时候会归零计时器(有包被收到了) - tick
由更上层的模块定时调用 每次tick的时候 会增加计时器 如果到达了RTO 说明很长时间没有包被接受了 所以要重新传第一个包(如果这个时候windowsize == 0 说明对面还没处理好 那么就正常重传即可)(如果不是 说明是网络拥堵导致的包没有传到 要指数退让)
假设网络现在堵得水泄不通,路由器每秒只能处理 100 个包,但有 200 个人每秒想发 1 个包。必然有一半的包会丢。
方案 A:简单增加 (Linear Backoff)
- 策略:RTO = 1s, 1.1s, 1.2s, 1.3s...
- 后果:
- 你发现包丢了,过了 1 秒重传。重传的包又去挤那个已经堵死的路由器。
- 如果成千上万个用户都只延迟一点点就立马重传,路由器的压力并没有实质性减轻。
- 路由器继续丢包 -> 大家继续频繁重传 -> 路由器压力更大 -> 丢更多的包。
- 这是一个恶性循环 (Positive Feedback Loop)。
- 结果:网络吞吐量跌到 0,谁也发不出去。这就是拥塞崩溃。
方案 B:指数翻倍 (Exponential Backoff)
- 策略:RTO = 1s, 2s, 4s, 8s, 16s...
- 后果:
- 第一次重传失败,你等 2 秒。
- 第二次失败,你直接等 4 秒。
- 第三次失败,你闭嘴等 8 秒。
- 关键点:随着重传失败次数增加,你的发送频率呈断崖式下跌。
- 宏观效果:如果网络中所有人都在使用这个策略,当拥塞发生时,大家都会迅速“闭嘴”,停止向网络灌水。
- 结果:路由器的队列迅速排空,拥塞解除,网络恢复正常。
如何设置RTO
情况 A:RTO 设置得太短 (RTO < RTT)
- 场景:
- 真实路况(RTT)需要 200ms。
- 你是个急脾气,设置 RTO = 100ms。
- 后果:
- 100ms 到了,你没收到 ACK,以为丢了,于是重传。
- 其实包没丢,只是还在路上。
- 结果:接收方收到了两份一模一样的数据(浪费带宽)。你的网卡发了双倍的数据。
- 这叫“虚假重传” (Spurious Retransmission)。
情况 B:RTO 设置得太长 (RTO >> RTT)
- 场景:
- 真实路况(RTT)只要 20ms。
- 你是个慢郎中,设置 RTO = 5000ms (5秒)。
- 后果:
- 包真的在第 10ms 的时候丢了。
- 你傻傻地等了 5 秒钟才意识到不对劲,才重传。
- 用户体验:网页卡住了 5 秒,转圈圈。
- 这叫“延迟过大”。
RTT动态估算
在 Linux/Windows 内核中,TCP 使用一套统计学公式来实时计算 RTO。
它维护两个变量:
- SRTT (Smoothed RTT):平滑后的 RTT 平均值(过滤掉偶尔的抖动)。
- RTTVAR (RTT Variance):RTT 的波动幅度(方差)。
计算公式(经典版):
RTO = SRTT + 4 × RTTVAR
如果网络很稳(RTTVAR 很小),RTO 就紧贴着 SRTT,丢包反应极快。
如果网络很抖(一会儿 20ms 一会儿 500ms,RTTVAR 很大),RTO 就会自动变大,留出足够的“安全余量” (Safety Margin),防止误判
RTT (往返时间) 是物理现实,是网络快慢的体现。
RTO (重传超时) 是逻辑决策,是发送方设定的“耐心阈值”。
重传机制 就是在 “太快重传浪费带宽” 和 “太慢重传影响体验” 之间寻找平衡的艺术。
快速重传
- 场景:你发了 1, 2, 3, 4。对方收到了 1,但是 2 丢了,然后对方收到了 3, 4。
- 机制:
- 收到 3 时,对方发现 2 没到,于是回一个“我想要 2”的 ACK(ACK=2)。
- 收到 4 时,对方又回一个“我想要 2”的 ACK。
- 发送方一旦收到 3 个重复的 ACK (Triple Duplicate ACK),它就知道:“别等超时了,第 2 个包肯定丢了,虽然连接没断。”
- 动作:立即重传 2 号包。
Lab4
将之前的TCP sender receiver应用到现实的网络传输中
祝贺你完成了 CS144 最具挑战性的 Lab 之一!能够独立排查出网络层面的配置问题是非常宝贵的经验。
这就为你总结一份适合写在博客里的 CS144 Lab 4 WSL2 网络配置排坑指南。你可以根据自己的风格进行修改。
解决 WSL2 下 webget 连接超时的问题
1. 问题现象
在完成 TCP Sender 和 Receiver 的代码后,通过了本地的所有测试,但在运行 Lab 4 的 webget 访问公网(如 cs144.keithw.org)时,程序会一直卡在 connect 阶段直到超时。
具体表现:
- 运行
./build/apps/tcp_native和./build/apps/tcp_ipv4进行本地双机通讯完全正常。 - 运行
cmake --build build --target check_webget报错:Test #2: t_webget .........................***Timeout 15.06 sec DEBUG: Function called: get_URL( "cs144.keithw.org", "/nph-hasher/xyzzy" ) DEBUG: minnow connecting to 104.196.238.229:80... - 代码逻辑检查无误,且本地握手成功,说明 TCP 协议栈实现没有问题。
2. 问题分析
既然代码没错,问题一定出在网络环境上。
CS144 使用 TUN 虚拟网卡(tun144)将用户态 TCP 协议栈产生的数据包发送给 Linux 内核。
在 WSL2 环境下(尤其是安装了 Docker 的环境),网络包的流向如下:
- 发出: 你的程序 ->
tun144-> Linux Kernel。 - 转发判定: Kernel 发现目标 IP 是外网 IP,需要进行 IP Forwarding(转发)。
- 防火墙拦截(关键点): 数据包进入
iptables的FORWARD链。由于 Docker 为了安全隔离,通常会将FORWARD链的默认策略设为DROP(丢弃)。 - 结果: 数据包在转发阶段被防火墙丢弃,根本没有到达
POSTROUTING链进行 NAT(伪装),也发不到公网。
证据:
通过 sudo iptables -t nat -L -v -n 查看,发现 POSTROUTING 链的包计数为 0,而 Chain DOCKER 存在,说明 Docker 接管了防火墙规则。
3. 解决方案
我们需要手动允许通过 tun144 接口的转发流量。
第一步:清理环境
防止之前的测试残留导致设备占用 (Device or resource busy)。
# 杀掉占用 tun 设备的进程
sudo fuser -k /dev/net/tun
# 删除旧设备
sudo ip tuntap del mode tun name tun144第二步:初始化 TUN 设备与 NAT
使用 Lab 自带的脚本建立基础网络环境(创建设备、开启 IP 转发、设置基础 NAT)。
# 重启 tun144 配置
sudo ./scripts/tun.sh restart 144第三步:添加防火墙放行规则
这是最关键的一步。强制允许进出 tun144 接口的转发流量。
# 允许从 tun144 发出的包转发到外网
sudo iptables -I FORWARD -i tun144 -j ACCEPT
# 允许从外网回来的包转发给 tun144
sudo iptables -I FORWARD -o tun144 -j ACCEPT注:-I (Insert) 会把规则插到最前面,优先级高于 Docker 的 DROP 规则。
第四步:验证
再次运行测试,顺利通过!
cmake --build build --target check_webget数据分析
Start: 2025-12-
HOST: LAPTOP Loss% Snt Last Avg Best Wrst StDev
1.|-- LAPTOP 0.0% 1 1.0 1.0 1.0 1.0 0.0
2.|-- 10.194.0.1 0.0% 1 46.1 46.1 46.1 46.1 0.0
3.|-- 10.3.8.21 0.0% 1 15.3 15.3 15.3 15.3 0.0
4.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
5.|-- 210.32.127.13 0.0% 1 19.6 19.6 19.6 19.6 0.0
6.|-- 100.64.171.2 0.0% 1 26.4 26.4 26.4 26.4 0.0
7.|-- 100.64.71.2 0.0% 1 15.9 15.9 15.9 15.9 0.0
8.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
9.|-- 101.4.118.250 0.0% 1 18.0 18.0 18.0 18.0 0.0
10.|-- 101.4.117.30 0.0% 1 63.5 63.5 63.5 63.5 0.0
11.|-- 100.64.62.1 0.0% 1 68.6 68.6 68.6 68.6 0.0
12.|-- 101.4.116.118 0.0% 1 71.3 71.3 71.3 71.3 0.0
13.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
14.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
15.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
16.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
17.|-- 210.25.189.237 0.0% 1 98.7 98.7 98.7 98.7 0.0
18.|-- 210.25.187.54 0.0% 1 111.5 111.5 111.5 111.5 0.0
19.|-- 210.25.189.134 0.0% 1 311.5 311.5 311.5 311.5 0.0
20.|-- fourhundredge-0-0-0-1.407 0.0% 1 274.5 274.5 274.5 274.5 0.0
21.|-- hpr-svl-agg10--internet2r 0.0% 1 235.9 235.9 235.9 235.9 0.0
22.|-- hpr-emvl1-agg-01--svl-agg 0.0% 1 231.0 231.0 231.0 231.0 0.0
23.|-- 137.164.26.241 0.0% 1 227.8 227.8 227.8 227.8 0.0
24.|-- campus-ial-nets-b-vl1120. 0.0% 1 235.4 235.4 235.4 235.4 0.0
25.|-- ??? 100.0 1 0.0 0.0 0.0 0.0 0.0
26.|-- web.stanford.edu 0.0% 1 276.5 276.5 276.5 276.5 0.0- 第2跳:内网网关 (
10.194.0.1),延迟约46ms - 第3跳:另一个内网节点 (
10.3.8.21),延迟约15ms - 第4跳:信息缺失 (
???),可能是防火墙阻止了回应 - 第5跳:公网地址 (
210.32.127.13),延迟约19ms - 第6-7跳:运营商网络节点 (
100.64.x.x系列),延迟在15-26ms之间 - 第8跳:再次信息缺失
- 第9-12跳:继续通过公网和运营商网络,延迟逐渐增加到71ms
- 第13-16跳:连续几跳信息缺失,可能遇到多个无法响应的设备
- 第17-19跳:公网地址 (
210.25.x.x系列),延迟显著增加到98-311ms - 第20-24跳:进入美国互联网主干网,包括 Internet2 研究教育网络
- 第25跳:信息缺失
- 第26跳:最终到达目标
web.stanford.edu,总延迟约为276ms
==============================
ANALYSIS REPORT
==============================
1. Overall Delivery Rate: 99.1623%
2. Longest Success Burst: 155 packets
3. Longest Loss Burst: 2 packets
------------------------------
4. Conditional Probability:
P(Success | Success): 99.3606%
P(Success | Failure): 75.6757%
(Compare to Uncond P): 99.1623%
-> Conclusion: Losses are BURSTY (Loss predicts more loss).
------------------------------
5. Min RTT: 212.0 ms
6. Max RTT: 774.0 ms
==============================![[Pasted image 20251205164547.png]]
![[Pasted image 20251205164604.png]]
- 看到一个陡峭的上升,然后在尾部有一条长长的尾巴(Long Tail)。
- 含义:绝大多数时候,RTT 接近最小值。但偶尔会有非常高的延迟。
![[Pasted image 20251205164627.png]]
1. 密集的蓝色团块 (The Cluster) - 左下角
- 位置:大约在 (220ms, 220ms) 坐标处。
- 含义:这是网络的空闲状态 (Steady State)。
- 绝大多数时候,网络是不拥堵的。包一来一回,只花了物理传播时间。因为 和 都没排队,所以它们的值都很小且相等,聚集在对角线上。
2. 沿对角线延伸的尾巴 (The Diagonal Tail) - 红色线方向
- 现象:点沿着 红线向右上角延伸。
- 含义:持续的拥堵 (Persistent Congestion)。
- 如果 是 300ms(说明排队了),那么 往往也是 300ms 左右。这说明队列长度在这一段时间内保持稳定(既没变长也没变短)。
- 结论:这就是为什么图里会有一条斜线。延迟是高度自相关的。
3. 垂直向上的散点 (Vertical Spread)
- 位置:X轴还在 220ms 左右,但 Y轴突然飙升到 300ms+。
- 含义:突发流量 (Traffic Burst)。
- 第 个包发过去时,队列还是空的(RTT低);但在发 个包的这 0.2秒 间隙里,突然有别人的流量(Cross Traffic)涌入了路由器,导致第 个包被迫排长队。
4. 水平向右的散点 (Horizontal Spread)
- 位置:X轴在 300ms+,但 Y轴回落到 220ms。
- 含义:拥塞缓解 (Congestion Relief)。
- 第 个包还在排队,但等到发 个包时,路由器的队列已经处理完了,网络恢复畅通。
1. 交付率 (Delivery Rate) 与 丢包突发 (Loss Bursts)
- 现象:交付率通常很高(比如 98%),但一旦丢包,往往不是丢 1 个,而是连续丢 3 个、5 个甚至更多(Longest Loss Burst)。
- 原因:路由器的缓冲区(Buffer)是有限的。
- 当网络拥堵时,路由器的队列满了(Tail Drop)。此时,所有新到达的包都会被扔掉,直到队列腾出空间。
- 这导致丢包往往是成批出现的(Bursty),而不是随机均匀分布的。
- TCP 的应对策略:
- 累计确认 (Cumulative ACK):TCP 不会每收到一个包就确认一个,而是确认“在这个序号之前的所有包都收到了”。这样即使 ACK 丢了一些,只要后续的 ACK 到了,前面的也能被确认。(这个机制是针对sender的 如果ACK包丢了也没问题 而不是会使劲重传前面的数据包)
- 快速重传 (Fast Retransmit):如果接收方收到 3 个重复的 ACK(说明中间丢了一个,但后面的到了),发送方就知道“出事了”,不等待超时定时器(RTO)到期,立即重传。这是专门为了应对非连续丢包设计的,但在突发丢包面前,TCP 往往只能退回到超时重传。
2. 条件概率 (Conditional Probability)
- 现象:P(Success | Failure) 通常远小于 P(Success | Success)。
- 翻译一下:如果上一个包成功了,下一个包大概率也会成功;但如果上一个包丢了,下一个包大概率也会丢
- 原因:网络状态具有相关性(Memory)。拥塞不是瞬间消失的,路由器队列排满需要时间消化。
- TCP 的应对策略:
- 慢启动 (Slow Start) 与 拥塞避免:既然丢包意味着网络“生病了”(拥堵),TCP 在检测到丢包后,会大幅度减小发送窗口(Congestion Window),让网络缓一缓,而不是继续头铁地重发数据,那样只会加剧拥堵。
3. RTT 最小值 (Min RTT)
- 含义:这是物理极限。
- Min RTT ≈ 传播延迟 (光在光纤中跑的时间) + 处理延迟 (路由器转发的时间)。
- 这个值与网络拥堵无关,只与物理距离有关。
- 作用:TCP 需要用这个值来计算“基准线”,任何超过这个值的延迟,都是因为“排队”造成的。
4. RTT 最大值 (Max RTT) 与 抖动
- 含义:Max RTT ≈ Min RTT + 最大排队延迟。
- 现象:Max RTT 可能是 Min RTT 的好几倍(比如 Min 200ms, Max 500ms)。
- TCP 的应对策略:
- RTO (Retransmission Timeout) 计算:TCP Sender 必须动态计算超时时间。
- 如果 RTO 设得太短(比如按 Min RTT 设),网络只是稍微堵一下,TCP 就以为包丢了进行重传,导致网络更堵(伪重传)。
- 如果 RTO 设得太长(比如按 Max RTT 设),丢包后恢复得太慢,用户体验极差。
- 所以 TCP 使用 EWMA (指数加权移动平均) 算法(SRTT 和 RTTVAR)来平滑这些抖动,算出一个合理的 RTO。
总结 - 现实网络要怎么样?
通过这些图表和数据,CS144 Lab 4 想让你明白,你在 Lab 1-3 写的 TCP 协议栈必须具备以下能力才能在真实互联网中生存:
kkk通过这些图表和数据,CS144 Lab 4 想让你明白,你在 Lab 1-3 写的 TCP 协议栈必须具备以下能力才能在真实互联网中生存:
- 适应性 (Adaptability):你不能硬编码一个超时时间(比如
const int timeout = 1000)。你必须根据 RTT 的测量值(那张图的分布)动态调整 RTO。 - 保守性 (Conservatism):看到丢包(Bursty Loss),必须认为是网络堵了,要主动减速(拥塞控制)。
- 鲁棒性 (Robustness):面对 ACK 丢失、乱序(图中的抖动会导致乱序到达),你的 Receiver 必须能利用
reassembler正确重组数据,你的 Sender 必须能正确处理重复 ACK。
Socket实现
Socket
本质上就是一个文件 提供一个读和写的接口 对于两端的计算机来说 传输数据就是从这个位置读写 我不需要考虑任何中间传输过程中的问题 比如乱序 拥塞 丢包等等
在 Linux/Unix 中,“一切皆文件”。但是,网络 socket 和本地文件最大的区别在于:文件存在硬盘上,而 socket 连接需要先建立通道。
所以在使用socket的时候 要进行bind connect等等操作
我们可以用 “打电话” 来比喻:
- Socket (创建 socket):
- 你买了一部电话机(或者打开了电话 App)。现在你有一个可以用来通话的设备,但还没连线。
- Bind (绑定):
- 含义:给你的电话机插上一张 SIM 卡,分配一个 电话号码(IP + Port)。
- 为什么需要:如果你只是想打给别人(Client),你其实不需要主动 Bind(操作系统会自动给你分配一个临时的),因为别人不需要知道你的号码。但如果你是 服务器(Server),你必须 Bind,否则别人不知道打哪个号码才能找到你。
- Connect (连接):
- 含义:你在电话机上输入对方的号码,并按下“拨号”键。
- 动作:这是 客户端(Client) 发起的动作。它触发了 TCP 的三次握手(SYN -> ...)。
- Listen & Accept (监听与接受):
Listen:把电话设为“响铃模式”,准备好接听。
Accept:电话响了,你拿其听筒(建立连接)。这会产生一个新的 socket 专门用于和这个打电话进来的人通话。
监听 Socket (Listen Socket):只有一个,一直存在,只负责“拉客”(处理握手)。
已连接 Socket (Connected Socket):accept 每成功一次就会产生一个新的。如果有 100 个客户同时在线,就会有 1 个监听 Socket + 100 个已连接 Socket。
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <iostream>
#include <cstring>
int main() {
// ==========================================
// 第一步:创建监听 Socket (迎宾员)
// ==========================================
// AF_INET = IPv4, SOCK_STREAM = TCP
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket failed"); return 1; }
// ==========================================
// 第二步:Bind (告诉大家我在哪)
// ==========================================
sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡
address.sin_port = htons(8080); // 端口 8080
if (bind(listen_fd, (sockaddr*)&address, sizeof(address)) < 0) {
perror("bind failed");
return 1;
}
// ==========================================
// 第三步:Listen (开始营业)
// ==========================================
// 3 表示等待队列的长度
if (listen(listen_fd, 3) < 0) {
perror("listen failed");
return 1;
}
std::cout << "Waiting for connections..." << std::endl;
while (true) {
// ==========================================
// 第四步:Accept (关键步骤!)
// ==========================================
// 这一步会阻塞,直到有客户端 connect 过来并完成三次握手。
// 注意:listen_fd 还是那个 listen_fd,它没变。
// 返回值 new_socket_fd 是一个全新的文件描述符!
sockaddr_in client_addr;
socklen_t addrlen = sizeof(client_addr);
// 此时,三次握手已经完成,OS 把建立好的连接交给你
int new_socket_fd = accept(listen_fd, (sockaddr*)&client_addr, &addrlen);
if (new_socket_fd < 0) {
perror("accept failed");
continue;
}
std::cout << "New connection accepted! FD: " << new_socket_fd << std::endl;
// ==========================================
// 第五步:读写数据 (使用新 Socket)
// ==========================================
// 这里可以使用多线程或者 fork() 来专门处理这个连接
// 为了演示简单,我们在主线程直接处理(但这会阻塞其他新连接)
char buffer[1024] = {0};
// 注意:我们是对 new_socket_fd 进行读写,而不是 listen_fd
read(new_socket_fd, buffer, 1024);
std::cout << "Client said: " << buffer << std::endl;
const char* hello = "Hello from Server";
send(new_socket_fd, hello, strlen(hello), 0);
// ==========================================
// 第六步:关闭连接
// ==========================================
// 只关闭和当前客户端的通话,listen_fd 依然在循环外存活
close(new_socket_fd);
}
// 服务器关闭时才关掉监听
close(listen_fd);
return 0;
}以上是一个服务端代码的示例 但是这个服务端只能处理一个客户端的连接
void handle_client(int socket_fd) {
// 这里是子线程:专门负责读写数据
read(socket_fd, ...);
write(socket_fd, ...);
close(socket_fd);
}
int main() {
int listen_fd = ...; // 建立监听
listen(listen_fd, ...);
while (true) {
// 1. 主线程阻塞在这里等待新连接
int new_fd = accept(listen_fd, ...);
// 2. 一旦有新连接,创建一个新线程去处理它
// std::thread 会启动一个新的执行流
std::thread t(handle_client, new_fd);
// 3. 让子线程自己去跑,主线程不管了,继续回到循环开头 accept
t.detach();
}
}一个简单的处理多客户端连接的方法是创建多个线程 对于么个连接 都创建新的线程detach出去 然他单独执行 这样主socket的线程只需要无限循环并且建立连接就行了
另一个更好的实现方法是IO多路复用
int main() {
int listen_fd = ...;
// 创建一个 epoll 实例 (Linux 下的高效列表)
int epoll_fd = epoll_create(...);
// 把 listen_fd 加入监听列表
add_to_epoll(epoll_fd, listen_fd);
while (true) {
// 1. 阻塞在这里。等待“任何事件”发生
// 如果没人发数据,也没人连,我就睡觉。
// 一旦有人发数据,或者有人连,我立刻醒来,返回一个 active_events 列表
int n = epoll_wait(epoll_fd, active_events, ...);
for (int i = 0; i < n; i++) {
int fd = active_events[i].data.fd;
if (fd == listen_fd) {
// 说明有新连接!
int new_fd = accept(listen_fd, ...);
// 把新连接也加入到 epoll 列表里盯着
add_to_epoll(epoll_fd, new_fd);
} else {
// 说明某个已连接的客户端发数据来了
read(fd, ...);
// 处理一下数据,如果处理得快,直接在这里回复
}
}
}
}TCPMinnowSocket源码结构
地基 FileDescriptor
在Linux下 可以使用 int fd 来获取和访问文件资源 这个类的核心作用就是把这个文件描述符变成符合RAII的安全的cpp对象
首先定义一个私有类
class FDWrapper {
int fd_;
~FDWrapper() {
::close(fd_); // 这里表示优先去全局作用域找这个方法 而不是当前类
}
}然后再在类中持有一个
std::shared_ptr<FDWrapper> internal_fd_;还需要封装一个读写的接口
size_t write(cosnt StringViewRange auto&& buffers) {
static thread_local std::vector<iovec> iovecs;
// buffer -> iovecs
return write(iovecs, total_size);
}iovec 是 Linux/Unix 系统编程中定义的一个C 语言结构体(定义在 <sys/uio.h> 中),专门用于 Scatter-Gather I/O(分散/聚集 输入输出)
可以创建一个iovec数组 然后交给系统写入
能够避免字符串片段拼接的重新melloc和cpy的额外开销 而是让系统直接寻找分散的片段 分别写入
struct iovec {
void *iov_base; // 指向数据的起始地址(指针)
size_t iov_len; // 这块数据的长度
};
::writev( fd_num(), iovecs.data(), static_cast<int>( iovecs.size() ) )
// 最后使用这个系统调用 将iovec的内容写入整个类的设计是在内部封装了一个RAII的fd类 保证了资源的释放
外部类持有一个shared_ptr 但是删除了拷贝函数 添加了duplicate方法 这种实现要求显示地声明复制操作 delete掉隐式的拷贝
因为在常规思维中 假设我们编写了传值(fd)的函数,我们认为在函数中操作的都是“副本” 但是实际上这里会影响到fd本身 所以禁用隐式拷贝 强行让程序员调用这一方法 能够避免这一错误——实际上这个实现应该类似直接用unique_ptr管理fdwrapper 但是我们留有一个方法 让他在需要的时候可以复制
Lab5 Network Interface 链路层
这就好比你要寄一个包裹去北京(IP: 北京):
你不能直接把包裹扔出窗外指望它飞到北京。你必须把包裹交给快递员(下一跳)。
- 第一跳(你的电脑 -> 路由器):
- 你的 TCP/IP 栈查看路由表,发现百度不在局域网,必须交给默认网关(路由器)。
- 于是,你把“给百度的 IP 包”装进一个信封(以太网帧)。
- 信封上写的收件人(MAC)是路由器的 MAC,而不是百度的 MAC。
- 关键点:Dst IP = 百度, Dst MAC = 路由器。
- 第二跳(路由器 -> ISP 网关):
- 路由器收到信封,拆开一看,MAC 是自己,收下。
- 再看里面的 IP,是百度。路由器查路由表,决定发给上一级 ISP。
- 路由器重新封装一个信封。
- 关键点:Dst IP = 百度(不变!), Dst MAC = ISP 网关(变了!)。
总结: 在整个传输过程中,IP 地址全程不变(像包裹上的收件地址),但 MAC 地址在每一跳都会改变(像是包裹在不同快递员、不同卡车之间交接)。
- IP 地址(网络层)是逻辑地址,具有层级性(Hierarchical)。
- 就像现实中的邮政地址(国家/省/市/街道/门牌号)。
- 它的设计是为了路由(Routing)。因为 IP 地址是按子网分配的(比如 Stanford 的 IP 都在 171.64.x.x),路由器只需要看 IP 的前缀就知道往哪个方向送。
- IP 地址决定了最终目的地。
- MAC 地址(链路层)是物理地址,是扁平的(Flat)。
- 就像人的身份证号或者指纹。
- 它是烧录在网卡里的,全球唯一,但没有任何地理位置信息。如果你知道一个 MAC 地址是 00:1A:2B:3C:4D:5E,你根本不知道它是在北京、纽约,还是就在你隔壁房间。
- MAC 地址决定了在当前这一根网线上,谁接收数据。
为什么不全部使用MAC
MAC 地址不可路由。
- 路由表爆炸:如果不使用分层的 IP 地址,路由器需要记录全球几十亿个设备的 MAC 地址分别在哪个方向。这需要无限大的内存和无法想象的查找时间。
- IP 的作用:IP 把几十亿台设备聚合成了几十万个“网络段”。路由器只需要记住“往 171.64 开头的 IP 走左边”即可,不需要记住里面的每一台电脑。
IP 是导航仪,MAC 是方向盘。 你心里知道要去哪里(IP),但在每一刻,你只能控制车轮怎么转(MAC)
在 CS144 的 Lab 5 中,我们需要实现 NetworkInterface。乍一看,它只是一个收发数据包的类,但从系统设计的角度看,它是逻辑世界(IP 协议) 与 物理世界(以太网) 之间的通用翻译官。
向上(IP 层):它面对的是全球互联网。在这里,它只关心“我要把包裹发给 IP 为 192.168.1.1 的下一跳”。
向下(链路层):它面对的是冰冷的物理线缆。在这里,没有 IP,只有 MAC 地址和电信号。
在链路层面前,众生平等。
无论是一台几十万的高端核心路由器,还是我手里的一部 iPhone,亦或是 CS144 Lab 中的虚拟主机,它们在接入网线的那一端时,运行的都是完全相同的 NetworkInterface 代码:
- 它们都需要维护 ARP 表。
- 它们都需要响应 ARP 请求。
- 它们都需要将 IP 包封装进 Ethernet Frame。
唯一的区别在于:
我的电脑通常只实例化了一个 NetworkInterface 对象(网卡)。
路由器实例化了多个 NetworkInterface 对象,并在它们之上运行了一个路由算法(Lab 6 的内容),决定将数据从“接口 A”搬运到“接口 B”
两种数据包
第一种包:ARP 包 (Type = ARP)
结构:[Ethernet Header] + [ARP Message]
- MAC 层检查:
- 网卡看到以太网头里的 Dst MAC 是 FF:FF:FF:FF:FF:FF(广播)或者 My MAC。
- 决定:收下。
- 分流:
- 看 Type 是 ARP,于是把后面那坨数据解析为 ARPMessage。
- ARP 层检查(逻辑层):
- 看 ARPMessage 里的 Target IP。
- 判断:是我的 IP 吗?
- 动作:如果是,就回信(Reply);不管是不是,都偷看一眼 Sender IP/MAC 记在小本本上(Snooping)。
第二种包:IPv4 数据包 (Type = IPv4)
结构:[Ethernet Header] + [IPv4 Datagram]
- MAC 层检查:
- 网卡看到以太网头里的 Dst MAC 是 My MAC(通常 IPv4 数据包是单播的)。
- 决定:收下。
- 分流:
- 看 Type 是 IPv4,于是把后面那坨数据当作 InternetDatagram。
- IP 层检查:
- NetworkInterface 的工作到此为止。它直接把这个包放入 datagrams_received_ 队列。
- 后续:上层的 TCPReceiver 或 Router 拿走这个包,才会去检查里面的 Dst IP 是不是自己。
ARP 包:
- 信封(Ethernet Header):写着“给所有人(广播)”或者“给张三”。标签是红色(Type=ARP)。
- 信纸(Payload):上面写着“寻人启事:谁是 192.168.1.1?”
IPv4 包:
- 信封(Ethernet Header):写着“给张三”。标签是蓝色(Type=IPv4)。
- 信纸(Payload):上面写着“你好,这是 TCP 握手数据...”。
路由表(IP 层):建立了 最终目的地 IP -> 下一跳 IP 的映射。
- “要去美国,先找上海。”
ARP 表(链路层):建立了 下一跳 IP -> 下一跳 MAC 的映射。
- “要找上海,请去坐标 (X, Y)。”
缺失任何一步都无法通信:
- 没有 IP 路由:你不知道该把包给谁(不知道该去上海)。
- 没有 ARP 链路:你知道该给谁,但你不知道怎么通过网线接触到他(不知道上海在哪)。
网络层级
- 老板 (应用层/ByteStream):
- 写好信的内容:“Hello World”。
- 秘书 (TCP Sender - Lab 3):
- 职责:负责把信切分成几页,并在每页上标号(Seqno),如果对方没收到就重寄。
- 产物:TCP Segment。
- 心态:“我不管这封信怎么送,我只关心对方秘书有没有确认收到。”
- 收发室 (IP 层):
- 职责:把秘书给的信装进一个写着“最终地址:北京”的信封。
- 产物:IP Datagram。
- 心态:“我负责导航,但我自己不开车。”
- 卡车司机 (NetworkInterface - Lab 5):
- 职责:拿到收发室的信封。但他不认识去北京的路,他只知道第一站要先送到“村口的集散中心(下一跳)”。
- 他会问(ARP):“谁是集散中心?哦,是那辆红色的车(MAC地址)。”
- 于是他把信封放进一个大箱子(以太网帧),箱子上写着“给红车”。
- 产物:Ethernet Frame。
- 心态:“我不管里面装的是信还是砖头,也不管它最终要去北京还是上海,我只负责把它安全地交给前面那辆车。”
所以说在“秘书”眼里 他正在面对面和对面的秘书对话 虽然这些话语可能是有重复、丢失的。但是我的秘书可以把他们排列好 秘书不关心是怎么和对面连接的
秘书(TCP) 实际上是在为老板(应用层)制造一种幻觉
- 老板以为:他和对面的合作伙伴之间有一根专属的管道,他把字条塞进去,对方马上就按顺序拿到了。
- 实际情况:字条被塞进了脏兮兮的卡车(NetworkInterface),卡车要在泥泞的道路上颠簸,中间可能翻车(丢包),可能走错路(乱序),甚至卡车可能被劫持。
“秘书不关心是怎么和对面连接的”,这对应了一个非常高级的特性:介质无关性(Media Independence)。
TCP 代码里没有任何一行是关于“光纤”、“WiFi”或者“以太网”的。它只认 IP 地址。只要底层能把那个 IP 包送过去,哪怕是用信鸽送过去的(RFC 1149 鸟类传输协议),TCP 都能照常工作。
这就像:
两个秘书坐在一个无限长、但是会抖动的桌子两端 - 秘书 A 把文件滑过去。
- 桌子可能会吞掉文件(丢包),可能会把先滑的挡在后滑的后面(乱序)。
- 但是秘书 A 眼里只有桌子对面的秘书 B。他不会去想桌子底下是不是有几千个工人在搬运,他只关心:“B,你收到第 5 页了吗?”
Lab6 Router
只有两个方法
一个是向路由表中添加项
一个是进行路由
- 遍历所有接口 收到的ipv4 数据包
- 包头中有目的地
- 通过路由表进行前缀匹配 (越长越好) 最坏的情况是匹配到了默认路由(匹配长度 == 0 ,一定能发过去)
- 向匹配到的路由对应的next hop IP (如果为空的话 代表在同一个局域网内 就不需要路由了 直接发送包头中的dst IP作为下一跳 直接找到) 通过对应的interface(都在路由表中记录) 发送这个数据包