参考文档: #
- 【蜗窝科技 - 内核同步机制】 http://www.wowotech.net/sort/kernel_synchronization/page/2
- 内核中的锁机制: https://www.kernel.org/doc/html/latest/locking/locktypes.html
- 【wait_completion_interrupt的中被打断的意思】 https://www.cnblogs.com/yfceshi/p/6800069.html
- https://www.kernel.org/doc/htmldocs/kernel-locking/locks.html
- 自旋锁和对应使用上下文级别: 【Cheat Sheet For Locking】https://www.kernel.org/doc/htmldocs/kernel-locking/cheatsheet.html
简介 & 概念 #
在多线程编程的概念中,存在“线程同步”这个理论问题。
生产者-消费者问题 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
| 消费数据
↓ ???数据怎么没有了
和上述问题模型类似的还有:
- The Producer–Consumer Problem (also called The Bounded Buffer Problem);
- The Readers–Writers Problem;
- The Dining Philosophers Problem.
为了解决上述问题,提出了“线程同步”的概念:指多个进程在某一点联合或 握手的想法,以达成一致或承诺某个动作序列
比如为了解决上述生产者和消费者问题时, 消费者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包?
真正的实现原理 #
有两种实现思路:
- 第一种是基于 软件 的方法:
- 进程互斥的软件实现方法,硬件实现方法以及互斥锁】https://blog.csdn.net/qq_61888137/article/details/133619968
- 【基于软件锁 FreeRTOS多核支持】https://jiladahe1997-note-images.oss-cn-chengdu.aliyuncs.com/Freertos%20and%20multicore.pdf】
- 进程互斥的软件实现方法,硬件实现方法以及互斥锁】https://blog.csdn.net/qq_61888137/article/details/133619968
主要使用peterson算法
- 第二种是基于 硬件 的方法:
- 【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:读写锁,读和写互斥,读和读不互斥,写和写互斥。
- mutex
例子:自旋锁 #
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);
}
注意事项: #
一.不能在中断上下文中使用锁: #
因为中断上下文中,没有线程的概念。 而锁的概念依赖于线程,会将线程休眠或者唤醒。
附录A: 如果只是对一个变量进行读写,多线程间是否不需要加锁? #
讨论:多线程对一个变量的读写到底是不是原子的?:https://www.zhihu.com/question/31325454/answer/2808469207
自我思考:
- 必须要考虑内存一致性的问题:《armV7内存读写一致性.md》。可能会导致写入后不能马上读取到。 甚至可能导致后写入的变量读到了,先写入的变量没读到! 参考《02- 内存顺序_内存屏障.md 举例: ARM64上的应用程序出现数据不一致的问题》
- 需要考虑跨cache line的情况,参考
- 其他情况是一致的
附录B:递归锁 Recursive Lock: #
锁是不能递归调用的,func A持有了锁,func A调用func B,funcB也持有锁,此时会造成死锁。 通常出现在混乱的代码设计中。
为了解决上述问题,提出了一个概念:递归锁 recursive lock。 可以在不重构代码的情况下,通过利用线程pid,来避免同一线程多次持有锁的情况。