本帖最后由 dirty 于 2025-1-22 12:06 编辑
本篇以笔记形式记录学习内核互斥技术,并做一定拓展学习。
在Linux内核中,可能出现多个进程、进程和硬中断、进程和软中断、多个处理器访问同一个对象等现象,我们需要使用互斥技术,确保在给定的时刻只有一个主体可以进入临界区访问对象。
如果临界区的执行时间比较长或者可能睡眠,可以使用下面这些互斥技术。
●信号量,大多数情况下我们使用互斥信号量。
●读写信号量。
●互斥锁。
●实时互斥锁。
如果临界区的执行时间很短,并且不会睡眠,可以使用下面这些互斥技术。
●原子变量。
●自旋锁。
●读写自旋锁,它是对自旋锁的改进,允许多个读者同时进入临界区。
●顺序锁,它是对读写自旋锁的改进,读者不会阻塞写者。
进程还可以使用下面的互斥技术。
●禁止内核抢占,防止被当前处理器上的其他进程抢占,实现和当前处理器上的他进程互斥。
●禁止软中断,防止被当前处理器上的软中断抢占,实现和当前处理器上的软中互斥。
●禁止硬中断,防止被当前处理器上的硬中断抢占,实现和当前处理器上的硬中互斥。
信号量
信号量(semaphore) 是进程和线程间控制共享资源访问的重要机制, 用于同步操作。信号量本质上是一个计数器, 用来跟踪资源的可用数量, 并通过增减信号量的值来控制对共享资源的访问权。 信号量的值可以理解为资源的数量, 信号量为 0 时表示资源已被占用, 当信号量为正数时表示资源可用。
内核使用的信号量结构体如下
include/linux/semaphore.h
struct semaphore {
raw_spinlock_t lock; //自旋锁,用来保护信号量的其他成员。
unsigned int count; //计数值,表示还可以允许多少个进程进入临界区
struct list_head wait_list; //等待进入临界区的进程链表。
};
初始化静态信号量的方法:
(1)SEMAPHORE INITIALIZER(name,n):指定名称和计数值,允许n个进程同时进入临界区。
(2)DEFINE SEMAPHORE(name):初始化一个互斥信号量。
在运行时动态初始化信号量的方法:
static inline void sema init(struct semaphore *sem, int val); //参数 val指定允许同时进入临界区的进程数量
获取信号量函数如下:
void down(struct semaphore *sem); //获取信号量,如果计数值是0,进程深度睡眠
int down_interruptible(struct semaphore *sem);//获取信号量,如果计数值是0,进程轻度睡眠。
int down_killable(struct semaphore *sem);//获取信号量,如果计数值是0,进程中度睡眠。
int down_trylock(struct semaphore *sem);//获取信号量,如果计数值是0,进程不等待。
int down_timeout(struct semaphore *sem, long jiffes),//获取信号量,指定等待的时间。
void up(struct semaphore*sem);//释放信号量
读写信号量
读写信号量是对互斥信号量的改进,允许多个读者同时进入临界区,读者和写者互斥猪和写者互斥,适合在以读为主的情况使用。
初始化静态读写信号量的方法:
DECLARE RWSEM(name);
在运行时动态初始化读写信号量的方法:
init rwsem(sem);
申请读锁的函数如下:
void down_read(struct rw semaphore *sem);//申请读锁,如果写者占有写锁或者正在等待写锁,那么进程深度睡眠。
int down_read_trylock(struct rw semaphore *sem);//尝试申请读锁,不会等待。如果申请成功,返回1;否则返回0。
void up_read(struct rwsemaphore *sem);//释放读锁
申请写锁的函数如下:
void down_write(struct rw_semaphore *sem);//申请写锁,如果写者占有写锁或者读者占有读锁,那么进程深度睡眼
int down_write_killable(struct rw_semaphore *sem);//申请写锁,如果写者占有写锁或者读者占有读锁,那么进程中度睡眠
int down_write_trylock(struct rw_semaphore *sem);//尝试申请写锁,不会等待。如果申请成功,返回1;否则返回0.
void downgrade_write(struct rw_semaphore *sem);//占有写锁以后,可以把写锁降级为读锁
void up_write(struct rw semaphore *sem);//释放写锁函数
互斥锁
互斥锁只允许一个进程进入临界区,适合保护比较长的临界区。尽管可以把二值信号量当作互斥锁使用,但是内核单独实现了互斥锁。 申请互斥锁的函数如下:
void mutex_lock(struct mutex *lock);//申请互斥锁,如果锁被占有,进程深度睡眠
int mutex_lock_interruptible(struct mutex *lock);//申请互斥锁,如果锁被占有,进程轻度睡眠。
int mutex_lock_killable(struct mutex *lock)//申请互斥锁,如果锁被占有,进程中度睡眠。
int mutex_tryock(struct mutex *lock);//申请互斥锁,如果申请成功,返回1:如果锁被其他进程占有,那么进程不等待,返回0.
void mutex_unlock(struct mutex *lock);//释放互斥锁
实时互斥锁
实时互斥锁是对互斥锁的改进,实现了优先级继承(priorityinheritance),解决了优先级反转(priority inversion)问题。
如果需要使用实时互斥锁,编译内核时需要开启配置宏CONFIGRTMUTEXES.
初始化静态实时互斥锁的方法:
DEFINE RT MUTEX(mutexname);
在运行时动态初始化实时互斥锁的方法:
rt mutex init(mutex);
申请实时互斥锁的函数如下:
void rt_mutex_lock(struct rt mutex *lock);//申请实时互斥锁,如果锁被占有,进程深度睡眠
int rt_mutex_lock_interruptible(struct rt mutex *lock);//申请实时互斥锁,如果锁被占有,进程轻度睡眠。
int rt_mutex_timed_lock(struct rt_mutex *lock, struct hrtimer sleeper *timeou//申请实时互斥锁,如果锁被占有,进程睡眠等待一段时间。
int rt_mutex_trylock(struct rt mutex *lock);//申请实时互斥锁,如果申请成功,返回1;如果锁被其他进程占有,进程不等待回 0.
void rt_mutex_unlock(struct rt mutex*lock);//释放实时互斥锁
原子变量用来实现对整数的互斥访问,通常用来实现计数器。
自旋锁
自旋锁用于处理器之间的互斥,适合保护很短的临界区,并且不允许在临界区睡眠申请自旋锁的时候,如果自旋锁被其他处理器占有,本处理器自旋等待(也称为忙等待)。进程、软中断和硬中断都可以使用自旋锁。
定义并且初始化静态自旋锁的方法如下:
DEFINE _SPINLOCK(x);
在运行时动态初始化自旋锁的方法如下:
spin_lock init(x);
申请自旋锁的函数如下:
void spin_lock(spinlock t *lock);//当前处理器自旋等待申请自旋锁,如果锁被其他处理器占有,
void spin_lock bh(spinlock t *lock);//申请自旋锁,并且禁止当前处理器的软中断。
void spin_lock irq(spinlock t *lock);//申请自旋锁,并且禁止当前处理器的硬中断。
spin_lock_irgsave(lock, flags);//申请自旋锁,保存当前处理器的硬中断状态,并且禁止当前处理器的硬中断
int spin_trylock(spinlock t *lock);//申请自旋锁,如果申请成功,返回1;如果锁被其他处理器占有,当前处理器不等待,立即返回 0。
释放自旋锁的函数如下:
(1)void spin unlock(spinlock t *lock);
(2)void spin unlock bh(spinlock t *lock);
释放自旋锁,并且开启当前处理器的软中断。
(3)void spin unlock irq(spinlock t *lock);
释放自旋锁,并且开启当前处理器的硬中断。
(4)void spin unlock irqrestore(spinlock t *lock, unsigned long flags);释放自旋锁,并且恢复当前处理器的硬中断状态。
读写自旋锁
读写自旋锁(通常简称读写锁)是对自旋锁的改进,区分读者和写者,允许多个读者同时进入临界区,读者和写者互斥,写者和写者互斥。如果读者占有读锁,写者申请写锁的时候自旋等待。如果写者占有写锁,读者申请读锁的时候自旋等待。
顺序锁
顺序锁区分读者和写者,和读写自旋锁相比,它的优点是不会出现写者饿死的情况。读者不会阻塞写者,读者读数据的时候写者可以写数据。顺序锁有序列号,写者把序列号加1,如果读者检测到序列号有变化,发现写者修改了数据,将会重试,读者的代价比较高。顺序锁支持两种类型的读者。
(1)顺序读者(sequencereaders):不会阻塞写者,但是如果读者检测到序列号有变化发现写者修改了数据,读者将会重试。
(2)持锁读者(lockingreaders):如果写者或另一个持锁读者正在访问临界区,持锁读者将会等待。持锁读者也会阻塞写者。这种情况下顺序锁退化为自旋锁。
如果使用顺序读者,那么互斥访问的资源不能是指针,因为写者可能使指针失效,读者访问失效的指针会出现致命的错误。
禁止内核抢占
内核抢占是指当进程在内核模式下运行的时候可以被其他进程抢占,编译内核时需要打开配置宏 CONFIG PREEMPT。
如果变量只会被本处理器上的进程访问,比如每处理器变量,可以使用禁止内核抢占的方法来保护,代价很低。如果变量可能被其他处理器上的进程访问,应该使用锁保护。
进程和软中断互斥
如果进程和软中断可能访问同一个对象,那么进程和软中断需要互斥,进程需要禁止软中新。
如果进程只需要和本处理器的软中断互斥,那么进程只需要禁止本处理器的软中断:如果进程要和所有处理器的软中断互斥,那么进程需要禁止本处理器的软中断,还要使用自旋锁和其他处理器的软中断互斥。
进程和硬中断互斥
如果进程和硬中断可能访问同一个对象,那么进程和硬中断需要互斥,进程需要禁止硬中断。如果进程只需要和本处理器的硬中断互斥,那么进程只需要禁止本处理器的硬中断;如果进程要和所有处理器的硬中断互斥,那么进程需要禁止本处理器的硬中断,还要使用自旋锁和其他处理器的硬中断互斥。
每处理器变量
在多处理器系统中,每处理器变量为每个处理器生成一个变量的副本,每个处理器访问自己的副本,从而避免了处理器之间的互斥和处理器缓存之间的同步,提高了程序的执行速度。每处理器变量分为静态和动态两种
内存屏障
内存屏障(memorybarrier)是一种保证内存访问顺序的方法,用来解决下面这些内存访问乱序问题。
内核支持3种内存屏障。
(1)编译器屏障。
(2)处理器内存屏障。
(3)内存映射I/O(Memory Mappping I/O,MMIO)写屏障。
RCU
RCU(Read-Copy Update)的意思是读-复制更新,它是根据原理命名的。写者修改对象的过程是:首先复制生成一个副本,然后更新这个副本,最后使用新的对象替换旧的对象。在写者执行复制更新的时候读者可以读数据。写者删除对象,必须等到所有访问被删除对象的读者访问结束,才能执行销毁操作。
RCU 的优点是读者没有任何同步开销:不需要获取何锁,不需要执行原子指令,(在除了阿尔法以外的处理器上)不需要执行内存屏障。但是写者的同步开销比较大,写者需要延迟对象的释放,复制被修改的对象,写者之间必须使用锁互斥。
RCU 根据数据结构可以分为以下两种:树型 RCU(tree RCU),微型 RCU(tinyRCU)。
死锁检测工具 lockdep
常见的死锁有以下4种情况。
(1)进程重复申请同一个锁,称为AA 死锁。例如,重复申请同一个自旋锁;使用读写锁,第一次申请读锁,第二次申请写锁。
(2)进程申请自旋锁时没有禁止硬中断,进程获取自旋锁以后,硬中断抢占,申请同一个自旋锁。这种 AA 死锁很隐蔽,人工审查很难发现。
(3)两个进程都要获取锁L1和L2,进程1持有锁L1,再去获取锁L2,如果这个时候进程2持有锁L2并且正在尝试获取锁L1,那么进程1和进程2就会死锁,称为AB-BA 死锁。
(4)在一个处理器上进程1持有锁L1,再去获取锁L2,在另一个处理器上进程2持有锁 L2,硬中断抢占进程2以后获取锁L1。这种AB-BA死锁很隐蔽,人工审查很难发现。
内核提供的死锁检测工具1ockdep用来发现内核的死锁风险。使用方法:死锁检测工具 lockdep 的配置宏如下:
(1)CONFIG_LOCKDEP:在配置菜单中看不到这个配置宏,打开配置宏CONFIGPROVE_LOCKING 或 CONFIG DEBUG LOCK ALLOC 的时候会自动打开这个配置宏。
(2)CONFIG PROVE LOCKING:允许内核报告死锁问题。
(3)CONFIG DEBUGLOCK ALLOC:检查内核是否错误地释放被持有的锁。
(4)CONFIG DEBUG LOCKING APISELFTESTS:内核在初始化的过程中运行一小段自我测试程序,自我测试程序检查调试机制是否可以发现常见的锁缺陷。
拓展
信号量的主要作用
控制共享资源的访问: 信号量充当标志, 控制对资源的并发访问, 保证只有一个进程或线程能占用资源。
进程与线程同步: 信号量可以协调进程或线程的执行顺序, 确保资源的访问按指定顺序进行。
Linux 中的信号量也分为 System V 和 POSIX 两种标准, 两者在用法和特性上有所不同; System V信号量提供了一个信号量集, 可以通过一个信号量标识符( semid) 来管理多个信号量。 其典型操作函数在 sys/sem.h 头文件中声明。 System V 信号量适用于需要多进程共享多个信号量的情况, 使用较为复杂,适合更细粒度的控制。 POSIX 信号量在 Linux 2.6 之后引入, 提供了单个信号量对象管理。 POSIX 信号量分为 命名信号量(可以在不同进程之间共享, 通过信号量的名称来标识) 和非命名信号量(只能在同一进程的线程间共享, 不支持跨进程) 两种。
System V 信号量的操作
创建和初始化信号量: 通过 semget 创建或获取信号量集。
信号量的 P(wait) 操作: 通过 semop 减少信号量的值, 如果信号量为 0, 则等待直到信号量大于0。
信号量的 V(signal) 操作: 通过 semop 增加信号量的值, 释放对资源的访问权。
控制和删除信号量: 通过 semctl 设置或获取信号量的值, 或删除信号量。
POSIX 信号量操作
相对而言, POSIX 信号量的操作更为简单, 主要使用以下函数:
创建和初始化信号量:
sem_open: 用于创建或打开命名信号量。
sem_init: 用于初始化非命名信号量。
信号量的 P(wait) 操作:
sem_wait: 等待信号量, 若信号量值为 0, 则阻塞。
信号量的 V(signal) 操作:
sem_post: 释放信号量, 增加信号量的值。
控制和删除信号量:
sem_close: 关闭信号量。
sem_unlink: 删除命名信号量。
本篇的内容特别概念原理是比较偏多较生涩,章节通过学习笔记了解内核互斥技术里所包含的术语定义、功能、原理以及内核实现函数,有一个较系统全面的轮廓。拓展是结合了实际工程应用需要一个点述。内核学习也是一个长期过程,也需要在实际工程应用中不断去学习领悟。
|