幕 I · 正名CH 02进度 02/25

同步与异步:一个关于「谁来等」的问题

这是六个词里被误用得最狠的一对。人们说「异步」的时候,心里想的可能是「快」、是「多线程」、是「不用等」、是「回调」—— 而这些没有一个是它的定义。同步和异步只回答一个问题,而且这个问题跟性能、跟线程、跟并行都没有直接关系我发出调用之后,我这条执行线还能不能往下走?能,就是异步;不能,就是同步。就这么简单,也就这么容易搞错。

调用契约回调Future / Promiseasync / await控制反转

定义:一句话,两个角色

一次调用里有两个角色:调用方(caller)和被调方(callee)。

◆ 定论 · 同步与异步的定义

同步(synchronous):调用方发出调用后,必须等到结果返回,才能执行下一行。调用方和被调方在时间上是「同步」的 —— 字面意思:共用一段时间

异步(asynchronous):调用立刻返回(但结果还没好)。调用方接着执行下一行。结果好了之后,由别的机制通知你(回调、Future、事件、消息)。

唯一的判据:那一行代码,会不会把你的控制流「粘」在原地。

注意这个定义里完全没有提到线程。这不是疏忽,这是重点 —— 同步/异步说的是控制流的形状,不是线程的下场(那是下一章的事)。

同一个 HTTP 请求,四种写法

下面这个 demo 把四种写法的时序泳道摆在一起。请重点看两条泳道之间的关系 —— 调用方那一条,什么时候是「粘住的」,什么时候是「自由的」。

异步的三次进化

异步这件事,业界折腾了三十年,走了三步。每一步都在解决上一步的痛。

第一步:回调 —— 把「接下来干什么」交出去

// 这是「控制反转」:你不再决定接下来执行什么,
// 你把「接下来执行什么」写成一个函数,交给别人替你调。
getUser(id, function (err, user) {
    if (err) return handle(err);
    getOrders(user.id, function (err, orders) {
        if (err) return handle(err);
        getItems(orders[0].id, function (err, items) {   // ← 三层了
            if (err) return handle(err);
            render(items);
        });
    });
});

能工作,而且是最底层、最诚实的形态 —— 所有的异步机制,剥到最底下都是回调。

但它有三个致命伤:

  • 回调地狱。每多一次异步调用,就多缩进一层。三层还能看,五层就没人愿意碰了。
  • 错误处理是手工的。每一层都得自己 if (err)try/catch 完全失效 —— 因为回调是在未来的某个时刻、由别人的栈调用的,你的 try 早就退出了。
  • 组合不了。「并发发三个请求,全部完成后继续」—— 用裸回调写,你得手工维护一个计数器。

第二步:Future / Promise —— 把「凭据」拿回来

关键的一步反转:不是把「接下来干什么」交出去,而是把「一张凭据」拿回来。

// 调用立刻返回一个 Deferred(Kotlin 里 Future 的名字)
val userD: Deferred<User> = async { api.getUser(id) }

doSomethingElse()          // 我还是自由的

val user = userD.await()   // 需要结果时,才来兑现这张凭据

凭据(Future / Promise / Deferred / CompletableFuture,名字各异,是同一个东西)是一个代表「未来的值」的对象。它有三个状态:进行中、成功、失败。

这一步解决了什么:

  • 可以组合了。awaitAll(a, b, c)Promise.all([...])errgroup.Wait() —— 「并发做三件事,都好了再继续」变成了一行。
  • 错误可以传播了。失败是凭据的一个状态,可以一路 .map() / .flatMap() 传下去。
  • 嵌套变成了链式。a.then(b).then(c) —— 至少不再往右缩进了。

但它还是有味道:.then().then().catch() 这种链,和你脑子里那个「先 A 再 B 再 C」的顺序流程,长得不一样。你得在两种心智模型之间来回翻译。

第三步:async / await —— 把异步伪装成同步

最后一步是语法层面的:既然人脑最擅长读顺序代码,那就让异步代码长得跟同步代码一模一样

回调版
getUser(id, (err, user) => {
  if (err) return handle(err)
  getOrders(user.id, (err, os) => {
    if (err) return handle(err)
    render(os)
  })
})
await 版
try {
  val user   = getUser(id)
  val orders = getOrders(user.id)
  render(orders)
} catch (e: Exception) {
  handle(e)          // try/catch 回来了!
}

右边那段代码看起来完全是同步的:从上往下读、局部变量、try/catchfor 循环、早返回 —— 所有你熟悉的控制流结构全都回来了

但它行为上是异步的:在每个挂起点,当前线程被交还回去干别的活了。

◆ 定论 · 过去十年的方向,就这一句

让代码「读起来是同步的」,同时「跑起来是非阻塞的」。

因为人脑擅长读顺序代码,而机器擅长填满空隙。这两件事本来是矛盾的,而 async/await(以及协程)就是那个把矛盾消掉的编译器技巧。

三门语言,三条路,同一个终点:

  • Kotlinsuspend —— 编译器把函数改写成状态机(第 14 章)。挂起点是隐式的。
  • Pythonasync/await —— 生成器机制的延伸。挂起点是显式的(必须写 await)。
  • Go连关键字都不要 —— runtime 直接在 conn.Read() 底下做手脚(第 16 章)。

「函数染色」:async/await 的税

async/await 不是免费的。它有一个著名的代价,叫函数染色(function coloring)——出自 Bob Nystrom 2015 年的博文《What Color is Your Function?》。

规则很简单,也很烦人:

  • 一个 async 函数,只能被另一个 async 函数调用。
  • 于是:你在调用链的最底层加了一个 async整条链上的每一个函数都得跟着变成 async
async def query_db(sql):        # 底层:改成了异步
    ...

async def get_user(id):          # 于是它也得是 async
    return await query_db(...)

async def handle_request(req):   # 于是它也得是 async
    return await get_user(...)

# 一路染到 main()。这就是「染色」。
# 你的代码库里从此有两个世界:async 的和非 async 的,
# 而它们之间的每一次穿越都要付出代价。

Kotlin 的 suspend 也有一模一样的问题。

◈ 三语 · 染色税,谁交了,谁没交
有染色吗代价 / 好处
Python 有,而且最重 整个生态被劈成两半:requests vs httpxpsycopg2 vs asyncpg你必须全栈异步,一个同步库就能毁掉一切(第 13 章)。
Kotlin 有,但轻 suspend 在签名上,一眼可见。而且 withContext(Dispatchers.IO) 提供了一个官方的逃生舱:把阻塞代码扔进线程池,照样能在协程里 await 它。旧的阻塞式库不需要重写。
Go 没有 这是 Go 最大的胜利。没有 async 关键字,没有两个世界。所有的库都是「同步」的,而 runtime 让它们全都变成非阻塞的。代价是:你失去了「这个函数会不会挂起」的可见性 —— 而且失去了结构化并发(第 18、20 章会算这笔账)。

染色不是纯粹的坏事。它至少是诚实的:签名上写着 suspend / async,你一眼知道「这里可能会等」。Go 的无色方案把这个信息藏起来了 —— 于是你看不出一段代码里哪一行可能挂起、哪一行可能被取消。

一个反直觉的推论

异步不会让单个请求变快。

这一点值得单独说,因为它是最常见的期望落空:

同一个请求:数据库查询 100ms同步 vs 异步
同步 阻塞等数据库 100ms
异步 挂起等数据库 100ms(线程还回去了)
判词这个请求的耗时,一模一样(甚至异步还多了一丁点调度开销)。 数据库还是要花 100ms —— 异步不会让数据库变快。
异步买到的是:在这 100ms 里,那个线程可以去服务另外 99 个请求。 它优化的是吞吐量(throughput),不是延迟(latency)。
⚠ 陷阱 · 「我们上了异步,P99 应该会降」

不一定,甚至可能升。

异步在低负载下,P99 通常会略微变差(多了一层调度和唤醒)。它的收益要在高负载下才显现 —— 因为在那时,同步方案的线程已经不够用了,请求开始在线程池队列里排队,P99 直接爆炸。

正确的说法是:异步把「性能悬崖」推远了。它不是让你跑得更快,是让你在压力上来时不至于摔死

还有一种「异步」:消息队列

最后提一下,免得混淆。当架构师说「这个流程改成异步的」,他说的经常不是上面任何一种,而是:

// 同步:用户等着,直到订单真的创建完
func CreateOrder(req) Response {
    order := db.Insert(req)      // 50ms
    sendEmail(order)             // 800ms  ← 用户为什么要等这个?
    updateInventory(order)       // 300ms
    return Response{order.ID}    // 用户等了 1150ms
}

// 「异步」:把不重要的活扔进队列,立刻返回
func CreateOrder(req) Response {
    order := db.Insert(req)      // 50ms
    mq.Publish("order.created", order)   // 1ms,扔出去就不管了
    return Response{order.ID}    // 用户只等了 51ms
}
// 发邮件、扣库存由别的 worker 慢慢做。

这是架构层面的异步,和语言层面的 async/await 是两个话题,但内核思想完全一致:调用方不等结果,接着往下走。

它的代价也很实在:最终一致性(用户看到「下单成功」时,邮件其实还没发)、失败处理(邮件发失败了怎么办?谁重试?重试几次?)、消息丢失(队列挂了呢?)。

✚ 动手

1. 翻你项目里最长的那条回调链(如果还有的话)。数一数缩进了几层,再想想它的错误处理是不是每一层都写了一遍。

2. 找一个 async 函数,往上追它的调用链,看它染了多少个函数的色。如果染到了 main() —— 那是正常的,那就是这个机制的设计。

3. 问自己:我们上异步是为了降延迟,还是为了提吞吐?如果答不上来,或者答案是「降延迟」—— 回来重读上面那张时序图。

下一章:把第二组词钉死。同步和阻塞不是一回事 —— 一个说的是「我能不能往下走」,一个说的是「那个线程还活着吗」。