目录
8.3 对整数的原子操作(atomic_t / atomic64_t)
8.4 原子位处理(set_bit / clear_bit / test_bit / ...)
12 互斥量(struct mutex / struct rt_mutex)
1 基本概念
1.1 可重入内核 / 可重入函数
所有的 unix 内核都是可重入的(reentrant),这意味着若干个进程可以同时在内核态下执行。
提供可重入的一种方式是编写函数,以便这些函数只能修改局部变量,而不能修改全局数据结构,这样的函数叫可重入函数。
可重入内核也可以包含非重入函数,并利用锁机制保证一次只有一个进程执行一个非重入函数。 《深入理解 LINUX 内核》P27
实现可重入内核需要利用同步机制。
如果内核控制器路径对某个内核数据结构进行操作时被挂起,那么,其他内核控制器路径就不应当再对该数据结构进行操作,除非它已被重新设置成一致性(consistent)状态。否则,两个控制器路径的交互作用将破坏所存储的信息。 《深入理解 LINUX 内核》P29
1.2 内核抢占 / 用户抢占
1.2.1 内核抢占
要给内核抢占下一个精确的定义简直太困难了。作为第一种尝试,我们说:如果进程正执行内核函数时,即它在内核态运行时,允许发生内核切换(被替换的进程是正执行内核函数的进程),这个内核就是抢占的。 《深入理解LINUX内核》P193
与其它大部分的Unix变体和其它大部分的操作系统不同,Linux完整地支持内核抢占。 《Linux内核设计与实现》P53
1.2.2 用户抢占
内核即将返回用户空间的时候,如果 need_resched 标志被设置,会导致 schedule()被调用,此时就会发生用户抢占。在内核返回用户空间的时候,它会知道自己是安全的,因为既然它可以继续区执行当前进程,那么它当然可以再去选择一个新的进程区执行。所以,内核无论是在中断处理程序还是在系统调用后返回,都会检查 need_resched 标志。如果它被设置了,那么,内核会选择一个其它(更合适的)进程投入运行。从中断处理程序或系统调用返回的返回路径都是体系结构相关的,在 entry.S 文件中通过汇编语言来实现。
简而言之,用户抢占可以在以下情况时产生:
1. 从系统调用返回用户空间时。
2. 从中断处理程序返回用户空间时。
《Linux 内核设计与实现》P53
1.3 临界区 / 竞争条件 / 同步
所谓临界区(也称为临界段)就是访问和操作共享数据的代码段。
多个执行线程并发访问同一个资源通常是不安全的,为了避免在临界区中并发访问,编程者必须保证这些代码是原子地执行——也就是说,操作在执行结束前不可被打断,就如同整个临界区是一个不可被分割的指令一样。
如果两个执行线程有可能处于同一个临界区中同时执行,那么这就是程序包含的一个bug。如果这种情况发生了,我们就称它是竞争条件(race conditions),这样命名是因为这里会存在线程竞争。
避免并发和防止竞争条件称为同步。
《Linux内核设计与实现》P131
临界区(critical region)是这样的一段代码,进入这段代码的进程必须完成,之后另一个进程才能进入。《深入理解LINUX内核》P30
临界区:在任意给定的时刻,代码只能被一个线程执行。 《LINUX设备驱动程序》(第三版)P112
几个进程在访问资源时彼此干扰的情况通常称之为竞态条件(race condition)。
竞态条件无法通过系统的试错法检测。只有彻底研究源代码(深入了解各种可能发生的代码路径)并通过敏锐的直觉,才能找到并消除竞态条件。 《深入LINUX内核架构》P278
竞态是一种极端低可能性的事件,因此程序员往往会忽视竞态。但是在计算机世界中,百万分之一的事件可能没几秒就会发生,而其结果是灾难性的。 《LINUX设备驱动程序》(第三版)P109
1.4 原子操作
原子操作可以保证指令以原子的方式执行——执行过程不被打断。 《Linux内核设计与实现》P141
1.5 细粒度锁、粗粒度锁、锁的竞争和系统的扩展性
《Linux内核设计与实现》P138
《深入LINUX内核架构》P291
《LINUX设备驱动程序》(第三版)P124
2 信号量(struct semaphore)
2.1 简介
当信号量用于互斥时(即避免多个进程同时在一个临界区中运行),信号量的值应该初始化为1。这种信号量在任何给定时刻只能由单个进程或线程拥有。在这种使用模式下,一个信号量有时也被称为一个“互斥体”,它是互斥的简称。linux内核中几乎所有的信号量均用于互斥。 《LINUX设备驱动程序》(第三版)P112
信号量只是受保护的特别变量,能够表示正负整数。其初始值为 1。
当进程在信号量上睡眠时,内核将其置于阻塞状态,且与其他在该信号量上等待的进程一同放到一个等待列表中。 《深入 LINUX 内核架构》P279
2.2 注意点
信号量的缺点:
为了检查信号量,内核必须把进程插入到信号量链表中,然后挂起它。因为这两种操作比较耗时,完成这些操作时,其它内核控制器路径可能已经释放了信号量。 《深入理解LINUX内核》P31
信号量比自旋锁提供了更好的处理器利用率,因为没有把时间花费在忙等待上,但是信号量比自旋锁有更大的开销。《Linux内核设计与实现》P152
当内核控制器路径试图获取内核信号量所保护的资源忙时,相应的进程被挂起。只有在资源被释放时,进程才再次变为可运行的。因此,只有可睡眠的函数才能获取内核信号量;中断处理程序和可延迟函数都不能使用内核信号量。《深入理解LINUX内核》P211
使用 down_interruptible()比使用 down()更为普遍(也更正确)。 《Linux内核设计与实现》P154
2.3 进程状态
从本质上说,__down()函数把当前进程的状态从TASK_RUNNING改变为TASK_UNINTERRUPTIBLE,并把进程放在信号量的等待队列。 《深入理解LINUX内核》P214
2.4 常用函数
void sema_init(struct semaphore *sem, int val);
DECLARE_MUTEX(name);
DECLARE_MUTEX_LOCKED(name);
void init_MUTEX(struct semaphore *sem);
void init_MUTEX_LOCKED(struct semaphore *sem);
void down(struct semaphore *sem);
void down_interruptible(struct semaphore *sem);
void down_trylock(struct semaphore *sem);
void up(struct semaphore *sem);
《LINUX设备驱动程序》(第三版)P113
3 读写信号量(struct rw_semaphore)
3.1 简介
许多任务可以划分为两种不同的工作类型:
1. 一些任务只需要读取受保护的数据结构。
2. 而其他的则必须做出修改。
允许多个并发的读取者是可能的,只要它们之中没有哪个要做修改。这样做可以大大提高性能。
《LINUX 设备驱动程序》(第三版)P115
3.2 常用函数
void init_rwsem( struct rw_semaphore *sem);
void down_read( struct rw_semaphore *sem);
int down_read_trylock( struct rw_semaphore *sem);
void up_read( struct rw_semaphore *sem);
void down_write( struct rw_semaphore *sem);
int down_write_trylock( struct rw_semaphore *sem);
void up_write( struct rw_semaphore *sem);
void downgrade_write( struct rw_semaphore *sem);
《LINUX 设备驱动程序》(第三版)P116
4 完成量(completion)
4.1 简介
completion 是一种轻量级的机制,它允许一个线程告诉另一个线程某个工作已经完成。 《LINUX 设备驱动程序》(第三版)P117
如果在内核中一个任务需要发出信号通知另一任务发生了某个特定事件,利用完成变量(completion variable)是使两个任务得以同步的简单方法。 《Linux 内核设计与实现》P158
completion 机制的典型使用是模块退出时的内核线程终止。
在这种原型中,某些驱动程序的内部工作由一个内核线程 while(1)循环完成。当内核准备清除该模块时,exit 函数会告诉该线程退出并等待 completion。为了实现这个目的,内核包含了可用于这种线程的一个特殊函数:
void complete_and_exit(struct completion *c, long retval);
《LINUX 设备驱动程序》(第三版)P117
4.2 注意点
wait_for_completion执行一个非中断的等待。如果代码调用了wait_for_completion且没有人会完成该任务,则将会产生一个不可杀死的进程。
一个completion通常是一个单次设备,也就是说,它只会被使用一次然后被丢弃。但是如果仔细处理,completion结构也可以被重复使用。 《LINUX设备驱动程序》(第三版)P117
4.3 进程状态
wait_for_completion()把 current 进程作为一个互斥进程加到等待队列的末尾,并把 current 设置为TASK_UNINTERRUPTIBLE 状态让其睡眠。一旦 current 被唤醒,该函数就把 current 从等待队列中删除。 《深入理解 LINUX 内核》P217
4.4 常用函数
初始化:
struct completion my_completion;
init_completion(&my_completion);
等待:
void wait_for_completion(struct completion *c);
触发/唤醒:
void complete(struct completion *c);
void complete_all(struct completion *c);
void complete_and_exit(struct completion *c, long retval);
5 自旋锁(spinlock_t)
5.1 简介
自旋锁用于保护短的代码段,其中只包含少量的 C 语句,因此会很快执行完毕。
大多数内核数据结构都有自身的自旋锁,在处理结构中的关键成员时,必须获得相应的自旋锁。
自旋锁的实现几乎全是汇编语言(与体系结构非常相关)。
《深入 LINUX 内核架构》P282
内核控制器路径发现锁由运行在另一个 CPU 上的内核控制器路径“锁着”,就在周围“旋转”,反复执行一条紧凑的循环指令,直到锁被释放。
自旋锁的循环指令表示“忙等”。 《深入理解 LINUX 内核》P203
5.2 注意点
如果获得锁之后不释放,系统将变得不可用。
自旋锁决不应该长期持有,因为所有等待锁释放的处理器都处于不可用状态,无法用于其它工作。
《深入 LINUX 内核架构》P282
自旋锁保护的代码不能进入睡眠。 《深入 LINUX 内核架构》P283
5.3 进程状态
忙等待
5.4 常用函数
spin_lock(&lock);
spin_trylock(&lock);
spin_trylock_bh(&lock);
spin_unlock(&lock);
spin_lock 定义为一个原子操作,在获取自旋锁的情况下可防止竞态条件出现。
spin_trylock 和 spin_trylock_bh 尝试获取锁,但在锁无法立即获取时不会阻塞。在锁操作成功时,它们返回非 0 值
(代码由自旋锁保护),否则返回 0(代码没有被锁保护)。
《深入 LINUX 内核架构》P282
5.5 调试方法
CONFIG_DEBUG_SPINLOCK
内核将捕获对未初始化自旋锁的操作,也会捕获诸如两次解开同一锁的操作等其他错误。
CONFIG_DEBUG_SPINLOCK_SLEEP
该选项将检查拥有自旋锁时的休眠企图。实际上,如果调用可能引起休眠的函数,这个选项也会生效,即使该函数可能不会导致真正的休眠。
《LINUX 设备驱动程序》(第三版)P77
6 读 / 写自旋锁(rwlock_t)
6.1 简介
只要没有内核控制路径对数据结构进行修改,读 / 写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径想对这个结构进行写操作,那么它必须首先获取读 / 写锁的写锁,写锁授权独占访问这个资源。《深入理解LINUX内核》P206
读 / 写自旋锁和读取者 / 写入者信号量非常相似。读 / 写自旋锁允许任意数量的读取者同时进入临界区,但是写入者必须互斥访问。《LINUX设备驱动程序》(第三版)P122
6.2 常用函数
rwlock_t my_rwlock;
rwlock_init(&my_rwlock)
read_lock
read_unlock
write_lock
write_unlock
《深入理解LINUX内核》P207
《LINUX设备驱动程序》(第三版)P122
7 顺序锁(seqlock)
7.1 简介
顺序锁(seqlock)与读 / 写自旋锁非常类似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待(除非另外一个写者正在写),缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。 《深入理解LINUX内核》P209
当要保护的资源很小、很简单、会频繁被访问而且写入访问很少发生且必须快速时,就可以使用 seqlock。
从本质上讲,seqlock 会允许读取者对资源的自由访问,但需要读取者检查是否和写入者发生冲突,当这种冲突发生时,就需要重试对资源的访问。 《LINUX 设备驱动程序》(第三版)P129
7.2 注意点
必须满足下述条件时才能使用顺序锁:
1. 被保护的数据结构不包括被写者修改和被读者间接引用的指针,否则,写者可能在读者眼皮下修改指针。
2. 读者的临界区代码没有副作用,否则,多个读者操作会与单独读操作有不同的结果。
读者的临界区代码应该简短,而且写者应该不常获取顺序锁,否则,反复的读访问会引起严重的开销。
《深入理解 LINUX 内核》P210
seqlock通常不能用于保护包含有指针的数据结构,因为在写入者修改该数据结构的同时,读取者可能会追随一个无效的指针。
《LINUX设备驱动程序》(第三版)P129
7.3 常用函数
典型的读取者代码:
unsigned int seq;
do{
seq = read_seqbegin(&seqlock);
...... /*临界区代码*/
}while(read_seqretry(&seqlock, seq));
《深入理解LINUX内核》P209
8 原子操作(atomic_t)
8.1 简介
有时,共享的资源可能恰好是一个简单的整数值。
针对这种类型的操作在 SMP 计算机的所有处理器上都确保是原子的。这种操作的速度非常快,因为只要可能,它
们会被编译成单个机器指令。 《LINUX 设备驱动程序》(第三版)P126
内核定义了atomic_t数据类型,用作对整数计数器的原子操作的基础。从内核的角度看,这些操作的执行方佛是一条汇编语句。《深入LINUX内核架构》P280
避免由于“读-修改-写”指令引起的竞争条件最容易的办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断。 《深入理解LINUX内核》P199
8.2 注意点
atomic_t变量中不能记录大于24位的整数。 《LINUX设备驱动程序》(第三版)P126
尽管linux支持的所有机器上的整型数据都是32位的,但是使用atomic_t的代码只能将该类型的数据当做24位来用。这个限制完全是因为在SPARC体系结构上,原子操作的实现不同于其他体系结构。具体信息请参考《Linux内核设计与实现》P142
8.3 对整数的原子操作(atomic_t / atomic64_t)
atomic_t v = ATOMIC_INIT(0);
int atomic_read(atomic_t *v);
void atomic_add(int i, atomic_t *v);
......
《LINUX设备驱动程序》(第三版)P127
atomic64_t和atomic_t无异,使用方法完全相同,不同的只是整型变量大小从32位变成64位。 《Linux内核设计与实现》P145
8.4 原子位处理(set_bit / clear_bit / test_bit / ...)
void set_bit(nr, void *addr);
void clear_bit(nr, void *addr);
void change_bit(nr, void *addr);
void test_bit(nr, void *addr);
void test_and_set_bit(nr, void *addr);
void test_and_clear_bit(nr, void *addr);
void test_and_change_bit(nr, void *addr);
《LINUX设备驱动程序》(第三版)P128
9 读取-复制-更新(RCU)
9.1 简介
它针对经常发生读取而很少写入的情形做了优化。被保护的资源应该通过指针访问,而对这些资源的引用必须仅由原子代码拥有。 《LINUX设备驱动程序》(第三版)P130
在需要修改数据结构时,写入线程首先复制出一个副本,然后修改副本,之后用新的副本替代相关指针。当内核确信老的版本上没有其他引用时,就可以释放老的版本。 《LINUX设备驱动程序》(第三版)P131
RCU允许多个读者和写者并发执行。而且,RCU是不使用锁的。需要内存屏障来协助,具体内容请参考《深入理解LINUX内核》P210
RCU 的性能很好,不过对内存有一定的开销,但大多数情况下可以忽略不计。
RCU 对潜在使用者提出的一些约束:
1. 对共享资源的访问在大部分时间应该是只读的,写访问应该相对很少。
2. 在 RCU 保护的代码范围内,内核不能进入睡眠状态。
3. 受保护的资源必须通过指针访问。
《深入LINUX内核架构》P284
RCU能保护的,不仅仅是一般的指针。内核也提供了标准函数,使得能通过RCU机制保护双链表,这是RCU机制在内核内部最重要的应用。此外,有struct hlist_head和struct hlist_node组成的散链表也可以通过RCU保护。 《深入LINUX内核架构》P285
9.2 注意点
RCU只保护被动态分配并通过指针引用的数据结构。
在被RCU保护的临界区中,任何内核控制路径都不能睡眠。 《深入理解LINUX内核》P210
10 优化屏障和内存屏障
《Linux内核设计与实现》P162
《深入理解LINUX内核》P201
《深入LINUX内核架构》P286
11 大内核锁(BKL,过时的概念,基本不用)
《Linux内核设计与实现》P159
《深入理解LINUX内核》P225
《深入LINUX内核架构》P288
12 互斥量(struct mutex / struct rt_mutex)
12.1 简介
尽管信号量可以用于实现互斥量的功能,信号量的通用性导致的开销通常是不必要的。因此,内核包含了一个专用互斥量的独立实现,它们不依赖信号量。
内核包含互斥量的两种实现。一种是经典的互斥量,另一种是用来解决优先级反转问题的实时互斥量。
《深入LINUX内核架构》P288
12.2 注意点
静态互斥量可以在编译时通过使用DEFINE_MUTEX产生,不要和DECLARE_MUTEX混淆,后者是基于信号量的互斥量。《深入LINUX内核架构》P289
各种锁机制的综合分析
自旋锁、读 / 写自旋锁 和 顺序锁
使用读 / 写自旋锁时,内核控制路径发出的执行read_lock或write_lock操作的请求具有相同的优先权:读取者必须等待,直到写操作完成。同样的,写者也必须等待,直到读操作完成。
顺序锁(seqlock)与读 / 写自旋锁非常类似,只是它为写者赋予了较高的优先级。
《深入理解LINUX内核》P209
只要内核控制路径获得自旋锁(还有读 / 写锁或RCU“读锁”),就禁用本地中断或软中断,自动禁用内核抢占(spin_lock函数里会调用preempt_disable)。 《深入理解LINUX内核》P220
唯一允许睡眠的锁(信号量和互斥体)
《Linux内核设计与实现》P156
信号量和互斥体
除非mutex的某个约束妨碍你的使用,否则相比信号量要优先使用mutex。当你写新代码时,只有碰到特殊场合(一般是很底层代码)才会需要使用信号量。因此建议首选mutex。如果发现不能满足其约束条件,且没有其他别的选择时,再考虑选择信号量。
《Linux内核设计与实现》P158
自旋锁和互斥体
在中断上下文中只能使用自旋锁,而在任务睡眠时只能使用互斥体。
需求 | 建议的加锁方法 |
低开销加锁 | 优先使用自旋锁 |
短期锁定 | 优先使用自旋锁 |
长期加锁 | 优先使用互斥体 |
中断上下文加锁 | 使用自旋锁 |
持有锁需要睡眠 | 使用互斥体 |
《Linux内核设计与实现》P158
自旋锁、信号量及中断禁止之间选择
访问数据结构的内核控制路径 | 单处理器保护 | 多处理器进一步保护 |
异常 | 信号量 | 无 |
中断 | 本地中断禁止 | 自旋锁 |
可延迟函数 | 无 | 无或自旋锁 |
异常与中断 | 本地中断禁止 | 自旋锁 |
异常与可延迟函数 | 本地软中断禁止 | 自旋锁 |
中断与可延迟函数 | 本地中断禁止 | 自旋锁 |
异常、中断与可延迟函数 | 本地中断禁止 | 自旋锁 |
《深入理解LINUX内核》P220
度量内核花费在锁上的时间(lockmeter工具)
《LINUX设备驱动程序》(第三版)P125
参考资料
《Linux内核设计与实现》原书第三版,机械工业出版社
《深入理解LINUX内核》第三版,中国电力出版社
《LINUX设备驱动程序》第三版,中国电力出版社
《深入LINUX内核架构》,人民邮电出版社