编程术语中的同步与异步:从"等不等"到工程取舍

0 阅读9分钟

开篇:先拆掉那句最流行的话

关于同步异步,流传最广的一句解释是"同步就是阻塞,异步就是非阻塞"。这句话不算全错,但它把两个不同层面的概念焊在了一起,是无数误解的起点。

更准确的结论只有一句:

同步和异步,描述的是调用方要不要原地等结果,以及结果通过什么方式回来。它不是快慢问题,不是线程问题,而是"等不等"的问题。

全文按四层往下拆:先定义,再划边界,然后讲价值与代价,最后落到工程选型。每一层都围绕同一个视角——站在调用者这边看,它有没有被要求原地等。

第一层:定义——谁在等谁

同步和异步描述的是调用方和被调用方之间的协作方式,具体就两件事:

  1. 发起操作后,调用方要不要原地等结果;
  2. 操作完成后,结果通过什么机制交回来。

同步:调用发起后,调用方等待操作完成并拿到结果,才继续往下执行。执行顺序线性、可预期。

异步:调用发起后立即返回,调用方可以先去做别的事;操作真正完成后,通过回调、事件、Future/Promise、消息通知等方式把结果交回来。

同步是"我发起,我等你回来再走";异步是"我发起,你先忙着,好了通知我"。

这里必须先立住一个视角:判断同步还是异步,看的是调用方有没有被要求原地等待,而不是被调用的任务本身快不快、是不是在后台跑。 任务在后台执行,不等于调用方就是异步的;调用方立刻 join 等结果,形式上异步也会退化成同步。

第二层:直觉——执行顺序会"说谎"

生活类比只要一个就够

去餐厅点餐。同步是你点完站在柜台前一直等到出餐,拿到才走,等待期间什么也干不了。异步是你拿一个取餐号回座位,继续聊天看手机,餐好了叫号再去取。

关键洞察在这里:异步并没有让"做饭"变快,它改变的是你在等待期间能不能做别的事。 异步提升的是等待资源的利用率,不是单次操作的绝对速度。

伪代码感受一下

同步写法,执行顺序和书写顺序完全一致:

发起请求(url)             # 程序在这里等着,直到响应回来
打印(响应内容)             # 拿到结果之后才执行这一行

异步写法,发起后立刻返回,后面的代码先跑:

打印("开始")
发起读文件("a.txt", 回调)  # 发起后立刻返回,不等文件读完
打印("这一行不等文件,先执行")
# 实际输出顺序很可能是:
# 开始
# 这一行不等文件,先执行
# 文件读完了(回调最后才被触发)

这就是异步最核心的特征:执行顺序和书写顺序不再一致,结果通过回调、事件、Promise 等机制交回来。

async/await 不是"同步"

函数 取数据():
    数据 = 等待(读文件("a.txt"))   # 挂起当前协程,让出执行权
    打印(数据)

await 看起来像"等",但它不等于阻塞整个线程。在 JavaScript、Python asyncio、Go 等模型里,它的语义是"当前协程在这里挂起,把执行权让出去,等结果好了再恢复",线程本身可以继续处理别的任务。所以判断同步异步,不能只看代码里有没有 await,而要看调用方在等待期间是否还能推进其他工作。

0.png

第三层:边界——四组概念分属四个维度

这是全文最需要讲透的部分。同步/异步之所以总被讲歪,是因为它经常和另外三组概念混在一起出现,但它们根本不在同一层。

同步/异步     → 调用协作方式:等不等结果、结果怎么回来
阻塞/非阻塞   → 线程状态:等待期间线程是否被挂起
多线程/协程   → 实现手段:用什么机制推进任务
并发/并行     → 任务推进方式:交错推进 vs 真正同时执行

1. 同步/异步 ≠ 阻塞/非阻塞

1.png

两者组合出四种情况:

2.png

关键结论:"同步"不等于"一定阻塞","异步"也不等于"一定非阻塞"。口语里可以凑合,技术上必须分开。

能拉开差距的工程观察:很多后端框架里的"异步",本质是"同步非阻塞 IO 多路复用"封装出来的异步编程模型,并不是操作系统原生的异步 AIO。 例如基于 epoll 的事件循环,应用仍主动轮询内核就绪事件,只是把轮询藏在了框架内部,对外表现为回调或 await。能说出这一层,说明你不只是背了术语。

2. 异步 ≠ 多线程

异步是一种任务组织方式,不是"必须开很多线程"。

  • 单线程事件循环(Node.js、Python asyncio):靠协作式调度在任务间切换,压根没开多线程。
  • IO 多路复用:网络 IO 的异步很多依托内核事件通知,全程不开启新线程。
  • 协程(Go goroutine、Kotlin 协程):在少量线程上调度大量轻量任务。
  • 线程池(Java CompletableFuture、C# Task):这种情况才确实涉及多线程。

反过来,多线程也不一定异步——你开了一个线程去执行任务,但主线程立刻 join 等它结束,对主线程来说仍是同步等待。

一句话:异步解决的是"IO 等待时线程空转浪费",多线程解决的是"CPU 密集型任务并行计算"。判断是不是异步,看的是调用方有没有被要求原地等待,而不是有没有开线程。

3. 异步 ≠ 并发,并发 ≠ 并行

  • 并发:多个任务在时间上交错推进,不一定同时执行。
  • 并行:多个任务真正同时执行,通常需要多核或多机。
  • 异步:一种避免原地等待、提升吞吐的编程方式,常用来实现并发。

一个单线程事件循环可以并发处理很多 IO 任务,但它们并不是并行执行的;一个多核程序可以并行计算,但内部逻辑完全可能是同步的。异步经常服务于并发,但异步不等于并发,更不等于并行。

第四层:价值与代价——为什么需要异步,以及它为什么不是银弹

异步的价值来自"浪费"

同步在跑纯计算任务时很合适——CPU 本来就满负荷在算,等不等都无所谓。真正的问题出在 IO 密集型任务上:网络请求、读写磁盘、查数据库,CPU 有大量时间在空等外部设备。同步等,意味着整条线程被白白占住。

量级感受:一个服务要同时处理几千个请求,用同步就得开几千条线程,内存开销和上下文切换成本都很吓人。异步的做法是在等待期间把线程让出来处理别的请求,于是少量线程就能撑起大量并发。

异步优化的不是单次请求耗时,而是单位时间内服务能承载的请求数量(吞吐量)。

异步的代价是真实的复杂度

异步不是免费的午餐,实际工程里会带来一组新的复杂度:

  1. 控制流被打断:代码不再顺序执行,逻辑被拆散,回调嵌套时尤其难读。
  2. 错误处理容易遗漏:回调里的异常、未捕获的 Promise rejection,都可能悄悄吞掉错误。
  3. 调试和链路追踪更难:异步任务可能跨线程、跨事件循环、跨消息队列,定位成本更高。
  4. 取消、超时、重试需要显式设计:任务发出去后,怎么取消、怎么设超时、失败后怎么补偿,都要额外设计。
  5. 并发控制更麻烦:异步容易一下子发起大量任务,需要限流、信号量、连接池控制并发度。

JavaScript 的演进史几乎就是一部"治异步之痛"的历史:回调 → Promise → async/await。现代语言用语法糖降低了书写成本,但并没有消除生命周期、取消语义、背压和可观测性这些真正的难点。

第五层:选型——什么时候用哪个

同步更合适:

  • 逻辑有严格的顺序依赖,结果马上要用;
  • 操作本身很快,等待成本可以忽略;
  • 代码简单性比极限吞吐更重要;
  • 写脚本、做工具、管理后台,并发压力不大;
  • 团队对异步模型掌握有限,维护成本需要可控。

异步更合适:

  • 操作耗时长,等待期间可以推进其他工作:网络请求、文件 IO、数据库访问、消息队列;
  • 系统需要在高并发下保持吞吐,不能为每个请求阻塞一个线程;
  • 任务之间相对独立,适合事件驱动或流水线式处理;
  • UI 主线程不能卡死,后台任务适合异步化。

一句话记忆:大部分时间在等外部设备(IO),就该考虑异步;时间都在做计算(CPU),同步更简单直接。

一个实用原则:不要为了"显得高级"而异步。 同步代码更容易推理、测试和排查问题;只有当等待成本真实存在、并且吞吐或体验确实受影响时,异步的复杂度才值得付出。

收尾:三个问题检验你是否真懂

真正理解同步异步,不是背一句"同步阻塞、异步非阻塞",而是能回答三个问题:

  1. 谁在等谁? 调用方有没有被要求原地等待结果。
  2. 等待期间线程能不能干别的? 这是阻塞/非阻塞要回答的,和同步/异步分属不同维度。
  3. 结果通过什么机制通知回来? 回调、Promise、事件循环、IO 多路复用,还是 join/get 直接取。

把这三个问题想清楚,同步异步就不再是术语游戏,而是可以拿来做工程判断的工具。

同步是"我等结果",异步是"结果找我"。选哪个,不是谁高级,而是场景说了算。