尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多线程与多进程并发编程实战:锁竞争、线程池与故障排查全解析

多线程与多进程并发编程实战:锁竞争、线程池与故障排查全解析 刚接了个排查任务线上服务毫无征兆地卡住CPU占用不高但请求全部超时翻日志发现线程全部BLOCKED在一个锁上。这种事干过几年的人基本都遇到过本质就是并发控制没做好线程和进程的使用姿势不对。今天把这几年在多线程、多进程并发上踩过的坑、摸出来的门道一次性整理出来从设计逻辑到实操细节再到故障排查希望能帮正在这块挠头的朋友省点时间。1. 并发解决什么问题又带来什么问题1.1 并发与并行这两个概念很多人一直没分清不少初学者把并发和并行混为一谈面试也经常栽在这。并发是同一段时间内多个任务交替执行宏观上同时发生微观上可能一个核在来回切换并行是同一时刻多个任务真正同时执行必须要有多个物理核心。举个容易理解的例子单核CPU跑多线程就是并发多个线程轮流用这一个核八核CPU跑八个线程才是并行每个核各干各的。理解这个区别后很多结论就能推导出来了。比如多线程一定能提升性能这句话在有足够多核的情况下才成立在单核机器上多线程反而可能因为上下文切换开销变慢。再比如IO密集型任务哪怕只有一个核多线程也能大幅提升吞吐量——因为线程在等待IO时会把CPU让出来给别的线程用单位时间内能干的事变多了。CPU密集型任务则反过来多线程意义不大多进程或换更好的CPU才是正路。1.2 线程和进程各自的边界进程是操作系统分配资源的基本单位有独立的内存空间彼此隔离。线程是CPU调度的基本单位依附于进程存在同一进程内的线程共享堆内存和静态区域。这个区别决定了它们的适用边界。进程之间天然隔离一个崩了不影响另外的适合稳定性要求高的场景。线程之间数据共享方便直接读写同一块内存就行但协作成本高容易出竞争条件。进程切换开销大大概比线程切换慢数倍到数十倍线程切换开销小但共享资源的同步问题是最大麻烦。进程可以分布到多台机器线程只能在一台机器的同一个进程内。1.3 为什么会卡死从并发到假死的演变路径回到开头那个排查场景。服务卡死的直接原因是锁竞争失控。系统用了一个互斥锁保护共享数据平时访问量不大没问题。某天流量上来后持锁线程遇到慢IO迟迟不释放锁后面大量线程在锁上排队。Java里用jstack看线程状态全是BLOCKED锁对象上挂了几百个等待线程。线程调度器反复唤醒、挂起光上下文切换就能把CPU吃掉但每个线程都在等锁业务处理能力归零所以CPU占用反而不高。这种问题光加锁不行不加锁更不行得从设计上优化锁粒度、缩短临界区、引入无锁结构或队列。这也是并发编程真正考验功力的地方并发只是手段正确地并发才是目的。它能带来更高的吞吐、更低的响应时间、更好的资源利用率但代价是复杂度上升死锁、竞态、活锁、线程饥饿等一系列问题都在等着你。2. 多线程还是多进程关键看任务类型和容错要求2.1 任务类型决定技术选型CPU密集、IO密集还是混合型做选型时我通常先回答三个问题任务是计算多还是等待多数据共享频繁吗单点故障能接受吗这三个问题对应三类典型场景。CPU密集型任务比如视频编码、图像处理、科学计算它们几乎一直在用计算单元多线程在多核机器上可以加速但受限于GIL或语言自身的线程模型加速比达不到线性。Python在这种场景下用多进程才有效因为每个进程有独立的解释器实例绕过GIL限制。Java和C#多线程就能用上多核但还是要注意竞争和内存带宽瓶颈。IO密集型任务比如网络请求转发、文件读写、消息消费它们大部分时间在等待IO完成。这种场景多线程极有价值因为等待阶段可以让其他线程执行。Python的asyncio或Java的NIO虽然能进一步降低开销但常规多线程方案已经足够应对大多数业务。在这个方向上线程池是标配避免反复创建销毁线程的昂贵开销。混合型任务既有大量计算又有IO等待需要拆解任务阶段。比如消息中间件既要接收网络请求又要做消息序列化和落盘。这种情况我习惯用多进程做隔离和水平扩展进程内部再用线程池处理并发IO相当于两层设计。Kafka的消费端往底层看也是这个逻辑。2.2 数据共享和隔离的取舍一个错误的进程模型引发的线上事故有次为做一个数据处理服务图省事用了多进程实现并发每个进程处理一批输入数据结果进程之间要共享一个计数器来分配任务。用文件锁做同步性能差到离谱改成共享内存后又要处理复杂的同步原语代码可读性直线下降。折腾一圈其实用一个多线程加队列的方案就解决了因为那个场景数据共享频繁线程天然适合。后来我做另一个离线分析任务时反过来每个任务负载很重占的内存和CPU都大而且失败率不低。这时候多进程的优势就体现出来了某个进程崩溃不影响其他进程进程退出后系统会回收它的资源不会污染主进程状态。设计上父进程负责分发任务和回收结果子进程专注干活用消息队列通信。一句话总结共享多、任务轻、要求响应快优先多线程隔离要求高、任务重、允许跨节点优先多进程。这个判断比背八股文实用得多。2.3 语言层面的约束以Python和Java为例对比不同语言的并发模型差异很大体现在细节上就可能坑死人。Python的threading受GIL限制同一时刻只有一个线程能执行字节码。IO密集场景问题不大因为等待IO时会释放GILCPU密集场景就要用multiprocessing或多进程池。我用ProcessPoolExecutor处理过一批图片特征提取任务八核机器上速度提升接近六倍。注意是这个接近受进程启动开销和父子进程通信开销影响加速比永远不会完美线性。Java没有GIL多线程能真正并行使用多核。java.util.concurrent提供了完整的并发工具集从原子类、各种锁到并发容器一应俱全。Java多线程虽然灵活但也更容易写出隐藏的并发bug因为什么都要你自己控制。在用ReentrantLock时如果一个分支忘了在finally里解锁后续任务全堵死。这种bug调试起来特别费劲jstack能看到是锁问题但定位到哪一行代码忘记释放还得靠代码审查。2.4 C#和Qt的并发姿势看似简单实则各有套路C#推荐直接用Task和async/await底层由TaskScheduler调度到线程池不需要手动管理线程。有个注意点async/await只适合IO异步不适合CPU密集计算。CPU密集任务用Task.Run把计算丢到后台线程避免阻塞UI线程。写WPF或WinForms时尤其要注意更新界面控件的代码必须回到UI线程跨线程操作控件会直接抛异常。Qt的信号槽机制是它处理多线程的有力武器。一个对象在子线程中发射信号可以安全地在另一个线程的槽函数中处理前提是connect时指定Qt::QueuedConnection或者用自动连接方式且发射线程和接收线程不同Qt会自动选择队列方式。队列方式靠事件循环支撑如果接收对象的线程没有跑事件循环比如普通std::thread不建Qt事件循环槽函数不会执行。所以跨线程传参数时信号必须通过emit发起参数类型要有qRegisterMetaType注册或本身就是元类型系统认识的类型否则运行时会拦下来说元类型未注册。我调试过自定义结构体在信号槽里传参失败的情况就是因为少了这一步注册。3. 锁竞争、队列与内存模型并发正确性的三条核心防线3.1 锁的粒度与锁的开销从误用到合理设计锁并不是越细越好。锁粒度太粗临界区执行时间过长其他线程都在等待吞吐量上不去锁粒度太细频繁加解锁会增加额外开销而且容易引入多个锁嵌套倒是死锁风险增加。有一次优化一个高频统计服务把单例对象上的大锁换成对各计数器分别加锁性能提升了两倍多但代码复杂度也上来了。后来改用LongAdder这类分段累加思路在统计精度允许范围内彻底绕开了锁效果更好。实践中经验法则是临界区里的代码只做必要的共享数据读写千万不要在持锁状态调用外部服务、发网络请求、读写慢速磁盘。这些不可控的等待时间会在高并发下被无限放大锁持有时间越长排队效应越明显。所以锁里只做事最少的事能挪出去的逻辑全挪出去。3.2 队列是最被低估的并发工具做并发设计时能把共享状态转化为消息传递能省掉大部分锁问题。生产者-消费者模式正是这么做的生产者线程把任务丢进队列消费者线程从队列取任务执行。队列本身是线程安全的所以生产者和消费者之间不需要额外的锁协调各自的状态互相隔离。Python标准库的queue.Queue、Java的BlockingQueue、C#的Channel都提供了线程安全队列。做数据流水线时我习惯在队列容量上做限制有界队列防止生产者速度过快导致内存被塞满。生产者阻塞等待队列有空位时天然形成了背压机制整个系统的处理速度不会被拖垮。Java里用ThreadPoolExecutor时工作队列的选择会显著影响行为无界队列会导致任务堆积、内存膨胀有界队列加拒绝策略则能在系统过载时及时抛错。很多高并发系统宕机不是CPU不够而是无界队列悄悄吃光了内存。3.3 死锁条件、实例、排查路径死锁是并发编程里最经典也最麻烦的问题。它要同时满足四个条件互斥、持有并等待、不可抢占、循环等待。项目里一次典型的死锁是两个服务各持有一把锁然后互相尝试获取对方的锁两个线程互相等待永远解除不了。死锁发生时的排查Java用jstack能看到明确提示Found one Java-level deadlock并列出线程栈。C/C程序不好办要用gdb附加进程查看各线程栈或者开tsanThreadSanitizer这类工具做动态检测。我在C项目里排查过一次死锁gdb下查了半天最后发现是锁顺序不一致线程A先锁X再锁Y线程B先锁Y再锁X。解决办法是约定全局锁顺序所有线程都按同样顺序加锁破坏循环等待条件或者用std::lock一次获取多个锁避免分步加锁。经验提示写并发代码时把锁顺序写进设计文档团队里所有人遵守同一约定。这比事后靠提醒和review靠谱得多。3.4 原子操作与内存模型为什么光加锁还不够锁解决的是互斥问题但现代CPU的乱序执行、多级缓存、内存屏障等内容也会引发看起来匪夷所思的bug。Java里经典的volatile关键字保证可见性和有序性但不保证原子性。i这类复合操作即使变量是volatile也是不安全的。正确做法是使用AtomicInteger它通过CAS比较并交换指令实现无锁的原子更新。Python和C#也有类似机制。还有一类坑是线程安全集合。以前用HashMap在多线程下并发读写轻则数据错乱重则CPU打满HashMap扩容死循环换成ConcurrentHashMap后问题消失。写代码时要养成习惯凡是会被多线程访问的容器一律用并发容器不做侥幸假设。加锁虽然能让普通容器变得安全但并发容器在读写混合场景的整体吞吐通常会更好。4. 实操从线程池到进程池从参数设置到性能压测4.1 线程池的合理参数不是越大越好线程池是管理线程的常用工具但它的参数设置很有门道。以Java为例ThreadPoolExecutor最关键的是核心线程数、最大线程数、工作队列容量和拒绝策略。线程数设得太大不会提升性能反而增加上下文切换开销设得太小又无法充分利用CPU。经验估算公式大致是CPU密集型任务线程数约等于核心数或核心数加一IO密集型任务线程数等于核心数乘以1 IO等待时间/CPU计算时间的比例实践中常用2倍核心数做起点再通过压测调整。注意这是起点不是终点。我前几年维护过一个网关服务8核机器初始设了64个线程结果是大量线程在排队执行逻辑CPU平均才一半。调成24线程后吞吐量反而上去了响应时间也降了。线程数增加带来的性能收益是边际递减甚至为负的看不见的上下文切换开销会吞噬性能。Python的ThreadPoolExecutor同样有最大工作线程数参数IO密集场景可以设大一些但CPU密集场景建议换ProcessPoolExecutor。用进程池时注意任务是函数和参数序列化的开销不可忽略如果任务本身执行时间很短毫秒级以下进程通信的序列化开销反而可能超过任务本身的性能收益。这种细粒度任务不该用多进程而应该合并成批次处理或转变数据共享方式。4.2 Python多进程与多线程实战对照写一段简单的Python代码做对比。先看线程版import threading import time counter 0 lock threading.Lock() def increment_with_lock(): global counter for _ in range(100000): with lock: counter 1 threads [threading.Thread(targetincrement_with_lock) for _ in range(4)] start time.time() for t in threads: t.start() for t in threads: t.join() print(fthreads with lock: {counter}, time{time.time()-start:.3f}s)换成进程版时要注意counter无法在进程间共享要么用multiprocessing.Value加锁要么通过队列把结果汇总或者直接用Manager。直接在每个进程里维护局部计数器、最后汇总通常性能更好因为不需要跨进程同步。from concurrent.futures import ProcessPoolExecutor def count_task(n): local 0 for _ in range(n): local 1 return local start time.time() with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(count_task, [100000] * 4)) print(fprocess pool total: {sum(results)}, time{time.time()-start:.3f}s)一眼能看出CPU密集场景下进程版在多核上优势明显线程版受GIL限制基本上只用一个核。IO密集场景反过来线程版因为无需进程间通信通常更轻快。选择什么方案先想透任务类型。4.3 Java并发参数、队列与监控Java里用的最多的还是ThreadPoolExecutor加LinkedBlockingQueue。为了稳妥我通常会自定义ThreadFactory给线程起有业务含义的名字比如order-consumer-%d。线上用jstack查问题时一眼就能看出哪个业务线的线程出状况不用慢慢数系统线程名。拒绝策略上默认的AbortException会在队列满时直接抛异常而CallerRunsPolicy会让提交任务的线程自己执行任务相当于自然背压。监控线程池开销不小但非常值得。核心指标是getActiveCount()活跃线程数、getQueue().size()队列积压、getCompletedTaskCount()完成任务数。我会在管理后台定时记录这些数据。看到活跃线程常年接近最大值、队列堆积持续增长就可以提前扩容或优化代码不用等到线上出故障才排查。4.4 消息消费端的顺序性保证Kafka分区与多线程如何共存热词里有人搜kafka消费端多线程如何保证消息顺序性这是个典型坑。Kafka只能保证单个分区内的消息顺序因此多线程消费时如果乱序处理顺序性必然被破坏。在具体的项目中我采用过一个比较稳妥的做法让每个分区对应一个固定的消费线程通过按partition号均匀分配消息保证同一分区的消息永远由同一线程处理。用Java的KafkaConsumer做多线程消费时有两点需要注意第一是KafkaConsumer本身不是线程安全不能在多个线程中共享同一个Consumer实例第二是把poll后的消息按照partition维度分发到各个单线程处理器中每个处理器内部维护自己的队列这样既保证了顺序也充分利用多线程吞吐。类似思路也适用于RocketMQ的顺序消息核心都是分区有序、多线程消费、单分区单线程处理。如果不需要全局顺序只想提高消费并发直接调整max.poll.records和session.timeout.ms并起多个线程各自独立消费即可但要设置好enable.auto.commitfalse改由自己控制offset提交否则可能出现消息没处理完就提交偏移量、造成数据丢失。提交时建议每条或每批消息处理完再commitSync虽然吞吐略低但重平衡时不会重复太多数据。4.5 压测怎么做JMeter参数化POST请求与并发数估算测并发能力离不开压测工具。JMeter可以模拟不同并发用户数。想模拟十个参数不同的POST请求方法不复杂线程组里设置线程数为10、循环次数定义好然后在HTTP请求里引用CSV文件中的数据作为参数每个线程执行时从CSV里读一行参数自然各不相同。具体步骤是先创建CSV数据集配置CSV Data Set Config写好文件路径和变量名比如param1,param2然后在HTTP请求的Body Data里写成{key1:${param1},key2:${param2}}JMeter会自动替换。压测时不要一上来就设上千并发先从小并发开始观察响应时间和错误率。这些指标的变化趋势比最终数值更重要。一个判断指标是当并发数上升但吞吐量不再线性增长时系统大概率接近瓶颈需要结合监控数据定位瓶颈是CPU、内存、数据库连接还是其他外部依赖。关于16C32G服务器支持多少并发这种问题没法给出一个确定答案。并发支持能力取决于业务逻辑复杂度、平均请求耗时、IO等待、数据量大小测试环境压出来的数字才有参考意义。一个粗略的计算方法如果单请求平均耗时60ms服务器能同时处理的线程是200个那么理论上吞吐量约等于200除以0.06每秒3300个请求。但这不是并发数概念——并发数通常说的是同时在线用户或同时在处理中的请求数。响应时间越短同样资源下能支撑的并发请求越多。最好通过压测建立模型再按峰值流量乘以冗余系数来定容量。4.6 数据库并发锁与并发连接数并发场景绕不开数据库。数据库连接池大小、锁等待、事务隔离级别都会在高并发下暴露问题。数据库连接数并不是配置得越多越好。在项目里PostgreSQL的连接数从默认100调高到300后反而出现了连接风暴现象很多连接都在等锁、都在慢查询数据库CPU和内存被打满吞吐量下降。后来退回100再用连接池复用连接调整慢查询和锁竞争整体才稳定。如果业务上确实需要高并发写操作务必要在事务中设计好加锁顺序避免两个事务互相等待对方持有的锁。遇到热点行并发写时可以考虑减小事务粒度、延迟加锁、乐观锁重试等策略。乐观锁的代码一般是读取时记版本号提交时用UPDATE ... WHERE version ?判断影响行数为0就重试。这个方法在更新量不大的场景下效果明显但热点太集中时重试次数飙升反而需要换成队列串行化等手段。5. 排查并发问题的思路与命令手册5.1 从现象反推原因卡顿、CPU高、内存涨分别指向谁服务卡顿且CPU不高多半是锁竞争、等待IO或死锁CPU持续接近100%且打满一个核多半是死循环或线程空转内存不断上涨可能是消息堆积在无界队列或线程局部缓存未释放。遇到卡顿我先拿线程dump检查线程状态分布大量WAITING或BLOCKED就重点看锁。查看线程的堆栈信息往往能直接定位到持有锁的代码位置。配合top -H -p能看具体哪个线程占CPU高这个线程的ID换算成十六进制后再去dump里找对应栈帧。Python程序排查用py-spy dump --pid可以打印进程内所有线程的Python栈比自己猜准得多。C用gdb attach后执行thread apply all bt拿到全部线程栈。先看出问题线程在哪里再往上追锁的持有链。5.2 活锁与线程饥饿比死锁更隐蔽的问题活锁看起来像在运行实际上任务没有进展。典型特征是CPU占用波动、线程反复重试却总是失败。比如乐观锁冲突时无限重试两个线程互相让步又同时重试永远成功不了。线程饥饿则是优先级的线程等不到调度低优先级线程常年吃不到资源。这种问题用锁和dump不容易一眼看到需要结合业务日志和统计指标判断——比如请求延迟逐步上涨但线程又没有互相阻塞基本就可以怀疑锁竞争退化成了忙等。处理方式重试加上限、加随机退避、检测重复冲突后切换策略。更根本的是减少临界区尽量避免多个线程同时抢同一个资源。现在很多高性能框架改用无锁队列或原子变量替代传统锁正是为了绕开这类问题。5.3 排查工具箱对照表场景工具/命令作用Java线程状态jstack PID查看线程堆栈、锁信息Java堆转储jmap -dump分析内存溢出、对象占用C/C线程栈gdb attachthread apply all bt查看多线程调用栈Python线程栈py-spy dump --pid PID挂在生产环境而不阻塞进程系统负载观测top/vmstat/pidstat查看CPU、进程/线程占用动态检查并发问题gcc -fsanitizethread/ valgrind --toolhelgrind检测数据竞争、死锁Java并发可视化JConsole / VisualVM观察线程池、锁等待、CPU趋势这套组合拳打下来大多数并发问题都能精确定位。如果还找不到就检查硬件层面的缓存一致性、多路CPU NUMA架构对性能的影响。并发问题不只是逻辑层的还可能是系统资源分配层面的。6. 常见问题速查表从并发基础到框架细节下面把我被问过比较多的并发问题整理成一个速查表按问题—原因—对策的方式写出来方便查阅。问题现象常见原因对策与实操建议Python多线程CPU密集任务没有加速GIL限制字节码并行改用ProcessPoolExecutor或换用C扩展/异步IO方式Java服务线程全部BLOCKED锁竞争失控或死锁jstack找锁持有者优化锁粒度统一锁顺序C# UI界面卡死耗时操作在UI线程执行用async/await或Task.Run把耗时逻辑丢到后台线程Qt跨线程信号槽收不到接收方没有事件循环或类型未注册确保线程内有QEventLoop自定义类型用qRegisterMetaType注册Kafka消费重复或乱序多线程并发消费同一分区offset提交时机不对分区固定线程处理手动控制offset处理完再提交数据库连接池被打满连接数配置过大慢SQL堆积或长事务持锁合理配置池大小参考CPU或慢查询量排查慢SQL和长事务队列积压导致内存疯涨用无界队列且生产速度大于消费速度换有界队列设置饱和策略增加消费者引入背压机制高并发下计数不准复合操作如i非原子用原子类或加锁允许近似时用分段累加单机进程池启动慢创建进程开销大复用进程池减少跨进程通信频率任务量大时考虑消息队列分发给多机再补充几个容易被忽视的小点。Thread.sleep不会释放锁所以习惯性在临界区里加个短sleep想让一下的人其实是在拖慢整体性能。wait/notify、await/signal这类操作必须在已经持锁的前提下调用否则直接抛异常。锁的公平性在某些场景下也有意义Java的ReentrantLock可以设置公平锁让等待时间长的线程先获得锁避免线程饥饿。但公平锁整体吞吐比非公平锁低一些业务能接受一点延迟波动就别开像交易系统这种对等待时间敏感的场景再考虑公平模式。7. 一个真实案例复盘16线程涨到128线程吞吐为何反而降了最后复盘一个让我印象深刻的案例。当时维护一个消息推送服务8核机器Java写的用固定线程池处理下游HTTP回调。刚上线时16线程压测每秒能扛约8000次推送响应时间中位数20ms。有次产品提了需求要提升推送吞吐我想着加线程总是好事直接把线程池调到128。结果压测一跑好的情况每秒才5000次响应时间中位数飙升到200ms错误率还上来了。用top -H -p查看线程CPU占比发现大量线程处于S状态睡眠/等待上下文切换次数暴涨到每秒几十万次。128个线程里真正在干活的只有十几个大部分在排队等下游HTTP响应。阻塞IO本来不适合用大量线程扛正确做法是用异步IO如Netty、虚拟线程或协成或者限制并发量让下游服务不至于被同时打爆。后面把线程池调回24线程通过每线程批量发送的方式提高效率吞吐恢复到了1万以上响应时间也降了。这个案例说明并发优化不是线性堆资源压测数据和系统监控才是最终依据。如果你也在调线程池大小或并发架构建议先小步调整每次只改一个变量用压测数据说话别一股脑把参数拉满。并发这块的内容确实多一个服务从单线程到高并发每一步都可能引入新问题。但核心始终是明确任务是CPU密集还是IO密集合理选择线程还是进程谨慎设计锁和队列用工具和数据支撑决策。你在实际项目里遇到最多的是哪种并发问题如果是线程调优或死锁排查类的按照上面的路径从头过一遍基本能找准方向。
返回列表