ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于NS2的TCP Reno拥塞控制仿真:从trace到cwnd曲线

基于NS2的TCP Reno拥塞控制仿真:从trace到cwnd曲线

简介:中国海洋大学计算机网络课程的TCP Reno实验包,面向正在学习传输层协议、拥塞控制与可靠传输机制的高校学生及自学者。全套围绕TCP Reno的快速重传、快速恢复、慢启动阈值与拥塞窗口动态调整等核心原理,并覆盖实验环境搭建、代码实现和网络行为测试分析,可帮助从算法层面理解拥塞控制如何提升网络效率与传输可靠性。资源共135个文件,压缩包约1.48MB,以Java源码、class字节码及HTML页面为主,同时包含XML工程配置、JAR依赖、TXT说明等辅助内容;源码中可见TCP_Sender、TCP_Receiver、CheckSum、OverTimerTask等模块,对应发送窗口、接收窗口、校验和计算与超时定时等关键环节,便于按模块研读和独立运行。目前已有801人学习下载,适合作为计算机网络实验报告撰写、协议代码剖析或考前复习的参考资料,也可直接参考其中“防止中间窗口收缩”等策略分析,加深对TCP性能调优的理解。

1. 高校计网课的 Reno 实验:为什么跑完仿真你手里只有一坨 trace

很多同学做计算机网络实验时,把 Reno 的慢启动、拥塞避免、快速重传、快速恢复背得很熟,但真到实验周,装仿真环境装了半天,跑完一段脚本,打开输出文件发现全是字符,完全不知道从哪里看拥塞窗口的变化轨迹。这个实验的难点不在理解 Reno,而在把课堂上的一段话变成一条能画出来的锯齿曲线。我收到过不少求助,基本都是同一个问题:脚本跑完了,但拿不出能证明“Reno 在工作”的图。

这篇文章就围绕“基于 NS2 的 TCP Reno 版本仿真实验”展开,讲清楚三件事:Reno 到底在改哪些参数、最小仿真脚本怎么搭、以及为什么绝大多数人卡在 trace 解析和参数调不出来。适合计网课学生、接手实验课的助教,以及想在本地把拥塞控制行为复现一遍的开发者。跳过了“背概念”的部分,直接讲可落地的实验路径。

2. 先把 Reno 的四个动作记牢:慢启动、拥塞避免、快速重传、快速恢复

2.1 慢启动与 ssthresh:窗口翻倍直到阈值

TCP Reno 的拥塞控制围绕两个变量展开:拥塞窗口cwnd和慢启动阈值ssthresh。连接刚建立时,发送方并不知道可用带宽,所以从很小的窗口(常见是 1 个 MSS)开始试探,每个 RTT 内窗口翻倍。这个过程叫慢启动,虽然名字里有“慢”,实际窗口是爆炸式增长:1、2、4、8……直到cwnd碰到ssthresh。

在仿真实验里,你看到的第一个现象就是 cwnd 陡峭上升。如果脚本里ssthresh_设得很大,慢启动会持续很久,在那个阶段曲线几乎是一条竖线;如果设得太小,还没看出指数增长就切换到拥塞避免了。我第一次做这个实验时,把 ssthresh 设成了 1000,仿真跑了 30 秒曲线还在直线爬升,浪费了不少时间。课堂上讲“翻倍增长”只是一句话,在图上对应的是前几秒那段急剧拉升的线段,这个对应关系值得在实验报告里写清楚。

2.2 拥塞避免的加法递增与丢包时的乘法递减

当cwnd越过ssthresh,TCP 进入拥塞避免阶段。此时窗口不再翻倍,而是每个 RTT 大约增加 1 个 MSS,也就是线性爬升。这个阶段曲线斜率明显变缓,是判断脚本是否配置正确的第二个标志。如果整条 cwnd 曲线只有线性爬升、没有丢包后的大幅回落,说明链路上没有注入丢包,或者丢包模型没有真正生效。

Reno 对丢包的反应是乘法递减:一旦检测到丢包,ssthresh被更新为当前cwnd的一半,cwnd也会降到ssthresh(快速恢复阶段)或直接掉到 1(超时重传)。这就是经典锯齿的来源。上升是线和缓的,下降是陡峭的。很多实验报告只画出了上升段,没有下降段,就是因为链路丢包率太低或仿真时间太短,这类报告一眼就能看出没跑通。

2.3 快速重传与快速恢复:为什么 Reno 恢复完 cwnd 砍半

丢包检测有两种方式:超时重传和重复 ACK。如果接收方收到乱序数据,会立即对期望序号发送 ACK 确认;发送方收到 3 个重复 ACK,就判断数据丢失,不等超时就重传,这就是快速重传。Reno 的快速恢复机制会在重传后把cwnd减半而不是降到 1,这样能继续发送新数据,避免链路空闲。

这个机制是 Reno 区别于 Tahoe 的核心,也是仿真实验里最值得验证的点。你在 cwnd 曲线上会看到一次丢包后窗口不是掉到 1,而是降到峰值的一半附近,然后继续线性爬升。如果看到掉到 1,说明丢包触发了超时而不是重复 ACK,可能是丢包率过高、RTT 过大,或者接收窗口太窄导致 ACK 无法及时返回。实验报告里如果能抓到一次完整的“快重传-快恢复”曲线,含金量会高很多。

2.4 为什么用 NS2 而不是直接改 Linux 内核

做这个实验首先会面临选型问题:真机上跑 TCP 不行吗?我的经验是,真机只能看到宏观的吞吐和 RTT 统计,很难精确控制哪一段链路丢多少包,更没法直接读 cwnd 的内部变化。Linux 里可以通过ss -tin观察部分的 cwnd 信息,但你需要 root 权限、要控制网络延迟、要制造指定概率的丢包,这些在教室的共享网络环境里几乎做不到。

NS2 的常见用法是把一条端到端链路拆成固定的拓扑,给链路设置带宽、延迟、队列管理和丢包模型,TCP 的窗口变化会以事件的形式写入 trace 文件。你可以随时改参数重跑,同一个脚本在不同机器上行为基本一致。用它可以准确看到慢启动、拥塞避免、快速恢复的切换时机。当然 NS2 年代久远,安装可能遇到兼容性问题,后面避坑章会专门讲。选择它的理由是可控性和可观测性,实验报告需要的所有数据它都能给。

3. 用 NS2 搭最小仿真链路:Reno 连接的 Tcl 脚本与参数怎么设

3.1 最小拓扑:两节点一条瓶颈链路

Reno 仿真不需要复杂的哑铃拓扑,最精简的配置是 4 个节点、3 条链路:发送端 n0、接收端 n3、中间节点 n2 作为瓶颈路由器。n0 到 n2 是接入链路,n2 到 n3 是瓶颈链路,瓶颈带宽必须小于接入带宽,否则拥塞发生在最后一跳,观测不到窗口收敛。

下面是完整的最小脚本,直接保存为reno.tcl运行。

# 创建仿真器 set ns [new Simulator] # 打开 trace 文件,记录所有事件 set tracefile [open out.tr w] $ns trace-all $tracefile # 打开 nam 文件,用于可视化(可选) set namfile [open out.nam w] $ns namtrace-all $namfile # 创建 4 个节点 set n0 [$ns node] set n1 [$ns node] set n2 [$ns node] set n3 [$ns node] # 接入链路:10Mb 带宽,5ms 延迟 $ns duplex-link $n0 $n2 10Mb 5ms DropTail $ns duplex-link $n1 $n2 10Mb 5ms DropTail # 瓶颈链路:1.5Mb 带宽,20ms 延迟,使用 DropTail 队列 $ns duplex-link $n2 $n3 1.5Mb 20ms DropTail # 瓶颈队列长度限制为 20 个包 $ns queue-limit $n2 $n3 20 # 创建 Reno TCP 连接,n0 发送,n3 接收 set tcp0 [new Agent/TCP/Reno] $tcp0 set window_ 100 $tcp0 set ssthresh_ 64 $ns attach-agent $n0 $tcp0 set sink0 [new Agent/TCPSink] $ns attach-agent $n3 $sink0 $ns connect $tcp0 $sink0 # 挂一个 FTP 应用,让连接一直有数据发送 set ftp [new Application/FTP] $ftp attach-agent $tcp0 $ftp start 0.5 # 错误模型:在瓶颈链路上注入 2% 随机丢包 set err [new ErrorModel] $err set rate_ 0.02 $err set event_ drop $ns lossmodel $err $n2 $n3 # 每隔 0.1 秒采样一次 cwnd,写入 cwnd.dat set cwndfile [open cwnd.dat w] proc plot_cwnd {} { global ns tcp0 cwndfile set now [$ns now] set cwnd [$tcp0 set cwnd_] puts $cwndfile "$now $cwnd" $ns at [expr $now + 0.1] "plot_cwnd" } $ns at 0.5 "plot_cwnd" # 20 秒后结束仿真 $ns at 20.0 "finish" proc finish {} { global ns tracefile namfile $ns flush-trace close $tracefile close $namfile exit 0 } $ns run

这个脚本里,window_是发送窗口上限,设成 100 表示 cwnd 最多涨到 100 个段;ssthresh_是慢启动阈值,初始设 64,这样慢启动在 6 个 RTT 内就会触发切换,曲线图能同时看到指数段和线性段。lossmodel挂在 n2 到 n3 方向的链路上,rate 0.02 表示约 2% 的包会被丢弃。采样部分直接读取 TCP 代理的cwnd_变量,每 0.1 秒记一次,得到的cwnd.dat就是画图用的原始数据。

3.2 把丢包注入链路:错误模型挂对方向

上一节里错误模型只挂了一个方向,这是很多同学忽略的点。NS2 的duplex-link是双向链路,但lossmodel的语法$ns lossmodel $err $n2 $n3只对 n2 到 n3 方向生效。如果反向 n3 到 n2 也需要丢包(比如双向都对传),必须再创建一个错误模型挂回去。

丢包率也不是越大越好。2% 丢包率在窗口接近 100 时,一个窗口内平均丢 2 个包,Reno 可能一次丢包后还没恢复完又一个丢包,曲线会变得很乱。而 0.5% 丢包率在 20 秒仿真里可能只触发一两次丢包,图形更清晰,适合演示快速重传。我一般先用 1% 跑通流程,确认曲线有锯齿后,再分别用 0.5% 和 2% 做对比实验,观察丢包率对窗口均值的影响。

3.3 跑仿真与检查输出:从 nam 动画到 trace 文件

脚本保存后,在终端运行命令启动 NS2:

ns reno.tcl

跑完后当前目录下会生成三个文件:out.tr是事件级 trace,out.nam是动画文件,cwnd.dat是采样到的拥塞窗口序列。先别急着画图,用head看下cwnd.dat的内容:

head -20 cwnd.dat

正常输出是两列:第一列时间(秒),第二列 cwnd 值(段)。如果第二列恒定不变,说明采样逻辑没问题但链路丢包没生效;如果第一列没有从 0.5 开始,说明采样函数没被触发。此时可以用nam reno.nam &打开动画,直观看到数据包从 n0 发到 n3 的过程,红色方块代表丢弃的包。

nam 动画适合给报告截图,但判断实验是否成功还要看数据。一条正在慢启动的曲线,cwnd 会在前几秒从 1 迅速爬到 ssthresh;进入拥塞避免后斜率变平;遇到丢包时快速掉到峰值一半。如果整条曲线是平的或只有一次下跌,先回 3.1 检查瓶颈链路带宽是否比接入链路低、丢包模型是否挂对、ssthresh 是否设得过大。

4. 从脚本输出里画出 cwnd 锯齿:数据提取、gnuplot 画图与调参

4.1 用 gnuplot 画 cwnd 曲线:基础脚本与输出格式

cwnd.dat 已经是两列纯文本,gnuplot 可以直接读。写一个plot_cwnd.gp脚本:

set terminal pngcairo size 800,600 set output "cwnd.png" set xlabel "Time (s)" set ylabel "cwnd (segments)" set grid plot "cwnd.dat" using 1:2 with lines title "Reno cwnd"

运行gnuplot plot_cwnd.gp,生成cwnd.png。这张图是你实验报告的核心证据。如果曲线不是锯齿而是持续上升,大概率是丢包没挂上;如果锯齿很密且 cwnd 长期在 2~4 之间跳动,大概率是丢包率过高。用 0.02 丢包率 + 20 秒仿真,预期得到 3~5 次明显的下跌,每次下跌后 cwnd 回到峰值一半左右。

4.2 从 out.tr 里提取丢包与吞吐:awk 的两种常用统计

cwnd 采样已经直接写在文件里,但有时还需要从 out.tr 统计链路吞吐量、丢包次数,这些需要 awk 处理。out.tr 每一行以+、-、r、d开头,分别代表入队、出队、接收、丢弃。下面的命令统计 n2 到 n3 链路上接收和丢弃的字节数:

# 统计接收数据量(链路 n2->n3 方向) awk '$1=="r" && $3==2 && $4==3 {sum += $8} END {print "received bytes:", sum}' out.tr # 统计丢弃的包个数 awk '$1=="d" && $3==2 && $4==3 {count++} END {print "dropped packets:", count}' out.tr

字段含义取决于 NS2 版本,但常见格式里$3和$4是源节点和目的节点,$8是包大小。用这个统计可以算出有效吞吐:接收字节数除以仿真时间,再对比理论值。理论最大吞吐约等于瓶颈带宽乘以(1 - 丢包率),实际值会略低,如果差太多说明队列丢弃占了很大比例。

4.3 三个必调参数:丢包率、瓶颈带宽与队列长度

我把这个实验里对 cwnd 形状影响最大的三个参数列成表,方便对照调整:

参数默认值示例调大后的效果调小后的效果
丢包率 rate_0.02锯齿变密,cwnd 长期偏低锯齿变疏,可能出现长时间无丢包
瓶颈带宽1.5Mbcwnd 可涨到更高值cwnd 更早触顶,吞吐受限
队列长度20队列吸收突发,丢包减少队列容易满,大量丢包引发超时

队列长度是容易被忽略的变量。DropTail 队列满后直接丢弃新到的包,如果队列只有 5 个包,一旦慢启动阶段窗口翻倍过快,大量包在同一轮被丢弃,Reno 会检测到多个丢包而不是一个,快速恢复来不及完成就进入超时重传。20 个包的队列能让单次丢包事件更可控,曲线也更有规律。修改队列长度时,同时观察 out.tr 里丢弃事件是否成簇出现。

4.4 对比实验:同一脚本只改丢包率

实验报告只画一条曲线说服力有限。一个常见的做法是保持其他参数不变,将丢包率从 0.01 改成 0.05,对比两条曲线。修改方法是重新复制脚本,改一行$err set rate_,跑完后用 gnuplot 把两个 cwnd.dat 画在同一张图里。此时能直观看到:低丢包率下 cwnd 平均水平更高,震荡幅度更大;高丢包率下 cwnd 被压得很低,传输效率显著下降。

这个对比本身就是对 Reno 行为的一个可验证结论:在随机丢包面前,cwnd 的均衡值大致反比于丢包率的平方根。不要求你推导这个公式,但实验曲线能反映趋势,报告里写一句“丢包率从 1% 提高到 5%,平均窗口大约降低到原来的三分之一”,这份实验就比纯截图有分量得多。

5. 避坑:Reno 实验里五个最容易翻车的点

5.1 现象:cwnd 曲线只有上升没有下跌

这是最典型的翻车现场,脚本跑得很顺,没有报错,但 cwnd 一直涨到 window 上限,整条曲线像一条斜线。原因是错误模型没有真正挂到瓶颈链路上。常见情况有两种:一是lossmodel挂反了方向,比如链路是 n2 到 n3 通,你写成了$ns lossmodel $err $n3 $n2;二是挂到了接入链路而不是瓶颈链路,接入链路带宽 10Mbps 本来就空余,丢一点点包很快被重传,窗口不会出现明显回落。

解决方法是先把丢包率临时调到 0.1,重新跑仿真,如果曲线还是平的,检查脚本里 lossmodel 那一行是否早于 duplex-link 执行——NS2 解析脚本是从上往下执行,必须先定义链路再挂错误模型。调大丢包率只是为了快速验证模型生效,验证完再改回 0.01~0.02 即可。

5.2 现象:慢启动阶段短得看不见,第一眼就是线性爬升

有些同学的 cwnd 曲线从第 0 秒起就是平缓上升,完全看不到指数段。原因多半是 ssthresh 被设置得很低,或者连接没有真正从 0 开始。比如ssthresh_设成了 4,cwnd 在 2 个 RTT 内就越过阈值,慢启动根本没来得及展示。

解决方法是显式设置window_和ssthresh_,不要依赖 NS2 版本的默认值。不同 NS2 编译版本对 TCP 默认参数的定义有差异,有些版本默认 ssthresh 很大,有些版本默认开启 SACK,这都会改变曲线形状。我在脚本里固定为window_ 100、ssthresh_ 64,就是为了让结果在不同机器上可复现。如果你想展示更长慢启动段,把 ssthresh 设成 128 或更大即可,但窗口上限也要同步放大。

5.3 现象:跑完没有生成 cwnd.dat

采样函数没生效。常见原因是在$ns run之前没有调用$ns at 0.5 "plot_cwnd",或者采样函数里使用了局部变量名而没有global声明。Tcl 脚本里,过程内部访问的变量默认是局部的,不写global tcp0 cwndfile,拿到的就是一个空值,写进文件的全是0 0。

另一个容易被忽略的坑是:采样开始时间早于 FTP 启动时间。如果 FTP 在 0.5 秒启动,采样在 0.1 秒启动,前 0.4 秒记录到的 cwnd 是初始值,画图时会出现一段水平的基线。把两者设为同一时间,或者让采样稍晚于 FTP 开始,曲线起点才干净。

5.4 现象:丢包后 cwnd 直接掉到 1,而不是减半

前面说过,Reno 的快速恢复应该让 cwnd 减半,但有时候你看到的是崩溃式下跌。原因有两个:一是丢包率过高,多个连续丢包让发送方等不到重复 ACK,触发了超时重传,超时后 cwnd 必然降到 1;二是队列长度太小,丢包成群出现,快速重传机制根本没有机会完成。

解决方法是降低丢包率到 0.01 左右,同时把队列长度从 20 提到 30。如果还是掉到 1,检查 trace 里丢包时间戳是不是集中在同一个窗口内。Reno 只能处理一个窗口内的单个丢包,连续丢包是它的大脑,后续实验换 NewReno 或 SACK 也是讨论这些场景。

5.5 现象:gnuplot 画出的图和 nam 动画对不上

同一份 trace,nam 里看到的拥塞发生在某个时间点,但 cwnd 曲线的下跌发生在几秒之后。这不是 bug,而是重复 ACK 触发快重传需要时间。数据包丢失发生在 t 时刻,但接收方要等到后续序号的数据到达才会发出重复 ACK,发送方收到 3 个重复 ACK 再降窗口,这个延迟等于一个 RTT 加处理时间。

所以画图时不需要让下跌点和 nam 的丢包点完全对齐,只要差值是 RTT 量级就是正常的。如果差值达到好几秒,检查是不是瓶颈链路延迟设得过大,或者 ACK 路径上也有丢包导致重复 ACK 被延迟。把这些时间关系写进实验结果分析里,也是一条很好的思考记录。

6. 把实验再往前推一步:用 RED 队列压平 cwnd 锯齿

DropTail 队列满才丢包,所有 TCP 流同一时间丢包,恢复时又同步增长,造成所谓的全局同步。一个常见的进阶做法是把瓶颈链路的队列换成 RED(随机早期检测),让丢包发生在队列还没满的时候,并且丢包概率随队列长度增长而增大。这样各个连接被丢包的时机不同,整体吞吐更平稳。

修改脚本只需要把duplex-link的队列类型和 RED 参数补上:

# 瓶颈链路改用 RED 队列 $ns duplex-link $n2 $n3 1.5Mb 20ms RED # 获取 RED 队列对象并设置参数 set redq [[$ns link $n2 $n3] queue] $redq set thresh_ 5 $redq set maxthresh_ 15 $redq set q_weight_ 0.002 $redq set bytes_ false

thresh 和 maxthresh 是队列长度的两个阈值:平均队列长度低于 thresh 时不丢包,高于 maxthresh 时所有到达的包都标记为丢弃,介于两者之间时按概率丢包。q_weight 决定了平均队列长度对瞬时队列变化的敏感度,值越小越平滑。第一次跑 RED 时,用默认的 5 和 15 即可。

跑完对比 cwnd 曲线,你会发现 RED 场景下的锯齿幅度明显小于 DropTail,且曲线在高丢包率下不会出现长时间低谷。这就是 RED 的价值:以轻微且随机的丢包为代价,换取更高的链路利用率和更小的窗口波动。做这个对比实验时,其余参数(带宽、延迟、丢包率、窗口上限)全部保持一致,只有队列机制不同,才说明差异来自队列策略。

这个实验之后,我养成了一个习惯:每次跑仿真脚本前,先打印所有关键参数的初始值。NS2 的脚本语言不够显式,依赖默认值很容易踩坑,而一条可信的实验曲线必须能回答“哪些参数是故意设的、哪些是默认继承的”。希望这篇笔记能帮你少走一些我当年走过的弯路,祝实验顺利。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进