尧图精选

同步异步、阻塞非阻塞、进程线程协程、并发并行:一文厘清九个易混概念

🕒 发布时间:2026/10/2 22:11:37 📁 来源:尧图网络
1. 先说结论这九个词到底在讲什么如果你在技术社区混过一段时间多少都见过类似提问同步和阻塞不是一个意思吗异步是不是就是并发进程、线程、协程到底谁替代谁说实话我刚开始接触这些概念时也被绕晕过。后来带过几个项目踩过线上事故才慢慢把这一团乱麻理清楚。这九个词之间其实是一种分层的关系不是同一个维度上的对立概念。同步与异步讲的是调用方式阻塞与非阻塞讲的是等待状态进程、线程、协程讲的是执行实体的粒度并发与并行讲的是任务推进的时间关系。它们经常被放在一起讨论是因为在真正的程序运行中这几个维度是叠加存在的比如一个进程里有多个线程每个线程发起一次异步调用而这个调用在网络I/O上又可能以阻塞或非阻塞的方式等待结果。想搞懂它们最有效的方法不是死背定义而是先建立一个坐标系调用方式、等待状态、执行实体、时间关系。你只要把每个概念放回自己的坐标轴上再看它们怎么组合就不会再混淆了。接下来我按这条思路逐个拆开讲尽量用实际场景和代码例子说明白。1.1 九个词分成三组各管各的我建议你把它们先分组记忆调用方式同步synchronous、异步asynchronous等待状态阻塞blocking、非阻塞non-blocking执行实体进程process、线程thread、协程coroutine时间关系并发concurrency、并行parallelism第一组回答的问题是我怎么发起一次操作发起之后要不要等结果。第二组回答的是等待结果时我是什么状态卡住不动还是边等边干别的。第三组回答的是谁在执行代码是一个重型的独立运行单元还是轻量级的执行流。第四组回答的是多个任务如何推进是轮流干还是一起干。这四个维度可以任意组合。于是就有了同步阻塞异步非阻塞多线程并发多进程并行这些说法。你只要记住它们属于不同维度自然就不会把它们混为一谈。1.2 为什么经常被混为一谈我觉得最大的原因在于很多教学材料把异步解释成不等了去做别的事把阻塞也解释成等结果卡住了听起来特别像一回事。另一个原因是在Linux I/O模型里同步、阻塞经常同时出现异步、非阻塞也经常同时出现导致大家默认它们绑定。再加上协程出现以后网上铺天盖地宣传协程能实现高并发又把并发和异步搅在一起。实际上协程只是提供了一种更轻量的并发执行单元它可以在一个线程内完成调度但它本身既不等于异步也不等于并行。2. 同步与异步调用方式的世界2.1 同步调用等结果回来再做下一件事同步这个词在不同场景下含义略有差异但在编程里最常见的含义是调用方发起一个操作后必须等待该操作返回结果才能继续执行后面的代码。这个等待是逻辑上的等待无论底层是CPU计算还是I/O读取。举个生活例子你去餐厅点餐站在柜台前等厨师做完拿到餐再走这就是同步。你打电话叫外卖挂了电话之后该干嘛干嘛等外卖送到再取这就是异步。同步的特点是调用顺序清晰代码读起来像一条直线出问题容易排查。但同步不等于慢。一个纯CPU计算任务即使性能再好它也是同步的因为调用方关心的是计算结果。同步关注的是调用方是否等待结果而不是任务快不快。2.2 异步调用发起请求后先不等待异步调用的特点是调用方向被调用方发起请求后不需要等待结果返回而是立即继续执行后续代码。结果通过回调、事件、Future/Promise、协程挂起等机制通知调用方。还是拿点餐举例。你在外卖App下单App返回一个订单已提交的确认然后你继续看视频、打游戏等骑手送到时手机响铃通知你。订单处理过程在另一个地方进行你没有被卡在提交页面。在代码层面一个典型的异步调用长这样Python示例仅为说明逻辑async def fetch_data(): result await http_get(https://example.com/data) return result程序执行到await时会把控制权交还给事件循环当前协程挂起。等网络数据回来后事件循环再把协程唤醒继续执行后续代码。调用方并没有被堵在原地。2.3 同步/异步与代码的对应关系同步代码通常是这样的你调用一个函数它内部执行完所有步骤返回值给你你拿到值再往下走。异步代码则常常表现为你调用一个函数它立刻返回一个凭证Future、Promise、Task你先把代码往下走后续通过await、then或回调获取真正的结果。需要注意的是异步不一定非得多线程。单线程事件循环里也可以有异步Node.js就是典型它用一个线程驱动事件循环把所有I/O操作交给底层完成等I/O完成后再触发回调。异步是一种调用方式它可以建立在单线程上也可以建立在多线程上。3. 阻塞与非阻塞等待者的状态3.1 阻塞被卡住了啥也干不了阻塞描述的是调用方在等待某个操作期间的状态如果该操作尚未完成调用方的线程被挂起CPU不再分配时间片给它直到操作完成才被唤醒。以网络读取为例。你用read函数从一个socket读取数据如果缓冲区没有数据线程会一直睡在系统调用里即使外面天塌下来也不会继续执行。这个状态就是阻塞。阻塞不一定会降低性能但它会占用一个线程资源而线程是系统资源数量有限。如果服务器的每个连接都用一个阻塞线程当连接数到达一定规模线程上下文切换的开销和新线程创建的开销会把CPU拖垮。这就是每连接一线程模式的主要瓶颈。3.2 非阻塞轮询、忙等与事件驱动非阻塞的意思是发起操作后不会一直等系统调用会立刻返回当前状态——数据还没准备好或数据已经准备好了。你可以在两次调用之间去做别的事情。最简单的非阻塞方式是轮询反复调用read查看有没有数据。这种方式优点是思路简单缺点是CPU空转程序在忙等。真正高效的通常不是轮询而是事件驱动用select、poll、epoll等I/O多路复用机制一次性监听多个文件描述符内核帮你监视哪些可以读写程序只在事件发生时处理。非阻塞解决的是等待者状态的问题它让线程可以同时观望多个操作。但要注意非阻塞不意味着操作是异步的。如果代码是这样的while (read(sock, buf, size) 0 errno EAGAIN) { // 处理其他事情然后再回来问 }你确实是非阻塞的但你是在同步地轮询结果。要理解这一点需要进入下一节的四象限。3.3 四象限组合把同步/异步和阻塞/非阻塞组合可以得到四象限。这是很多面试题爱考的地方也是我建议每一个人都掌握的知识点。维度组合调用方式等待状态典型场景同步阻塞等待结果线程挂起Java传统BIO、文件同步读取同步非阻塞等待结果线程不挂起但持续轮询轮询式socket读写异步阻塞不等待结果线程挂起少见别扭罕见通常代码组织有问题异步非阻塞不等待结果线程不挂起事件驱动Node.js、epoll、Java NIOSelector真正有实用价值的主要是同步阻塞和异步非阻塞两象限。同步阻塞代码最简单适合并发量不高的场景。异步非阻塞虽然实现复杂但能在有限的线程资源下支撑极高的I/O并发。这里说一个很多人踩过的坑他们把异步阻塞理解成异步调用然后等待Future结果。严格来说future.get()确实会让当前线程阻塞但这属于异步发起同步等待结果本质上调用方在等待的这一刻变成了同步阻塞。如果代码里大量使用这个模式异步带来的吞吐收益会被抵消不少。4. 进程、线程与协程任务的不同承载粒度4.1 进程完整的执行环境进程是操作系统分配资源的基本单位。一个进程拥有独立的地址空间、内存映射、文件描述符表、信号处理器等。进程之间默认不共享内存需要通过进程间通信IPC来交换数据。启动一个进程的成本相当高。以Linux举例fork要复制父进程的页表、文件描述符等元数据虽然采用写时复制技术实际物理内存不会立刻全量复制但创建、销毁以及上下文切换的开销仍然远高于线程。进程最大的价值是隔离。进程A崩了进程B一般不会受牵连。所以现在很多服务把不同业务模块拆成不同进程来部署用消息队列或RPC通信用一次崩溃的代价换取整体的稳定。4.2 线程进程内的执行单元线程是操作系统调度CPU资源的基本单位。一个进程内可以有多个线程这些线程共享进程的地址空间和资源。线程之间的切换比进程轻因为不需要切换地址空间和页表只切换上下文加栈指针。由于共享内存线程间通信可以直接读写共享变量非常方便但也由此带来了并发安全问题多个线程同时修改一个变量轻则数据错误重则死锁、崩溃。因此你需要加锁、加原子操作或者设计无锁数据结构。线程是抢占式调度的线程切换的时机不完全由代码控制内核决定什么时候切换。这个特性让线程很容易出现竞态条件你无法预测两个线程交错的时刻。Java里synchronized、ReentrantLock、ConcurrentHashMap等工具都是围绕如何处理共享资源的竞争展开的。4.3 协程线程内的用户态调度单元协程是一种由用户态代码控制的执行流它可以在函数内部主动让出执行权之后由调度器决定何时恢复。协程运行在线程之上一个线程可以包含多个协程线程负责执行某个时刻的某个协程。协程切换时不需要陷入内核只需要保存和恢复栈、寄存器等上下文开销比线程小得多。我见过有人把协程描述成用户态线程或轻量级线程这个类比方向是对的但要注意它和真正的线程在调度机制上有本质区别。协程的关键能力是挂起和恢复。挂起时当前执行位置、局部变量、调用栈都会保存下来控制权交还给其他协程或事件循环。恢复时它从上次挂起的位置继续。这种协作式调度天然避免了竞态条件吗并不是。协程虽然不会像线程那样在任意指令处被抢占但如果在多个线程上同时调度同一批协程该加的锁还得加。我个人的看法是协程最适合的是I/O密集型场景大量等待网络、磁盘、数据库响应的任务在协程模型下可以跑得很好。如果是CPU密集型计算协程没有太大优势因为它在单线程内一次只能跑一个你真正需要的可能是多线程或并行。4.4 三者的开销对比这里我给一个粗略的经验表方便你快速理解层面创建/切换开销共享方式调度方式典型规模进程高独立地址空间需IPC内核抢占式一台机器几十~几百个线程中等共享内存加锁保护内核抢占式一台机器几千~几万个协程低同线程内共享注意安全用户态协作式一台机器可达十万~百万上百万协程听着很夸张但如果你只是让它们绝大部分时间在await上挂起内存占用是可控的。只要每个协程的栈按需增长控制住平均大小这个规模在64位进程里是可以达到的。5. 并发与并行同时进行的不同含义5.1 并发交替执行时间片轮转并发指的是多个任务在同一个时间段内都在推进但在任意一个瞬间可能只有任务A在运行。操作系统通过时间片轮转让任务A跑几毫秒、任务B跑几毫秒看起来就像同时在跑。生活的类比是你一边看电视一边烧水。电视节目播放的间隙你去厨房看一眼水回来接着看。你只有一个脑袋但在一定时间段内两项任务都在推进。这就是并发。并发关注的是如何组织和调度多个任务它是程序设计层面的目标。单核CPU也可以实现并发只要操作系统支持上下文切换。也正因为如此并发系统总会引入切换成本和竞争共享资源的问题。5.2 并行同时执行多个核心并行指的是多个任务在同一个瞬间真正同时执行。这要求硬件上存在多个计算核心或者多个计算单元能同时跑多个任务。并行是物理层面的同时发生跟操作系统调度没有必然关系。回到生活类比如果还有一个家人帮你烧水你接着看电视同一时间有两个人同时做两件事这就是并行。并行可以让计算速度在理想条件下翻倍但现实中受限于数据依赖、内存带宽、同步开销很难达到线性加速。用学术一点的表述并发是结构并行是执行。并发不保证并行并行依赖并发来组织和调度。你可以有只包含并行但不刻意设计并发结构的程序——比如多个独立计算任务同时跑在不同核上但更常见的模式是先设计并发结构再把它映射到并行的硬件上。5.3 4核8线程与CPU亲和很多机器参数里写的4核8线程这里的线程指的是硬件超线程不是任务线程。一个物理核心可以提供两个逻辑核心让操作系统以为有8个CPU可用。超线程的原理是一条物理流水线在执行任务A时很多部件的利用率不高于是把另一条逻辑线程的任务穿插进去提高硬件利用率。理解这点以后你就能明白8个逻辑核心并不意味着能直接达到8倍的并行速度。如果是纯计算密集任务超线程带来的收益大约在15%~30%之间远达不到2倍。如果任务是I/O密集超线程有可能帮助更大因为一个逻辑线程在等待内存访问时另一个逻辑线程可以利用执行单元。再补充一个CPU亲和的概念。如果你把线程绑定到某个物理核心上避免它频繁在不同的核心上迁移高速缓存的命中率会显著提高。Java里可以用Thread.setAffinity之类的库去做C/C一般用sched_setaffinity。你可能不需要默认开启但在延迟敏感的系统中这个技巧很实用。6. 把这九个词拼在一起I/O模型中的完整体现6.1 阻塞式同步I/O这是最传统的一种网络I/O模型。线程发起read内核等待数据到达然后复制数据到用户空间read返回线程继续执行。线程在等待期间是阻塞状态因为是同步调用调用方一直等待结果。代码写起来非常顺手逻辑线性。可问题在于如果同时有一万个连接你可能需要一万个线程。线程太多后上下文切换开销会让你怀疑人生。很多老项目的性能瓶颈就是这么来的。6.2 非阻塞同步I/O把socket设置成非阻塞模式read立即返回。内核没有数据时返回EAGAIN或EWOULDBLOCK有数据时才返回内容。你可以在一个线程里轮询多个socket但轮询本身会消耗大量CPU。这是一种同步非阻塞模型适合对实时性要求不高、连接数中等的情况。它的缺点是每个轮询周期都在做无意义的系统调用浪费了不少时间在用户态和内核态的切换上。6.3 多路复用同时监听一批I/O事件select、poll、epoll都属于I/O多路复用。它们让一个线程同时注册多个文件描述符内核告诉你哪些已经就绪。拿epoll来说它是一种事件驱动机制你只需要把关心的文件描述符加入等待队列然后阻塞在epoll_wait上。fd一旦有事件内核唤醒你你逐个处理就绪事件。从调用方式看很多人会问这算同步还是异步。答案是这仍然是同步的因为epoll_wait需要你主动去取事件处理数据的过程还是你自己来而且epoll_wait本身是阻塞调用。它解决的阻塞问题是把等待多个I/O变成了等待一个事件列表但并没有帮你完成数据复制和处理。真正的异步I/O另有其人。6.4 异步I/O内核帮你做完所有事异步I/OAIO如Linux io_uring会把整个I/O过程交给内核你提交一个请求内核完成数据读取并将其复制到你的缓冲区然后通知你完成了。整个期间你的线程无需等待无需轮询也无需自己调用read去拷贝数据。如果你看Linux的io_uring会发现它是这个方向的终极形态用两个共享内存的队列提交队列、完成队列和若干系统调用让应用与内核高效交互。配合协程可以在一个线程内管理海量I/O操作。不过要提醒一句io_uring本身很强大但使用门槛不低。它要求你对内存生命周期、队列深度、内核版本都有足够了解。日常业务如果只是写个HTTP接口先用好epoll和异步框架就足够了不必过早引入复杂机制。7. 实操中的常见误区与排查技巧7.1 误区把异步当并发来理解有个项目里前端调后端接口后端代码用一个异步HTTP客户端去请求第三方服务。开发人员跟我说这个请求是异步的所以并发没问题。结果压测一上来第三方服务返回慢后端的线程池被打满大量请求超时。问题出在他把异步I/O和高并发吞吐之间画了绝对等号。异步确实让单个线程不必等待I/O但不代表后端节点的整体并发能力无限。每个异步请求依然有内存占用、有Future对象、有超时控制同样需要管理资源上限。本质上异步只是把线程等待的问题变成了任务队列和回调管理的问题。7.2 误区线程越多越好我曾经维护过一个定时任务系统每个任务都开一个新线程去跑高峰期任务一多线程快上千个。系统CPU不高但load average很高响应延迟却很大。后来用Arthas一看大量线程在阻塞等待锁上下文切换彻底拖垮了性能。线程池才是正道。Java里ThreadPoolExecutor可以控制核心线程数、最大线程数、队列长度和拒绝策略。你需要根据任务类型决定参数CPU密集型核心线程数设为CPU核心数1左右避免频繁切换。I/O密集型核心线程数可以设大比如CPU核心数×2甚至更高但要关注后端资源上限。别死记公式先压测再调。线程池的队列也有讲究SynchronousQueue适合传手递任务LinkedBlockingQueue适合缓冲任务有界队列一般比无界队列更安全否则积压的任务会在内存里膨胀到OOM。7.3 通信机制的取舍进程之间通信有管道、消息队列、共享内存、信号、Socket等线程之间通信有共享内存加锁、条件变量、信号量等。很多人问哪种好其实取决于场景。跨机器基本是网络通信选RPC或消息队列。同机器但需要故障隔离选多进程本地Socket或共享内存。同进程高吞吐协作选多线程锁。高频状态共享且逻辑复杂优先线程想要健壮性和模块化进程更合适。协程之间的通信则更轻比如Go语言里的channel、Python协程配合队列本质上是值传递和消息传递的方式避免直接共享内存。把数据的所有权从一个协程转移到另一个协程能省掉大量加锁的麻烦。8. 概念串讲一个真实请求里九个词的身影用一个典型场景把这九个词完整串一遍你就能感受到它们如何组合。假设有一个在线文档系统用户打开文档时浏览器向后端发起请求后端需要做三件事查数据库读文档内容、调用存储服务拿附件元信息、记录一条访问日志。这三个操作彼此独立。后端如果用Go实现每来一个请求就起一个Goroutine这个Goroutine是一个协程运行在线程之上。它在等待数据库查询结果时主动挂起协程机制事件循环或调度器让另一个协程开始执行。数据库查询结果返回后再恢复这个协程。这就是一个典型的同步代码写法异步调度手段的模式线程仍然在线但不是在傻等协程的挂起让线程可以去忙别的。如果把实现换成Java NIO加Selector那么一个线程同时监听多个连接的事件叫并发当部署在4核机器上四个线程真的同时在4个核心上跑叫并行。每个网络请求本质上是异步非阻塞I/O而每个回调函数可以看作一个轻量级任务。在这个例子里同步/异步描述了后端如何调用数据库和存储服务、以及如何交付HTTP响应阻塞/非阻塞描述了网络线程在等待事件时是否被系统挂起进程/线程/协程描述了任务是如何承载的并发/并行描述了任务之间的时间关系。你只有把这四层拆开才能准确描述系统行为。我在实际项目中经常用这套分析框架来做技术选型先确定调用模型是同步还是异步、I/O等待模型是阻塞还是非阻塞再决定用线程还是协程承载任务最后评估并发度与并行度需求。顺序不能反先想清楚调用方式和等待状态再谈执行实体和调度模型。很多人一上来就问协程好还是线程好这个问题本身就没问到点子上。工具适合什么场景才重要。低并发、简单业务同步阻塞加线程池就够了。高并发、海量连接、I/O密集异步非阻塞加协程事件循环是主流方向。CPU密集计算多进程并行配合消息队列才是重点。9. 常见问题速查与排查实录整理一份我在实际调试中经常被问到的问题希望对你有帮助。症状可能原因排查思路高并发下CPU不高但延迟很高大量线程阻塞、锁竞争、上下文切换频繁查看线程转储、锁等待压测确认切换次数异步代码偶尔报超时回调线程池太小任务排队监控回调队列长度和线程池活动量协程数量很多但吞吐上不去协程内在做CPU密集运算用多个线程调度协程或将计算下沉到专用进程使用了线程池仍然OOM无界队列积压任务换有界队列并定义拒绝策略同步调第三方接口每次都慢等待响应时没有做超时控制强制超时失败走降级两个进程需要高吞吐通信用了跨网络的消息队列同机改用Unix Socket或共享内存线程安全BUG难以复现共享集合在并发下被修改改用并发容器、加锁跑压力测试复现程序使用协程后偶尔异常协程内调用了阻塞I/O将阻塞调用放到独立线程避免卡住事件循环我还记得有一次处理过一个诡异的死锁代码在分布式锁内部又发起了异步调用而异步结果需要等待锁释放才能处理。表面看是锁问题本质上是同步等待异步结果 锁顺序不一致。排查的核心是先画调用链标出每个线程在等什么资源再标出谁占用了资源不放。这类问题用线程转储thread dump几乎一眼就能看出来不要靠猜。另一个经验是给线程、协程、队列起名字一定要有意义不要用默认的pool-1-thread-1。生产环境上出事你看到线程堆栈里一堆无意义线程名心里真的很绝望。多花十秒钟起名排障时能省几个小时。10. 最后分享一个小技巧我自己记忆这组概念的土办法是编一个餐厅比喻。同步你在柜台前等餐做好。异步你拿了取餐号去坐着玩手机叫号再来取。阻塞你等餐期间什么都不能干整个人挂机。非阻塞你等餐期间看手机、回消息时不时瞄一下取餐屏。进程一个独立的餐厅门店有自己的厨房、菜单、账本。线程门店里的一个厨师多个厨师共享一个厨房但抢同一个灶台会打起来。协程一个厨师在炖汤的时候跑去切菜汤好了再继续炒下一道同一口灶只能一个人用但切换很快。并发一个厨师的时间被分成很多小片轮流处理不同菜。并行好几个厨师同时各炒各的菜菜是真的同时在变熟。这套比喻虽然不完美但胜在直观。遇到复杂场景比如异步非阻塞 协程 高并发你可以理解为一个非常高效的大厨同时盯着好几口锅非阻塞每口锅的菜都提前备好料协程挂起到火候就处理一下事件驱动餐厅里同时有好几个这样的大厨在各自的灶台前真是同时出菜并行。概念本身并不难难的是把它们放在同一个脑图里还不打架。你可以把这篇文章收藏起来下次写代码卡住的时候回来看一眼每一组定义再对应到你的项目里思路就会清晰很多。我踩过的那些坑写在这里了希望你能少走一点弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →