
1. 一切要从“程序卡住”说起你在某个业务系统里写过这样一个功能批量拉取几百家上游接口的数据然后逐条清洗入库。第一次跑的时候你发现整个过程要花四十多分钟而真正在“计算”的时间其实不到一半绝大部分时间都耗在了等待网络响应上。于是你自然会想能不能让多个请求同时发出去而不是一个个排队等这个念头就是并发编程最初的火花。我自己第一次感觉到该学并发编程也是在类似的场景。当时接手一个数据同步模块每天凌晨抓取外部数据任务队列一长运行时间直奔两个小时。一开始我以为是网速问题后来加了日志才发现CPU使用率一直只有5%左右。换句话说整个机器大部分时间在“发呆”程序却在按部就班地排队。那不是机器慢是程序设计导致资源的巨大浪费。这篇博文不教某个语言的API也不带你速成某个框架而是把并发编程的底子梳理一遍进程、线程、并发、并行、临界区、锁、调度、上下文切换一个个讲清楚。无论你用Go、Java、Python还是C这些概念都是通用的是你判断“并发方案该怎么设计”的地基。适合刚入门并发编程的开发者也适合写过一些多线程代码但总觉得“差点意思”的人。再退一步说就算你现在主要写业务逻辑理解这些概念也能帮你更早发现并发bug而不是等上线后让它在生产环境教做人。2. 先分清台前演员进程、线程与协程2.1 进程资源容器也是隔离边界进程是操作系统进行资源分配的最小单位。这句话背下来很容易真正理解它需要想清楚“资源”包括什么内存地址空间、文件描述符、环境变量、信号处理、线程列表……基本上一个正在运行的程序所拥有的家当都会被操作系统登记在一个叫做“进程控制块”的结构里。进程之间默认是互相隔离的。一个进程崩溃一般不会直接拖垮另一个进程。A进程里把内存写烂了错误也在A自己的地址空间里操作系统顺手就把A终结了其他进程照常运行。这种隔离既是好处也是代价。好处是稳定安全代价是进程间通信成本极高。跨进程传递数据得走管道、消息队列、共享内存、套接字这些IPC机制每一样都伴随着数据的拷贝或者系统调用性能远比不上同进程内线程间的数据共享。在并发编程里进程通常不是我们“造并发”的第一选择但它定义了“隔离”与“稳定”的边界。你在设计一个系统时如果不同模块之间需要很强的故障隔离才考虑拆成多进程。2.2 线程真正干活的最小单位线程是CPU调度的最小单位。比进程更“轻”因为同一个进程内的多个线程共享进程的资源同一块堆内存、同一个文件描述符表、同一种全局状态。线程自己只保留一小块独立的栈空间以及寄存器上下文。正因为共享内存线程之间的数据交换变得特别简单。你定义一个全局变量所有线程都能访问不需要管道也不需要消息队列。但也正因为共享麻烦事来了两个线程同时改同一个变量结果可能错乱。第三章和第五章会重点展开这部分。从模型上看一个进程至少有一个线程也可以有很多线程。线程由操作系统内核来调度创建和切换都需要陷入内核态这也就决定了线程不可能无限多——后面讲上下文切换的时候你就能直观感受到它的成本。2.3 协程用户态里的“轻量级线程”协程常被叫做“用户态线程”。它和线程最大的区别是协程的调度不依赖操作系统内核而是由应用程序自己控制。线程是被操作系统抢来抢去地调度抢占式协程则是代码主动让出执行权协作式。你用协程写并发本质上是在一个线程里交替执行多段逻辑。当前协程遇到I/O等待比如网络请求发出去了就主动把执行权交给另一个协程让CPU继续干别的。因为切换发生在用户态不需要陷入内核所以协程切换的开销比线程小一到两个数量级。这也是“高并发”服务端广泛采用协程的根本原因。大量请求同时进来如果为每个请求建一个线程线程数一多光切来切去系统就累垮了改用协程一个线程里可以挂着成千上万个“轻量任务”I/O等待时自动让位性能提升非常明显。不过协程也有挑剔的地方如果代码里某个操作是纯CPU密集且不让出那其他协程就只能干等着。协作式调度信任每个协程“自觉”遇到阻塞点要主动让位。这也让协程调试比线程更隐蔽——出问题往往不是“数据竞争”而是“某个协程卡住了整个线程”。2.4 一张表看三者关系维度进程线程协程资源分配单位是独立地址空间否共享进程资源否在线程内部调度CPU调度单位否是否由用户态调度创建/切换成本最高较高极低数据隔离强弱共享内存同线程内共享适用场景强故障隔离、多实例服务多核并行计算、I/O并行海量I/O并发、请求密集场景选型上我的经验是需要强隔离就上进程需要压榨多核CPU就上线程需要扛住海量并发I/O就上协程。很多成熟的运行时已经把协程封装得很好了你甚至不需要关心底层细节但知道这些边界会让你在调优时少走弯路。3. 并发与并行很多新手栽在这里的一字之差3.1 并发逻辑上同时物理上交替并发强调的是“能同时处理多个任务”的能力。哪怕你的机器只有一个CPU核也可以并发操作系统把CPU时间切成一段段让任务A跑一下、任务B跑一下、任务C跑一下切换得足够快你感知上好像它们同时在跑。用生活里的例子讲一个服务员要给三桌客人上菜。他不可能同时出现在三张桌子边但他可以这一秒给A桌上菜下一秒给B桌上菜三桌人看起来都得到了服务。这是并发——交替处理但同一时刻手上只有一件事。在并发模型里这是任务之间的“交错执行”。每个任务都被分割成极小的时间片操作系统负责在多个任务间快速切换。你写的多线程代码在单核CPU上运行就是典型的并发而不是并行。3.2 并行物理上真的同时并行是“同时执行多个任务”要求CPU提供多核支持每个核在同一个时刻各干各的。回到服务员例子餐厅新雇了三个服务员一个盯A桌一个盯B桌一个盯C桌三桌同时被服务。这是并行。并行天然包含并发的思想但反过来不成立并发只表明“有同时处理多任务的能力”不保证物理层面的同时执行。一个并发程序放到单核机器上照样能跑但一个并行程序如果只有单核那它最多只能是并发。程序员经常在这两个词上翻车最典型的是面试和讨论时你说“我的程序是并发的”对方问“那并行了吗”这俩不是同义词别混着用。3.3 为什么你写的并发程序不一定并行很多人写完多线程代码发现跑起来并没有更快甚至更慢。原因很扎心你只制造了并发没有制造并行。一个程序要真正做到并行至少得满足几个条件机器有多核CPU任务能被拆分到多个线程各个线程之间没有强依赖不需要频繁等待彼此线程切来切去的额外开销小于并行带来的收益。如果你只是把任务塞给线程池但任务本身全是共享变量上的串行操作那这些线程要么在抢锁要么在等锁真正的“并行执行”只是一个幻觉。我之前写过一个统计程序数据按切片分给8个线程去处理但主线程要等所有子线程的结果汇总。子线程之间不争抢资源时性能确实逼近线性加速可一旦我在某个共享计数器上加了锁8个线程变成轮流抢一把锁速度立刻掉到比单线程还难看。那次之后我深刻明白并行的收益取决于你能否减少争用而不是单纯增加线程数。3.4 常见并发模型对照模型核心思想代表场景主要风险多线程共享内存线程通过共享变量通信桌面应用、传统后端服务数据竞争、死锁Actor模型每个实体独立只靠消息传递分布式系统消息设计复杂事件循环单线程循环处理事件非阻塞I/O前端JS、高性能网络服务回调地狱、CPU密集任务卡循环数据并行同一操作作用于大量数据的不同切片图像处理、数值计算负载均衡难做流水线并行任务分成多阶段阶段间接力处理生产消费者模型各阶段速度不匹配时瓶颈明显选哪种模型不是看哪个“高大上”而是看任务特性。I/O密集型任务适合事件循环和协程CPU密集型任务适合多线程加数据并行任务间边界清晰、需要扩展性强时Actor模型是个好思路。3.5 并发能力由什么决定决定一个系统并发能力的不只是线程数。瓶颈往往在三个地方CPU核心数决定你真正能做到多少并行。I/O等待时间决定有多少时间被白白浪费这也是并发优化的主要空间。共享资源争用锁、队列、数据库连接等争用越大并发收益越小。所以评估并发方案时先问自己这个任务到底是CPU密集还是I/O密集如果是I/O密集协程或异步I/O能带来巨大收益如果是CPU密集要多核并行才有意义此时要小心锁竞争和线程数如果两者混杂那就要做混合设计这也是很多真实系统最复杂的地方。4. 底层机制调度器、上下文切换与临界区4.1 调度器在忙什么时间片只是个幌子操作系统的调度器负责决定“下一个该谁上CPU”。它有很多调度算法常见的思路包括优先级调度、时间片轮转、多级反馈队列。实际运行中每个线程并不会老老实实跑完整个时间片因为I/O等待、锁阻塞、时钟中断都会打断它。大多数通用操作系统采用抢占式调度内核认为某个线程用CPU太久了直接剥夺它的执行权交给另一个线程。这种设计让系统响应更及时但也意味着你的线程什么时候被切走是不确定的。这种不确定性是并发bug最难查的根源之一——你在本地跑一百次都对到生产环境偶发一次错大概率就是调度时序变了。调度还分“内核线程”和“用户线程”。很多语言运行时把用户线程协程/绿色线程映射到内核线程上例如多对一模型、一对一模型、多对多模型。一对一模型下每个用户线程对应一个内核线程利用多核方便但线程一多内核资源就紧张多对多模型则更灵活由运行时负责在有限的内核线程上调度海量用户任务。4.2 上下文切换的成本比你想的高得多上下文切换就是暂停当前线程、保存它的状态寄存器、程序计数器、栈指针等然后加载另一个线程的状态继续执行。听起来只是“保存和恢复”但这个动作一点都不廉价。它包含几部分开销保存/恢复CPU寄存器更新内核栈和调度数据结构CPU缓存被新线程的状态污染后续访问缓存的命中率下降涉及用户态和内核态之间的切换如果切换对象是不同进程的线程还会涉及地址空间的切换代价更高。实测经验一次线程上下文切换通常耗时在微秒量级看起来不多但如果你有1000个线程在疯狂争抢CPU每秒可能出现几万次切换这些开销会直接吃掉一整个核心的计算能力。这解释了为什么“线程越多越好”是错的。当线程数超过CPU核数太多时每个线程分到的时间片短切换开销占比高整体吞吐量不升反降。我见过一个项目为了追求“并发量”把线程池调到500个结果监控面板显示系统CPU大量消耗在切换上业务进度反而比50个线程时更慢。后来把线程压到与核心数相仿加上I/O等待时的主动让位性能立刻回升。4.3 竞态条件并发bug的源头竞态条件指的是程序的结果依赖于多个线程的执行顺序而这个顺序又无法被保证。换句话说同样的输入每次运行的输出可能不同。一个最简单的例证两个线程同时对共享变量count执行“读-加-写”操作。假设初始值是0两个线程都读到了0各自加1后都写回1最终结果是1虽然逻辑上应该是2。问题出在“读-加-写”这三步不是一个不可分割的原子操作线程A读到0后可能被切走线程B也读到0两者互相覆盖。这种bug致命的点是它不会每次都触发可能跑几千次才出现一次。你很难复现也很难定位。真实项目里竞态往往和某种微妙的调度条件绑定比如恰好某个线程先跑了一小段时间、恰好内存屏障没生效、恰好两个请求同时到达。它是并发编程里最典型的“隐形杀手”。4.4 临界区与不可分割操作要解决竞态核心是让“读-加-写”这种复合操作变成不可分割的整体。这个不可分割执行的区域就叫临界区。进入临界区之前线程需要获取某种机制保护自己最常见的是锁出了临界区再释放锁确保同一时刻只有一个线程能执行这段代码。这样即使线程在临界区内被切走其他线程也进不来必须等它把整个操作做完。这里有个容易被忽略的点临界区必须覆盖完整的关键操作序列既不能“缺斤少两”也不应随意扩大范围。临界区太小保护不全竞态依然存在临界区太大其他线程等着的时间变长并发性能大打折扣。划定合适的临界区边界是并发编程的基本功也是性能调优的重要发力点。比如更新共享计数器完整操作是“读旧值、加增量、写回”。你只给“加增量”那一步加锁锁等于白加你把从用户提交请求到所有后续处理都锁上那就成了串行程序。正确的做法是把“读-加-写”作为一个临界区范围尽量小。5. 并发控制工具箱锁、原子操作、等待通知5.1 互斥锁最朴素也最可靠的方案互斥锁Mutex是最基础的同步原语。它保证任何时刻最多只有一个线程能持有锁。线程在进入临界区前调用加锁操作离开时调用解锁操作。如果锁已经被其他线程持有后来的线程就会阻塞等待直到锁被释放。用互斥锁要注意两点第一加锁和释放必须成对出现异常或提前返回时也要释放锁否则其他线程会永久阻塞。现代语言提供了try-with-resources、RAII、defer之类的机制来避免手动释放的遗漏能用的尽量用。第二不要在持锁期间调用可能长时间阻塞的操作比如网络请求、等待用户输入。这会让所有线程都堵在一个锁上系统吞吐量瞬间归零。5.2 读写锁读多写少场景的优化读写锁把“读”和“写”分开对待。多个线程可以同时持有读锁但写锁是排他的持有写锁时其他线程既不能读也不能写。这样设计是因为“纯读取共享数据”是安全的大家读同一个值不会互相破坏。适用场景很典型配置表、路由表、词库等“读多写少”的数据结构。假如用普通互斥锁所有读线程都必须串行白白浪费并发能力。换成读写锁读线程可以并行只有偶尔的更新才需要独占写锁。但别天真地以为读写锁一定更快。如果写操作非常频繁写锁经常被持有读线程也频繁等待加上读写锁本身的维护开销可能比普通互斥锁还慢。选读写锁前先估一估读写比例经验上读远多于写比如10:1以上才划算。5.3 自旋锁等着比切换更划算自旋锁的特点是线程拿不到锁不进入阻塞而是原地循环“转圈”持续尝试获取锁直到拿到为止。因为线程没有睡过去省去了上下文切换的开销但代价是它占用着CPU空转什么正事也不干。自旋锁适合临界区特别短、且锁被持有的平均时间极短的场景。比如你要保护一个只有几条指令的变量更新持锁时间通常只有几十纳秒此时用互斥锁光是切换线程的成本就超过了自旋空转的成本自旋锁反而更高效。实际使用时要小心如果临界区较长或者多个线程竞争激烈自旋锁会让CPU浪费大量时间在空转上。Linux内核里就大量使用自旋锁保护短临界区但它在临界区不允许睡眠。你写用户态代码时下意识用互斥锁通常更稳妥只有确认临界区极短且经过性能测试后再换成自旋。5.4 原子操作无锁的基石如果把“读-加-写”这种复合操作用一条特殊的CPU指令直接完成就不需要锁了。这类由硬件提供支持、执行过程中不可中断的操作称为原子操作。比如compare-and-swapCAS、fetch-and-add、load-link/store-conditional。CAS是最有代表性的一个它把“比较-交换”做成原子操作。线程读取内存值预期它是某个旧值如果是就写入新值如果不是说明别人改过了那就重新读取再试。这也就是所谓“无锁编程”的基础。无锁意味着线程不会被阻塞但代价是编码复杂度和调试难度远高于加锁版本。高性能场景中某些关键路径用原子操作替代锁可以显著减少争用。比如统计请求计数用原子自增就比每次加锁轻量得多。一个非常经典的提醒原子操作保护的是“单条指令”的原子性不是你业务逻辑的原子性。你用它解决计数器问题没问题用CAS去模拟一个多步骤的业务事务就很容易出错因为批处理中间时刻状态的一致性需要额外手段保证。5.5 等待通知与条件变量很多时候线程要做的事情依赖某个“条件”成立。比如生产者线程往队列里放数据消费者线程只有在队列非空时才能取数据。如果消费者发现队列为空它有两种选择一直问“有数据了吗”忙等待或者睡过去等生产者通知它“有数据了”再醒来。条件变量就是为第二种方案设计的。它通常配合互斥锁使用线程在循环中检查条件不满足就调用等待接口同时释放锁另外的线程修改条件后调用通知接口唤醒等待线程。这里有个新手容易犯的错条件变量存在“虚假唤醒”线程被唤醒后条件可能还是不满足所以必须在循环里重新检查条件而不是用简单的if判断。规范写法是# 伪代码生产者消费者模型中的消费者 while queue.empty(): condition_var.wait() data queue.pop()这个while就是防止虚假唤醒以及多线程同时被唤醒时“抢不到数据”的保障。写完代码后检查自己是否用的是while而不是if这个小细节能帮你躲开线上偶发的空队列异常。6. 实践中的四个大坑死锁、活锁、过量线程与伪共享6.1 死锁互相等对方的钥匙死锁最经典的定义一组线程中每个成员都在等待其他成员占用的资源没人释放全部永久卡死。举个例子线程A先锁lock1再锁lock2线程B先锁lock2再锁lock1。如果A拿到lock1后、还没拿到lock2时B恰好拿到了lock2接下来A等B释放lock2B等A释放lock1两个线程永远等下去。避免死锁最实用的原则有两条按固定顺序加锁。所有线程都按lock1再到lock2的顺序获取锁就不会出现环路等待。加锁设置超时。拿不到锁在一定时间内放弃释放已持有的锁重试或有条件地失败退出。实际排查死锁时多数语言的线程Dump工具能帮你看到每个线程等待的锁以及持有它的线程是谁。看到“两个线程互相等待彼此持有的锁”这个环死锁所在的位置基本就锁定了。6.2 活锁与饥饿让线程“吃不上饭”的另一种方式活锁的情况是线程并没有阻塞而是在不停地响应另一个线程并让出自己的执行机会结果大家互相谦让谁也无法前进。比如两个并行任务在走廊相遇都想给对方让路结果你闪到左边我也闪到左边你闪到右边我也闪到右边反复闪躲始终走不过去。活锁比死锁更难发现因为程序看起来还在“跑”只是没有实际进展。解决思路通常是引入随机退避或者固定优先级让一方先走打破僵局。饥饿则是某些线程迟迟得不到CPU时间或锁资源虽然最终可能执行但等待时间远超合理范围。常见原因是锁的公平性设计不公平——高优先级线程反复抢占低优先级线程的资源导致低优先级线程长期得不到执行。使用公平锁能缓解部分饥饿问题但公平锁本身也有额外成本要结合实际场景权衡。6.3 线程不是越多越好上下文切换会吃掉性能很多人有个误区并发能力线程数量。实际上线程太多时系统绝大部分时间在调度和切换真正干活的CPU时间被严重压缩。经验公式可以参考CPU密集型任务线程数通常设置为CPU核心数的1到2倍左右I/O密集型任务可以多一些但也不能无限增加。更精细的做法是压测找到“拐点”即线程数继续增加但吞吐量不再上升甚至下降的点。我现在做服务端代码时会开启线程池的监控指标观察活跃线程数、队列等待时长和任务耗时。一旦发现活跃线程数长期接近上限且任务耗时激增第一个怀疑对象就是线程数配多导致的切换开销而不是业务代码变慢了。6.4 伪共享一个诡异的性能黑洞伪共享是并发性能优化中一个不那么直观的坑。它的原理涉及CPU缓存CPU不是按单个变量去缓存数据而是按“缓存行”为单位常见大小是64字节。如果两个线程需要频繁访问两个不同的变量但这两个变量恰好落在同一条缓存行上CPU为了保证缓存一致性会把这整条缓存行在多个核之间反复同步。于是线程A修改变量X时线程B访问变量Y也会被拖慢尽管两者根本没有共享任何数据。解决办法很直接让两个变量尽量分布到不同的缓存行上。可以通过填充字节、按缓存行大小对齐等方式实现。高级语言里一些框架直接提供了“缓存行填充”的工具类或注解比如Java的Contended、Go手写结构体填充等。排查伪共享的典型现象是你用多个线程并行修改不同字段但性能上不去加锁似乎也不是瓶颈CPU缓存命中率异常低。用性能分析工具查看各线程的缓存失效情况时如果发现大量cache line bouncing八九不离十就是伪共享。7. 一个实际案例从串行爬虫到并发任务队列7.1 场景和数据用上面这些概念我拆一个真实做过的例子批量抓取外部网页并解析。当时要处理大约5000个URL每个请求的响应耗时约200到500毫秒解析耗时约50毫秒。机器配置是4核8线程。串行版本的处理时间很容易估算假设平均每任务300毫秒响应50毫秒解析5000个任务就是5000×350毫秒约1750秒接近半小时。CPU利用率不超过20%因为大部分时间都在等待网络I/O。这种任务的特征非常典型I/O密集任务之间无共享状态天然适合并发。唯一要注意的是不要对目标系统造成过大压力以及控制并发上限。7.2 串行版本先写一个串行的伪代码作为基线for url in urls: html fetch(url) # 网络I/O耗时200-500ms result parse(html) # CPU解析约50ms save(result)逻辑简单到不能再简单缺点是明显的每次都要等网络响应网络等待期间CPU和系统资源完全空闲。这也正是并发优化最大的切入点。7.3 多线程版本与性能变化我换了线程池方案设计了两个参数线程数和任务分片大小。初始线程数取16用Python的ThreadPoolExecutor跑with ThreadPoolExecutor(max_workers16) as pool: results pool.map(process_one, urls)运行结果很直观总耗时从约1750秒降到了约160秒提升约10倍。16个线程时已经接近理论最优值。因为网络等待时间远大于CPU解析时间线程数量大于核心数几倍是合理的但如果继续冲到64个线程总耗时不仅没有明显下降反而因为过度请求和调度开销出现延迟波动、对方服务器限流的问题。这个对比告诉我们一个道理并发收益跟线程数的关系是倒U形的前期增长明显中途趋于平缓过了某个点开始恶化。找到那个拐点通常要靠实测而不是拍脑袋。7.4 问题定位与最终优化跑完多线程版本后我遇到了一个经典问题部分请求超时。我一开始以为是网络波动后来看了监控才发现线程数拉高到64时短时间内的并发请求量太大触发了对方接口的限流。这说明并发编程不只是“跑得快”还要尊重下游系统的承载能力。最终优化方案包含三块使用信号量限制最大并发数比如固定为20增加失败重试和指数退避退避时间从1秒开始每次翻倍最多重试3次处理完一批再拉下一批而不是一次性把5000个任务全塞进去。优化后总耗时回升到约180秒但系统稳定性大幅提升整个批次没有出现任务失败CPU和内存占用也都保持在合理范围。7.5 我的体会和几个可复用的建议在多个项目里用过并发之后我总结了几条可以复用的经验送给刚入门的同学。第一个先搞清楚任务是I/O密集还是CPU密集。这直接决定了线程模型和线程数量的选择也是并发设计的分水岭。第二个并发代码一定要加“限流阀”。不管线程池多大都要通过信号量或者队列长度限制同时进行中的任务数量。无限并发是生产事故的温床。第三个能共享数据尽量少共享不能少共享就老老实实加锁。很多并发bug最终都绕不开共享可变状态。换一个角度思考——如果能把数据设计成不可变的、或者按线程隔离一切问题从根上消失比任何高级同步原语都可靠。第四个性能调优时先用监控数据说话。CPU使用率、线程切换次数、锁等待时间、任务队列深度这几个指标能真实反映你的并发设计是否健康。没有数据支撑的“感觉优化”最后多半靠运气。这篇内容偏基础但如果能把进程线程、并发并行、临界区、锁、死锁这些概念的底层关系理清楚后续学任何语言的多线程API、任何并发框架都会顺很多。