前言
rust语言的特点和优势自不必说,这里我们通过解读一款牛叉的压测工具 cargo-whero,它的并发模型设计非常经典,结合了 Tokio 异步运行时与多生产者单消费者模式,来看看实现亿级并发的实践思路。
并发模型核心组件表
| 组件 | 技术实现 | 作用 |
|---|---|---|
| 任务调度 | tokio::spawn |
启动多个异步 Worker 协程 |
| 并发控制 | tokio::sync::Semaphore |
限制同时运行的请求数不超过 -c |
| 流量整形 | tokio::time::sleep |
基于 -q 参数实现 QPS 限流 |
| 数据共享 | Arc<AggregatedStats> |
跨协程共享统计状态 |
| 同步原语 | AtomicU64 / Mutex |
无锁计数与安全的数据聚合 |
| 通信机制 | mpsc::channel |
Worker 向 CSV Writer 传递原始数据 |
核心骨架,五步流程
分任务(channel/自计算)-> 控流量(信号量) -> 发请求(连接池) -> 传数据(共享内存/Channel) -> 出报告(原子累加/单点聚合)
第一步:任务分配(任务数 & 工人数)
任务分配阶段主要解决"做多少事"以及"如何分给工人"的问题。
代码在 async_main 函数中完成了这一逻辑。
第二步:控制并发(通过 信号量)
这是整个压测的"总阀门"。通过 Arc<Semaphore> 配合 .acquire().await,确保了无论启动多少个协程,同一时刻真正发出网络请求的 数量 严格被限制在你设定的并发数(-c)以内。
第三步:数据请求(复用 HTTP 的连接池)
这一步是压测工具高性能的关键。
在代码中,reqwest::Client 被克隆并分发到了各个 Worker 中。由于底层的 hyper 库实现了连接池(Connection Pooling/Keep-Alive),这些协程在发送请求时,会复用已经建立好的 TCP 连接,避免了频繁进行 TCP 三次握手带来的巨大开销,从而能把 CPU 资源集中在"发压"本身。
第四步:数据传递(共享内存 + channel 信息传递)
在 Rust 的高并发设计中,通常会有两种传递方式:
-
共享内存(用于核心统计指标) :
每个请求的耗时、状态码等核心数据,是直接通过
Arc<AggregatedStats>(内部包含AtomicU64和Mutex)进行内存共享的。Worker 直接更新主线程也能看到的变量,这种方式比 Channel 更快,适合高频的计数器。 -
Channel 通道(用于 CSV 导出或日志) :
代码中确实使用了
mpsc::channel。但这部分主要用于将完整的请求明细传递给另一个专门的协程去写文件(CSV)。使用 Channel 是为了把"发请求"和"写磁盘"这两个速度不匹配的操作解耦,防止写文件阻塞发压。
第五步:结果统计(两部分,两阶段: 实时 & 最终)
-
实时统计(分布式) :
在压测过程中,其实所有的 Worker 协程 都在充当"生产者",它们一边发请求,一边通过
原子操作(Atomic)实时累加 各种结果报告上的 总计数据 等。 -
最终统计(单消费者) :
"只有一个消费者",在代码中完美对应压测结束后的
generate_summary环节。当所有 Worker 停止后,主协程(Main Task)会作为唯一的消费者 ,把大家共享的那份AggregatedStats里的原始数据(如 100 万个耗时样本)拿出来,进行排序、计算 P99、生成直方图,并打印出最终的报告。
第一步:任务分配
以例子为引:
sh
whero -c 100 -n 100000000 http://xxx.com
方式一: 任务 Channel 分发
将 这一亿个请求正是通过一个 Channel(通道) 分发给 Worker 协程的 过程,
我们可以把它想象成一个高效的"中央厨房"和"外卖骑手"系统:
🍔 1. 主线程(中央厨房):生产任务
当你输入 -n 100000000 时,主线程并不会把一亿个请求的数据一次性塞进内存,而是创建一个专门用来传递任务的 Channel(比如 mpsc::channel)。
然后,主线程会启动一个**任务生产者协程**,它的逻辑大概是一个循环:
rust
// 生产者逻辑
for i in 0..100_000_000 {
// 往通道里发送一个"任务指令"(比如包含请求的 URL)
task_tx.send(RequestTask { url: "http://xxx.com".to_string() }).await.unwrap();
}
主线程 就像中央厨房,源源不断地把一亿个"外卖订单"打包好,扔进传送带(Channel)。
🛵 2. Worker 协程(外卖骑手):领取任务
Worker 就像 随时待命的外卖骑手,它们手里都拿着通道的接收端(task_rx),并处于一个死循环中:
rust
// Worker 逻辑
while let Some(task) = task_rx.recv().await {
// 1. 从通道里抢到一个任务(订单)
// 2. 去获取信号量许可(拿到出门资格)
let _permit = semaphore.acquire().await.unwrap();
// 3. 真正发起 HTTP 请求(去送餐)
send_request(&task.url).await;
// 4. 归还信号量许可(回来继续抢单)
}
🚦 3. 信号量
信号量(Semaphore) 负责控制并发 :虽然 Channel 里可能堆积了很多任务,但 semaphore.acquire() 保证了同一时刻只有 100 个 Worker 能真正执行到 send_request 这一步。
方式二:本地循环计数
这个方式下 Worker 协程并不是"等 channel 接任务",而是启动后就进入一个死循环(Loop),自己计算要不要继续干活。
结合代码(main.rs 第 330-370 行):
rust
// Worker 内部的逻辑(简化版)
loop {
// 1. 检查停止标志(Stop Flag)
if stop_flag.load(...) { break; }
// 2. 检查配额(Quota):我该干的活干完了吗?
if let Some(max) = requests_per_worker {
if request_count >= max {
break; // 干完这一份配额(比如 1e8 / 100 = 100万次),就退出
}
}
// 3. (这里才是发请求的地方)
// 直接调用 reqwest 发送 HTTP 请求...
// ...
request_count += 1; // 计数器 +1
}
这一亿个请求(-n 100000000)是通过**"配额分配 + 循环计数"**实现的,而不是通过 Channel 传输的。
-
主线程的计算(配额分配) :
主线程在启动 Worker 前,先做了一道除法(第 218 行):
rustlet requests_per_worker = Some(cli.n.div_ceil(cli.c))假设
-n 100000000(1亿),-c 100(100并发)。主线程算出:每个 Worker 需要干
1,000,000次(100万次)。 -
Worker 的执行(自循环) :
每一个 Worker 启动后,手里拿着这个"配额单"(
requests_per_worker)。它进入
loop,自己维护一个计数器request_count。- 第 1 次:
1 < 1000000,继续发请求。 - 第 2 次:
2 < 1000000,继续发请求。 - ...
- 第 1000000 次:
1000000 >= 1000000,循环结束,Worker 退出。
- 第 1 次:
对比 总结
这两种方式,其实对应两种 并发场景,
- 如果 总的任务数 固定, 那么方式一、方式二 都可行,但是 方式二的效率 会高很多, 相比而言,channel 的性能损耗 和 阻塞等待 还是不可忽略的;
- 如果 总的任务数量 不固定, 那么 只能选择 方式一。
可省 信号量 的情况
如果 并发数量 的 worker 是可以确定, 那么 信号量 就是 冗余, 是可以去掉的。
固定数量 Worker(外卖员)的情景下,信号量确实是多余的,甚至是一种性能上的浪费。
🛵 举例解释
假设你启动了 100 个 Worker 协程(外卖员),并且有一个任务 Channel(传送带):
- 任务源源不断地放到传送带上。
- 100 个外卖员不停地从传送带上抢任务、去执行。
- 因为只有 100 个外卖员,所以同一时刻,最多也只能有 100 个请求在同时执行。
在这种情况下,再引入一个"信号量"(比如每次执行任务前还要去领个号),就像是:明明店里只有 100 个骑手,老板却非要在门口再设一道关卡,每次骑手出门还得再领个"出门证",回来再交还"出门证"。这不仅多此一举,还会增加额外的锁竞争开销,拖慢速度。
🚦 那为什么很多代码里还要用信号量?
既然多余,为什么很多教程或工具(包括我们之前讨论的代码逻辑)还会用信号量呢?这通常是因为它们采用了另一种架构。
信号量通常用于**"动态创建协程(Goroutine/Task)"**的场景, 即 Worker(外卖员)数量 不 固定。
第二步:控制并发
以下是基于代码的 并发模型 深度解析:
1. 并发控制 (Concurrency Control)
- 通过
信号量 (Semaphore)机制 来 控制并发数。 - 通过
时间控制机制 来控制QPS(每秒查询率)。
2. 控制 并发数 (Concurrency Limit)
代码使用了 Tokio 提供的信号量(tokio::sync::Semaphore)来严格控制 同时运行 的 请求数量,防止 系统资源耗尽 (因为打开太多 Socket 而耗尽本地文件描述符) 或 目标服务器被瞬间压垮。
核心说明
* **初始化**:在 `async_main` 中,创建了一个 `Semaphore`,其许可数(Permits)等于用户指定的并发数 `-c`(即 `cli.c`)。
```rust
let semaphore = Arc::new(Semaphore::new(cli.c as usize));
```
* **获取许可**:在每个 Worker 任务中,发送请求前必须先获取一个许可。如果当前并发数已达到上限(许可用完),任务会在此处 **异步等待(Await)**,直到其他任务释放许可。
```rust
let _permit = semaphore.acquire().await;
// 只有拿到许可,才能继续发送请求
```
* **释放许可**:当请求完成(无论成功或失败)并记录统计信息后,`_permit` 变量 离开作用域,许可 会自动被释放回信号量中。
具体过程
核心逻辑是:"生产许可证 -> 消费许可证 -> 归还许可证"。
以下是提取出的核心代码片段及其逐行深度解析:
第一部分:信号量的初始化 (在 async_main 函数中)
这是在主线程中发生的,负责创建 "通行证桶"。
rust
// 创建 worker 信号量
let semaphore = Arc::new(Semaphore::new(cli.c as usize));
let semaphore = ...: 声明一个变量,用来保存这个信号量的智能指针。Arc::new(...):- 作用 :
Arc代表"原子引用计数"(Atomically Reference Counted)。 - 原因 :因为我们要把这个信号量分发给多个异步任务(Workers),Rust 需要知道何时真正销毁它。
Arc允许多个所有者共享这个值,并在最后一个所有者离开作用域时自动清理内存。
- 作用 :
Semaphore::new(cli.c as usize):- 核心:这是并发控制的源头。
- 逻辑 :
Semaphore::new接受一个数字,代表最大并发数。 - 来源 :
cli.c是用户通过命令行参数-c传入的数值(例如-c 50)。 - 结果 :这里创建了一个拥有
50个"通行证"的桶。这意味着,无论你启动多少个 Worker 任务,同一时间最多只有 50 个任务能拿到通行证并执行请求。
第二部分:信号量的克隆与分发 (在 Worker 启动循环中)
为了让每个 Worker 都能去申请许可证,我们需要把刚才创建的信号量分发给它们。
rust
let semaphores: Vec<_> = (0..cli.c).map(|_| semaphore.clone()).collect();
// ... (省略其他代码) ...
for i in 0..cli.c as usize {
let semaphore = semaphores[i].clone(); // 实际上在 move 闭包内会再次 clone
// ... 其他代码 ...
let handle = tokio::spawn(async move {
// 这里的 semaphore 是从外层 move 进来的
// ... 具体使用见下文 ...
});
}
semaphore.clone(): 因为Arc的存在,这里的clone非常轻量。它不会复制整个信号量的数据结构,仅仅是将引用计数加 1,并让新的 Worker 持有一个指向同一块内存的指针。- 目的 :确保所有 Worker 操作的都是同一个"通行证桶",而不是各自独立的桶。
第三部分:信号量的使用 (在 Worker 的 loop 循环中)
这是并发控制真正生效的地方。这是每个 Worker 任务执行的逻辑。
rust
// Acquire permit for concurrency control
let _permit = semaphore.acquire().await;
semaphore.acquire():- 含义:尝试从桶里拿出一张"通行证"。
- 行为 :
- 如果桶里还有通行证 :立即拿出一张,
await立即返回,程序继续向下执行(发送 HTTP 请求)。 - 如果桶里没通行证了(并发已达上限) :当前这个 Worker 任务会在此处暂停(Yield),并把 CPU 让给其他任务。它会进入等待队列,直到其他正在执行的 Worker 任务完成并归还了通行证。
- 如果桶里还有通行证 :立即拿出一张,
.await: 因为获取通行证可能是异步的(可能需要等待),所以必须使用.await。let _permit:- 关键点 :这里获取到的对象(
SemaphorePermit)被赋值给了一个变量。 - 自动归还机制 :Rust 的所有权机制保证了当这个变量
_permit离开作用域(即当前请求处理完毕,循环回到开头或结束) 时,它持有的那张"通行证"会自动被放回桶中。 - 无需手动释放:这是 Rust RAII(Resource Acquisition Is Initialization)理念的完美体现,彻底避免了"忘记释放锁/信号量"导致死锁的风险。
- 关键点 :这里获取到的对象(
直观的例子
我们可以把这段代码想象成一个公共厕所的管理模型:
- 初始化 (
Semaphore::new(50)) : 建了一个有 50 个隔间 的厕所。管理员手里只有 50 把钥匙。 - 排队进场 (
acquire().await) :- 第 1 个人来,拿走 1 把钥匙,进去上厕所(发送请求)。
- 第 50 个人来,拿走最后一把钥匙。
- 第 51 个人来,发现没钥匙了,只能在门口排队等待(
.await阻塞)。
- 释放资源 (离开作用域) :
- 第 1 个人上完厕所出来(请求结束),把钥匙放回柜台(
_permit变量被销毁)。 - 排队的第 51 个人立刻拿到这把钥匙,进去上厕所。
- 第 1 个人上完厕所出来(请求结束),把钥匙放回柜台(
结论 :通过这种机制,代码完美地保证了无论 -n(总请求数)是多少,同一时间只有 -c(并发数)个请求在活跃执行,从而实现了精准的并发控制。
3. 控制 QPS (Rate Limiting)
QPS 限流 是为了让 请求 按照 指定的 频率 发出,而不是 一拥而上。
- 机制实现 :
-
计算间隔 :根据用户指定的
-q(QPS),计算出每个请求之间应该间隔的时间(qps_interval)。
Interval=1/QPSInterval = 1 / QPSInterval=1/QPS -
基于时间的阻塞 :在 Worker 循环中,通过计算当前时间和期望时间的差值,使用
tokio::time::sleep进行补偿等待。rustif let Some(interval) = qps_interval { let elapsed = Instant::now() - worker_start; let expected = Duration::from_nanos((request_count as f64 * interval.as_nanos() as f64) as u64); if elapsed < expected { tokio::time::sleep(expected - elapsed).await; } } -
逻辑:如果当前时间还没到"理论上的下一个请求时间",就睡眠差值时间。这确保了即使并发度很高,整体的发送速率也不会超过设定的 QPS。
-
第三步:数据请求
这是实际与服务器交互的环节,发生在每个 Worker 的循环体内。
- HTTP 客户端构建 :使用
reqwest库构建 Client。所有 Worker 共享同一个Client实例(通过 Clone 传递),这意味着它们共享连接池(Pool)、Cookie 存储和配置,这是高性能的关键(复用 TCP 连接, 极大减少三次握手的次数)。 - 请求构造 :
- 根据参数设置 Method (GET/POST)、Headers (Host, User-Agent, Content-Type 等)。
- 如果是 POST 请求,会附加 Body 数据(来自
-d或-data-file)。 - 支持代理、Basic Auth、HTTP/2 等高级特性。
- 精细化计时 :在发送请求的前后,代码插入了多处
Instant::now(),用于计算 DNS 解析、TCP 连接、请求发送、等待响应头(Latency)、响应体读取等各个阶段的耗时。
两种 最终数据报告 的 样式
命令行 的 数据报告
默认输出到 命令行 的 数据报告通常由 四大核心板块 构成。
1. 概览与吞吐量指标 (Summary & Throughput)
这部分是整个报告的"标题",让你对压测的规模和整体产出有一个最直观的认识。
- 总耗时 (Total Time):整个压测过程持续了多久。
- 总请求数 (Total Requests):成功发出的请求总量。
- 吞吐量 (Throughput / RPS):即每秒请求数(Requests per second),这是衡量服务器承载能力最核心的指标。
- 数据传输量 (Data Transferred):压测期间总共传输了多少字节(或 MB/GB)的数据。
2. 响应时间分布 (Response Time Statistics)
这是性能分析的重中之重。因为"平均值"往往会掩盖系统的卡顿问题,所以命令行报告通常会提供一组百分位数据(Percentiles)。
- 平均值 (Average/Mean):所有请求耗时的算术平均值。
- 最快/最慢 (Fastest/Slowest):单次请求的极限耗时。
- 百分位数据 (Latency Percentiles) :
- P50 (中位数):50% 的请求都在这个时间内完成,代表系统的常规表现。
- P90 / P95:90% 或 95% 的请求都在这个时间内完成。
- P99 :99% 的请求都在这个时间内完成。这个指标非常关键,它代表了系统在极端情况下的表现(长尾延迟),如果 P99 很高,说明系统偶尔会出现严重的卡顿。
3. 请求详情直方图 (Request Details Histogram)
为了让你更直观地看到耗时分布,在命令行打印一个ASCII 字符组成的直方图。
- 它会把耗时划分成多个区间(比如 0-10ms, 10-20ms...)。
- 用
#或*号的数量来展示落在该区间的请求占比。 - 作用:一眼就能看出大部分请求是集中在某个低耗时区间,还是分散得很开。
4. 状态码与错误统计 (Status Codes & Errors)
这部分用于判断请求的"质量"。光有速度不行,还得保证请求是成功的。
- 状态码分布 :列出各个 HTTP 状态码的出现次数和占比。
- 例如:
[200] 9998 responses,[500] 2 responses。
- 例如:
- 错误详情:如果发生了非 HTTP 状态码的错误(比如 DNS 解析失败、连接超时、TCP 握手失败),会在这里列出具体的错误类型和数量。
📝 命令行 报告示例
当你运行完压测命令后,终端上通常会呈现类似下面这样的结构:
text
Summary:
Total time: 10.0534 secs
Total requests: 100000
Requests/sec: 9946.88 <-- 核心指标:QPS
Data transferred: 52.4 MB
Response Time (毫秒):
Average: 10.02 <-- 平均响应时间
Fastest: 1.20
Slowest: 85.40
50% in: 9.50 ms <-- P50
90% in: 15.30 ms <-- P90
99% in: 42.10 ms <-- P99 (重点关注)
Response Time Histogram (直方图):
0-10ms : 55000 (55.0%) ####################
10-20ms : 35000 (35.0%) #############
20-30ms : 8000 (8.0%) ###
30ms+ : 2000 (2.0%) #
Status Codes:
[200] 99995 responses <-- 成功数
[503] 5 responses <-- 失败数
CSV文件 的 数据报告
默认的 命令行的 输出 内容 和 csv 的 输出内容是 完全不一样的。
在数据形态、信息量以及使用场景上有着天壤之别。
简单来说:命令行输出的是"体检报告单"(高度汇总的统计结果),而 CSV 输出的是"原始化验数据"(每一次请求的明细流水账)。
我们可以从以下三个维度来详细对比:
1. 数据形态与内容不同
- 命令行输出(汇总报告) :
它呈现的是经过计算和聚合后的宏观指标 。你看到的是最终的分析结果,比如"平均响应时间是多少"、"P99是多少"、"总共有多少个请求"。你看不到第 5321 次请求具体花了多少毫秒。 - CSV 输出(明细流水) :
它记录的是压测过程中每一次请求的原始数据 。CSV 文件通常是一个巨大的表格,里面可能有成千上万行数据,每一行代表一次请求。它包含的是具体的字段,比如请求序号, 耗时, 状态码, 时间戳。
2. 包含的信息量不同
- 命令行:只展示核心结论,信息密度高,但丢失了细节。
- CSV :保留了全量细节。它不仅包含命令行里能算出来的耗时,通常还包含时间戳 (用于分析某个时间点是否出现了性能毛刺)、具体的报错信息等。这些细节在命令行的汇总报告里是被"平均"或"掩盖"掉的。
3. 使用场景不同
- 命令行 :适合人眼快速查看。压测一结束,你扫一眼终端,就能立刻判断这次压测是成功还是失败,性能是否达标。
- CSV :适合机器读取和深度分析。你无法直接用肉眼看几万行的 CSV,但它可以用 Excel 打开做透视表,用 Python/Pandas 做复杂的数据清洗,或者导入 Grafana 绘制精美的历史趋势图。
无 汇总数据
CSV 文件中 没有那些原子增加的汇总数据(比如总请求数、总字节数等) 。
CSV 文件里装的全是单次请求的明细数据。
💡 直观 例子
假设你压测了 3 次请求,耗时分别是 100ms、200ms、300ms。
命令行输出(汇总报告)大概长这样:
text
Summary:
Total Requests: 3
Average Latency: 200ms <-- 算好的平均值
Min Latency: 100ms <-- 算好的极值
Max Latency: 300ms
CSV 输出(明细流水)大概长这样:
csv
请求序号,延迟时间(秒),时间戳,状态码
1,0.100,1714371200,200
2,0.200,1714371201,200
3,0.300,1714371202,200
总结 的 对照表
| 维度 | 默认命令行输出 | CSV 文件输出 |
|---|---|---|
| 数据本质 | 统计结果(聚合后的宏观指标) | 原始明细(每一次请求的流水记录) |
| 核心内容 | 平均值、P99、QPS、状态码分布 | 单次耗时、精确时间戳、单次状态码 |
| 阅读对象 | 适合人眼快速扫视和判断 | 适合程序/Excel进行深度二次分析 |
| 典型用途 | 压测结束后的即时反馈与验收 | 绘制趋势图、排查偶发毛刺、性能回归对比 |
所以,命令行报告就像是老师最后给你的期末成绩单 (语文90,数学95),而 CSV 文件则是你每一次随堂测验的原始卷子,两者相辅相成,缺一不可。
第四步:数据累计 & 数据传递
我们可以把 命令行报告中 的数据,按照它们的 "出身"分为两类:
- 可以通过 多个 worker 通过 原子操作 的 统计数据
- worker 通过 channel 将 请求数据 传递给 消费者后 消费者协程 整理的数据。
1. 通过共享内存(原子操作)实时累加得来的数据
这部分数据通常是简单的数字(计数器或累加值) 。成千上万的 Worker 在发完请求后,会直接通过原子操作(如 fetch_add)去更新全局的共享变量。它们的特点是 更新极快、不需要加锁、不阻塞发压 。
在报告中,以下指标是典型的原子计算产物:
- 总请求数 (Total Requests):每完成一个请求,全局计数器就原子加 1。
- 成功/失败请求数:根据请求是否报错,原子累加对应的成功或失败计数器。
- 数据传输总量 (Data Transferred):每个请求的响应体字节数,会被原子累加到全局的总字节数中。
- 总耗时 (用于算平均值):每个请求的耗时(纳秒)会被原子累加,最后除以总请求数得出平均值(Average)。
- 最快/最慢响应时间 (Fastest/Slowest):Worker 会拿着自己这次的耗时,通过 CAS(比较并交换)原子操作,去和全局记录的最大/最小值比对,如果更极端就替换掉。
2. 通过 Channel 传递给单个消费者协程整理的数据
这部分数据通常是复杂的对象或明细,包含文本、多个字段,或者需要进行排序、分组等复杂计算。Worker 会把它们打包成一个结构体,扔进 Channel,由后台那个专门的"消费者协程"去慢慢处理(比如写 CSV 文件,或者在内存里做复杂的统计聚合)。
在报告中,以下指标是典型的 Channel 传递并整理的产物:
- 百分位数据 (P50, P90, P99) :计算百分位数必须先把所有请求的耗时收集起来,然后进行排序。原子操作无法排序,所以 Worker 会把每次的耗时样本通过 Channel 传出去,消费者收集全了之后统一排序计算。
- 响应时间直方图 (Histogram) :直方图需要知道每个耗时区间(比如 10-20ms)具体落了多少个请求。这需要消费者拿到所有明细耗时后,进行区间划分和统计。
- 状态码分布 (Status Codes) :虽然也可以用原子 Map 实现,但通常为了逻辑解耦,Worker 会把具体的
状态码(如 200, 500)作为明细数据通过 Channel 传出去,由消费者在后台做分组计数(Group By)。 - 错误详情 (Error Details) :当请求失败时,具体的报错文本(如 "Connection refused")是字符串,无法用原子操作累加。Worker 会把这些报错信息通过 Channel 传递,由消费者进行归类整理。
对比表
| 报告数据板块 | 具体指标 | 数据来源方式 | 为什么用这种方式? |
|---|---|---|---|
| 概览与吞吐量 | 总请求数、总传输量 | 共享内存(原子操作) | 简单的数字累加,追求极致速度,无锁并发 |
| 平均响应时间 (Average) | 共享内存(原子操作) | 只需要累加总耗时,最后做一次除法即可 | |
| 响应时间分布 | 最快/最慢 (Min/Max) | 共享内存(原子CAS) | 只需要比大小,原子 CAS 操作足够高效 |
| P50 / P90 / P99 | Channel + 消费者整理 | 必须收集所有样本并排序,无法实时原子计算 | |
| 直方图 | 耗时区间分布图 | Channel + 消费者整理 | 需要对海量明细数据进行区间划分和统计 |
| 状态码与错误 | 状态码分布 (200/500等) | Channel + 消费者整理 | 涉及分组计数,传递明细由后台统一处理更解耦 |
| 错误详情文本 | Channel + 消费者整理 | 包含复杂的字符串信息,只能通过对象传递 |
简单来说:凡是能"直接加减比大小"的简单数字,都走共享内存原子操作;凡是涉及"排序、分组、存文本"的复杂明细,都走 Channel 交给后台协程整理。 这种设计完美兼顾了压测的高性能与报告的丰富度。
详细 动作 拆解
- CSV 文件 和 命令行 ** 它们其实共用**了这同一个 Channel 的数据流
- 大概流程:Worker 发送样本 -> Channel -> 统计协程收集样本(命令行的数据统计) & 写入 CSV。
Worker 每完成一个请求,它会 做 两件事(双管齐下):
动作一:直接更新共享内存(不走 Channel)
- 操作 :
TOTAL_REQUESTS.fetch_add(1)、TOTAL_BYTES.fetch_add(len)。 - 去向:原子变量(Atomic)。
- 最终归宿 :命令行报告中的"总请求数"、"吞吐量"、"最快/最慢时间"。
动作二:发送样本到 Channel(走唯一的 Channel)
- 操作 :
tx.send(latency).unwrap()。 - 去向 :Channel(通道)。
- 消费者(Receiver) :
- 用途 A(算 P99/直方图) :消费者协程从 Channel 捞出耗时数据,存入
Vec,最后排序算出 P99/直方图 。这部分结果会显示在命令行报告里。 - 用途 B(写 CSV) :消费者协程(或者同一个协程)将捞出来的数据(或者 Worker 直接发送的更详细的结构体)格式化,写入 CSV 文件。
- 用途 A(算 P99/直方图) :消费者协程从 Channel 捞出耗时数据,存入
具体代码流程
在 Rust 的高并发压测工具中,数据传输的 设计 非常精妙,它采用了快慢分离的双轨制:
- 一条是追求极致速度的共享内存(原子操作),
- 另一条是追求解耦 与稳定的Channel(通道)。
下面我将结合具体的代码逻辑,为你逐行拆解这两条数据流水线是如何运转的。
🚀 轨道一:基于共享内存的核心指标传输(追求极致速度)
这条轨道负责传输最频繁、最核心的统计数据(如:总请求数、成功数、总耗时)。它不使用 Channel,而是让所有 Worker 直接更新同一块内存,从而避免了数据拷贝和上下文切换的开销。
1. 主线程创建共享的"记分牌"
在压测开始前,主线程会创建一个被 Arc(原子引用计数)包裹的统计结构体。Arc 保证了这块内存可以被多个异步任务安全地共享。
rust
// 主线程中:创建共享的统计数据容器
let stats = Arc::new(AggregatedStats::new());
2. 克隆"记分牌"分发给各个 Worker
在启动成百上千个压测协程(Worker)时,主线程会将这个 stats 克隆并移动到每个异步任务中。注意,这里克隆的只是指针,底层的内存地址是同一块。
rust
// 循环启动 Worker 任务
let stats_clone = stats.clone(); // 增加引用计数,指向同一块内存
tokio::spawn(async move {
// Worker 协程内部逻辑...
});
3. Worker 请求完成后,直接原子写入
当某个 Worker 完成一次 HTTP 请求后,它会直接调用 record 方法。这里使用了 fetch_add(原子加法),它能在没有任何锁(Mutex)的情况下,安全地让全局计数器加 1。
rust
// Worker 内部:请求结束,直接更新共享内存中的计数器
// Ordering::Relaxed 保证了极高的写入性能
self.total_requests.fetch_add(1, Ordering::Relaxed);
📨 轨道二:基于 Channel 的明细数据传输(追求解耦与稳定)
这条轨道负责传输体积较大、不需要实时计算的数据(如:每一个请求的详细耗时、状态码、响应体大小等),主要用于生成 CSV 报告。
1. 主线程创建通道(Channel)
主线程使用 tokio::sync::mpsc::channel 创建一个多生产者、单消费者的异步通道。100_000 是通道的缓冲区大小,意味着可以暂存 10 万条明细数据,防止消费者处理慢时阻塞生产者。
rust
// 主线程中:创建通道,tx 是发送端,rx 是接收端
let (tx, rx) = tokio::sync::mpsc::channel::<RequestStats>(100_000);
2. 启动唯一的"消费者协程"接管接收端
主线程会提前启动一个专门的协程,它拿着通道的接收端 rx,唯一负责把数据写入 CSV 文件。这完美对应了你总结的"只有一个消费者协程负责统计"。
rust
// 主线程中:启动消费者协程,专门负责写文件
let csv_handle = tokio::spawn(async move {
// 只要通道没关闭,就一直循环接收数据
while let Some(stat) = rx.recv().await {
// 将明细数据写入 CSV 文件
writer.write_record(&[stat.url, stat.status.to_string()])?;
}
});
3. Worker 将明细数据打包送入通道
各个 Worker 协程拿着通道的发送端 tx。每当一个请求结束,Worker 会把这次请求的详细数据打包成一个 RequestStats 结构体,然后异步发送到通道中。
rust
// Worker 内部:请求结束,将明细数据发送到通道
let request_stat = RequestStats { url: "...", status: 200, duration: ... };
tx.send(request_stat).await; // 异步发送,如果缓冲区满会等待
第五步:结果统计
在压测工具中,如何在高并发下安全、高效地收集成千上万个请求的结果,并最终计算出平均值、P99、直方图等数据,是衡量其性能的核心指标。
基于你提供的 whero 代码,"最终统计结果" 的生成流程可以分为三个阶段:
- 数据收集 (Collection):Worker 将单个请求的数据写入共享结构。
- 数据聚合 (Aggregation):主线程在压测结束后,从共享结构中提取原始数据。
- 格式化输出 (Formatting):计算百分位数、平均值等并打印。
以下是结合代码的逐行深度解析:
第一阶段:数据收集 (Worker -> AggregatedStats)
这是高频操作,发生在每个 Worker 任务的 stats.record(&stat) 中。
1. 代码位置:impl AggregatedStats 中的 record 方法
rust
fn record(&self, stat: &RequestStats) {
// 1. 原子计数器:增加总请求数
self.total_requests.fetch_add(1, Ordering::Relaxed);
// 2. 判断是否成功
if stat.error.is_none() {
// 成功请求的处理
self.success_requests.fetch_add(1, Ordering::Relaxed);
// 3. 计算耗时 (纳秒)
let duration_ns = stat.duration.as_nanos() as u64;
// 4. 更新最大值/最小值 (使用 CAS 循环)
// --- 更新最小值 ---
loop {
let current_min = self.min_duration.load(Ordering::Relaxed);
// 如果当前值比记录的最小值大,或者 CAS 成功,则跳出
if duration_ns >= current_min ||
self.min_duration.compare_exchange(current_min, duration_ns, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
break;
}
}
// --- 更新最大值 (逻辑同上) ---
loop { ... }
// 5. 累加总耗时 (用于计算平均值)
self.total_duration.fetch_add(duration_ns, Ordering::Relaxed);
// 6. 字节统计
if stat.content_length > 0 {
self.total_bytes.fetch_add(stat.content_length as u64, Ordering::Relaxed);
}
// 7. 存储耗时样本 (用于计算百分位数/直方图)
// 注意:这里限制了 100 万个样本,防止内存溢出
if self.durations.lock().len() < 1_000_000 {
self.durations.lock().push(duration_ns); // 这里使用了 Mutex
}
// 8. 状态码分布统计
*self.status_codes.lock().entry(stat.status).or_insert(0) += 1;
// 9. 阶段耗时统计 (DNS, TCP等)
if let Some(dns) = stat.dns_duration { ... }
} else {
// 10. 错误统计
self.error_requests.fetch_add(1, Ordering::Relaxed);
if let Some(ref err) = stat.error {
*self.errors.lock().entry(err.clone()).or_insert(0) += 1;
}
}
}
逐行讲解:
- 原子操作 (Atomic) :第 1、5、6 行使用了
fetch_add。这是无锁操作,性能极高,适合高频计数。 - CAS 循环 (Compare-and-Swap) :第 4 行更新最大/最小值。因为
Mutex太重,这里使用了自旋锁式的 CAS 操作。虽然有自旋开销,但在更新极值这种低频竞争场景下是可接受的。 - 采样限制 :第 7 行
if len < 1_000_000。这是一个非常聪明的设计。如果压测 1000 万次请求,不可能把所有耗时都存下来(内存爆炸)。只存 100 万个样本,既保证了统计学意义,又防止了 OOM。 - Mutex 保护 :第 7、8 行对
Vec和HashMap的操作必须加锁(parking_lot::Mutex,比标准库快)。
第二阶段:数据聚合 (主线程读取)
压测结束后,主线程调用 generate_summary 函数来读取 AggregatedStats。
2. 代码位置:async_main 函数末尾
rust
// 等待所有 Worker 结束
for handle in handles { let _ = handle.await; }
// 1. 获取总耗时
let total_time = start_time.elapsed();
// 2. 生成摘要
let summary = generate_summary(&stats, total_time);
- 流程 :主线程先等待所有 Worker 任务结束(确保
record不再被调用),然后才开始读取数据。这保证了数据的一致性(读写分离)。
第三阶段:格式化输出 (计算与打印)
这是最复杂的计算部分,代码在 generate_summary 函数中。
3. 代码位置:fn generate_summary
rust
fn generate_summary(stats: &AggregatedStats, total_time: Duration) -> String {
// 1. 读取原子计数器
let total = stats.total_requests.load(Ordering::Relaxed);
let success = stats.success_requests.load(Ordering::Relaxed);
// 2. 计算 RPS (每秒请求数)
let rps = total as f64 / total_time.as_secs_f64();
// 3. 获取耗时样本并排序 (关键步骤)
let durations = stats.durations.lock(); // 获取锁
if !durations.is_empty() {
let mut sorted = durations.clone(); // 克隆数据 (避免长时间持有锁)
sorted.sort_unstable(); // 排序是计算百分位数的前提
// 4. 计算平均值、最小值、最大值
let avg_ns = total_ns / success;
// 5. 计算百分位数 (Percentiles)
let percentiles: [f64; 7] = [10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0];
for p in percentiles {
// 公式:索引 = (百分比 / 100) * (数组长度 - 1)
let idx = ((p / 100.0) * (sorted.len() - 1) as f64) as usize;
// 取出对应的耗时值
let latency = Duration::from_nanos(sorted[idx]);
}
// 6. 生成直方图 (Histogram)
output.push_str(&generate_histogram(&sorted));
// 7. 阶段耗时统计 (DNS/TCP等)
output.push_str(&generate_stage_details(&sorted, stats));
}
// 8. 状态码分布
let status_codes = stats.status_codes.lock();
// ... 格式化输出 ...
output // 返回最终字符串
}
逐行讲解:
- 排序 (
sort_unstable) :第 3 行。计算 P99(99% 的请求耗时低于多少)必须先对数组排序。sort_unstable比稳定排序快,因为我们只关心数值大小,不关心原始顺序。 - 百分位数算法 :第 5 行。这是统计学标准公式。例如,数组长度为 1000,要找 P99,索引就是
(99/100) * 999 = 989,取排序后数组第 989 个元素的值。 - 锁的粒度 :代码尽量在持有锁时只做
clone()(第 3 行),然后释放锁再去排序和计算。这是为了防止在计算耗时的汇总数据时,阻塞 Worker 线程的写入(虽然此时 Worker 已经停了,但这是良好的编程习惯)。
总结:最终统计流程图
- 写入 (高频) :Worker ->
AtomicU64(计数) /Mutex<Vec>(存样本)。 - 停止:所有 Worker 退出,不再有写入操作。
- 读取 (低频) :主线程 -> 锁住
Mutex<Vec>-> 克隆数据 -> 释放锁 -> 排序克隆的数据 -> 计算 P99/平均值 -> 生成字符串。
这种设计在高并发写入 和复杂统计计算之间找到了一个完美的平衡点。