同题对答:并发抓 100 个 URL,三种语言
语言对比最容易变成宗教战争,因为大家比的是「Hello World 谁短」。这一章不这么比。我们给三门语言出同一道真实的题 —— 一道带着限流、超时、部分失败、取消穿透的题,也就是你真正会在生产代码里写的那种。然后逐行对照。你会发现,语法上的差异是最不重要的。真正的差异只有一个:当出事的时候,谁替你收拾残局。
题目
- 并发抓取 100 个 URL —— 不能串行(那要 100 × 200ms = 20 秒)。
- 限流:最多 10 个并发 —— 否则你会把对方的服务打挂,或者把自己的连接池耗尽。
- 整体超时 5 秒 —— 超过就放弃。而且要真的取消掉底下的请求,不能只是「不等了」。
- 单个失败不炸全局 —— 100 个里有 3 个 404,剩下 97 个照样要返回。
- 函数返回时,不能有任何东西还在后台跑 —— 没有孤儿协程,没有泄漏。
第 3 条和第 5 条是分水岭。大多数教程里的「并发抓 URL」例子,都在这两条上不及格。
三份实现,切换看:
逐条对照
需求 ①:并发
| 怎么写 | 代价 | |
|---|---|---|
| Kotlin | urls.map { async { ... } }.awaitAll() |
100 个协程 ≈ 几十 KB。随手就开。 |
| Go | for ... { g.Go(func() error { ... }) } |
100 个 goroutine ≈ 200KB。也是随手就开。 |
| Python | [tg.create_task(one(u)) for u in urls] |
100 个 Task ≈ 几百 KB。能开,但别开十万个。 |
三家打平。这是最简单的一条,也是所有教程唯一会讲的一条。
需求 ②:限流
全都是信号量,只是名字不同:
| Kotlin | Semaphore(10).withPermit { ... } |
| Go | g.SetLimit(10)(或者手写:一个容量 10 的 channel 当信号量 —— sem <- struct{}{} / <-sem) |
| Python | async with asyncio.Semaphore(10): |
限流不只在你这一层。你的 HTTP 客户端底下还有一个连接池:
- Go:
http.DefaultTransport的MaxIdleConnsPerHost默认只有 2!
你开了 100 个 goroutine,但对同一个 host 的连接只有 2 条 —— 剩下 98 个在排队。
这是 Go 里一个极其常见的「为什么我的并发没生效」。(修:自己建一个 Transport,把MaxIdleConnsPerHost调大。) - Python httpx:
limits=httpx.Limits(max_connections=100),默认 100。 - Kotlin OkHttp:
Dispatcher.maxRequestsPerHost默认 5。
永远去查你的 HTTP 客户端的连接池配置。它才是真正的限流器。
需求 ③:超时(这一条开始分化)
| 怎么写 | 它真的取消底下的请求吗 | |
|---|---|---|
| Kotlin | withTimeout(5.seconds) { ... } |
✅ 会。它取消整个 scope → 所有子协程收到 CancellationException → 挂起点处抛出。但前提:那个 HTTP 库要是 suspend 的(Ktor / Retrofit 的 suspend 函数)。如果你在 withContext(IO) 里调阻塞的 OkHttp 同步 API —— 取消对它无效(第 15 章)。 |
| Go | context.WithTimeout(ctx, 5*time.Second) |
✅ 会 —— 如果你把 ctx 传下去了。 用 http.Get(url):不会。(它用的是 context.Background()。)用 http.NewRequestWithContext(ctx, ...):会。这个区别,全靠你自己记住。 |
| Python | async with asyncio.timeout(5): |
✅ 会。它向 task 里注入 CancelledError。但如果那个 task 里有一行同步阻塞调用 —— 注入不进去。整个循环卡死,超时也不会生效(第 13 章)。 |
弱超时(fire-and-forget):「我不等了。」
→ 但底下那个请求还在跑。它还占着连接、还在消耗对方的 CPU、还会在几秒后返回一个没有人要的结果。
强超时(真取消):「我不等了,而且你也别干了。」
→ 底层的 socket 被关闭,对方服务器的那个请求被中断。
为什么这个区别是钱:想象你的服务超时了 1000 个请求/秒。如果是弱超时,那 1000 个请求仍然在打你的下游数据库 —— 你以为你在保护自己,其实你在用完全无用的负载压垮下游。这就是雪崩的一种典型成因。
三门语言的机制都支持强超时。但三门语言都可以被你写成弱超时。
需求 ④:部分失败(这一条差异最大)
// coroutineScope 的默认行为是「一个失败,全部取消」——
// 所以要「部分失败」,必须在【每个 async 内部】自己 catch:
async(Dispatchers.IO) {
gate.withPermit {
runCatching { client.get(url) } // ⚠️ 注意:runCatching 会吞 CancellationException!
} // 生产代码要用第 15 章那个 coRunCatching
}
// 如果不 catch:一个 404 → 整个 coroutineScope 被取消 → 剩下 99 个全废。
// errgroup 的默认行为也是「第一个 error → 取消其他」。
// 所以要「部分失败」,不要 return err:
g.Go(func() error {
r, err := fetch(ctx, url)
if err != nil {
results[i] = Result{Err: err} // 把错误【存起来】,而不是 return
return nil // ← 返回 nil,不触发全局取消
}
results[i] = r
return nil
})
// 想「一个失败就全停」?那就 return err。【这个选择权在你手上,而且很明确。】
# TaskGroup 的默认行为同样是「一个失败,全部取消」。
# 而且它会把【多个】异常打包成 ExceptionGroup:
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(one(u)) for u in urls]
except* httpx.HTTPError as eg: # 注意那个星号
for e in eg.exceptions: # 可能有好几个 task 同时失败
log.warning("挂了: %s", e)
# 想「部分失败」?在 one() 里面自己 catch:
async def one(url):
async with gate:
try:
return await client.get(url)
except httpx.HTTPError as e:
return ErrorResult(e) # 返回错误,而不是抛出
三门语言的默认行为都是「一个失败 → 全部取消」(fail-fast)。
这个默认值是对的,因为:
- 大多数时候,如果一个子任务失败了,整个操作已经没有意义了 —— 继续跑剩下的只是浪费。
- 「快速失败」比「慢慢地部分失败」更容易调试。
要「部分失败」是一个显式的选择,你必须主动地在每个子任务里 catch。这个设计是对的:让危险的东西需要额外的动作。
需求 ⑤:没有孤儿(分水岭在这里)
| 谁保证 | 能绕过吗 | |
|---|---|---|
| Kotlin | 语言。coroutineScope { } 不会在子协程完成前返回。这是编译器和 runtime 一起保证的。 |
能(GlobalScope.launch),但 IDE 会警告你,而且这是公认的坏味道。 |
| Python 3.11+ | 标准库。async with TaskGroup() 退出时保证所有 task 已结束。 |
能(裸 create_task),而且没有任何警告。 |
| Go | 你自己。errgroup 是个第三方库(x/sync)。你完全可以在它旁边写一个裸 go f()。 |
随时。编译器一个字都不会说。 |
三份代码,长度差不多,可读性差不多,性能差不多。
真正的差别只有一个:当你写错的时候,会发生什么。
- Kotlin 里写错 —— 大概率编译不过(
suspend调用不在协程里)或者行为立刻不对(scope 不返回)。错误被逼到了编译期和显眼处。 - Python 里写错 —— 你会得到一个
RuntimeWarning、一条「Task exception was never retrieved」,或者整个事件循环卡死。错误在运行时、在日志里,但至少它会说话。 - Go 里写错 —— 什么都不会发生。你的 goroutine 静静地泄漏,你的 ctx 没有被传下去,你的超时是弱的。一切看起来都很正常,直到某天凌晨内存曲线撞到天花板。
Go 用「无染色、无仪式感」换来了最好的写作体验,代价是把「正确性」完全押在了程序员的纪律上。
Kotlin 用「染色 + 结构化并发」换来了最强的保证,代价是你必须一路写 suspend。
Python 处在中间,而且是被 GIL 逼到那个位置的。
没有哪个是「对」的。但你必须知道你买的是什么。
如果你只能记住三条
| 写 Kotlin 时 |
不要用 GlobalScope。永远在一个有生命周期的 scope 里(viewModelScope、coroutineScope)。不要用 runCatching。它会吞取消。让每个 suspend 函数 main-safe(自己包 withContext)。
|
| 写 Go 时 |
ctx 是第一个参数,一路传到底。每个 go f() 都要能回答:谁等它?谁取消它?把 goleak 和 -race 加进 CI。(这两行代码能消灭一整类事故。)
|
| 写 Python 时 |
用 TaskGroup,不要用 gather。(3.11+)整条链路不能有任何同步阻塞调用。一行就够毁掉一切。 CPU 密集的活,滚出事件循环。 |
1. 把你项目里最复杂的那个并发函数翻出来,对照上面五条需求打分。我打赌第 3 条(真取消)和第 5 条(无孤儿)会有问题。
2. 如果你写 Go:立刻去查 MaxIdleConnsPerHost。默认值 2 —— 它可能正在把你的并发压成串行,而你完全不知道。
3. 做一个实验:让你的服务超时,然后看下游的 QPS 有没有下降。如果没有 —— 你的超时是「弱超时」,你正在用无用的负载压垮下游。
幕 III 结束。三门语言,三种活法,讲完了。但故事没完 —— 因为这三门语言正在往同一个方向收敛,而那个方向有一个名字。