跳过正文

Linux kernel锁机制详解

·294 字·2 分钟
jiladahe1997
作者
jiladahe1997
To see the world as it is, and to love it

参考文档:
#




简介 & 概念
#

在多线程编程的概念中,存在“线程同步”这个理论问题。

生产者-消费者问题 https://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem 此问题描述了一个多线程编程的基础模型,假设有多个消费者时,由于消费时要进行多个步骤:1.判断有没有数据 2.取出数据 3.数据数量-1。 而当多个消费者同时消费时,会出现问题。

试想现在生产了1个数据,消费者A执行了1步骤后被打断,消费者B又执行了1、2、3步骤,此时再回到消费者A,执行2步骤时,已经没有数据了,此时程序会出现未知的状态。

时间顺序
   |           A              B
   |        判断有数据         
   |                       判断有数据
   |                       消费数据
   |                       数据-1
   |        消费数据
   ↓       ???数据怎么没有了

和上述问题模型类似的还有:


为了解决上述问题,提出了“线程同步”的概念:指多个进程在某一点联合或 握手的想法,以达成一致或承诺某个动作序列

比如为了解决上述生产者和消费者问题时, 消费者A和消费者B在1步骤前进行同步,确保同时只有一个消费者进入执行1步骤。



实现原理
#

为了实现“线程同步”,需要一种方法,能够确保一条语句同时只能有一个线程在执行

单行C代码
#

考虑下面两句代码: a=a+1; a=a-1; 这两句代码都只有一行,是否能满足“一条语句同时只能有一个线程在执行”这个要求?

答:不能,汇编后的语句如下:

//a=a+2的汇编
ADD  g_a, $2, %0
MOV  %0, g_a

//a=a-1的汇编
SUB g_a, $1, %1
MOV %0, g_a

深入:单行汇编代码
#

即使是一行汇编代码操作变量,在执行时也可能也不能一定保证就是线程同步的。 考虑下面这个 未对齐 变量:

struct Data {
    char padding[62]; // 62字节
    int32_t value;  // 4字节
} data; 

data.value = 6; //未对齐访问

//汇编
 str	w0, #0x6

此时虽然只有一行汇编代码,但是在 x86 架构上CPU硬件在执行时也会分成两行来执行,这是由于CPU硬件决定的。 然后在 arm 架构上则会直接报错,因为arm不支持未对齐的内存访问。

参考: 如果对齐的内存写入是原子的,为什么我们需要sync/atomic包?



真正的实现原理
#

有两种实现思路:

  1. 第一种是基于 软件 的方法:
    1. 进程互斥的软件实现方法,硬件实现方法以及互斥锁】https://blog.csdn.net/qq_61888137/article/details/133619968
    2. 【基于软件锁 FreeRTOS多核支持】https://jiladahe1997-note-images.oss-cn-chengdu.aliyuncs.com/Freertos%20and%20multicore.pdf】

主要使用peterson算法

  1. 第二种是基于 硬件 的方法:
    1. 【ARM Architecture Reference Manual ARMv7-A and ARMv7-R edition】A3.4 Synchronization and semaphores

即硬件上实现了LDREX、STREX等汇编指令,用于锁。 也叫做原子指令。

Peterson算法 纯软件实现:Peterson算法是一种完全依赖软件逻辑的互斥锁实现,使用两个变量(一个标志位和一个turn变量)来协调两个线程的访问。 严格要求有序性:算法假设内存操作的顺序是完全可预测的,需要依赖程序的执行顺序,没有乱序执行或优化。

Peterson算法 单核环境:由于其依赖严格的操作顺序,Peterson算法在单核或没有内存乱序的环境中更可靠。 线程数量限制:经典的Peterson算法仅适用于两个线程。尽管可以通过扩展实现支持更多线程(如Lamport算法或其他N线程互斥算法),复杂性会显著增加。

Peterson算法 慢:Peterson算法在多线程环境中需要反复检查和等待变量状态,导致忙等待(busy-waiting)。 不适合多核乱序执行:在现代多核CPU中,缓存一致性和乱序执行会导致性能问题。



例子1:armv7 架构下 自旋锁的实现
#

spi_lock
->__raw_spin_lock
 ->do_raw_spin_lock
  ->arch_spin_lock
   ->汇编指令ldrex/strex


例子2: armv7 架构下 mutex的实现
#

mutex_lock
->__mutex_trylock_fast
 ->先通过 atomic_long_try_cmpxchg_acquire 尝试
  ->如果返回false
   ->__mutex_lock_slowpath
    ->__mutex_lock_common
     ->__mutex_trylock
      ->atomic_long_cmpxchg_acquire
      








实际应用
#

上面描述了概念和实现原理,下面来说一下在linux中有哪些锁机制,并且如何使用:

  • 自旋锁:如果获取不到锁,以while(1)的方式进行忙等待
    • spin_lock(或 spin_lock_irq 或 spin_lock_irqsave)
  • 互斥锁:如果获取不到锁,该线程置于休眠状态,转而调度其他线程
    • mutex
      • semaphore
      • completion: 完成量,类似于flag。只有0和1两种状态。
      • rw_lock:读写锁,读和写互斥,读和读不互斥,写和写互斥。


例子:自旋锁
#

static DEFINE_SPINLOCK(xxx_lock);
static a=1;

void threadA(){
  spin_lock_irq(&xxx_lock);
  a=0;
  spin_unlock_irq(&xxx_lock);
}

void threadB(){
  spin_lock_irq(&xxx_lock);
  a=1;
  spin_unlock_irq(&xxx_lock);
}

如果在中断中使用,需要使用**spin_lock_irqsave**这个函数,以保存中断。

spin_lock 利用原子操作获取锁
spin_lock_irq 并关闭中断
spin_lock_irqsave 关闭中断前保存当前的中断寄存器(armv7是cpsr,arm8是daif pstate寄存器)

注意,不能在自旋锁上下文中进行休眠
#

spin_lock()
//**sleep()  禁止调用sleep!!!
spin_unlock()

参考: Sleep-in-Atomic-Context Bugs in the Linux Kernel

对于单核系统:自旋锁==关闭中断,关闭中断后进行休眠,CPU调度到另外的线程B执行,且系统会没有sys_tick调度器。此时系统调度会出现异常,大概率系统会卡死。

对于多核系统:自旋锁==关闭中断,关闭中断后进行休眠,导致此CPU无法响应中断,但有可能还能正常的进行调度,因为其他的CPU还可以正常调度和更新线程。 不一定会导致问题。



例子:使用completion完成量实现flag机制
#

这样

static DEFINE_COMPLETION(xxx_cmp)
    
void threadA(){
    while(1){
    	wait_for_completion(&xxx_cmp);
        
        printk("a:[%d]\r\n",a);
    
    	reinit_completion(&xxx_cmp);
    }
}

void irq_handle(){
    a++;
    
	complete(&xxx_cmp);
}

注意事项:
#

一.不能在中断上下文中使用锁:
#

参考: https://stackoverflow.com/questions/1053572/why-kernel-code-thread-executing-in-interrupt-context-cannot-sleep#:~:text=You%20cannot%20sleep%20in%20an,%22nothing%20to%20wake%20up%22.

因为中断上下文中,没有线程的概念。 而锁的概念依赖于线程,会将线程休眠或者唤醒。









附录A: 如果只是对一个变量进行读写,多线程间是否不需要加锁?
#

讨论:多线程对一个变量的读写到底是不是原子的?:https://www.zhihu.com/question/31325454/answer/2808469207

自我思考:

  1. 必须要考虑内存一致性的问题:《armV7内存读写一致性.md》。可能会导致写入后不能马上读取到。 甚至可能导致后写入的变量读到了,先写入的变量没读到! 参考《02- 内存顺序_内存屏障.md 举例: ARM64上的应用程序出现数据不一致的问题》
  2. 需要考虑跨cache line的情况,参考
  3. 其他情况是一致的


附录B:递归锁 Recursive Lock
#

锁是不能递归调用的,func A持有了锁,func A调用func B,funcB也持有锁,此时会造成死锁。 通常出现在混乱的代码设计中。

为了解决上述问题,提出了一个概念:递归锁 recursive lock。 可以在不重构代码的情况下,通过利用线程pid,来避免同一线程多次持有锁的情况。