最近我在帮一个团队调一个IM服务的性能单机压测到5000连接就上不去了排查了三天才发现问题根本不在业务代码而是并发模型从一开始就选错了——所有长连接往一个共享队列里塞十几个线程抢同一把锁CPU花在锁竞争上的时间比处理业务还多。这种场景我在不同项目里见过太多次了所以想写一篇关于多线程和多进程并发的实战笔记。这篇文章不讲教科书概念只讲我这些年踩过的坑、压测验证过的方案、以及真正落地时容易翻车的细节。无论你写Java、Python还是C无论你是在做Web服务、桌面工具、消息中间件还是准备多线程面试题只要你写的程序不止一个执行流这篇文章都值得你停下来看看。1. 并发从哪来线程和进程到底在争什么1.1 单核时代为什么也需要并发很多人有个误解觉得多线程多进程是“多核时代”才有的东西单核CPU上谈并发是自欺欺人。其实不然。单核机器上并发照样有价值核心原因就一个字等。一次磁盘读IO可能要等几毫秒甚至几十毫秒一次网络请求从发出到返回经常几十到几百毫秒而一条CPU指令只需要零点几纳秒。如果不做并发程序就会卡在这段“等待”上CPU空转用户在那干瞪眼。哪怕只有一个核心把“正在等待IO”的任务挂起切到另一个“有事情可算”的任务去跑整个系统的吞吐量都能提升好几倍。这就是并发的本质不是让人同时做两件事而是把“人在等快递”的时间利用起来去干别的活。多线程和多进程都是实现这种“切换”的手段只是切换的单位和成本不一样。1.2 进程与线程的底层差异地址空间、切换成本、隔离性我常拿“公司”和“部门”来打比方。进程像一个公司每个公司有独立的办公场地虚拟地址空间、独立的账本文件描述符、内存映射、独立的法人资格。线程像公司里的部门大家共用同一个办公场地和财务系统部门之间沟通直接吼一声就行代价是得靠公司制度锁来防止两个部门同时去改同一份报表。从操作系统的角度看区别主要集中在三块对比维度进程线程地址空间完全独立页表各自维护共享进程地址空间上下文切换成本高需要切换页表、刷新TLB低只需切换寄存器与栈数据通信需要管道、共享内存、Socket等IPC直接读写共享变量/队列隔离性一个进程崩溃不影响其他进程一个线程崩溃可能拖垮整个进程创建开销高低一个数量级这些差异决定了后面所有选型判断。比如你想做崩溃隔离那就毫不犹豫上多进程你想追求极致的共享数据实时性多线程更合适你想在进程间传几百MB的数据又不想拷贝共享内存几乎是唯一答案但代价是你要自己处理同步和竞态条件。1.3 GIL不是免死金牌Python并发需要注意的真相涉及到Python写并发GIL全局解释器锁是绕不开的话题。GIL一护到底同一时刻只有一个线程能执行Python字节码。所以写CPU密集计算用threading写多线程经常发现跑出来性能还不如单线程因为线程切换本身就是额外开销。但GIL“锁”的是执行权不是“等IO”的时间。线程一旦进入IO等待读文件、请求HTTP、查数据库GIL就会释放其他线程能在这段空隙跑起来。所以在爬虫这类以网络等待为主的任务里Python多线程依然有效果。数据密集型任务如果非要用Python做并行计算正确姿势是多进程把任务分给多个解释器进程绕开GIL的限制。代价是数据进出进程要序列化任务本身太碎的话序列化开销可能大过收益。2. 选型原则什么场景该上多线程什么场景该上多进程2.1 CPU密集和IO密集的判断方法之前有个团队问我为什么他们的Python图像压缩服务用线程池之后一点提升都没有甚至更慢了。我说你先看任务是“算得慢”还是“等得长”。图像压缩这种纯计算任务CPU一直满载线程切换只会添乱正确方案是直接用多进程把一张大图切分成多块交给多个进程并行处理。反过来接入层请求转发、爬虫、调用外部API任务大部分时间在等网络包这时多线程的并发收益非常明显而且Python的GIL在等待期会释放用的还是同一个套路。判断方法很简单用单线程跑一遍统计CPU利用率和耗时构成。CPU利用率长时间大于80%的是CPU密集哪个指标都上不去但耗时很长的大概率在等IO。再做决定就顺理成章了。2.2 数据共享需求决定技术路线第二个判断维度是数据。你的多个执行流之间需要频繁共享大块可变内存吗比如要做实时协同编辑多个用户同时改同一个文档状态多进程天然做不到高效共享得加共享内存、加信号量复杂度飙升这时直接选多线程配合细粒度锁把文档对象放进线程栈能直接访问的堆区。反过来如果任务之间只需要传一个任务定义、收一个结果数据量小多进程的隔离性反而是加分项——某个worker被脏数据搞崩了主进程毫发无伤拉起一个新进程接着跑。我自己的原则是能传消息就不共享内存能共享就尽量有人兜底。消息传递天然规避了大部分锁竞争问题但这种方案需要颗粒度合适的任务拆解不是所有业务都能拆成消息流。2.3 不同语言生态里的“潜规则”选多线程还是多进程很多时候语言生态已经把路铺好了。Java生态里线程池是标准工具ThreadPoolExecutor、ForkJoinPool非常成熟你很少会看到Java项目用多进程来扛并发。Python因为GIL惯用做法是“IO用线程CPU用进程”再用concurrent.futures把两种实现统一到同一个接口后面。Go从语言层面提供了goroutine它本质上是协程调度器加在进程内的并发模型概念上最接近“轻量线程”对绝大多数服务端场景足够用了。C/Qt的场景更复杂一些QThread落地时还要考虑信号槽的连接方式、事件循环、线程亲和性细节很多。2.4 我的选型决策清单个人经验总结我整理了一张决策清单项目里每遇到一个并发需求我都先过一遍任务是CPU密集还是IO密集CPU密集优先多进程/多核利用IO密集优先多线程/协程。多个执行流之间共享的数据量大不大大且频繁→多线程小且稀疏→多进程。单个任务的崩溃影响可接受吗不能拖垮主服务→多进程只是局部异常→多线程加try-catch即可。通信模式是“你问我答”还是“你推送我收”同步问答用统一线程池异步推送用消息队列解耦。团队熟悉哪种方案再完美的架构团队玩不转就是负资产。不妨先用最熟悉的模型跑起来再逐步演进。3. 多线程落地锁、队列、信号量的实战用法3.1 生产者消费者模型从理论到代码生产者消费者是并发编程的“Hello World”也是解决绝大多数解耦问题的底座。核心思想很简单一个生产任务的线程把任务放进队列一个消费任务的线程从队列里取两个角色之间不直接交流而是通过队列缓冲区隔离。Python里的写法大概是这样的from queue import Queue from threading import Thread, Event task_queue Queue(maxsize100) stop_event Event() def producer(): for i in range(1000): if stop_event.is_set(): break task_queue.put(i) task_queue.put(None) # 结束哨兵 def consumer(): while True: item task_queue.get() if item is None: task_queue.task_done() break process(item) # 实际业务处理 task_queue.task_done()这里有两个细节特别值得注意。第一是maxsize它天然实现了背压生产者放不下就得等避免任务堆积吃掉内存。第二是“结束哨兵”用一条None消息通知消费者退出比直接去终止线程干净得多也避免出现队列里最后一个任务没被处理就退出的情况。Java里的等价物是BlockingQueue的put()和take()它们都支持阻塞语义代码套路几乎一样。C里如果追求高性能有人会用无锁队列比如用boost.lockfree但无锁队列对内存序和ABA问题的要求很高业务没到那个量级之前老老实实加锁更稳妥。3.2 锁的粒度一把大锁省心细分锁要手艺锁是并发里最容易出问题的地方常见的是锁粒度没控制好。有人图省事一个类里所有的公共方法都加synchronized或threading.Lock锁住确实安全但本质上是把并发程序退化成串行。更麻烦的是锁的粒度粗了系统的吞吐量直接跌到单线程水平锁的粒度细了又要面对死锁和竞态条件的折磨。我的经验是先保证正确性再用profiler说话。锁竞争严重就拆分锁拆的时候注意加锁顺序必须全局一致否则就会死锁。Java里有个经典的对比synchronized方法锁的对象是整个实例ReentrantReadWriteLock或StampedLock则允许读读并发、写写互斥——读多写少的场景收益非常明显。数据库并发锁也是同一套思路只是锁的对象变成了行记录。比如库存扣减过去很多系统靠悲观锁SELECT ... FOR UPDATE硬扛代价是锁等待时间飙升。现在我会优先用乐观锁UPDATE inventory SET stock stock - 1 WHERE id ? AND stock 0;受影响行数为0说明库存已不足再走补货或失败分支。这种写法天然防超卖不需要显式加行锁在高并发下性能要好一个数量级。代价是你得接受“部分请求会失败”的事实业务上可以做重试和补偿。3.3 Qt信号槽跨线程传参最容易翻车的地方Qt的多线程跟前两套写法完全不同因为窗口控件有严格的线程亲和性要求UI只能在主线程操作。后台线程想更新界面正确的做法是用信号槽把数据传回主线程。我见过不少踩坑案例后台线程里直接改setText()程序一阵乱跑然后崩溃跨线程发信号传自定义类型忘了qRegisterMetaType结果信号列表里能看到方法名槽函数却怎么都不响应。要在Qt里安全地做跨线程传参核心是记住三点。第一后台线程用moveToThread把一个worker对象挪到新线程而不是自定义线程类里写逻辑。第二信号和槽的连接方式用默认的AutoConnection它默认对跨线程连接会转换成队列连接参数会被拷贝到事件队列里。第三自定义类型一定要注册qRegisterMetaTypeMyPayload(MyPayload);跨线程传参时参数尽量定义成值类型而不是指针因为队列连接里传指针风险很大对象销毁了槽函数里的指针就会悬空。实测下来Qt的这套模型比裸写线程锁要稳健得多常规业务根本不需要手动加锁。3.4 Kafka消费端多线程如何保证消息顺序性消息中间件的顺序性是个高频问题尤其用Kafka做订单流转的时候更要谨慎。Kafka保证顺序的前提条件是“同一个分区内”它不是全局有序的。消费者如果是单线程一个分区一个线程消费天然有序一旦为了吞吐起多线程去消费同一个分区顺序就可能乱。我给团队的方案很简单要么一个分区只对应一个单线程消费器想扩吞吐就加分区数要么多线程但按“分区号取模”给线程哈希让同一个分区的所有消息只进同一个线程的处理队列。这两种方案都能保证逻辑上的偏序关系。还有另一个同样常见的坑是max.poll.interval.ms。消费端单线程处理太慢超过了这个阈值消费者会被判定为死亡触发rebalance结果又导致重复消费和乱序。所以要把“拉取消息”和“处理消息”解耦一个线程纯拉取放进本地队列处理线程慢慢消费同时手动管理offset提交确保提交的进度确实已经处理完成。这样既能并行也能保住顺序。4. 多进程实战通信框架与进程管理4.1 Linux多进程通信框架管道、共享内存、消息队列、Socket多进程之间的通信选型决定了整个系统的架构风格。Linux下常见的IPC手段我按场景分成几类通信方式速度复杂度典型场景管道/Pipe快低父子进程传送字节流共享内存极快高大数据量、低延迟需配信号量同步System V消息队列/POSIX消息队列中中小消息的异步收发Unix Domain Socket中中本机可靠双向通信支持流式TCP/UDP中高跨主机通信需要加协议层做选型时我倾向直接看两个问题需要不需要跨主机数据量是KB级还是MB级跨主机只能走TCP/HTTP/gRPC没得选本机且数据量大优先共享内存加信号量数据量不大但消息频率高Unix Domain Socket是个很舒服的选项既能复用Socket编程经验又没有TCP的端口和连接管理负担。我参与过一个视频分析项目多个进程并发处理视频帧主控进程把原始帧写进共享内存几个分析进程轮流去取帧。核心流程是共享内存里放一个环形缓冲区每个槽位带状态写进程往空槽位写读进程从非空槽位读再用一个信号量做满空控制。这套设计让单机吞吐量比管道方案翻了三倍但调试竞态条件的时候也确实熬人。4.2 Python多进程的实战套路Python的multiprocessing给多进程开了个便捷之门但它有两个直接坑进程对象要可pickle、目标函数要在模块顶层定义。from concurrent.futures import ProcessPoolExecutor def cpu_intensive_task(batch): # 只处理一批数据返回结果对象 return do_heavy_calculation(batch) if __name__ __main__: with ProcessPoolExecutor(max_workersos.cpu_count()) as executor: results list(executor.map(cpu_intensive_task, all_batches))用ProcessPoolExecutor而不是直接Process好处是它自带任务调度和结果收集异常也不会直接让主进程崩溃。如果进程间需要共享状态multiprocessing提供了Value/Array/Manager。但要注意Manager对象的读写性能远不如本地变量它本质上是内部起了个服务进程代理数据。所以我的建议是默认用消息传数据共享内存只在明确需要超大共享数据时才用。4.3 进程管理的冷酷现实崩溃、拉起、优雅退出进程隔离性是一把双刃剑。进程崩了不拖累别人但你总得知道它崩了并且把它拉起来。这就牵扯到进程守护。同一台机器上成熟的思路是交给systemd或supervisor托管配置好自动重启策略。真正容易忽略的是优雅退出。收到SIGTERM信号后进程需要停止接收新任务完成当前任务最后关闭资源退出。我在Java里习惯用Runtime.getRuntime().addShutdownHook()注册清理逻辑Python里则常写一个信号处理器来设置停止标志让主循环体检查到标志后顺滑退出。这套机制在滚动发布时特别重要——新进程起来、旧进程退掉如果旧进程直接kill那批正在进行中的任务就全部断掉了。还有一点必须提醒多进程并不等于高枕无忧。如果多个进程同时访问Redis、数据库、共享文件这些外部资源照样需要分布式锁或事务机制。进程内存隔离只解决“变量不共享”的部分解决不了“外部系统同一份数据被并发改”的问题。5. 高并发系统的真实场景IM、消息队列和压测5.1 高并发IM怎么扛连接数≠并发数很多刚入门的同学把“并发”理解成在线连接数然后开口就要支撑百万连接。其实在高并发IM场景里这两个数字差着量级。连接数是“在线但是闲着”的通道数比如你挂着微信可能半小时不发一条消息并发数是“同一瞬间真正在处理请求”的数量。设计系统时如果拿在线连接数去设计线程池一定会把资源白白浪费。IM的经典演进路线是最早一连接一线程每来一个连接就开一个线程连接多了以后线程数动辄几万上下文切换直接打爆CPU。后来演进到事件循环模型用epoll/select监听海量连接有数据到达才去安排处理前端Accept线程只负责收包真正业务用工作线程池来处理。这套模型配合多进程每个进程负责自己那一批连接单机支撑几十万在线连接是很正常的。现在很多人问“AI agent是怎么扛并发的”。其实Agent服务里真正的计算发生在调外部模型API上模型响应快不快你完全控制不了所以整个服务的关键瓶颈是IO等待。我的经验是把用户请求放进异步队列由一个后台工作池统一去调模型API再用回调或结果表把答案返回给用户。前端入口可以同时接很多请求但底层的模型API并发数反而要限流防止第三方服务把我们限流或拖垮。本质上还是个典型的生产者消费者模型。5.2 并发数估算公式别再凭感觉配线程池不管什么并发系统最终都要回答一个问题要开多少线程进程我总结一个通用口径单机支撑的QPS ≈ 线程数 ÷ 单请求平均响应时间这里的QPS是每秒能完成多少个请求。如果单次请求耗时50毫秒一台机器开100个线程理论上限就是100 ÷ 0.05 2000 QPS。再往上加线程不一定有用因为线程数和上下文切换的开销会反噬。真正的做法是压测找到拐点随着线程数增加QPS先上升后平台期最后下降。平台期之前的线程数就是这台机器的甜点值。容量规划也按这个口径来预测峰值在线用户100万假设10%的用户在峰值瞬间有操作每秒钟每个用户产生1次请求那就是10万QPS。假如单机经过调优能扛5000 QPS那至少需要20台机子。做这个估算不是要算到分毫不差而是要给出一个可以检验的“数量级”防止架构师拍脑袋说“我们机器不够”或“机器太多了”。5.3 用JMeter做真实压测十个并发、不同参数的POST请求怎么配压测是检验并发模型有效性的唯一方式。我自己常用JMeter做轻量压测这里分享一个高频场景模拟10个并发用户同时往被测服务发参数不同的POST请求。在线程组里线程数设10Ramp-up Period设1秒意思是10个线程在1秒内全部启动循环次数设100或者勾选“无限”。接下来关键是参数化同一个请求头包里不同用户要带不同数据。我一般用CSV Data Set Config准备一个叫users.csv的文件里面放用户名、订单号这些变化字段然后让每个线程从文件里循环读取。没有CSV也可以直接用${__Random(1,1000)}生成随机数字。要注意的是压测本身也要避免“压测端瓶颈”。JMeter跑在一台小机器上并发量一大发压端自己先累了这时要看曲线整体形状——如果QPS停滞且CPU被打满先升级压测端再说。压测结束看聚合报告重点不是平均响应时间而是吞吐量、错误率和90%/99%分位响应时间。平均数会被最慢的请求拉高掩盖了大部分用户正在享受的延迟。6. 并发编程中最容易翻车的四个细节6.1 共享可变状态失控书上的并发讲“原子性、可见性、顺序性”实际项目里最常翻车的还是共享可变状态。多个线程都能改同一个变量锁却只锁了其中一条路径一个变量被一个线程改了另一个线程看不到最新值可见性问题编译器或CPU做了指令重排让代码执行顺序和源码不一致。这三件事叠加起来程序会在测试环境永远正确一上高并发就崩。我的应对套路是写并发代码之前先问自己——这个变量会被几个线程写这几个线程的写入路径有没有都能看到的锁如果答案是否定的解决办法不是继续加锁而是换一种不需要共享的设计用不可变对象、用线程本地存储、用消息传递。能用“设计”消灭的问题不要用“锁”去对抗。6.2 线程池参数拍脑袋线程池不是越大越好。Java里有个初级公式CPU密集任务线程数设为CPU核数1IO密集任务线程数设为CPU核数×2×(1等待时间/计算时间)。但这个公式只能作为出发点最终还是要压测。我自己更重视两点一是线程池的队列长度必须设上限否则任务无限堆积延迟会像雪崩一样恶化二是要给线程池设置拒绝策略和兜底告警宁可暂时丢掉一些任务也不能让核心服务被拖死。线上很多事故的根因不是单机算力不够而是线程池把内存耗尽、CPU空转本可以优雅降级却因为完全没有边界而全线崩溃。6.3 伪共享一个被忽略的真凶手多核CPU的缓存是以“缓存行”为单位同步的常用的缓存行大小是64字节。如果两个线程并发修改两个相邻的变量而它们落在同一个缓存行里即使两个线程完全不相干也会导致缓存行不断在核间同步性能下降一个数量级。这就是伪共享。Java里JDK内部不少类会通过Contended注解或字段填充把热点变量放到独立缓存行C里可以手动alignas(64)。我们自己的业务系统很少会亲手去写这种底层优化但遇到“看起来没锁但多核性能就是上不去”的时候值得怀疑一下是不是伪共享。排查办法是性能profiler的缓存未命中指标或者直接做A/B测试对比字段布局。6.4 顺序性线程执行顺序和消息处理顺序是两回事多线程里没有全局的先后顺序除非你显式同步。业务上要求“A必须发生在B之前”时不要赌操作系统调度顺序而是用明确的事件机制比如CountDownLatch或者消息队列的分区顺序来保证。前面讲的Kafka分区顺序性问题就是典型案例多消费者并行处理消息时同一分区的顺序没有保障必须靠线程模型设计来保住偏序关系。数据库里的自增ID也一样并发插入时不要期待id大小与插入先后一致要保序就得走事务或专门的序列服务。6.5 多线程面试题背后真正考察的能力我看到网上那么多“多线程面试题”其实翻来覆去就是考察四个维度能不能说清锁和同步原理synchronized和Lock的区别、volatile的作用、能不能处理死锁和竞态、能不能根据场景做选型线程池、并发容器、队列、有没有真正看懂过线上并发问题CPU飙升、死锁定位、线程dump分析。面试官给出一道“生产者消费者”题看起来是考队列和锁实际是在看你的并发建模能力。能从“线程模型设计”而不是“怎么加锁”的角度回答的人才是真正有并发经验的人。我给读者的建议是不要背八股把线程dump、压测报告、内存模型这些基本功练扎实面试时讲自己的排障过程比背一百道题都有说服力。最后再分享一个小技巧。我自己写并发代码至少有两年的一条规定上线前必须做一轮“人为混乱测试”强制随机kill进程、随机重启、随机断数据库连接看系统会不会自动恢复、会不会留下脏数据。真正扛过这些“事故演习”的并发系统才有资格谈高可用。