聊聊并发编程中那些让人头疼又不得不面对的坑

最近在重构公司的一个高并发系统,说实话,真是被各种并发问题折磨得不轻。今天趁着周末有空,把这段时间踩过的坑和一些心得整理下,希望对大家有所帮助。

从一次线上事故说起上个月我们线上出现了一次严重的性能问题,用户反馈系统响应特别慢,甚至有些请求直接超时了。排查后发现是并发控制没做好导致的。这让我深刻认识到,并发编程真不是简单地开几个线程那么简单。

并发控制到底在控制什么?很多刚接触并发的同学可能会问,为啥要控制?让程序跑得越快不是越好吗?其实不然。打个比方,这就像高速公路,车越多不代表通行效率越高,没有红绿灯和交通规则,反而会造成拥堵甚至事故。

在我们的系统中,主要用到了以下几种并发控制手段:

控制方式

适用场景

优点

缺点

实际使用频率

互斥锁(Mutex)

临界区保护

简单直观

可能造成死锁

★★★★★

读写锁(RWLock)

读多写少场景

读操作可并发

实现复杂

★★★☆☆

信号量(Semaphore)

限制资源访问数

灵活控制并发度

容易用错

★★☆☆☆

条件变量

线程间通信

避免忙等待

使用门槛高

★★☆☆☆

原子操作:最轻量级的同步机制说到并发控制,不得不提原子操作。刚开始写并发代码时,我特别喜欢用锁,感觉锁住了就安全了。后来发现很多场景下,一个简单的原子操作就能搞定,性能还更好。

比如统计请求数这种场景:

代码语言:java复制// 以前我会这么写

private int count = 0;

private Object lock = new Object();

public void increment() {

synchronized(lock) {

count++;

}

}

// 现在我会用原子类

private AtomicInteger count = new AtomicInteger(0);

public void increment() {

count.incrementAndGet();

}性能测试结果让我大吃一惊:

并发线程数

加锁方式耗时(ms)

原子操作耗时(ms)

性能提升

10

125

45

2.8x

50

680

120

5.7x

100

1850

235

7.9x

500

9200

890

10.3x

死锁:并发编程的噩梦要说并发编程最怕什么,死锁绝对排得上号。我记得有一次,系统突然挂了,所有请求都卡住不动。查了半天日志,最后用jstack一看,好家伙,十几个线程互相等待,形成了一个完美的死锁环。

死锁是怎么产生的?理论上说,死锁需要四个条件同时满足。但实际编码中,最容易出问题的是加锁顺序不一致。举个我们实际遇到的例子:

线程A:先锁用户数据,再锁订单数据

线程B:先锁订单数据,再锁用户数据

这种情况下,如果时机不巧,就会死锁。

我们的死锁检测方案后来我们实现了一套死锁检测机制,主要思路是:

预防为主:统一加锁顺序,比如按照资源ID从小到大检测为辅:定期扫描线程状态,发现死锁立即处理快速恢复:检测到死锁后,选择牺牲代价最小的线程检测算法的核心是构建等待图:

检测指标

阈值

说明

处理方式

线程等待时间

30秒

可能死锁

记录日志

等待环路

存在

确定死锁

中断线程

CPU使用率

<5%

可能全部阻塞

人工介入

等待线程数

10

并发过高

限流降级

线程池:不是越大越好很多人觉得线程池设置得越大,处理能力就越强。我以前也这么想,直到有一次把线程池设置成1000,结果系统直接OOM了。

线程池大小的学问经过多次调优,我总结出了一套经验公式:

CPU密集型任务:线程数 = CPU核心数 + 1IO密集型任务:线程数 = CPU核心数 × 2混合型任务:需要根据实际情况测试我们系统不同模块的线程池配置:

模块名称

任务类型

核心线程数

最大线程数

队列大小

拒绝策略

API网关

IO密集

20

50

1000

CallerRuns

数据处理

CPU密集

8

10

100

Abort

日志收集

混合型

15

30

500

Discard

定时任务

IO密集

10

20

200

DiscardOldest

线程池的监控指标光配置好还不够,还得实时监控。我们主要关注这些指标:

活跃线程数:判断负载情况队列积压数:发现处理瓶颈拒绝任务数:评估容量是否足够平均等待时间:用户体验的直接体现一些实战经验1. 能不用锁就不用锁这是我的第一原则。很多时候通过合理的设计可以避免加锁,比如:

使用ThreadLocal避免共享采用无锁数据结构(ConcurrentHashMap等)函数式编程,减少可变状态2. 加锁要细粒度如果必须加锁,锁的范围要尽可能小。我见过有人在方法级别加synchronized,结果性能惨不忍睹。

3. 注意锁的公平性非公平锁性能更好,但可能造成线程饥饿。我们的原则是:

高频操作用非公平锁低频但重要的操作用公平锁4. 做好降级预案并发高的时候,要有降级方案。比如:

限流:超过阈值直接拒绝熔断:错误率高时暂停服务降级:返回缓存或默认值总结并发编程确实是个技术活,需要大量的实践才能掌握。这篇文章分享的都是我在实际工作中积累的经验,希望能帮大家少踩些坑。

记住,并发控制的目的不是限制性能,而是在保证正确性的前提下提高效率。过度设计和不够重视都是问题,找到平衡点才是关键。

最后想说,遇到并发问题不要慌,先冷静分析,很多时候问题没有想象的那么复杂。当然,如果真的搞不定,记得找身边的大佬帮忙,毕竟独行快,众行远嘛。

有什么问题欢迎留言讨论,大家一起进步!

Top