第一次在 OPNET 里把 ALOHA 和 CSMA 摆进同一个场景跑对比,是接一个链路层协议分析任务的时候。当时手上就一句需求——“给出两种随机接入协议在不同负载下的性能差异”,没有参考模型、没有既定参数、也没有人说清楚“性能”到底指吞吐还是时延。我最初的想法很单纯:摆几个节点、发点包、挂两个统计量,一两天怎么也能出图。结果从工程建起来到曲线敢拿去汇报,前后拖了将近三周,中间踩的坑比写的代码还多。这篇就把这套 ALOHA 和 CSMA 的 OPNET 仿真实验从头到尾拆一遍,包括协议本身的数学底子、OPNET 里进程模型和节点模型怎么分工、统计量怎么注册和采集、参数扫描怎么组织,以及那几次结果离谱到怀疑人生的排查过程。不管你是刚接触网络仿真、要赶一份实验报告,还是已经用过 OPNET 但没做过共享信道类的协议对比,下面这些内容应该都能直接拿去改改用。
1. 先把实验目标想清楚:这两组协议到底在比什么
动手建模之前,有件事必须先定下来——这个仿真到底要回答什么问题。很多人(包括当年的我)一上来就急着摆节点、连线、点运行,跑到一半发现不知道该看哪个统计量,最后导出一堆曲线却讲不出结论。ALOHA 和 CSMA 的对比实验,本质上是在回答同一个问题:多个节点共享一条信道时,随着业务量上升,把信道资源“抢”着用的效率如何衰减。所有的参数设计、统计量选择,都应该围绕这个核心问题展开。
1.1 从“想说就说”到“先听后说”:协议演进的驱动力
ALOHA 的思路极其朴素:有帧就发,发完等确认,超时没收到确认就随机等一段时间再重发。它诞生于早期无线分组网络,那个年代终端算力有限、链路速率低,能省掉协调开销比提高信道利用率更重要。代价是明显的——两个节点只要发送时间有任意重叠,整帧就废了,而且冲突双方都不知道对方存在,只能靠超时重传“碰运气”。
CSMA 加了一个动作:发之前先听。信道忙就等,信道闲再发。这一听就把大量本可以避免的冲突消灭在发送之前。但“听”这件事本身有物理限制——信号在介质上传播需要时间,A 节点开始发送的瞬间,B 节点可能还没收到这个信号,于是 B 也判定信道空闲,照样撞上。这就是所谓的传播延迟窗口,也是 CSMA 永远无法做到零冲突的根本原因。
把这两条路线放在一起仿真,价值不在于证明“CSMA 比 ALOHA 好”——这是课本上的定论,不用仿真是个人都知道。真正的价值在于量化:在给定的帧长、速率、节点数、传播距离下,两者的吞吐差距到底有多大、时延拐点分别在什么负载位置、差距随哪个参数变化最敏感。这些数字才是仿真能给出的东西,也是后面写报告时真正有分量的部分。
1.2 为什么选 OPNET 而不是自己写脚本打表
有人会问:纯 ALOHA 和分槽 ALOHA 的吞吐公式就那么两个,CSMA 的经典分析结果也查得到,为什么还要费劲跑仿真?直接画公式曲线不是五分钟的事?
这里有个很实际的区分。解析公式大多建立在强假设之上——无限节点数、泊松到达、帧长恒定、忽略捕获效应、重传无限次。一旦节点数变成有限的 10 个、20 个,一旦加入最大重传次数限制,一旦帧长有分布,公式就不准了。而 OPNET 这类离散事件仿真器的强项恰恰是处理这种“假设被打破”的场景:你可以精确控制每个节点的行为、记录每一个事件的时刻、观察瞬态过程。
至于为什么是 OPNET 而不是别的工具,从我做这类实验的体会看,主要三条。一是三层建模体系分得清——网络域管拓扑、节点域管内部结构、进程域管状态机,做协议对比时改一层不影响另外两层,改起来心里有底。二是自带统计采集框架,op_stat_reg注册一下就能自动进结果库,不用自己写 CSV 拼装。三是参数化研究(Parametric Study)是内建功能,把负载、帧长、节点数设成可扫描参数,一次配置跑一组场景,最后直接比对曲线,这个流程比手动改参数重跑省太多事。
1.3 实验变量的三层设计:负载、节点数、帧长
变量设计这件事,看起来简单,其实决定了整个实验有没有说服力。我的做法是分成三层:主变量、控制变量、验证变量。
主变量是归一化负载 G,也就是单位时间内到达的信道业务量与信道容量之比。它不能直接设,要通过“到达率 × 帧传输时间”换算出来。控制变量是帧长和信道速率,这两个决定了单帧传输时间 T,进而决定传播延迟比 a 的大小,是影响 CSMA 表现的关键量。验证变量是节点数 N,它不影响解析公式(公式里基本假设无限节点),但会显著影响仿真结果,用来检验模型和理论之间的偏差是否合理。
下面这张表是我常用的一组基准参数,各组实验在这套基准上做单变量调整:
| 参数 | 基准值 | 扫描范围 | 说明 |
|---|---|---|---|
| 信道速率 | 1 Mbps | 固定 | 决定 T 的量级 |
| 帧长 | 1024 bit | 512 / 1024 / 2048 | T 随帧长线性变化 |
| 传播延迟 | 20 us | 5 / 20 / 100 | 控制 a = 延迟/T |
| 节点数 | 10 | 5 / 10 / 30 | 检验有限节点效应 |
| 归一化负载 G | 0.1 至 2.0 | 步长 0.1 | 主变量 |
| 最大重传次数 | 8 | 固定 | 影响高负载区时延 |
| 仿真时长 | 每点 1e5 帧时 | 固定 | 保证统计稳定 |
把这套参数先定死,后面所有建模细节才有参照。反过来,如果参数边做边改,最后你会发现自己收集的一堆数据互相之间不可比,白跑。
2. ALOHA 部分的建模细节与参数推导
ALOHA 看起来是两种协议里简单的那个,但真要在 OPNET 里跑出和理论吻合的曲线,细节上一个都不能马虎。尤其是分槽 ALOHA 的时隙对齐,写不好就会出现“理论值 0.368,仿真跑出 0.29”这种让人抓狂的偏差。
2.1 纯 ALOHA 和分槽 ALOHA 的吞吐公式,自己推一遍才踏实
先把这个推导过程过一遍,因为后面调参、看曲线全靠它。
设帧传输时间为 T,全网总到达率为 λ(帧/秒),定义归一化负载G = λT,表示“每个帧时内平均到达的帧数”。信道容量归一化为 1,吞吐 S 就是“每帧时内成功传输的帧数”。
纯 ALOHA 的脆弱期是 2T。为什么是 2T 而不是 T?因为一个帧要想成功,它开始发送之前的 T 时间内不能有别的帧开始发(否则那帧会覆盖到它),它开始之后的 T 时间内也不能有别的帧开始发(否则它覆盖到别人)。前后各 T,总共 2T 的窗口内必须“干净”。泊松到达下这个概率是 e^(-2G),于是:
S_pure = G · e^(-2G)对 G 求导令其为 0,得 G = 0.5,此时 S = 1/(2e) ≈ 0.184。
分槽 ALOHA 把发送时刻强制对齐到时隙边界,时隙长度等于 T。这样帧要么整体落在某个时隙里,要么不发生,脆弱期从 2T 缩短到 T。成功概率变成 e^(-G):
S_slotted = G · e^(-G)G = 1 时取最大值 1/e ≈ 0.368。
这两个数字(0.184 和 0.368)就是我在仿真里首先要验证的靶子。如果纯 ALOHA 的峰值吞吐跑出来偏离 0.18 太远,说明模型里有问题,必须先停下来查,而不是急着加 CSMA。
2.2 泊松到达在 OPNET 里的落地方式
理论公式假设泊松到达,仿真里也必须用泊松到达,否则两条曲线没有可比性。OPNET 的进程模型里对应的是指数分布的到达间隔:op_dist_exponential(mean),其中 mean 是平均间隔。
这里有个容易写错的地方——mean 填的是间隔均值,不是速率。如果目标到达率是 λ 帧/秒,mean 应该是 1/λ 秒。我见过把 λ 直接填进去的写法,结果负载小了一个数量级,曲线整体左移,还以为是模型精度问题查了半天。
具体做法是在进程模型的初始化状态里,为每个节点调度第一个到达自中断:
/* 进程模型:init 状态的进入代码 */ double lambda_node; /* 从仿真属性读取全局到达率,再按节点数均分 */ lambda_node = op_ima_sim_attr_get_double("global_lambda") / (double) num_nodes; /* 调度第一个包到达事件 */ op_intrpt_schedule_self(op_sim_time() + op_dist_exponential(1.0 / lambda_node), ARRIVAL_CODE);然后在处理到达事件的状态里,先发包,再调度下一个到达:
/* 进程模型:处理 ARRIVAL_CODE 的转移条件分支 */ op_intrpt_schedule_self(op_sim_time() + op_dist_exponential(1.0 / lambda_node), ARRIVAL_CODE);这里的关键是先把下一个到达调度好,再去处理发包逻辑。因为发包过程中可能涉及重传、退避等待,如果放到后面调度,容易在退避期间漏掉新的到达事件,导致实际到达率偏低。
注意:
op_dist_exponential的参数必须是秒,且必须严格大于 0。如果扫描参数时把 λ 设成了 0 想代表“无业务”,这个函数会返回无穷大或者报错,得单独处理这个边界。
2.3 分槽机制的实现:时间槽对齐是个体力活
分槽 ALOHA 的实现比纯 ALOHA 麻烦不少,因为它引入了全局时间参考。所有节点必须共享同一个时隙边界,否则“分槽”就退化成纯 ALOHA 了。
我的做法是在初始化时统一计算时隙长度slot_len(等于帧传输时间 T),然后每个节点的发送时刻强制取整到最近的时隙边界:
/* 计算下一个可用时隙的起点 */ double now = op_sim_time(); double next_slot = ceil(now / slot_len) * slot_len; /* 如果在时隙边界上,往后推一个槽,避免和当前槽冲突 */ if (next_slot <= now + 1e-12) next_slot += slot_len; op_intrpt_schedule_self(next_slot, TX_CODE);这段代码里那个1e-12的容差必须加。浮点数在做除法取整时会有精度误差,ceil(now / slot_len) * slot_len算出来的结果可能比now小那么一点点,不加容差就会调度到过去的时间点,OPNET 会直接把事件丢弃或者报“调度时间早于当前时间”的错误。
还有一个隐蔽问题:时隙长度必须和帧传输时间严格相等。如果节点发包耗时因为加了处理延迟而略大于 T,帧就会跨槽,分槽的假设就破了。我的做法是把帧长度和信道速率设成整数比,比如 1024 bit / 1 Mbps = 1.024 ms,然后把所有处理延迟设成 0 或者远小于 T 的值,保证单帧传输严格落在槽内。
3. CSMA 部分:监听逻辑比想象中难写
从 ALOHA 切到 CSMA,代码量大概翻一倍,难点全部集中在“监听”这个动作怎么建模上。共享信道在真实世界里是物理介质,在仿真里必须显式实现一个“信道状态”的共享变量,否则每个节点都以为自己独占信道。
3.1 三种坚持策略的取舍与实现位置
CSMA 有三个常见变种,选哪个直接决定了曲线形状:
| 策略 | 信道忙时的行为 | 实现难度 | 特点 |
|---|---|---|---|
| 1-坚持 | 持续监听,一空闲立即发 | 低 | 冲突概率高,高负载下性能塌陷快 |
| 非坚持 | 随机退避一段时间再听 | 中 | 冲突少,但空闲期浪费多 |
| p-坚持 | 以概率 p 发送,1-p 延迟一个槽 | 中高 | 分槽系统里表现最好,参数敏感 |
我的建议是先做 1-坚持,再补非坚持。1-坚持逻辑最简单——信道忙就等着,一空闲马上发,不需要额外的退避状态机,跑通了能快速拿到一条基准曲线。之后加非坚持,只需要在“信道忙”分支里插入一段op_dist_uniform的随机等待,改动量很小。
p-坚持不建议第一次就上。它需要分槽、需要为每个等待节点维护一个“已等待槽数”、还要处理多个节点在同一槽内以概率 p 同时决定发送导致的二次冲突,状态机复杂度直接上一个台阶。等前两种跑稳了再考虑。
3.2 传播延迟与 0 延迟假设的后果
共享信道状态怎么实现,这里有个分岔路口,很多人第一次做会选错。
做法一:零延迟全局状态。在进程模型里维护一个全局变量channel_busy,任何节点发包时置 1,发完置 0,其他节点读这个变量判断。实现简单,但等于把传播延迟设成了 0,CSMA 退化成完美监听——冲突率接近 0,曲线好看到不真实。
做法二:带延迟的信道状态传播。每个节点在 t 时刻发出的信号,要在 t + τ 之后才能被其他节点感知到(τ 是传播延迟)。实现上,发包节点在发出后调度一个自中断,延迟 τ 后才把全局channel_busy置 1;结束时再延迟 τ 后置 0。这样别的节点在 τ 窗口内仍然认为信道空闲,冲突就自然产生了。
选做法二,这是让仿真结果有说服力的前提。具体到代码:
/* 节点开始发送:立即置忙(本地感知无延迟) */ op_intrpt_schedule_self(op_sim_time() + prop_delay, BUSY_ON_CODE); /* 发送结束时,延迟 τ 后再释放 */ op_intrpt_schedule_self(op_sim_time() + frame_time + prop_delay, BUSY_OFF_CODE);这样就自然产生了“A 已经开始发,B 还没感知到,于是 B 也发”的冲突窗口,和真实物理过程一致。传播延迟 τ 的取值直接决定冲突率——τ 越大,冲突越多,这也是为什么 CSMA 在长距离链路上表现明显不如短距离局域网。
3.3 冲突判定、退避与重传上限
冲突判定逻辑要单独说。当一个节点准备发包时,除了检查信道忙闲,还要检查是否已经有别的节点正在发送。在带延迟的信道模型下,这两个判断是分开的:信道上可能已经有信号在传播(别的节点刚发),但本节点还没感知到。
我的实现方式是给每个帧打一个“发送起始时刻”的标签,冲突判定发生在帧接收端:如果接收期间检测到有另一个帧的发送区间与自己重叠,就标记为冲突,两个帧都丢弃。这比在发送端判断更准确,也更接近物理过程。
退避策略方面,二进制的指数退避(BEB)是常用选择:第 n 次冲突后,从[0, 2^n - 1]个时隙中随机选一个等待。但要注意n 要有上限,一般取 8 到 10。如果不设上限,高负载下退避窗口会指数膨胀到几千个时隙,仿真事件数爆炸,跑一晚上出不来结果——这个坑我在第 6 节会详细讲。
重传上限同样要设。超过上限就丢帧,丢帧率本身也是个有意义的统计量,能反映系统在高负载下的稳定性。
4. OPNET 工程搭建全流程实录
前面讲的是协议逻辑,这一节讲怎么把它落到 OPNET 的三层模型里。整个流程我按实际操作顺序走一遍,中间的关键设置标注清楚。
4.1 从新建工程到网络域摆节点
新建工程时选“空场景”,不要用自带的模板场景,模板里预置的节点和流量配置会干扰实验。工程建好后先设场景尺度——这个参数很多人忽略,但它决定了坐标单位和传播延迟的换算方式。共享信道实验里节点摆得越开,传播延迟越大,如果尺度设错了,实际延迟和你以为的差好几个数量级。
节点摆放有两种思路。一是星型/总线型物理拓扑,所有节点连到一个中心节点或者一条总线上,用 OPNET 自带的链路对象建模共享介质。二是逻辑共享、物理独立,节点之间不连线,共享信道的忙闲状态完全靠进程模型里的全局变量维护。做协议对比实验,我推荐第二种,因为第一种的链路对象自带一套信道接入逻辑,会和你要实现的 CSMA 打架,调起来极其难受。
节点数量直接影响仿真时间。10 个节点跑 1e5 帧时的仿真,在普通工作站上大概几分钟;30 个节点的事件数差不多是 3 倍以上,因为每多一个节点就多一路到达流和一套监听判断。做参数扫描时节点数一般不超过 30,再多就得考虑并行或者简化模型。
网络域的属性设置里,有几个仿真属性必须提前定义好:global_lambda(全局到达率)、frame_len(帧长)、prop_delay(传播延迟)、max_retry(最大重传次数)。用**仿真属性(Simulation Attribute)**而不是硬编码,是为了后面做参数化扫描时能直接扫描这些值,不用改代码重编译。
4.2 节点模型与进程模型的分工
OPNET 三层模型的分工,用一句话概括:节点模型管“有什么”,进程模型管“怎么动”。
节点模型里放的是模块:一个source模块负责产生业务,一个sink模块负责收包统计,一个processor模块挂载协议进程。模块之间用包流(packet stream)连成数据通路,用统计线(stat wire)连成统计通路。做共享信道实验时,我的节点模型一般就三个模块:app_gen(产生帧)、mac_proc(协议主体,挂进程模型)、sink(接收并统计)。
进程模型是状态机。我的状态划分是这样的:
| 状态 | 含义 | 主要动作 |
|---|---|---|
init | 初始化 | 读仿真属性、注册统计量、调度首个到达 |
idle | 空闲等待 | 等待到达事件或信道空闲事件 |
sense | 监听判断 | 检查信道状态,决定发送或退避 |
tx | 发送中 | 占用信道,等待发送完成 |
backoff | 退避等待 | 定时器到期后回到sense |
状态转移用转移条件(宏)控制,每个状态有进入代码(enter)和离开代码(exit)。这套结构写熟了很顺手,但第一次搭的时候容易把“事件类型”和“状态”搞混——op_intrpt_type()返回的是事件类型码(自中断、流中断、统计中断等),不是状态。我早期写错过,把到达事件的处理逻辑写在了流中断分支里,结果永远进不去。
4.3 统计量的注册、采集与导出
统计量是仿真结果的全部来源,注册错了等于白跑。OPNET 的统计量分两类:标量(scalar)和矢量(vector)。
标量是仿真结束时的一个汇总值,适合记录“总吞吐”“平均时延”这种一次性结果。矢量是随时间变化的采样序列,适合观察瞬态过程,比如“时延随仿真时间的变化”,用来判断系统是否进入稳态。
注册方式是在进程模型的函数块里调用:
/* 进程模型:注册全局统计量 */ stat_thru_handle = op_stat_reg("channel.throughput (bits/sec)", OPC_GLOBAL, OPC_STAT_INDEX);OPC_GLOBAL表示全局作用域,所有节点写同一个统计量,自动累加。这一点很关键——如果误用了节点作用域,每个节点各写各的,最后导出来是 N 条曲线,还要自己手动求和。
写统计的时机也有讲究。吞吐量建议每成功接收一帧就写一次增量:
/* 帧成功接收后累加吞吐 */ op_stat_write(stat_thru_handle, (double) op_pk_total_size_get(pk));而时延建议在帧接收时记录一个样本值:
/* 计算端到端时延并写入 */ double delay = op_sim_time() - op_pk_creation_time_get(pk); op_stat_write(stat_delay_handle, delay);采集配置在“配置/运行离散事件仿真”对话框里,把需要的统计量勾选上,指定结果文件名。这里有个必须注意的点:结果文件是二进制格式,不能直接拿 Excel 打开,得先用结果查看器导出成 CSV 或者直接看曲线。我见过有人到处找“结果文件打不开”的原因,其实就是格式问题。
4.4 参数扫描与结果比对
单点仿真只能得到一个数据点,画不出一条曲线。要画吞吐-负载曲线,至少得跑 15 到 20 个负载点。手动改参数跑 20 次,累且容易出错,用参数化研究功能可以一次配置完。
配置方式:在参数化研究对话框里,把global_lambda设成扫描变量,给定起始值、步长、点数。OPNET 会自动生成一组场景,每个场景一个参数值,依次运行。跑完之后用“比较结果”功能,把多个场景的同名统计量画在同一张图上。
这里有两个实操细节。一是扫描点的间隔要合理。负载从 0.1 扫到 2.0,如果均匀取 20 个点,间隔 0.1,在低负载区(0.1 到 0.5)分辨率太低,而 ALOHA 的峰值恰恰在 0.5 附近,容易错过峰值位置。我的做法是在 0.3 到 0.8 区间加密到 0.05 的步长,两头可以粗一点。
二是每个负载点要用不同的随机种子。如果用同一个种子跑所有点,随机数序列相同,不同负载下的结果之间存在人为相关性,做统计推断时置信区间会偏窄。种子一般设在仿真属性里,每个场景给一个不同的整数值。
5. 结果曲线怎么读才有意义
曲线出来只是开始,读不懂等于没做。这一节讲三个关键观察点,都是我在反复对比之后才慢慢有感觉的。
5.1 吞吐-负载曲线的三个关键点
一条标准的吞吐-负载曲线,至少要看三个位置。
峰值位置和峰值高度。纯 ALOHA 的峰值应该在 G ≈ 0.5 处,高度 0.18 附近;分槽 ALOHA 在 G ≈ 1.0 处,高度 0.36 附近;1-坚持 CSMA 在低 a 值下峰值能到 0.5 甚至更高。如果仿真跑出来的峰值位置整体偏移,八成是负载换算出了问题——比如到达率是按“每帧时”还是“每秒”算的没统一。
下降段的斜率。过了峰值之后,吞吐随负载上升而下降。下降越快说明协议在高负载下的“自我调节”能力越差。ALOHA 的下降段很陡,因为冲突后的重传又制造了新冲突,形成正反馈。分槽 ALOHA 下降稍缓。1-坚持 CSMA 在高负载下反而可能出现吞吐断崖式下跌,因为所有节点都在同步监听、同步发送,退避窗口不够大时冲突集中爆发。
曲线右端的渐近行为。负载继续加大,吞吐趋近于某个稳定值还是继续下探到 0,反映了重传上限设置的影响。有限重传次数会让系统在高负载下“丢帧自保”,吞吐稳定在一个低水平,而不是无限恶化。
5.2 时延曲线的拐点才是真正的分水岭
吞吐曲线好看,但真正能说明问题的往往是时延曲线。在负载低于某个阈值时,时延基本稳定在一个小值,因为大部分帧一次发送成功。过了阈值之后,时延急剧上升,因为重传次数增加、排队变长。
这个拐点的位置,就是系统的实际可用容量。纯 ALOHA 的时延拐点大概在 G = 0.3 到 0.4 之间,比吞吐峰值位置(0.5)更靠左——这意味着如果按峰值吞吐来设计负载,实际时延已经很难接受了。这个结论在课本上往往只是一句话,但自己跑出来看到两条曲线的相对位置,印象会深得多。
CSMA 的时延拐点通常比 ALOHA 更靠右,在中低负载区时延明显更低。但当负载接近饱和时,CSMA 的时延上升更陡——因为退避窗口指数增长,等待时间突然拉长。两条时延曲线在高负载区交叉的现象,我见过好几次,这个交叉点在不同参数下位置不同,很值得单独做一组扫描。
5.3 仿真时间、预热期和随机种子
这三个东西决定了结果可不可信,但新手最容易忽略。
仿真时长。太短的话,统计样本量不够,曲线抖动严重。我的经验是每个负载点至少跑 1e5 个帧传输时间,如果帧传输时间是 1 ms,模型时间就是 100 秒。这个时长下,G = 0.5 时的平均到达帧数约 5 万,够统计用了。
预热期。仿真刚开始时,所有节点都处于初始状态,系统还没进入稳态,前一段时间的统计会拉低平均值。做法是设一个预热时长(比如总时长的 10%),在预热期内收集统计量,但预处理时把这段时间的数据丢弃。OPNET 里可以通过统计过滤或者后处理实现。不做这一步,低负载区的吞吐会系统性偏低。
随机种子。单个种子的结果带有随机性,严格来说应该跑多个种子取平均并给出置信区间。做课程实验可以简化,但至少要用 3 个不同种子跑一遍,看看结果的波动范围。如果三个种子的吞吐峰值差了 20% 以上,说明样本量不够,得加长仿真时间。
6. 排查实录:那几次结果离谱的仿真
这一节是整篇里我最想写的部分。前面讲的都是“应该怎么做”,这里讲“做错了会怎样”。
6.1 吞吐曲线全为 0 的三种可能
第一次跑通工程,满怀期待地看结果,曲线是一条贴着横轴的水平线。当时排查的顺序是这样的:
第一种可能:统计量根本没注册成功。op_stat_reg调用时的名字必须和结果采集配置里勾选的名字完全一致,包括大小写和空格。名字对不上,统计量就写到了一个没人采集的地方,结果文件里自然什么都没有。
第二种可能:包根本没发出去。在发包状态里插入调试打印,看op_pk_send有没有被调用。我遇到过一次,是包格式(packet format)没在包的创建函数里注册,op_pk_create_fmt返回空指针,后面的发送直接跳过,也不报错。
第三种可能:包发了但接收端全判成冲突。这个最隐蔽。如果冲突判定逻辑写错,比如把“任何两个帧”都判成冲突(而不是时间重叠才冲突),那所有帧都会被丢弃,吞吐自然是 0。排查方法是在接收端加一个计数器,分别统计“收到”“冲突”“格式错误”三种情况,看比例。
6.2 曲线出现人为天花板的两个来源
有一次跑出来的吞吐曲线,在 G 超过 1.0 之后完全水平,最大值恰好是某个整数。这个“整数天花板”一眼就是人为的,不是物理规律。
来源之一是统计量单位错。如果吞吐量的统计单位是“包/秒”而不是“比特/秒”,而每包的比特数又恰好被当成了 1,那算出来的数值就是包数,看起来像封顶了。检查方法是把同一个统计量同时用两种单位写一遍,对比数值。
来源之二是重传上限卡死。重传次数设成 8,意味着一个帧最多尝试 9 次。在高负载下,大部分帧都在 9 次内失败被丢弃,吞吐就不再随负载变化了。这个天花板本身是真实存在的(有限重传系统确实会饱和),但它掩盖了协议本身的性能差异,做对比实验时应该把重传上限设得足够大,让系统在高负载区能充分体现拥塞行为。
6.3 仿真跑一晚上没跑完的根因
这是最让人崩溃的情况:点运行,进度条爬得极慢,一晚上过去还在 20%。
根因基本都是事件数爆炸。常见触发点有三个:
一是退避窗口没有上限。第 n 次冲突后从2^n个时隙里选,n 长到 20 就是百万级时隙,每个时隙都要调度一个定时器事件,事件数直接失控。加个n = min(n, 10)就解决了。
二是监听逻辑形成了忙等循环。如果“信道忙”分支里没有退避,而是调度一个极短的定时器立刻回来重查(比如每次只等 1 us),那系统会以极高的频率产生自中断。正确的做法是退避一个随机时长,或者等信道释放事件,而不是高频轮询。
三是统计量写得过于频繁。如果吞吐统计在每次事件里都写一次,而不是每帧写一次,矢量数据量会大到 IO 成为瓶颈。改成按帧写之后,运行时间能缩短一个数量级。
6.4 常见问题速查表
把上面这些整理成一张表,方便对照:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 吞吐恒为 0 | 统计名不匹配 / 包未发出 / 冲突判断过严 | 逐项加调试计数定位 |
| 吞吐封顶在整数 | 单位换算错 / 重传上限太小 | 统一单位,调大重传上限 |
| 峰值位置整体左移或右移 | 负载换算的 λ 和 T 不匹配 | 复核 G = λT 的量纲 |
| 分槽 ALOHA 表现接近纯 ALOHA | 时隙未对齐或槽长不等于 T | 检查取整逻辑和容差设置 |
| CSMA 冲突率接近 0 | 传播延迟被设成 0 | 改为带延迟的信道状态传播 |
| 时延曲线抖动剧烈 | 仿真时间太短 / 样本不足 | 加长仿真,多跑几个种子 |
| 仿真进度极慢 | 退避无上限 / 忙等轮询 / 统计过频 | 对应加限、改事件驱动、降采样 |
| 低负载区吞吐系统性偏低 | 未设预热期 | 丢弃前 10% 仿真数据 |
7. 跑了这么多组之后的一点体会
这套实验做下来,最深的感受是:仿真最容易骗人的地方不是算错,而是把假设不小心简化掉了。零延迟的共享信道、无上限的退避、忽略预热期——每一个单独看都是“合理简化”,合起来就让曲线变得漂亮但不真实。当你发现仿真结果比理论预期还好看的时候,先别高兴,多想想是不是哪个假设被悄悄抹平了。
另一个体会是关于参数扫描的耐心。我一开始总想用最少的点画出最完整的曲线,结果峰值附近的数据点太少,曲线形状完全变形。后来固定做法:峰值区加密、两端放粗,宁可多跑十几个点,也不让关键区域留空白。这套参数表我后来复用到了好几个类似的信道接入仿真里,改改帧长和速率就能直接用,省下的重复劳动比想象中多。