同步与异步:一个关于「谁来等」的问题
这是六个词里被误用得最狠的一对。人们说「异步」的时候,心里想的可能是「快」、是「多线程」、是「不用等」、是「回调」—— 而这些没有一个是它的定义。同步和异步只回答一个问题,而且这个问题跟性能、跟线程、跟并行都没有直接关系:我发出调用之后,我这条执行线还能不能往下走?能,就是异步;不能,就是同步。就这么简单,也就这么容易搞错。
定义:一句话,两个角色
一次调用里有两个角色:调用方(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)
})
})
try {
val user = getUser(id)
val orders = getOrders(user.id)
render(orders)
} catch (e: Exception) {
handle(e) // try/catch 回来了!
}
右边那段代码看起来完全是同步的:从上往下读、局部变量、try/catch、for 循环、早返回 —— 所有你熟悉的控制流结构全都回来了。
但它行为上是异步的:在每个挂起点,当前线程被交还回去干别的活了。
让代码「读起来是同步的」,同时「跑起来是非阻塞的」。
因为人脑擅长读顺序代码,而机器擅长填满空隙。这两件事本来是矛盾的,而 async/await(以及协程)就是那个把矛盾消掉的编译器技巧。
三门语言,三条路,同一个终点:
- Kotlin:
suspend—— 编译器把函数改写成状态机(第 14 章)。挂起点是隐式的。 - Python:
async/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 httpx、psycopg2 vs asyncpg。你必须全栈异步,一个同步库就能毁掉一切(第 13 章)。 |
| Kotlin | 有,但轻 | suspend 在签名上,一眼可见。而且 withContext(Dispatchers.IO) 提供了一个官方的逃生舱:把阻塞代码扔进线程池,照样能在协程里 await 它。旧的阻塞式库不需要重写。 |
| Go | 没有 | 这是 Go 最大的胜利。没有 async 关键字,没有两个世界。所有的库都是「同步」的,而 runtime 让它们全都变成非阻塞的。代价是:你失去了「这个函数会不会挂起」的可见性 —— 而且失去了结构化并发(第 18、20 章会算这笔账)。 |
染色不是纯粹的坏事。它至少是诚实的:签名上写着 suspend / async,你一眼知道「这里可能会等」。Go 的无色方案把这个信息藏起来了 —— 于是你看不出一段代码里哪一行可能挂起、哪一行可能被取消。
一个反直觉的推论
异步不会让单个请求变快。
这一点值得单独说,因为它是最常见的期望落空:
异步买到的是:在这 100ms 里,那个线程可以去服务另外 99 个请求。 它优化的是吞吐量(throughput),不是延迟(latency)。
不一定,甚至可能升。
异步在低负载下,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. 问自己:我们上异步是为了降延迟,还是为了提吞吐?如果答不上来,或者答案是「降延迟」—— 回来重读上面那张时序图。
下一章:把第二组词钉死。同步和阻塞不是一回事 —— 一个说的是「我能不能往下走」,一个说的是「那个线程还活着吗」。