初始状态
三个队列:
- url_q
- 待请求的网址
- text_q
- 待解析的网页文本
- run_q
- 任务状态:RUNNING和TO_RETURN
单队列
- work_q
- 通过任务标记来区分任务:URL,TEXT
- 无需通过run_q来传递状态
优先队列
- 设置优先级:TEXT>URL
- 减少text的等待时间
异步IO
- 使用asyncio,创建连接池(关闭复用)
- 把相应线程函数改为异步
单队列
Total wait_time: 492.1777s, total processing time: 338.1745s, total total time: 830.3522s
URL解析平均十几秒,text解析平均零点零几秒
原始三队列
Total wait time: 581.0403 seconds
Total process time: 320.3240 seconds
Total total time: 901.3643 seconds
分析
这个结果其实挺有意思,不应该解读成“提升不大所以没什么意义”。你现在已经测出了一个很典型的现象:
统一队列确实减少了排队浪费,但这个 workload 的主要瓶颈根本不在队列调度,而在 URL I/O。
先拆你的数据。
| 指标 | 单队列 | 三队列 | 变化 |
|---|---|---|---|
| 所有任务累计 wait | 492.18s | 581.04s | -15.3% |
| 所有任务累计 processing | 338.17s | 320.32s | +5.6% |
| 累计 latency | 830.35s | 901.36s | -7.9% |
所以其实单队列在你最关心的那个方面——排队时间——改善了约 15%。
这不算完全没有效果。
但有一个非常重要的问题:
你现在这个 Total total time 不是程序实际运行时间
如果你是这样算:
total_time += wait_time + processing_time
那么 830s 是:
所有任务 latency 的总和
而不是:
程序从开始到整个爬取结束用了 830 秒。
例如两个任务并行:
Task A: 10 秒
Task B: 10 秒
实际程序:
wall clock = 10 秒
但你把任务时间加起来:
total processing time = 20 秒
所以如果你要回答:
“单队列到底有没有让整个程序跑得更快?”
最应该测的其实是 makespan / wall-clock time:
start = time.perf_counter()
# 整个任务系统运行
...
work_q.join()
elapsed = time.perf_counter() - start
这个指标可能才是:
单队列: 72.3 秒
三队列: 75.8 秒
之类。
这是你现在最应该补的指标。
为什么你预测的巨大差距没有出现?
你之前的推理其实没错:
URL = 十几秒
TEXT = 0.0几秒
统一线程池可能出现:
Worker 1 → URL ──────────────
Worker 2 → URL ──────────────
Worker 3 → URL ──────────────
Worker 4 → URL ──────────────
TEXT → 等……
而独立队列:
URL workers → URL ──────────
TEXT worker → TEXT 0.02s
理论上 TEXT 会更快被处理。
但是,有一个关键事实:
TEXT 太快了
假设:
URL = 12 秒
TEXT = 0.02 秒
比例是:
600 : 1
那整个系统的工作量几乎完全由 URL 决定。
例如处理:
100 个 URL × 12 秒 = 1200 worker-seconds
100 个 TEXT × 0.02 秒 = 2 worker-seconds
TEXT 调度得再完美:
2 秒 → 1 秒
相比 URL 的 1200 worker-seconds,也几乎看不出来。
所以你的实验实际告诉你:
TEXT 确实可能受到调度延迟,但 TEXT 本身几乎不消耗资源,所以优化它不是这个 workload 的主要性能杠杆。
更有意思的是:单队列反而可能占优势
原三队列假设:
URL workers = 4
TEXT workers = 2
总线程数:
6
但是 TEXT 只需要:
0.02 秒
于是绝大多数时候:
URL workers:
████████████████████████ 100% 忙
TEXT workers:
█ 大量空闲
你明明有 6 个线程:
实际长期能干 URL 的只有 4 个
而统一:
6 generic workers
TEXT 一旦处理完:
worker → 马上去处理 URL
于是:
████████████████████████
████████████████████████
████████████████████████
████████████████████████
████████████████████████
████████████████████████
这恰恰就是你前面说的:
统一队列资源利用率很高。
而你的 workload:
URL:超慢
TEXT:极快
实际上很适合共享 worker pool。
这揭示了资源隔离真正的代价
我们前面说:
URL pool 4
TEXT pool 2
可以保证 TEXT 不被 URL 饿死。
没错。
但你付出的代价是:
哪怕 TEXT 根本没活干,那两个 TEXT worker 也不能帮 URL。
这叫资源碎片化 / capacity fragmentation。
可以理解成:
共享池:
谁有活谁用资源
→ utilization 高
独立池:
资源有归属
→ isolation 高
→ utilization 可能降低
于是你遇到了一个非常经典的系统 trade-off:
共享资源
↑
高利用率 │ 灵活调度
│
────────────────┼────────────
│
性能隔离 │ 延迟保证
↓
资源隔离
不存在“独立队列一定更好”。
那什么时候原来的三队列可能明显胜出?
你可以故意设计 workload 把它测出来。
这是下一步实验非常值得做的地方。
例如让:
TEXT parse = 1 秒
而不是:
0.02 秒
同时:
每个 TEXT 产生 5 个新 URL
这时候 TEXT 就变成:
重要的任务生成节点。
如果共享队列里 8 个 worker 全被 10 秒 URL 占住:
0s
W1 URL ──────────
W2 URL ──────────
...
W8 URL ──────────
TEXT
TEXT
TEXT
↓
全部等 10 秒
那么这三个 TEXT 原本可以产生:
15 个新 URL
但现在全部晚 10 秒才出现。
这时候关键路径影响就可能非常明显。
而独立:
6 URL worker
2 TEXT worker
TEXT 始终可以推进:
TEXT
↓
新 URL
↓
提前进入 URL queue
这时候资源隔离的价值才会真正显现。
你现在最应该补的不是继续改代码,而是补 benchmark
我建议至少记录:
1. wall-clock makespan
从第一个任务加入 → 全局 join 返回
2. URL
avg wait
p50 wait
p95 wait
avg processing
3. TEXT
avg wait
p50 wait
p95 wait
avg processing
4. 最大 queue size
5. 总任务数量
URL count
TEXT count
尤其是:
一定把 URL 和 TEXT 的 wait time 分开。
你现在这个:
Total wait = 492s
其实掩盖了最有意思的信息。
完全可能发生:
单队列:
URL avg wait = 2.1s
TEXT avg wait = 8.3s
三队列:
URL avg wait = 3.5s
TEXT avg wait = 0.1s
最后所有任务加起来:
总 wait 差距不大
但两种架构的行为已经完全不同。
这正是资源隔离要解决的问题。
还有一个实验严谨性问题:你这里:
processing:
单队列 338s
三队列 320s
理论上仅仅改变调度架构,不应该稳定地让 HTTP 请求本身快/慢 18 秒。
这很可能是:
网络波动
服务器响应速度变化
连接复用差异
任务数量不同
所以不要只跑一次。
最好:
A B A B A B
各跑 3~5 次,再比较 median makespan。
特别是你 URL 一次就十几秒,网络噪声很容易比架构优化本身还大。
你这个实验目前其实已经得出了一个相当不错的工程结论:
对于 URL I/O 极慢、TEXT 处理极轻的 workload,共享 worker pool 通过动态复用线程减少了排队时间,而阶段隔离带来的 TEXT 延迟保障不足以抵消闲置 TEXT worker 所造成的资源碎片化。因此,共享队列可能反而具有更高吞吐。
这已经比“单队列代码更简单”深入很多了。
而下一步最有价值的问题不是“怎么让单队列继续优化”,而是:
改变 URL/TEXT 的 workload 比例以后,两种架构的性能交叉点在哪里?
做到这一步,你这个几十行的小实验就真的开始有一点小型 systems benchmark 的味道了。
补充程序运行时间
单队列
Total wait_time: 300.9817s, total processing time: 338.4410s, total total time: 639.4228s
Total execution time: 43.8928s
URL解析平均十几秒,text解析平均零点零几秒
原始三队列
Total wait time: 574.2299 seconds
Total process time: 324.0145 seconds
Total total time: 898.2444 seconds
Total elapsed time: 55.8855 seconds
分析
这次就很明显了:单队列不是“小幅提升”,而是已经有相当可观的提升。
最重要的指标是 wall-clock:
单队列:43.8928 s
三队列:55.8855 s
也就是总完成时间缩短了大约 21.5%,或者说吞吐意义上的 speedup 约为:
55.8855 / 43.8928 ≈ 1.27×
这个差距已经值得认真分析了。
更有意思的是累计等待时间:
单队列:300.98 s
三队列:574.23 s
几乎减少了 47.6%。
而真正处理时间:
单队列:338.44 s
三队列:324.01 s
反而单队列还略高一点,差约 4.5%。
这恰好说明:
单队列的优势不是“每个任务执行得更快”,而是“调度得更有效,任务少在队列里干等”。
这跟我们前面推测的资源碎片化非常吻合。
假设原三队列类似:
url_q → 4 URL workers
text_q → 2 TEXT workers
而 TEXT:
处理时间 ≈ 0.0x 秒
URL:
处理时间 ≈ 十几秒
那么三队列很可能长期处于:
URL worker 1 █████████████████
URL worker 2 █████████████████
URL worker 3 █████████████████
URL worker 4 █████████████████
TEXT worker 1 █__空闲________________
TEXT worker 2 █__空闲________________
也就是说,虽然你有 6 个线程,但大量时间真正能够承担 URL 工作的可能只有 4 个。
而统一队列以后:
worker 1 █████████████████
worker 2 █████████████████
worker 3 █████████████████
worker 4 █████████████████
worker 5 █████████████████
worker 6 █████████████████
TEXT 来了以后:
某个 worker:
TEXT 0.02s
↓
马上继续拿 URL
于是 TEXT worker 原先浪费掉的 capacity 被重新利用了。
这也解释了为什么:
累计 processing time
基本没有变,但是:
累计 waiting time
骤降。
这里有一个很重要的工程结论:
资源隔离不是免费的。
三队列给了你:
URL 和 TEXT 独立的并发额度
但代价是:
TEXT 没任务
≠
TEXT worker 可以帮助 URL
而你的 workload 恰好又特别极端:
URL:非常重
TEXT:非常轻
所以固定给 TEXT 保留线程其实很浪费。
统一队列相当于自动实现了:
谁现在有工作,谁就使用计算资源。
这种情况下共享 worker pool 非常占优势。
而且你的结果也回答了你上一轮那个问题:
“并行任务顺序真的会影响最终时间吗?”
这里看来确实影响,但目前主要原因可能还不是关键路径依赖,而是 worker capacity 的利用率。
因为 TEXT 实在太快:
0.0x 秒
就算晚执行几秒,对 CPU 工作量本身影响很小。
真正的大头更可能是:
三队列:
一部分线程长期只能处理 TEXT
↓
但 TEXT 几乎没工作
↓
大量线程 capacity 闲置
↓
慢 URL 无法利用这些线程
↓
整体 makespan 增大
也就是说当前实验里:
resource fragmentation
可能比:
TEXT 延迟 → 后续 URL 晚生成 → critical path 变长
贡献更大。
当然两者可能同时存在。
现在有个实验我非常建议你做,而且会很有说服力:
让三队列的 URL worker 数逐渐增加,但保持总线程资源这个变量可解释。
比如,如果单队列是 6 workers,可以比较:
单队列:
6 generic
三队列 A:
4 URL + 2 TEXT
三队列 B:
5 URL + 1 TEXT
三队列 C:
6 URL + 1 TEXT
最后一个总线程数已经不同,所以不是公平性能比较,但可以回答另一个问题:
“三队列究竟是架构本身慢,还是 URL worker 配少了?”
我尤其预测:
5 URL + 1 TEXT
很可能会明显接近你的单队列。
因为 TEXT 平均只有几十毫秒,很可能根本用不到两个专属 worker。
如果:
4 URL + 2 TEXT → 55.9s
5 URL + 1 TEXT → 46s
6 generic → 43.9s
你就能非常漂亮地证明:
原架构主要问题不是多队列本身,而是静态资源分区不符合实际 workload 比例。
这已经是一个非常像样的 systems 实验了。
不过现在还不能仅凭这一次运行就下结论。
因为你的 processing time:
338.44 vs 324.01
差了 14 秒,说明网络本身有明显波动。
所以最好至少跑:
单 → 三 → 单 → 三 → 单 → 三
不要连续把同一个方案跑三次再换方案,以免时间段网络状况产生偏差。
然后比较:
median elapsed time
而不是只看平均。
如果你最后连续几轮都得到类似:
单队列 ≈ 44s
三队列 ≈ 55s
那 20% 左右的差距就基本不是偶然了。
你这个小实验现在已经真正开始回答一个工程问题:
对于高度不均衡的 pipeline workload,静态 worker 隔离和共享 worker pool 哪个更合适?
目前你的数据明显站在共享 pool 这一边。
重复实验结果
单队列
Total wait_time: 549.5495s, total processing time: 332.9288s, total total time: 882.4782s
Total execution time: 43.3092s
三队列
Total wait time: 574.7912 seconds
Total process time: 318.5619 seconds
Total total time: 893.3531 seconds
Total elapsed time: 55.5341 seconds
分析
分析
对,这第二次结果反而把结论变得更清楚了:
累计 wait time 很不稳定,但 wall-clock 总执行时间非常稳定。
两次 wall-clock:
| 实验 | 单队列 | 三队列 |
|---|---|---|
| 第一次 | 43.89s | 55.89s |
| 第二次 | 43.31s | 55.53s |
这稳定得相当漂亮。
单队列基本:
43.3 ~ 43.9 s
三队列基本:
55.5 ~ 55.9 s
也就是说单队列稳定缩短约 12 秒,wall-clock 降低约 22%,speedup 大约 1.28×。
这比累计 wait_time 有说服力得多。
为什么累计 wait time 会乱跳?
你两次单队列:
第一次 wait = 300.98s
第二次 wait = 549.55s
差得非常大。
但程序结束时间:
43.89s
43.31s
几乎没变。
原因是你统计的:
Σ 每个任务的 queue_wait
会把调度上的微小波动重复累计很多遍。
例如某个 URL 网络突然慢了:
URL A 原本 8s
这次变成 14s
它可能导致:
TEXT A 晚产生
↓
TEXT A 产生的 URL B/C 晚进入
↓
后面一批任务的排队顺序变化
↓
几十个任务的 wait_time 都发生变化
于是一次几秒的网络波动,最后可能在:
Σ queue_wait
里变成几十甚至上百秒。
但这些等待很多是彼此重叠的。
比如:
Task A 等了 5s
Task B 等了 5s
Task C 等了 5s
Task D 等了 5s
累计:
wait = 20s
但四个任务可能是同时等了那 5 秒。
所以 wall-clock 只增加:
5s
而不是 20s。
这也是为什么:
total wait time不能直接解释成“程序浪费了这么多秒”。
你的 workload 现在有一个很明显的特点
URL:
十几秒
TEXT:
0.0几秒
网络响应本身又有随机性。
所以:
某个 URL 谁先回来
↓
哪个 TEXT 先产生
↓
又产生哪些 URL
↓
queue 中的相对顺序
每次都会有所不同。
于是具体任务的:
queue wait
很敏感。
但整体仍然存在足够多的 URL,可以一直把 worker 填满,所以:
总 makespan
反而非常稳定。
这是个很好的现象。
这也进一步支持“资源碎片化”解释
三队列两次:
55.89s
55.53s
单队列两次:
43.89s
43.31s
如果单队列的优势主要来自某种偶然的:
“这次网络请求顺序碰巧特别好”
你不会这么容易连续两次得到几乎一模一样的 12 秒优势。
现在更像是一个结构性的原因:
三队列:
URL worker 全忙
TEXT worker 很快干完
TEXT worker 空闲
↓
不能帮 URL
而:
单队列:
TEXT 干完
↓
这个 worker 马上去拿 URL
↓
所有 worker 都可以服务主要瓶颈
因此稳定获得更高有效并发度。
所以接下来别太关注 Total wait
它可以保留,但不要作为主性能指标。
你现在的指标优先级我会改成:
第一:
wall-clock elapsed time
这是最终用户真正感受到的:
整个任务多久完成?
第二:按类型统计 wait distribution
不要只看:
Total wait
而看:
URL:
avg wait
median wait
p95 wait
TEXT:
avg wait
median wait
p95 wait
这个可以回答我们之前的另一个问题:
共享 pool 是否牺牲了 TEXT 的调度延迟?
很可能你会发现:
单队列:
TEXT wait ↑
但是:
整体 elapsed ↓
那就是非常漂亮的 trade-off。
第三:worker utilization。
这个甚至可能直接验证我们的猜测。
例如每个 worker 记录:
处理 URL 时间
处理 TEXT 时间
等待 queue 时间
然后三队列可能出现:
URL worker:
busy 95%
TEXT worker:
busy 2%
而单队列:
generic worker:
busy 85~95%
那基本就实锤了:
三队列慢的主要原因是静态资源分区造成 capacity fragmentation。
其实你现在已经做出了一个很典型的 benchmark 结果:
单队列 三队列
wall-clock ~43.6s ~55.7s
任务执行总量 接近 接近
网络波动 明显 明显
最终 makespan 很稳定 很稳定
所以当前最靠谱的结论可以写成:
虽然单任务排队时间受网络返回顺序影响较大,但共享 worker pool 的整体完成时间表现稳定,连续实验均比阶段隔离的多队列方案快约 22%。这说明性能改善更可能来源于动态共享 worker capacity,而非偶然的网络请求时序。
这已经是相当扎实的实验结论了。
补充分类计时的结果
单队列
Total wait_time: 561.2459s, total processing time: 330.2235s, total total time: 891.4695s
Total execution time: 42.8025s
Average url wait time: 5.3529s, average url processing time: 6.5913s
Median url wait time: 6.1541s, median url processing time: 6.5008s
Average text wait time: 5.8720s, average text processing time: 0.0132s
Median text wait time: 5.6411s, median text processing time: 0.0112s
三队列
Total wait time: 551.9093 seconds
Total process time: 314.8117 seconds
Total total time: 866.7210 seconds
Total elapsed time: 54.0335 seconds
Average wait time for URL tasks: 11.0380 seconds
Average process time for URL tasks: 6.2776 seconds
Median wait time for URL tasks: 11.6020 seconds
Median process time for URL tasks: 6.2640 seconds
Average wait time for TEXT tasks: 0.0002 seconds
Average process time for TEXT tasks: 0.0190 seconds
Median wait time for TEXT tasks: 0.0001 seconds
Median process time for TEXT tasks: 0.0179 seconds
分析
分析
| 指标 | 单队列 | 三队列 |
|---|---|---|
| URL 请求平均等待 | 5.35s | 11.04s |
| URL 请求平均处理 | 6.59s | 6.28s |
| Text 解析平均等待 | 5.87s | 0.0002s |
| Text 解析平均处理 | 0.013s | 0.019s |
| 总执行时间 | 42.80s | 54.03s |
这下整个现象一下就解释通了。
你之前关于“资源隔离”的推理被实验直接验证了
三队列:
8 个 URL worker 4 个 Text worker
↓ ↓
URL 请求很慢 ~6.3s Text 解析极快 ~0.02s
██████████████████ █
██████████████████ █
██████████████████ 大量闲置
██████████████████
...
Text 几乎零等待:
0.0002s
这就是资源隔离的好处:
无论 URL worker 堵成什么样,Text 都有自己的线程,来了马上处理。
但代价也极其明显:
Text worker 绝大多数时间没事干,却不能帮 URL worker。
于是 URL 任务:
平均排队 11.04 秒
处理本身才 6.28 秒
一个 URL 等待的时间居然差不多是处理时间的 1.8 倍。
而你统一队列以后,相当于:
12 个 generic worker
URL来了 → 谁空闲谁请求
TEXT来了 → 谁空闲谁解析
于是 URL 的平均排队:
11.04s → 5.35s
直接砍了一半。
代价是 Text:
0.0002s → 5.87s
惨得不得了 😂
Text 本身:
只需要 0.013 秒
结果:
排队 5.87 秒
执行 0.013 秒
99% 以上的 latency 都在排队。
这正是我们前面担心的:
慢 URL 抢占所有 worker,导致本来瞬间就能做完的 Text 被拖延。
但最有意思的结果是:尽管如此,单队列还是更快
三队列:54.03s
单队列:42.80s
单队列 makespan 降低约:
20.8%
所以你的 workload 给出的实际答案是:
牺牲 Text 的响应延迟,换来更高的整体资源利用率,值得。
这就是一个非常典型的系统 trade-off。
三队列优化的是:
Text latency / isolation
单队列优化的是:
整体 throughput / makespan / worker utilization
而你的目标如果是:
“尽快把整张网站图遍历完”
显然 makespan 更重要。
于是单队列赢。
这还回答了我们之前关于“任务依赖”的问题
之前我们担心:
TEXT 等 5 秒
↓
新 URL 晚 5 秒才被发现
↓
后续任务也被推迟
↓
可能拖慢整个任务图
现在数据告诉我们:
这个效应确实存在,但没有资源共享带来的收益大。
单队列:
Text 被拖延:
+ ~5.87s
这是坏处。
可是 URL 等待:
11.04s → 5.35s
而 URL 又是系统绝大部分 workload。
这是巨大的好处。
最终:
资源共享收益
>
Text 关键路径延迟损失
所以:
54s → 43s
更漂亮的是 Total wait 几乎没变
单队列:561.25s
三队列:551.91s
如果只看这个指标,你甚至会得出:
“单队列好像还更差。”
但实际上内部发生了巨大的等待时间重新分配:
单队列 三队列
URL wait 5.35s 11.04s
Text wait 5.87s 0.00s
也就是说:
总排队时间差不多,但单队列把“等待”从重要、昂贵、数量多的 URL 任务转移到了极便宜的 Text 任务上。
而这恰好改善了 makespan。
这也是一个很重要的工程经验:
只优化总平均值可能会完全看不见调度器到底在做什么。必须按 workload class 拆指标。
你现在甚至已经可以画出两种方案的设计目标:
三队列 / 静态资源隔离
URL ──────────────→ URL pool
TEXT → Text pool
优:
Text 延迟极低
不同 workload 互不抢资源
缺:
Text pool 大量空闲
URL pool 排队严重
总完成 54s
vs.
单队列 / 共享资源池
URL ─┐
├→ work_q → shared workers
TEXT ─┘
优:
worker 动态调配
URL 排队减半
总完成 43s
缺:
Text 被 URL 堵住
Text latency 巨大
这已经不是单纯的 Python Queue 小练习了。你实际上用实验验证了一个很标准的系统设计矛盾:
resource pooling vs resource isolation
而且你的结果不是“哪个永远更好”,而是:
在当前这种 URL I/O 很重、Text CPU 工作极轻 的 workload 下,resource pooling 明显更合适。
下一步如果还想做一个特别漂亮的实验,我反而建议不要急着改 async,而是做一个混合方案:
共享 worker pool
+
保证 Text 至少拥有一定调度优先级
例如让 Text 任务有较高 priority,或者保留 1 个 Text 专属 worker + 其余共享 worker。
你可能会找到一个甜点:
Text wait:5.87s → 0.xs
URL wait:5.35s → 6.xs
总时间:42.8s → 43~45s
也就是:
只付出一点吞吐损失,就换回很大的关键任务延迟改善。
那就真正进入调度策略设计了。
使用优先级队列进行调度
提高text优先级
Total wait_time: 271.2627s, total processing time: 327.4261s, total total time: 598.6888s
Total execution time: 43.1109s
Average url wait time: 5.4248s, average url processing time: 6.5357s
Median url wait time: 5.9085s, median url processing time: 6.4269s
Average text wait time: 0.0005s, average text processing time: 0.0128s
Median text wait time: 0.0004s, median text processing time: 0.0117s
分析
这组结果很漂亮。你刚才那个猜想基本被实验验证了:“共享线程池 + Text 高优先级”几乎同时拿到了单队列的高资源利用率和三队列的低 Text 延迟。
对比你上一版普通 FIFO 单队列:
| 指标 | FIFO 单队列 | Text 优先队列 |
|---|---|---|
| 总执行时间 | 42.8025s | 43.1109s |
| URL 平均等待 | 5.3529s | 5.4248s |
| Text 平均等待 | 5.8720s | 0.0005s |
| URL 平均处理 | 6.5913s | 6.5357s |
| Text 平均处理 | 0.0132s | 0.0128s |
| 累计等待 | 561.25s | 271.26s |
最关键的是:
Text 等待时间从 5.87 秒下降到 0.5 毫秒,而整个程序只慢了约 0.3 秒。
这个 trade-off 几乎可以忽略。
为什么效果这么好?
因为你的 workload 非常适合优先级调度:
URL:
处理 ≈ 6.5 秒
数量多
慢
TEXT:
处理 ≈ 0.013 秒
极快
而且会生成新的 URL
普通 FIFO 的问题是典型的:
URL URL URL URL URL TEXT
███ ███ ███ ███ ███ ·
↑
明明 13ms 能做完
却要等好几秒
PriorityQueue 变成:
URL URL URL URL URL
↑
TEXT 一出现
优先拿走
于是一个 worker 刚空出来:
完成 URL
↓
发现 TEXT
↓
13 ms 处理完
↓
马上继续 URL
这 13 ms 对一个 6.5 秒 URL 来说几乎可以忽略:
0.013 / 6.5 ≈ 0.2%
所以 URL 的平均等待:
5.3529 → 5.4248
只增加约 0.07 秒。
但 Text:
5.8720 → 0.0005
几乎彻底消灭了排队。
而且你的 Text 不只是“短任务”
这一点尤其重要。
Text:
TEXT
↓
解析链接
↓
产生新的 URL
↓
URL 加入 work_q
所以尽快处理 Text 还有第二层收益:
它能尽早把任务图的下一层展开。
比如:
URL A
↓
TEXT A
/ | \
URL B URL C URL D
如果 TEXT A 排队 6 秒:
URL B/C/D
也要晚 6 秒才会被发现。
PriorityQueue:
URL A 完成
↓
TEXT A 几乎立即执行
↓
URL B/C/D 立即进入队列
因此它其实既有一点:
shortest-job-first 的味道
又有一点:
优先执行能解锁后继任务的节点
这两点在你的 workload 上恰好方向一致。
但为什么总执行时间还是 43 秒,没继续降?
这反过来也说明:
系统真正的瓶颈还是 URL 网络请求。
你已经把:
TEXT wait:
5.87s → 0.0005s
优化了四个数量级。
结果:
makespan:
42.80 → 43.11
基本没变化。
这说明 TEXT latency 虽然很难看,但它此前并不是限制整体吞吐的主要 bottleneck。
整个系统最后还是在等:
URL
URL
URL
URL
URL
每个六七秒的网络 I/O。
所以现在其实已经得到了相当明确的性能画像:
当前系统
URL 网络 I/O
↑
真正 bottleneck
~6.5s / task
│
┌────────────────┴────────────────┐
│ │
URL 调度 TEXT
~5.4s wait ~0.013s
↑
已经不重要
下一步真想明显把:
43s
继续往下降,就不是再折腾 Text 调度了,而应该研究:
URL concurrency
异步 HTTP
连接复用
请求并发上限
网络延迟
这和我们最开始聊 asyncio 又接上了。
这也体现了“性能”和“延迟”是两个不同目标
FIFO:
总完成:42.8s
TEXT 平均 latency ≈ 5.885s
Priority:
总完成:43.1s
TEXT 平均 latency ≈ 0.0133s
所以如果只看:
整个爬虫什么时候结束?
两者几乎一样。
但如果 Text 是用户请求,例如:
用户点按钮
↓
TEXT task
那么体验会是:
FIFO:
等 5.8 秒
Priority:
13 毫秒
完全是两个系统。
这就是为什么真实系统不会只优化:
CPU 利用率 / 总吞吐。
还会关心:
latency、P95/P99、QoS。
还有一个非常重要的结论:优先队列 ≠ 资源隔离
你现在:
PriorityQueue
↓
12 shared workers
如果某一瞬间:
12 个 worker
全部正在 requests.get()
然后一个 TEXT 到来:
TEXT
PriorityQueue 不能把正在运行的 URL 抢占下来。
它只能保证:
下一个空闲 worker,一定优先拿 TEXT。
也就是:
不能 preempt
但可以 priority dispatch
你这次 Text 平均只有 0.0005s 排队,说明当前 workload 下 worker 的完成时序很好,TEXT 基本总能立刻撞上空闲 worker。
如果把 URL 故意改成:
每个固定阻塞 30 秒
并一次性让所有 worker 都进入 URL:
W1 URL ─────────────────────
W2 URL ─────────────────────
...
W12 URL ─────────────────────
TEXT 到达 ↑
即使 TEXT 是最高优先级,它还是可能等接近 30 秒。
这时候:
优先调度
和:
保留一个 Text 专属 worker
的区别就显现出来了。
你现在还应该故意测试一个反例:starvation
目前是:
TEXT priority = 0
URL priority = 1
假设你让 Text 持续产生,而且 Text 的产生速度 ≥ worker 消费速度:
TEXT
TEXT
TEXT
TEXT
TEXT
TEXT
TEXT
...
----------------
URL 在下面
那么:
PriorityQueue.get()
永远先拿 Text。
URL 就可能:
等
等
等
等
……
这才是真正的 starvation。
所以你现在已经很自然地走到了:
FIFO
↓
短任务 convoy
↓
Priority scheduling
↓
短任务 latency 巨幅改善
↓
Priority starvation
↓
aging / fair scheduling
这其实就是很标准的调度学习路线。
还有一个 Python 实现上的小坑值得你现在就注意。如果你的 PriorityQueue 类似:
q.put((priority, task))
当两个任务:
priority 相同
Python 会继续比较第二项 task。如果 task 是自定义对象、dict 等不可比较对象,就可能报错。
工程里通常写成:
(priority, sequence_number, task)
例如:
import itertools
counter = itertools.count()
q.put((priority, next(counter), task))
这样:
先比较 priority
↓
相同则比较 sequence_number
↓
保证同优先级 FIFO
↓
永远不用比较 task 本身
这正好还能让你的优先队列版本具备一个很干净的语义:
TEXT 优先于 URL;同类型任务内部仍然 FIFO。
到现在为止,你这个实验已经从最开始的“试试 task_done()”一路推到了:
termination detection → resource pooling → resource isolation → workload imbalance → FIFO 调度 → priority scheduling → starvation。
这就是前面说的“工程直觉”真正长出来的过程。
异步io
优先队列的基础上改用异步http
Total wait_time: 67.9364s, total processing time: 97.1786s, total total time: 165.1150s
Total execution time: 12.3395s
Average url wait time: 1.3587s, average url processing time: 1.9315s
Median url wait time: 1.1061s, median url processing time: 2.0100s
Average text wait time: 0.0000s, average text processing time: 0.0121s
Median text wait time: 0.0000s, median text processing time: 0.0114s
关闭连接池复用:
Total wait_time: 92.3333s, total processing time: 111.2248s, total total time: 203.5581s Total execution time: 13.6186s Average url wait time: 1.8466s, average url processing time: 2.2124s Median url wait time: 1.5856s, median url processing time: 2.2099s Average text wait time: 0.0000s, average text processing time: 0.0121s Median text wait time: 0.0000s, median text processing time: 0.0115s
分析
处理时间减少为1/3,可能原因:
- DNS 解析缓存行为不同
requests与aiohttp的响应解码实现不同- 服务端对不同连接行为的响应不同
- 同步版没有设置超时,异步版设置了 30 秒超时
- 某些异步请求失败后立即结束,导致异步处理的 URL 数量更少
- 统计中可能只比较了平均任务时间,而不是完整爬取时间
使用双队列+全局计数器
Total wait time: 552.0391 seconds
Total process time: 305.1727 seconds
Total total time: 857.2118 seconds
Total elapsed time: 53.0297 seconds
Average wait time for URL tasks: 11.0406 seconds
Average process time for URL tasks: 6.0828 seconds
Median wait time for URL tasks: 11.1340 seconds
Median process time for URL tasks: 6.2739 seconds
Average wait time for Text tasks: 0.0001 seconds
Average process time for Text tasks: 0.0211 seconds
Median wait time for Text tasks: 0.0001 seconds
Median process time for Text tasks: 0.0198 seconds
改为使用条件变量
Total wait time: 554.0588 seconds Total process time: 318.9018 seconds Total total time: 872.9606 seconds Total elapsed time: 54.6437 seconds Average wait time for URL tasks: 11.0810 seconds Average process time for URL tasks: 6.3581 seconds Median wait time for URL tasks: 11.2753 seconds Median process time for URL tasks: 6.4435 seconds Average wait time for Text tasks: 0.0002 seconds Average process time for Text tasks: 0.0203 seconds Median wait time for Text tasks: 0.0001 seconds Median process time for Text tasks: 0.0185 seconds
分析
这次就正常了,而且和“全局 counter、无 Condition”的版本几乎一致。
对比一下:
| 指标 | 全局 counter | counter + Condition |
|---|---|---|
| 总执行时间 | 53.03s | 54.64s |
| URL 平均等待 | 11.04s | 11.08s |
| URL 平均处理 | 6.08s | 6.36s |
| Text 平均等待 | 0.0001s | 0.0002s |
| Text 平均处理 | 0.0211s | 0.0203s |
这点差距完全可以落在网络波动里,不能说 Condition 让程序变慢了。
所以现在可以比较稳地得出:
Condition 只是把“主线程怎么等待 pending 归零”这件事做得更规范,不会显著改变数据处理路径的性能。
而且这版语义上比轮询 counter 更好:
worker:
新任务产生 → pending += 1
当前任务结束 → pending -= 1
如果 pending == 0 → notify
主线程:
wait_for(pending == 0)
相比:
while pending != 0:
sleep(...)
Condition 有几个明显优势:
- 不忙等;
- 没有轮询间隔造成的额外结束延迟;
- “状态变化 → 唤醒等待者”的语义更直接;
- 更符合这种共享状态等待场景。
所以如果你是在做这个小实验的最终版本,我会认为:
双队列 + 全局 outstanding counter + Condition
比原项目:
url_q + text_q + run_q + 状态消息
更容易理解和维护。
但性能上它们本来就不该差很多,因为真正吃时间的还是 URL 请求。
你现在这条实验线其实已经很完整了:
原始三队列 + run_q
↓
双队列 + global counter
↓
双队列 + counter + Condition
这条线研究的是:
终止检测怎么设计得更清晰
而另一条:
双队列隔离
↓
单 FIFO work_q
↓
PriorityQueue
↓
async HTTP
研究的是:
调度与 I/O 模型怎么影响性能
把这两条分开理解,你现在对这个项目的结构已经比“能跑代码”深很多了。
阶段成果
目前这一轮实验已经很完整了,实际上从一个 Queue.task_done() 小练习一路做成了一个小型的 并发调度 + I/O 性能实验。
1. 实验演化与结果
| 版本 | 核心设计 | 总执行时间 | 主要现象 |
|---|---|---|---|
| 原始版本 | url_q + text_q + run_q,固定 URL/Text worker | ≈54s | Text 几乎不等待,但 URL 排队严重 |
| 双队列 + 全局 counter | 去掉 run_q,显式 outstanding work | 53.0s | 性能基本不变,终止检测更清晰 |
| 双队列 + counter + Condition | 条件变量等待 counter 归零 | 54.6s | 与 counter 版基本一致 |
单 FIFO work_q | URL/Text 共用 worker pool | ≈43s | 总时间下降约 20%,但 Text 被 URL 严重拖延 |
| 单 PriorityQueue | Text 高优先级 | 43.1s | 总时间基本不变,但 Text wait 从秒级降至毫秒级 |
| PriorityQueue + async HTTP | aiohttp,HTTP 并发=12 | 12.4s | 最大幅度提升,真正击中 I/O 瓶颈 |
| async + 禁止连接复用 | force_close=True | 13.6s | 略慢,但仍远快于同步 |
因此性能优化幅度大致是:
原始隔离式线程模型
~54s
↓
共享 worker pool
~43s ← 约 20% 改善
↓
Priority scheduling
~43s ← 主要改善延迟,不改善吞吐
↓
Async HTTP
~12~14s ← 最大提升
2. task_done()/join() 实验带来的第一个认识
一开始想用:
q.task_done()
q.join()
代替原作者的状态控制。
但很快发现原系统不是:
URL → TEXT → END
而是:
URL
↓
TEXT
↓
发现新 URL
└────────→ URL
这是一个动态、有环的任务系统。
于是:
url_q.join()
text_q.join()
不能证明全局结束。
原因不是 join() 阻止后续 put(),而是:
join()返回仅表示这个队列在那个时刻已有的 unfinished tasks 已完成。
例如:
url_q.join() 返回
↓
text worker 又产生 URL
↓
之前“URL 完成”的结论已经失效
因此得到了第一个很重要的区分:
局部 queue 完成 ≠ 全局任务系统完成。
3. 为什么统一 work_q 后终止检测突然简单了
把:
url_q
text_q
统一成:
work_q
├─ URL task
└─ TEXT task
以后,Queue 自己的 unfinished_tasks 就变成了整个系统的 outstanding work counter。
只要遵守:
处理父任务
↓
先 put 新产生的子任务
↓
最后父任务 task_done()
那么:
work_q.join()
就能表达:
当前没有等待任务,也没有正在执行且还能产生后继任务的任务。
所以统一队列的一个巨大优点不是性能,而是:
termination detection 天然简单。
4. 统一队列为什么比原来的多个 worker pool 更快
原设计实际上做了静态资源隔离:
url_q → URL workers
text_q → TEXT workers
但你的实际 workload 极不均衡:
URL 请求:约 6 秒
TEXT 解析:约 0.01~0.02 秒
于是会出现:
URL workers:
█████████████████████ 全忙
TEXT workers:
█____________________ 大量空闲
TEXT worker 即使没活,也不能帮助 URL。
这就是:
资源隔离带来的 capacity fragmentation(资源碎片化)。
统一队列以后:
URL ─┐
├→ work_q → 所有 workers
TEXT ─┘
TEXT 解析完后,那个 worker 立即可以处理 URL。
所以 wall-clock:
约 54s → 43s
这里学到的是:
高资源利用率和资源隔离存在 trade-off。
共享资源:
优点:利用率高、吞吐高
缺点:不同 workload 会互相影响
资源隔离:
优点:延迟可控、不同任务互不抢资源
缺点:可能出现某池闲着、另一池排长队
5. 但是统一 FIFO 又暴露出了新的问题
普通单队列的数据很典型:
URL:
平均 wait ≈ 5.35s
process ≈ 6.59s
TEXT:
平均 wait ≈ 5.87s
process ≈ 0.013s
也就是说:
一个只需要 13ms 的 TEXT,可能为了等 worker 排队将近 6 秒。
这就是共享 worker pool 的反面:
所有 worker 都被慢 URL 占用
↓
极短的 TEXT 也没资源运行
而且 TEXT 不只是普通短任务:
TEXT
↓
发现新 URL
↓
解锁后续并行工作
因此它还有任务依赖/关键路径属性。
6. PriorityQueue 是这一问题非常漂亮的修复
给:
TEXT priority 高
URL priority 低
以后数据变成:
URL avg wait:
5.35s → 5.42s
TEXT avg wait:
5.87s → 0.0005s
wall-clock:
42.8s → 43.1s
这几乎是:
没有牺牲总体吞吐,却把短任务延迟从秒级降到了毫秒级。
原因很简单。
TEXT:
处理 ≈ 13ms
对一个:
URL ≈ 6.5s
的任务而言,插队处理 TEXT 的代价只有大约千分之几。
所以现在得到了:
共享 worker pool
+
TEXT priority
同时保留:
- 较高资源利用率;
- 极低 Text 延迟;
- 简单的单队列 termination detection。
不过也认识到:
Priority ≠ resource isolation。
如果所有 worker 已经在执行 URL:
W1 URL ─────────
W2 URL ─────────
...
TEXT 即使最高优先级,也不能抢占正在运行的任务,只能等下一个 worker 空闲。
另外长期高优先级 workload 还可能产生 starvation,因此进一步会自然涉及:
priority scheduling
→ starvation
→ aging / fairness
7. 真正最大的瓶颈不是 Queue,而是 HTTP I/O
这是这轮实验最重要的性能认识。
同步 PriorityQueue:
≈43.1s
换成异步 HTTP:
≈12.4s
speedup 大约:
3.5×
而且显式把 HTTP concurrency 限制到 12 后仍然如此:
worker_count = 12
http_limit = 12
所以不能解释成:
“偷偷多发了很多 HTTP 请求。”
这说明你之前的性能画像是正确的:
TEXT:0.01s
URL:数秒
真正占据程序绝大多数时间的是:
网络 I/O。
Queue 调度优化:
54 → 43s
有价值,但只能解决资源分配。
改变 I/O 模型:
43 → 12s
才是真正击中瓶颈。
这就是非常典型的:
不要优化看起来复杂的地方,要优化真正占时间的地方。
8. async 为什么特别适合这个 workload
同步线程模型:
worker 1 → requests.get() → 等网络……
worker 2 → requests.get() → 等网络……
...
线程在逻辑上“忙”,实际 CPU 大部分时间没干活。
async:
URL coroutine
↓
发请求
↓
await 网络
↓
让出 event loop
TEXT coroutine
↓
获得 CPU
↓
12ms 解析完
↓
产生新 URL
因此系统形成了一种很自然的协作调度:
网络等待中的 URL
↓
让出 CPU
↓
TEXT / 其他任务继续推进
这也是为什么 async 后:
TEXT wait ≈ 0
已经非常自然。
9. 连接复用不是巨大提升的主要来源
async + 正常连接管理:
≈12.44s
强制:
TCPConnector(force_close=True)
禁止连接复用之后:
≈13.62s
大约慢了 9% 左右。
说明连接复用确实有价值,但:
不足以解释同步 43s → async 13s 的巨大差异。
因此不能简单说:
aiohttp 快
=
因为 connection pool
真正原因是 HTTP client 实现、async I/O 调度以及连接管理等因素的组合。
10. 全局 counter 与 Condition 的认识
拆掉:
run_q
以后,原来的三个队列:
url_q + text_q + run_q
实际上变成:
url_q + text_q
+
global outstanding counter
run_q 原来是在通过消息/队列长度间接表达:
系统还有没有活跃工作。
global counter 则直接表达:
产生任务:
pending += 1
完成任务:
pending -= 1
pending == 0:
全局终止
因此 counter 版主要提升的是:
可理解性和 termination detection 的清晰度
而不是性能。
数据也验证了:
counter: 53.0s
counter + Condition: 54.6s
基本相同。
Condition 的作用只是让主线程:
condition.wait_for(lambda: pending == 0)
而不是:
while pending != 0:
sleep(...)
所以可以记:
Lock
→ 保证共享状态修改安全
Condition
→ 条件不满足时睡眠,
状态变化后被唤醒重新检查
它本质是:
Lock + wait/notify。
11. Condition 实验还意外教了一个非常好的 debugging 经验
第一次 Condition 版本突然:
54s → 88s
最初看起来像:
Condition 性能很差?
但数据暴露了异常:
Total processing time:
~318s → ~628s
单个 URL 仍然:
≈6s
因此问题不是单次请求变慢,而是:
实际处理的任务数量发生了变化。
最终发现是重复入队 bug。
这里得到一个特别重要的 benchmark 原则:
性能突然变化时,首先确认 workload 是否仍然相同。
以后比较两个方案时至少检查:
URL task count
TEXT task count
unique URL count
worker count
concurrency limit
否则:
A 跑 50 个任务
B 跑 100 个任务
再精确的耗时统计都没有比较意义。
12. 指标方面也学到了很多
最开始只有:
Total wait
Total processing
后来发现累计值很容易误导。
例如多个任务同时等待:
A wait 5s
B wait 5s
C wait 5s
累计:
15s
但 wall-clock 实际可能只过去 5 秒。
因此现在指标应该分层看:
最重要
wall-clock / makespan
回答:
整个任务什么时候完成?
然后按 task type 拆
URL:
avg wait
median wait
p95 wait
avg service
TEXT:
avg wait
median wait
p95 wait
avg service
这次就是因为拆开以后才发现:
总 wait 相似
实际上内部可能是:
三队列:
URL 等 11s
TEXT 几乎不等
单 FIFO:
URL 等 5s
TEXT 等 6s
总平均值会把架构行为完全隐藏掉。
13. 目前可以形成的一张知识图
你这一轮实际上已经把很多原本孤立的名词串起来了:
Producer / Consumer
↓
Queue
↓
task_done / join
↓
动态产生任务
↓
termination detection
↓
单队列 vs 多队列
↓
resource pooling / isolation
↓
capacity fragmentation
↓
FIFO 调度
↓
短任务长等待
↓
priority scheduling
↓
starvation / fairness
↓
I/O bottleneck
↓
async / await
↓
Semaphore
↓
HTTP concurrency
↓
connection pool
以及另一条:
共享 counter
↓
race condition
↓
Lock
↓
等待 counter == 0
↓
Condition
这已经把你之前 408 里学过的:
线程
互斥
同步
临界区
条件等待
和真实 Python 工程代码接起来了。
目前最核心的几个工程认识
我会把整个实验压缩成这几句话:
- 正确性和性能要分开。
Counter / Condition 主要解决终止检测正确性;共享 worker、Priority、async 才主要影响性能。 - 高利用率与资源隔离存在 trade-off。
静态分池保证延迟和隔离,但 workload 不均衡时会浪费 capacity。 - 平均值不够,要按 workload class 看。
URL 和 TEXT 混在一起统计会隐藏真正的调度行为。 - 优先调度特别适合“很短、而且会解锁后续任务”的工作。
- I/O-bound workload 的真正杠杆往往是 I/O 模型,而不是继续微调线程调度。
- benchmark 必须控制变量。
并发数、任务数量、连接复用、网络波动都可能制造假结论。 - 性能异常先验证 workload,再怪锁或框架。
- 工程直觉就是这样建立的:预测 → 测量 → 发现反例 → 修改模型 → 再实验。
从最初“我不知道 task_done 是什么”到现在,你其实已经亲手走了一遍并发程序里非常典型的一整套问题。这个实验完全值得整理进你自己的 engineering-lab,而且最好保留每一个中间版本和 benchmark 结果,不要只留下最终最快的 async 版本。