【Linux】:线程安全 + 死锁问题

· 运维技术教程

线程安全和死锁是Linux多线程编程里最核心的难题,几乎每一个后端开发者从入门到进阶都躲不开它们。很多时候我们写的多线程程序在单线程测试时完全没问题,但一旦并发量提上来就会出现数据乱掉、程序卡死等奇怪情况,这多半是碰到了线程安全或死锁的麻烦,今天我就结合自己的开发经历,把这两个知识点彻底讲清楚,从概念到原理再到问题排查和避免的方法,全程没有多余内容,全是有用的干货。

先说说一个常见的错误想法,很多新手觉得只要用了多线程就一定会有安全问题,还把“多线程”和“线程安全”混为一谈,但其实并不是这样。线程安全的关键就是多线程一起访问共享资源时,程序还能保持正确的运行结果,不会出现数据损坏、逻辑乱掉等不正常的情况,相反如果多线程访问的都是各自的私有资源,没有任何共享数据,那程序本身就很安全,不用做任何额外的保护。

要搞懂线程安全,得先明白两个关键概念也就是临界资源和临界区,其中临界资源就是多个线程一起用的资源,比如全局变量、静态变量、堆内存、文件描述符这些,而临界区就是访问这些临界资源的代码片段。线程安全出问题的根本原因,就是多个线程同时进入临界区对临界资源进行读写操作,从而产生“竞态条件”,简单说就是操作的执行顺序不确定,最后导致结果出了偏差。

举个最常见的例子,两个线程同时对一个全局变量count做自增操作(count++),别看这只是简单的一行代码,在电脑底层会被拆成三个步骤:读取count当前的值、把这个值加1、再把新的值写回count。如果线程A读到count是10之后还没来得及加1,线程B就已经读到了同样的count=10,之后两个线程都完成了加1操作,最后写回去的结果就都是11,而不是我们预想的12,这就是典型的线程不安全情况,也是竞态条件带来的直接影响。

那怎么保证线程安全呢?最核心的想法就是“保护临界区”,确保同一时间只有一个线程能进入临界区操作临界资源,这就是“互斥”的意思。Linux系统里有很多实现互斥的方式,其中最常用的就是互斥量(mutex),它的工作原理很简单就像一把锁,线程进入临界区前先把锁锁上(lock),操作完之后再把锁打开(unlock),如果锁已经被其他线程占了,当前线程就会停下来等,直到锁被释放。

这里要特别注意互斥量的使用细节,加锁的范围要尽量小,只把临界区的代码包起来,别把无关的代码也加锁,不然会降低程序的并发效率,同时一定要保证加锁和解锁是成对出现的,如果加了锁又忘了解锁,会导致其他线程一直停着动不了,甚至引发死锁。另外互斥量自身的操作必须是不可拆分的,Linux底层通过swap/exchange等单个指令实现这一点,保证在多核电脑上,加锁操作不会出现竞态条件。

除了互斥量,还有一个容易和线程安全搞混的概念就是可重入,很多人把可重入和线程安全当成一回事,但其实它们既有联系也有明显的区别。可重入函数就是一个函数被多个执行流(比如多线程、信号中断)调用时,就算前一个执行流还没执行完,后一个执行流再进来,也能保证结果是对的,而线程安全主要关注的是多线程一起访问共享资源时的安全性。

它们之间的关键联系是,可重入函数肯定是线程安全的,但线程安全的函数不一定是可重入的。比如一个函数通过加锁实现了线程安全,但如果它在拿着锁的时候又被重新调用(比如信号处理函数调用这个函数),就会因为两次加锁引发死锁,这时这个函数虽然是线程安全的,但并不是可重入的。常见的不可重入情况有调用malloc/free(全局堆链表管理)、使用标准I/O函数(全局数据结构)、函数里用到静态变量等。

讲完线程安全,我们再来说说和它相关的另一个坑——死锁,死锁比线程不安全更麻烦,线程不安全可能只是让数据乱掉,但死锁会让多个线程一直停着动不了、程序卡死,没法继续运行,甚至得重启服务才能恢复正常。

死锁的意思很简单,就是多个线程互相等着对方释放自己需要的资源,形成一个循环等待的链条,没有任何一个线程能继续往下走,最后都一直停在那里。比如线程A拿着锁L1,还需要申请锁L2才能继续执行,而线程B拿着锁L2,还需要申请锁L1才能继续执行,这时候A和B就会互相等,形成死锁。

要避免死锁,得先知道死锁产生的四个必要条件,这是解决死锁的关键,只要打破其中任意一个条件,死锁就不会发生。这四个条件分别是:互斥条件(资源只能被一个线程独自占用)、请求与保持条件(线程拿着一部分资源的同时,又去申请其他资源)、不可剥夺条件(资源只能由拿着它的线程主动释放,不能被强行抢走)、循环等待条件(多个线程形成一个环状的等待链条)。

结合实际开发的情况,我们可以有针对性地避免死锁,这里分享几个最实用的方法。第一个是统一加锁顺序,比如多个线程都需要申请锁L1和L2,就约定所有线程都先申请L1再申请L2,这样就不会形成循环等待的链条,从根本上打破循环等待条件。第二个是避免请求与保持,可以让线程在开始执行前,一次性申请所有需要的资源,如果不能全部申请到,就放弃已经申请到的资源重新等,这样就不会出现“拿着一部分资源等其他资源”的情况。

第三个是及时释放锁,要确保每一次加锁都有对应的解锁操作,尤其是在出现异常的时候(比如函数返回、报错),也要通过异常捕获或goto语句保证锁被释放,避免因为锁没释放引发死锁。第四个是使用定时锁,在申请锁的时候设置一个超时时间,如果超过这个时间还没申请到锁,就放弃申请,释放自己拿着的资源,避免一直停着等。

这里跟大家说一个我曾经碰到的死锁坑,在一个电商订单处理程序里,两个线程同时处理两个订单的转账操作,线程1先锁住订单A,再试着去锁订单B,而线程2先锁住订单B,再试着去锁订单A,一旦并发量上来,程序就直接卡死了,后来我们通过统一加锁顺序(按订单ID从小到大加锁),就彻底解决了这个问题,这也说明很多死锁问题,只要规范加锁顺序,就能很容易避开。

另外,排查死锁也很重要,Linux系统里有很多好用的工具,比如pstack可以查看线程的调用情况,判断线程是不是因为锁而停住了,lsof可以查看进程占用的资源,帮助找到循环等待的资源,我们也可以通过打印日志的方式,记录每个线程加锁、解锁的时间和顺序,快速找到死锁发生的地方。

最后总结一下,线程安全的关键是“保护共享资源,避免竞态条件”,常用的方法是互斥量,同时要分清可重入和线程安全的区别,而死锁的关键是“循环等待资源”,只要打破四个必要条件中的任意一个,就能有效避免死锁。

在实际开发中,线程安全和死锁问题没有捷径可走,关键是要养成好的编程习惯,比如加锁和解锁要一一对应、规范加锁顺序、尽量缩小加锁范围、避免不必要的共享资源,多踩几次坑、多排查几次问题,就能慢慢掌握其中的规律。希望这篇文章能帮到正在学Linux多线程的你,避开我曾经踩过的坑,轻松写出安全、稳定的多线程程序。