先问一个比较残酷的问题:你上一次自信地说出“多线程很简单”之后,程序跑到第几个小时开始出幺蛾子的?我自己的答案是四个小时。当时手里的批处理任务用Java的ThreadPoolExecutor跑了四小时之后,某个统计数值开始对不上,最后发现是计数器用普通int自增,线上并发一上来就丢数据了。从那天起我就明白一件事:多线程的“样例”好写,但真正能落地的并发处理,难在细节里。
这篇内容我围绕“多线程并发处理样例”展开,结合我这些年在Java、C++、Qt、Delphi里写过的真实场景,把并发任务从拆解、编写、压测到线上排查的完整链路过一次。标题虽然带“样例”两个字,但我会把样例背后为什么要这么写、有哪些坑、怎么验证结果都讲透。适合刚入门并发编程、正在写批处理或IM类高并发服务、以及想在存量项目里安全引入多线程的同学参考。
1. 为什么我劝你别信“多线程很简单”
1.1 教科书和面试题都没告诉你的真相
大部分人第一次接触多线程,都是看网上的例子:创建几个线程,跑一个循环,输出一下“Hello”,然后发现结果是对的,就觉得“我会了”。但这种认知会在你第一次面对真实业务的时候被击穿。教科书里讲的Thread、Runnable、join这些确实重要,但面试题和教程最多只会告诉你“加锁可以防止重复写入”,却不会告诉你:线上偶发的数据错乱、CPU突然飙到99%、进程明明没有死却不再响应,这些跟多线程有什么关系。
我在维护老系统的时候见过太多类似情况。比如一个库存服务,并发扣减库存时用了synchronized保护单条库存记录,但两个线程同时读到了同一个库存快照,各自扣减后写回,结果库存只减了一次。这类问题不是“线程没创建对”,而是“共享状态的处理方式错了”。多线程真正的复杂度来自共享可变状态、线程调度的不确定性、以及你永远无法在单机单线程环境里复现的偶发问题。
1.2 三个常见的翻车现场
翻车现场之一:订单号重复。两个线程从同一个内存计数器取号,计数器先自增再返回,但因为自增这个操作本身不是原子的,两个线程可能拿到同一个数字。订单服务一般会做数据库唯一索引兜底,然后你就在日志里看到一堆DuplicateKeyException。
翻车现场之二:一上压测CPU就爆。业务逻辑很简单,100个线程各自处理一批数据,里面有一个ConcurrentHashMap,写入的时候不做任何合并,每来一条就put一次,然后在循环里不断get。本来以为并发能提高吞吐,结果锁竞争一激烈,线程大部分时间都在等待,CPU上下文切换频繁,吞吐量反而不如上单线程。
翻车现场之三:某个服务“假死”。主线程里启动了一个工作线程做长任务,工作线程内部因为数据库连接池耗尽卡住了,主线程在等Future.get(),前端请求全部排队,整个服务看起来就像死掉了一样。这时候你用jstack去看,线程状态大部分是WAITING和BLOCKED,日志里没有任何异常。这种问题最难定位,因为它不是报错,而是“性能劣化到不可用”。
1.3 这篇样例文章到底能帮你解决什么
这篇文章说的“多线程并发处理样例”,不是那种只跑一遍演示完就扔的玩具代码,而是从真实业务里抽出来的通用模式。我会从底层认知开始,然后分别给出Java、C++、Qt、Delphi里的完整样例代码,再延伸到高并发场景下的数据库锁、锁粒度、压测方法,最后用一次真实排查过程展示偶发性问题的定位思路。你不需要所有的代码都看懂,但里面的设计逻辑和排查方法论是可以照搬到任何语言里的。
2. 写并发代码前的三个底层认知
2.1 线程不是越多越快——线程数与CPU核心数的关系
很多新人对并发有一个直觉:既然一个线程处理太慢,那就开100个线程。这个认知在CPU密集型任务里是坚决错误的。每开一个线程,操作系统都要给它分配栈空间,常规线程栈默认在1MB到8MB之间,而且线程调度本身要花时间。如果线程数量超过CPU核心数太多,大部分线程其实在排队等待CPU时间片,上下文切换的开销甚至超过计算本身。
有一个大致可参考的经验公式:CPU密集型任务,线程数建议设为CPU核心数 + 1;IO密集型任务,线程数可以放宽到核心数的数倍甚至更多,因为线程大部分时间在等待IO。像Java里常见的newFixedThreadPool(8),如果你跑的是纯计算任务,机器是4核,那这个配置大概率是负优化。我自己的习惯是先压测再调参,而不是拍脑袋定线程数,后面我会专门讲JMeter压测时到底该看哪些指标。
2.2 共享可变状态才是万恶之源
这句话值得加粗:并发问题的根源几乎永远是“多个线程同时读写同一个可变对象”。不共享状态,就没有竞争;没有竞争,就不需要锁。所以很多高并发系统的第一原则并不是“怎么加锁”,而是“能不能不共享”。
最典型的手段是任务拆分时做“数据分片”。比如有10万条订单要处理,8个线程处理的话,不是让8个线程都去读同一个全局队列,而是把10万条订单按主键哈希分成8段,每个线程只处理自己管辖的那一段,处理过程中根本不写共享数据,最后再用一个ConcurrentHashMap汇总结果。这样共享范围被压缩到了最小,锁的竞争压力也就小很多。如果业务规则不允许这样做,必须多个线程同时操作同一份库存、同一个账户余额,那才需要引入锁或者CAS。
2.3 语言选择决定你的并发玩法:Java/C++/Qt/Delphi
每门语言对并发的抽象层次不同,写出来的代码风格也完全不同。我列个表格总结一下,后面每一门的样例都会基于这张表展开。
| 语言/框架 | 核心线程模型 | 典型用法 | 主要坑点 |
|---|---|---|---|
| Java | 线程池 + 锁 + 原子类 | ThreadPoolExecutor、CountDownLatch、AtomicInteger | 锁粒度过大、线程池参数拍脑袋 |
| C++ | 标准线程库 + 原子操作 | std::thread、std::atomic、std::async | 未定义行为多,数据竞争难查 |
| Qt | 主线程 + 工作线程 + 信号槽 | QThread+moveToThread | 在子线程直接操作UI控件必崩 |
| Delphi | TThread+ 主线程消息同步 | TThread.Execute+Synchronize | 主线程阻塞、跨线程访问VCL组件 |
我见过不少项目,选型的时候根本没考虑并发模型,等业务量上来了才发现框架本身不支持细粒度并发,只能在应用层打补丁。所以写并发代码之前,先想清楚你的运行环境和框架约束。
3. 多线程并发处理样例:一份可抄的作业
3.1 Java样例:线程池、CountDownLatch和AtomicInteger的组合
这个样例是我平时用得最多的模式:批量处理一批订单,统计成功数量,全部跑完之后再统一返回结果。
import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class BatchProcessor { public static void main(String[] args) throws InterruptedException { List<Order> orders = createOrders(10000); int threadCount = Runtime.getRuntime().availableProcessors(); ExecutorService pool = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(orders.size()); AtomicInteger successCount = new AtomicInteger(0); for (Order order : orders) { pool.submit(() -> { try { boolean ok = processOrder(order); if (ok) { successCount.incrementAndGet(); } } finally { latch.countDown(); } }); } boolean finished = latch.await(30, TimeUnit.SECONDS); pool.shutdown(); System.out.println("全部完成:" + finished); System.out.println("成功数量:" + successCount.get()); } }这里有两个特别容易出错的点。第一个是CountDownLatch——它存在的意义是让主线程等待所有子任务真正结束,而不是用Thread.sleep()去猜“大概跑完了”。await的超时时间要设置,否则子线程池里的任务线程因为异常没有执行countDown(),主线程就会永久等下去。第二个点是成功数量计数器用AtomicInteger而不是普通int。普通int的自增“先读取,再加一,再写回”三步不是原子的,并发下必然丢数据,这个我在开头翻车现场里已经演示过了。
3.2 C++样例:原子变量和未定义行为的边界
C++的并发模型比Java更底层,标准从C++11开始有了std::thread和std::atomic,但也正因为底层,你更容易写出“看起来能跑、偶尔不对”的代码。下面这段就是我在调试时常用的最小化复现场景:
#include <atomic> #include <iostream> #include <thread> #include <vector> constexpr int kThreadCount = 8; constexpr int kOpsPerThread = 100000; int g_badCounter = 0; std::atomic<int> g_goodCounter{0}; void badIncrement() { ++g_badCounter; // 非原子操作:读-改-写三步 } void goodIncrement() { g_goodCounter.fetch_add(1, std::memory_order_relaxed); } int main() { std::vector<std::thread> threads; for (int t = 0; t < kThreadCount; ++t) { threads.emplace_back([]() { for (int i = 0; i < kOpsPerThread; ++i) { badIncrement(); goodIncrement(); } }); } for (auto& th : threads) { th.join(); } std::cout << "bad counter: " << g_badCounter << std::endl; std::cout << "good counter: " << g_goodCounter.load() << std::endl; return 0; }这段代码每次跑的结果可能都不一样。g_badCounter理论上应该是80万(8乘以10万),实际结果往往是小几万或者几十万,取决于线程调度和CPU缓存的竞争时机。g_goodCounter用了std::atomic基本上每次都是精确的80万。
这里我特别想提醒memory_order_relaxed的使用边界。它只保证原子性,不保证内存序,如果你一边在写数据一边在别的线程读这个数据来做逻辑判断,只用relaxed可能不够,需要std::memory_order_acquire和std::memory_order_release配合。很多新手把“用了atomic就万事大吉”挂在嘴边,其实atomic只是最基本的一层保障。
3.3 Qt样例:别在工作线程里碰UI控件
Qt里的多线程和普通C++不太一样,它有一套自己的线程亲和性规则。简单说,一个QObject属于创建它的线程,你只能在那个线程里直接操作它。UI控件都归属于主线程,所以在子线程里直接调用label->setText()是未定义行为,可能不报错,但界面不会刷新,甚至直接崩溃。
正确姿势是把任务封装在继承自QObject的类里,然后用moveToThread把工作对象移动到工作线程,通过信号和槽实现跨线程通信。
class Worker : public QObject { Q_OBJECT public slots: void doWork(const QString ¶m) { QString result = heavyCompute(param); emit workFinished(result); } signals: void workFinished(const QString &result); }; // 在你的主窗口类中 void MainWindow::startWork() { QThread *thread = new QThread(this); Worker *worker = new Worker(); worker->moveToThread(thread); connect(thread, &QThread::started, worker, &Worker::doWork); connect(worker, &Worker::workFinished, this, &MainWindow::onWorkFinished); connect(worker, &Worker::workFinished, thread, &QThread::quit); connect(worker, &Worker::destroyed, thread, &QObject::deleteLater); thread->start(); }这个例子里的Worker对象没有父对象,moveToThread之后所有槽函数都会在目标线程执行。注意workFinished信号连接到了两个地方:一个是主线程的界面更新方法,一个是thread->quit()。Qt的信号槽机制会自动处理队列,跨线程时会排入事件循环,所以你不需要手动加锁。这也是Qt里多线程比原生C++舒服的地方——框架帮你把线程切换封装掉了。
3.4 Delphi样例:老牌TThread的稳妥玩法
Delphi的多线程模型跟Java、C++不一样,它的TThread类把线程创建、启动、销毁都做了封装,核心是重写Execute方法。加上Synchronize之后,可以在工作线程里安全地更新主线程的VCL组件。这个类比Qt的信号槽更古老,但胜在稳定。
type TCalcThread = class(TThread) private FStartNum: Integer; FEndNum: Integer; FResult: Integer; procedure UpdateUI; protected procedure Execute; override; public constructor Create(AStartNum, AEndNum: Integer); end; implementation constructor TCalcThread.Create(AStartNum, AEndNum: Integer); begin inherited Create(False); FStartNum := AStartNum; FEndNum := AEndNum; FreeOnTerminate := True; end; procedure TCalcThread.Execute; var I: Integer; begin FResult := 0; for I := FStartNum to FEndNum do Inc(FResult, I); Synchronize(UpdateUI); end; procedure TCalcThread.UpdateUI; begin // 这里已经在主线程中了,可以放心操作界面组件 Form1.Label1.Caption := IntToStr(FResult); end;我在老项目中用过不少这种模式,它的核心思路就是“重活放后台,结果回主线程”。但我要提醒:Synchronize虽然是封装好的,本质上依然是“工作线程等主线程执行完方法再继续”,所以不能在工作线程里频繁调用Synchronize,否则就等于把工作线程降级成主线程的附庸,并发效果大打折扣。大批量处理的时候,更合理的做法是工作线程把结果累积到内存队列里,定时一次性同步回主线程。
4. 从样例走向真实战场:高并发场景下的三个硬问题
4.1 数据库并发锁:从超卖说起
代码层面的多线程跑通了,真正的挑战在数据库层。库存扣减是经典的并发问题:用户发起下单,服务端读取库存,判断大于0,扣减1。两个用户同时读到库存1,都认为可以扣,结果都返回成功,库存变成-1,这就是超卖。
解决超卖的前提是理解数据库的并发控制。悲观锁做法是SELECT ... FOR UPDATE,把这条库存记录锁住,其他事务必须等当前事务提交后才能读取。乐观锁做法是给库存表加版本号字段,更新的时候带上旧版本号,如果更新影响行数为0,说明版本已经变了,重新尝试。
我从实践角度给一个建议:不要一开始就上分布式锁。很多库存业务的并发量,单库的悲观锁或者乐观锁完全扛得住,先解决数据库层面的正确性,再去考虑Redis锁、ZooKeeper锁这些分布式方案,否则引入的复杂度反噬会让你调优越来越难。
4.2 锁的粒度控制:读多写少的读写锁与分段锁
锁的粒度是并发性能的分水岭。同一把锁保护的数据范围越大,线程之间的相互等待越多;范围越小,并发度越高,但实现难度越大。Java里ConcurrentHashMap的设计值得学习:它把整个Map分成多个段,不同段的写入互不干扰,只有同一个段内的线程才需要竞争。
如果你的场景是读多写少,可以考虑ReentrantReadWriteLock:多个读者可以同时进入临界区,只有写者和写者之间、写者和读者之间才互斥。但读写锁也有代价,如果写操作非常多,读者会被频繁阻塞,反而比独占锁更慢。我踩过这个坑——当时觉得读多写少必须用读写锁,结果压测发现写线程一多,锁的等待时间和上下文切换反而把性能拖垮了。
4.3 压测观察什么:JMeter跑完不是结束,而是开始
写完多线程样例,第一件事不是上线,而是压测。JMeter里常遇到的问题就是“5个用户并发登录”这种测试:它只能证明“在5个并发下系统没报错”,证明不了系统在50个、500个并发下的行为。
我一般压测时至少看四个指标:TPS(每秒事务数)、错误率、平均响应时间、TP99响应时间。TPS上不去了,不代表系统不行,要看瓶颈在哪:是数据库连接池满了,还是线程池里的线程都在等待锁,还是GC频繁。JMeter的聚合报告里有这些数据,但光看平均值不行,TP99才能反映最坏情况下的体验。
如果你压测的对象不是HTTP接口而是数据库操作,建议直接用专门的压测工具或者写脚本并发执行SQL。用JMeter压数据库接口有时候会因为连接池配置和真实环境不一致,得出完全失真的结果。
5. 并发程序踩坑排查的完整思路还原
5.1 偶发性数据错乱:从结果反推竞态条件
我处理过一个真实的偶发问题:系统在高峰期会随机出现订单金额对不上,但又不是必现,很难复现。排查的第一步不是改代码,而是先把数据错乱的规律问出来——是哪个字段错了?错的场景里有没有共同特征?最后发现所有错乱的订单都涉及同一个优惠活动,而这个活动的余量字段被多个线程同时读改写。
复现不了的问题,要通过“嫌疑共享变量”的方式去定位。我当时的做法是写一个独立的测试程序,用多线程疯狂反复执行那段业务逻辑,再对结果做校验。只要确定是共享变量的问题,把那个字段改成原子类型或者加锁,再跑一次长稳定测试,问题就基本可以坐实了。后来我总结了一条经验:并发问题一旦是偶发的,先默认是竞态,别去怀疑硬件、别去怀疑数据库,把代码里所有共享可变状态列出来,一个个排除。
5.2 死锁的定位套路:jstack、日志和线程状态分析
死锁的特征是进程还活着,但业务卡住不走了。最直接的定位工具在Java里是jstack,它会打印所有线程的快照。如果两个线程互相持有对方需要的锁,jstack会明确告诉你锁的拥有者是谁、等待者是谁。
我自己的排查套路是:先jstack连续抓两次,间隔几秒,看哪些线程两次都在同样的WAITING状态,这些基本就是卡住的线程。然后看日志里最近活动的业务代码路径,把锁的使用顺序梳理一遍。死锁的修复通常不是“换一把更大的锁”,而是“所有线程按同一顺序拿锁”,或者在拿锁失败时用超时机制重试。
5.3 容易翻车的隐藏地雷:线程池泄漏与连接池耗尽
最后一个容易漏掉的坑:线程池不是开完就没事了,它里面的工作线程如果因为异常退出,整个线程池可能悄然变成一个空壳。Java的线程池里,任务执行时抛出未捕获异常,工作线程会被销毁,线程池会自动补一个新的,但如果异常在submit()的Future里,调用方没去get(),异常就静默丢了。
连接池耗尽也是常见问题。数据库连接池配置了maxPoolSize=20,结果业务高峰期所有线程都在等连接,线程池排队的任务越来越多,最后响应时间从几十毫秒涨到几十秒。排查的方法是看连接池监控指标,同时把连接获取的超时时间设短一些,宁可快速失败也不要无限等待。我对线程池参数的建议:不要迷信默认值,corePoolSize、maxPoolSize、队列大小这三个参数要根据实际压测的TPS和响应时间去调,并且加上监控报警。
我在实际项目中还有一个习惯:线程池里的任务里要写完整的日志,尤其是任务入口和出口。因为并发的错误往往是随机分布的,没有完整的日志链路,你很难把“那一次偶发”和“哪一段代码”对应起来。日志要记录线程ID、核心数据快照、耗时,这些信息在排查并发问题时比什么都珍贵。
最后再分享一个小技巧:并发代码写完不要急着强调性能,先用一个带断言或校验的程序连续跑几十轮,确保每次结果都正确。这一步做扎实了,再去调线程数、调锁粒度,这样即使线上出了问题,也可以比较有底气地说一句“至少正确性是把过关了”。我踩过的所有并发相关的坑,几乎都绕不开“没验证正确性就追求性能”这个原点。希望这份多线程并发处理样例和背后的排查思路,能帮你少走几步弯路。