单核多线程自旋锁至CPU假死

今天填了个CPU假死的大BUG(其实本质还是违背了spin_lock的设计原则),赶快写篇相关的文章,记录下。先说下结论吧:在各种锁中,只有自旋锁处于busy-wait状态时,自旋着的线程/进程将获得大量时间片,占用大量cpu资源,从而引发该CPU假死状态(理论场景中其实还有可能获得锁(上锁的线程以龟速处理完逻辑,释放锁),所以在理论场景中,不能算是死锁(此时死锁条件=CPU假死+获得spin锁的线程需要处理实时的大流量任务))

需要了解的知识

要解释CPU假死现象,首先需要知道以下知识点:
使用默认参数创建的线程,系统是根据优先级来分配时间片的(而不是简单均分的),优先级高的分配的时间片越多,并且系统自身存在优先级的动态补偿特性(也就是说若线程A和线程B的用户指定的优先级相同(用户只能指定静态优先级),但是操作系统中的调度系统自身会会算出基于静态优先级的动态优先级,并且以该优先级来分配各自的时间片,这叫做优先级的动态补偿)(结合优先级的动态补偿,就可以解释自旋线程占用大量时间片的原因了)

具体运行模型

首先该设备是多核机(但是做了核隔离,接手的QOS模块的模型是单核多线程)。
导致CPU假死并升级为死锁的线程模型如下图(当初设计思想应该是想追求高性能,所以大量使用了自旋锁,谁知道最后偷鸡不成蚀把米):

CPU假死及升级为死锁的原因分析

首先(CPU假死):
在某一时刻,线程1获得了spin锁A,接着处理它自身的任务,当线程1的时间片用完,此时该CPU被切换到了线程2。注意,此时线程1的任务还未处理完,spin锁A也还未被释放。在线程2中,线程2尝试获取spin锁A,显然它不能拿到锁A,于是它就进入了busy-wait状态,在该状态下,系统调度程序将其动态优先级调度到了最高(猜测,暂无法证实),使得线程2接下来获得了大部分的时间片(CPU大部分时间用于线程2的自旋),导致线程1只能分配到很少的时间片,这样一来,CPU很大一部分时间就进入了自旋空跑状态(CPU假死),线程1的处理时间变得更长
其次(升级为死锁):
若线程1处理的是需要长时间处理的任务(实时的大流量或大数据量的任务),线程1将无法在少量的时间片内处理完这些任务,无法解锁,从而引发死锁

试验程序

根据以上分析,编写了相关试验程序:
https://github.com/avalonLZ/Practices/blob/master/C_and_CPP/C/%E9%AA%8C%E8%AF%81%E7%A8%8B%E5%BA%8F/test_spin_lock.c

1、使用gcc test_spin_lock.c -lpthread编译程序
2、使用taskset -c 3 ./a.out将程序绑定在第3个核上运行
3、使用top -H -d 0.1进入top,进入后并按1,显示每个CPU的状态
等待一段时间,可以发现CPU3被跑满,并且线程2进入了自旋状态,此时观察线程1的TIME+选项,会发现它获得的时间片大量减少了,从而证实了以上分析(疑问点:线程2按道理它的S项应该在线程1的S项变成R时,变为S才对,但也可能是线程1的R时间太短,top中S字段的状态还未刷新吧),总的试验结果和分析吻合。

以后使用spin需要注意的地方

单核多线程使用spin:
1、在需要长时间处理的线程上使用pthread_spin_lock获取锁,其他实时性不强的线程需要使用tryspin
2、或者谨遵spin的设计原则,上锁后保证只处理短时间的任务,保证在时间片用完前可以释放锁
3、若需求中不可避免的有长时间的任务,则使用sleep-wait型的mutex锁代替spin锁
4、或想其他办法代替,但总的来说spin的效率应该还是最高的

多核多线程使用spin:
可以在需要上锁的地方都使用pthread_spin_lock或取锁,但要注意实时性强的线程需要绑定在独立的CPU上,否则就会出现CPU假死,甚至升级为死锁