幕 III · 三种活法CH 19进度 19/25

同题对答:并发抓 100 个 URL,三种语言

语言对比最容易变成宗教战争,因为大家比的是「Hello World 谁短」。这一章不这么比。我们给三门语言出同一道真实的题 —— 一道带着限流、超时、部分失败、取消穿透的题,也就是你真正会在生产代码里写的那种。然后逐行对照。你会发现,语法上的差异是最不重要的。真正的差异只有一个:当出事的时候,谁替你收拾残局。

限流超时部分失败取消穿透逐行对照

题目

◆ 需求 · 五条,每一条都是生产环境的血泪
  1. 并发抓取 100 个 URL —— 不能串行(那要 100 × 200ms = 20 秒)。
  2. 限流:最多 10 个并发 —— 否则你会把对方的服务打挂,或者把自己的连接池耗尽。
  3. 整体超时 5 秒 —— 超过就放弃。而且要真的取消掉底下的请求,不能只是「不等了」。
  4. 单个失败不炸全局 —— 100 个里有 3 个 404,剩下 97 个照样要返回。
  5. 函数返回时,不能有任何东西还在后台跑 —— 没有孤儿协程,没有泄漏。

第 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。能开,但别开十万个。

三家打平。这是最简单的一条,也是所有教程唯一会讲的一条。

需求 ②:限流

全都是信号量,只是名字不同:

KotlinSemaphore(10).withPermit { ... }
Gog.SetLimit(10)
(或者手写:一个容量 10 的 channel 当信号量 —— sem <- struct{}{} / <-sem
Pythonasync with asyncio.Semaphore(10):
⚠ 陷阱 · 「我开了 100 个协程」≠「我发了 100 个并发请求」

限流不只在你这一层。你的 HTTP 客户端底下还有一个连接池

  • Go:http.DefaultTransportMaxIdleConnsPerHost 默认只有 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 里(viewModelScopecoroutineScope)。
不要用 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 结束。三门语言,三种活法,讲完了。但故事没完 —— 因为这三门语言正在往同一个方向收敛,而那个方向有一个名字。