目录
1 并发与竞态
1.1 竞态概念
1.2 竞态场景
1.2.1 对称多处理器SMP
1.2.2 内核抢占调度
1.2.3 中断机制
1.3 互斥与同步的区别
2 Linux内核中的上下文判断
2.1 上下文与preempt_count字段
2.2 preempt_count字段布局
2.3 preempt_count字段操作
2.3.1 禁止内核抢占计数操作
2.3.2 软中断处理中标志操作
2.3.3 禁止软中断计数操作
2.3.4 硬中断嵌套计数操作
2.3.5 NMI中断处理中标志操作
2.3.6 需要重新调度标志操作
2.4 通过preempt_count字段判断上下文类型
2.5 原子上下文
3 互斥机制
3.1 禁止内核抢占
3.1.1 操作API
3.1.2 实现原理
3.1.3 原子性讨论
3.1.4 生效场景
3.2 屏蔽本地中断
3.2.1 操作API
3.2.2 实现原理
3.2.3 原子性讨论
3.2.4 生效场景
3.3 原子操作
3.3.1 实现原理
3.3.2 操作API
3.3.2.1 int型原子操作
3.3.2.2 long型原子操作
3.3.2.3 bit型原子操作
3.3.3 原子性讨论
3.3.4 生效场景
3.4 自旋锁
3.4.1 操作API
3.4.2 实现原理
3.4.2.1 数据结构
3.4.2.2 初始化操作
3.4.2.3 上锁操作
3.4.2.4 解锁操作
3.4.3 原子性讨论
3.4.4 生效场景
3.5 互斥锁
3.5.1 操作API
3.5.2 实现原理
3.5.2.1 数据结构
3.5.2.2 初始化操作
3.5.2.3 上锁操作
3.5.2.4 解锁操作
3.5.3 原子性讨论
3.5.4 生效场景
4 同步机制
4.1 信号量
4.1.1 操作API
4.1.2 实现原理
4.1.2.1 数据结构
4.1.2.2 初始化操作
4.1.2.3 获取信号量操作
4.1.2.4 释放信号量操作
4.1.3 原子性讨论
4.2 等待队列
4.2.1 操作API
4.2.2 实现原理
4.2.2.1 数据结构
4.2.2.2 初始化操作
4.2.2.2.1 初始化等待队列头部
4.2.2.2.2 初始化等待队列节点
4.2.2.3 加入等待队列
4.2.2.4 移出等待队列
4.2.2.5 在等待队列上唤醒
4.2.3 原子性讨论
4.2.4 使用实例讨论
4.3 等待事件
4.3.1 操作API
4.3.2 实现原理
4.3.3 原子性讨论
4.3.4 使用实例讨论
4.4 完成量
4.4.1 操作API
4.4.2 实现原理
4.4.2.1 数据结构
4.4.2.2 初始化操作
4.4.2.3 等待完成量操作
4.4.2.4 释放完成量操作
4.4.3 原子性讨论
5 globalmem设备驱动互斥实现示例
5.1 共享资源分析
5.2 互斥机制选择
5.3 互斥机制使用实例
1. 并发(Concurrency)是指多个执行单元在同一时间段内执行(但并不一定在同一时刻),而并发的执行单元对共享资源(包括硬件资源和软件资源)的访问就会导致竞态(Race Conditions)
2. 此处的执行单元可以是进程、线程或中断处理程序(其中又可以分为中断顶半部和中断底半部)
3. Linux内核提供了一系列并发控制机制用于确保在并发环境下能安全地访问共享资源
说明1:由于是分析Linux设备驱动中的互斥与同步,因此本文的讨论范围局限于内核态
说明2:访问共享资源的代码区域称为临界区(Critical Sections),各种并发控制机制保护的就是临界区
所谓竞态场景就是上文所述的多种执行单元在访问共享资源时的并发场景
1. SMP(Symmetric Multi-Processor,对称多处理器)是一种紧耦合、共享存储的系统模型,他的特点是多个CPU使用共同的系统总线,因此可以访问共同的外设和存储器
2. 在SMP场景中,不同CPU的进程和中断处理程序之间访问共享资源时构成竞态
3. SMP导致的竞态属于核间竞态
1. Linux 2.6之后的内核支持内核抢占调度(用户态抢占调度则是始终支持的),即一个进程在内核态执行时可能因时间片耗尽或者有更高优先级的进程要执行而被打断
2. 进程与抢占他的进程访问共享资源时构成竞态
3. 内核抢占调度导致的竞态属于核内进程间竞态
说明1:早期的Linux内核不支持内核态抢占调度,以Linux 0.11内核为例,如果进程在内核态执行时耗尽时间片,内核并不会触发调度,而是会让进程在内核态继续运行,直到进程返回用户态或主动放弃CPU
说明2:对于支持内核抢占的内核,在编译时需要打开CONFIG_PREEMPT配置宏才能使能内核抢占功能
1. 中断可以打断正在执行的进程,如果中断服务程序和被打断的进程访问共享资源,则构成竞态
2. 中断机制导致的竞态属于核内进程与中断间竞态
说明1:在SMP环境中,考虑到内核抢占与中断,可能构成竞态的完整场景如下图所示。针对不同的竞态对象,需要选择合适的并发控制机制进行处理
说明2:上述竞态场景中,只有SMP场景是真正的并行,其他都是在单CPU上的"宏观并行,微观串行"(但是他们引发的问题与SMP类似)
说明3:关于中断嵌套
① 中断嵌套是指中断处理函数被新的更高优先级的中断打断,如果新的中断处理函数和被打断的中断处理函数访问共享资源,则构成竞态
② 在Linux 2.6.35之前的内核版本中软件层面支持中断嵌套,在申请中断时可以设置IRQF_DISABLED标志以避免中断嵌套
③ 从Linux 2.6.36开始内核默认不支持中断嵌套,因此IRQF_DISABLED标志变得无效,从而被移除。相关内容可参考[PATCH v2] Remove deprecated IRQF_DISABLED flag entirely
1. 互斥关注的是资源的独占访问,目的是确保在任何时刻只有一个执行单元可以访问共享资源,互斥原语包括自旋锁(spinlock)和互斥锁(mutex)等
2. 同步关注的是执行单元之间的执行顺序,目的是在执行单元之间建立先后关系,同步原语包括信号量(semaphore)和完成量(completion)等
说明:本文对Linux内核提供的互斥机制和同步机制进行了区分,互斥机制侧重于对临界区的保护,而同步机制侧重于协调不同执行单元的执行顺序
后续互斥与同步机制的实现分析与适用性讨论中会涉及Linux内核中的上下文判断,因此单列章节进行说明
1. Linux内核中的上下文表示程序执行的环境,在特定的环境下只能做特定的事,或者不允许做某些事。一个典型的例子,就是在中断上下文中不允许睡眠
2. 在Linux内核中,上下文的设置与判断通过进程中的preempt_count字段实现,该字段定义在thread_info结构体中
3. Linux内核在preempt_count字段中打包了禁止内核抢占计数、软中断处理中标志、禁止软中断计数、硬中断嵌套计数(硬中断处理中标志)、NMI中断处理中标志和需要重新调度标志。内核通过设置与判断preempt_count字段中的不同位域,来设置与判断当前的上下文类型
Linux 5.4.70内核中的preempt_count字段布局如下图所示,
说明1:preempt_count字段各位域起始比特位宏定义
通过preempt_count字段各位域长度宏定义(即PREEMPT_BITS等宏),可以计算得到各位域起始比特位
说明2:preempt_count字段各位域掩码宏定义
通过preempt_count字段各位域长度和起始比特位宏定义,可以计算得到各位域掩码
说明3:preempt_count字段各位域偏移量宏定义
通过preempt_count字段各位域的起始比特位宏定义,可以计算得到各位域偏移量,该偏移量后续用于在位域上进行计算
说明4:关于preempt_count字段的基本操作
① 获取preempt_count字段
② 在preempt_count字段的位域中进行累加操作
③ 在preempt_count字段的位域中进行递减操作
说明5:在ARM64体系结构中,preempt_count字段通过preempt结构体实现,其中count字段在低32位,need_resched字段在高32位。因此PREEMPT_NEED_RESCHED宏是将1左移32位,从而设置到need_resched字段
1. preempt_disable函数会将禁止内核抢占计数加1,从而禁止内核抢占
2. preempt_enable函数会将禁止内核抢占计数减1,如果计数变为0,则开启内核抢占。如果当前满足内核抢占条件,还会触发调度
需要注意的是,根据__preempt_count_dec_and_test函数的判断标准,只有当preempt_count字段整体为0,也就是除了禁止内核抢占计数为0,还需要软中断处理中标志 / 禁止软中断计数 / 硬中断嵌套计数 / NMI中断处理中标志 / 需要重新调度标志均为0时,才会触发调度
说明1:__preempt_count_dec_and_test函数判断是否需要触发调度的标准为,
① 不在原子上下文中(即preempt结构体中的count字段为0)
② 并且当前进程需要重新调度(即preempt结构体中的need_resched字段为0)
该函数在判断时,使用了thread_info.preempt_count字段,从而达到通过联合体同时判断上面2个字段的目的;由此也可以看出将thread_info.preempt.need_resched字段设置为0时表示需要重新调度的原因
说明2:相较于__preempt_count_dec_and_test函数,preemptible函数只是用于判断当前是否可抢占,具体标准如下,理解时需要加以区分
① 不在原子上下文中(即preempt结构体中的count字段为0)
② 并且没有关中断
3. preempt_enbale_no_resched函数会将禁止内核抢占计数减1,如果计数变为0,则开启内核抢占,但是该函数不会触发调度
说明:barrier编译器屏障(compiler barrier)的作用
① barrier编译器屏障的作用是防止编译器优化导致指令重排序,在某些情况下,这种重排序可能导致错误的程序行为
② 在preempt_diable和preempt_enable函数中增加barrier编译器屏障的目的,是确保要保护的代码一定是在禁止内核抢占的条件下运行,如下图所示,
preempt_disable() |
1. 因为软中断在单个CPU上不会嵌套执行,所以对于preempt_count字段中软中断相关的bits [15:8]位域,
① 使用bit [8]标记当前是否处于软中断上下文
② 使用bits [15:9]记录禁止软中断计数
2. 内核在处理软中断的过程前后,会设置和清除软中断处理中标志
3. 可以通过in_serving_softirq宏判断当前是否正在处理软中断
1. 可以通过local_bh_disable函数增加禁止软中断计数,通过local_bh_enable函数减少禁止软中断计数。从函数名看,这2个函数用于禁止和使能本地CPU的底半部运行,可见此处的底半部是指软中断(或基于软中断实现的机制,e.g. tasklet机制)这类运行在中断上下文中的底半部机制
2. 如果禁止软中断计数不为0,则在退出中断处理的过程中不会处理软中断
说明:__local_bh_enable_ip函数在当前满足内核抢占条件时会触发调度
1. 早期的Linux内核支持硬中断嵌套,但是目前的内核版本已不再支持内核嵌套,因此硬中断嵌套计数中实际只使用一位,用于标记当前是否处于硬中断上下文
2. 内核在处理硬中断的过程前后,会增加和减少硬中断嵌套计数
1. NMI中断处理中表示用于标记当前是否处于NMI中断上下文,内核在处理NMI中断的过程前后会设置和清除NMI中断处理中标志
2. 在Linux 5.8之前的内核版本中,不支持NMI中断嵌套,所以NMI_BITS宏值为1。从Linux 5.8版本开始,内核支持NMI中断嵌套,因此将NMI_BITS宏值修改为4,相关内容可参考[PATCH v3 01/22] hardirq/nmi: Allow nested nmi_enter() - Peter Zijlstra
说明:ARMv9.2之前的ARM64体系结构不支持NMI中断,但是可以配置CONFIG_ARM64_PSEUDO_NMI条件编译宏,此时内核会使用GICv3的高优先级中断模拟NMI,相关内容可参考Linux内核性能剖析的方法学和主要工具_linux arm64 nmi实现
1. 在ARM64体系结构中,是将need_resched字段设置为0时表示当前进程需要重新调度
说明:为什么需要重新调度时need_resched字段设置为0,反之设置为1?
① 在需要重新调度时将need_resched字段设置为0,可以使得当thread_info.preempt_count字段为0时表示preempt.count字段为0(即所有计数和处理中标志均为0,或者说不在原子上下文中)并且当前进程需要重新调度。从而简化判断条件,无需单独判断preempt结构体中的count字段和need_resched字段
② 在preempt_enable函数中就使用了上述判断方法,通过thread_info.preempt_count字段来判断是否应该触发调度
2. 中断返回过程中的内核抢占操作
中断返回过程中的内核抢占操作标准,与__preempt_count_dec_and_test函数是相同的,都是要求preempt_count字段整体为0。从el1_irq函数的实现可见,如果当前禁止内核抢占计数不为0,在中断返回过程中就不会触发内核抢占,从而确保中断返回后仍继续执行禁止内核抢占的进程
说明:set_preempt_need_resched函数与set_tsk_need_resched函数辨析
内核中2组设置需要重新调度标志的函数,如下图所示,
① 从设置标志的角度
② 从操作对象的角度
内核中提供了一系列宏定义,用于判断当前的上下文类型,
说明:根据in_task宏的实现,当处于进程上下文时,
① preempt_count字段中的禁止内核抢占计数可以不为0,所以在进程上下文中可以进行禁止内核抢占操作
② preempt_count字段中的禁止软中断计数可以不为0(注意in_task宏中使用的是SOFTIRQ_OFFSET[1 << 8],而不是SOFTIRQ_MASK[0x0000ff00]),所以在进程上下文中可以进行禁止软中断操作
1. 内核中提供了in_atomic宏用于判断当前是否处于原子上下文,判断的标准就是thread_info结构体中的preempt.count字段不为0,也就是只要存在禁止内核抢占 / 软中断处理中 / 禁止软中断 / 硬中断处理中 / NMI中断处理中的任何一种情况,都属于原子上下文
2. 根据内核注释,通过in_atomic宏判断当前是否处于原子上下文是有风险的。主要是在不支持内核抢占的内核版本中,自旋锁上锁操作不会增加禁止内核抢占计数,此时虽然属于原子上下文,但是无法正确体现在preempt_count字段中
3. 在原子上下文中不应进行可能导致睡眠的操作,内核中提供了might_sleep函数,帮助检查在当前上下文中是否允许睡眠。该函数可以帮助捕获潜在的错误,以确保不会在不允许睡眠的上下文中调用可能导致睡眠的函数,这有助于防止内核死锁或其他不稳定行为
可见如果preempt.count字段不为0,或者中断被关闭,都属于原子上下文,不能进行可能导致睡眠的操作
说明:中断上下文、进程上下文和原子上下文的关系如下图所示,
① 中断上下文均属于原子上下文
② 在进程上下文中进行相关操作(e.g. 禁止内核抢占,禁止软中断)可以导致进入原子上下文状态
操作API |
功能 |
禁止内核抢占API |
|
preempt_disable() |
将禁止内核抢占计数加1,从而禁止内核抢占 |
使能内核抢占API |
|
preempt_enable() |
将禁止内核抢占计数减1,如果计数变为0,则开启内核抢占。如果当前满足内核抢占条件,还会触发调度 |
preempt_enable_no_resched() |
将禁止内核抢占计数减1,如果计数变为0,则开启内核抢占,但是该函数不会触发调度 |
1. 如上文所述,在支持内核抢占的内核版本中,通过禁止内核抢占计数来标识当前是否可抢占
① 如果禁止内核抢占计数为0,则可以抢占
② 如果禁止内核抢占计数大于0,则不可抢占
2. 内核在各抢占调度点会检查禁止内核抢占计数,从而判断当前是否可抢占。只有在可抢占时,内核才会触发任务调度,选择一个新的任务来运行。以preempt_schedule函数为例,只有当preempt_count字段为0且没有关闭中断时才会触发调度,否则直接返回
说明:内核互斥与同步机制的原子性讨论包含2个方面,
① 该机制是否可以在原子上下文中使用
② 该机制构成的临界区是否是原子上下文
1. 禁止内核抢占不会导致睡眠,而且可以嵌套禁止内核抢占(因为禁止内核抢占通过累加计数值的方式实现),因此可以在原子上下文中使用
2. 通过禁止内核抢占构成的临界区属于原子上下文,因此不能在其中进行可能导致睡眠的操作
说明:临界区原子性实验验证
① 以Linux设备驱动基础03:Linux字符设备驱动 中实现的字符设备驱动为基础进行验证,在open回调函数中增加禁止内核抢占操作,并且在临界区内调用schedule函数触发调度
② 经过验证,在schedule函数中会检测出在原子上下文中触发调度的错误
其中schedule函数报错流程如下图所示,
说明1:需要特别注意的是,禁止内核抢占操作设置的是task_struct结构体中的thread_info.preempt.count字段,因此禁止内核抢占是以进程为单位的(而不是以CPU为单位)
也就是说,如果在进程A禁止内核抢占,并在此条件下切换到进程B(下一条说明就展示了这种情况),那么切换后的内核抢占状态是由进程B的task_struct.hread_info.preempt.count字段控制的。如果再次切换会进程A,则仍然是禁止内核抢占的状态
说明2:关于schedule函数与禁止内核抢占的讨论
① 由schedule函数的实现可见,在实际进入进程调度流程之前内核会调用preempt_disable函数禁止内核抢占。也就是说,进程调度是在禁止内核抢占的条件下进行的
② 这么做的目的是确保在进程调度期间不会发生内核抢占,即进程调度的过程是串行进行的,从而保证进程调度过程中相关数据的原子性。因此在schedule_debug函数判断是否可调度时使用的是in_atomic_preempt_off函数,也就是thread_info.preempt.count字段值为1,即除了禁止内核抢占计数为1,其他hardirq / softirq / nmi计数均为0
③ 那既然允许scheduler在进程调度时禁止内核抢占,为什么不允许内核进程在禁止内核抢占的情况下进行可能导致睡眠的操作呢?
这是因为如果允许内核进程在禁止内核抢占的情况下进行可能导致睡眠的操作,如果切换后的进程也要访问通过禁止内核抢占保护的临界区,就破坏了临界区的原子性,场景如下图所示,
④ 同理,scheduler在进程调度时禁止内核抢占也需要处理好临界区原子性的问题,因为调用schedule函数切换后的进程也可能调用schedule函数进行进程切换。所以说schedule函数中调用的__schedule函数肯定是处理好了对共享资源(e.g. 运行队列run queue)的访问原子性问题,才可以在禁止内核抢占的情况下进行进程切换操作
⑤ 从上述实现中可见,禁止内核抢占是一套纯软件机制
1. 可以处理核内进程间竞态
禁止内核抢占可以禁止CPU上当前正在内核态执行的进程被其他进程抢占,因此可以处理核内进程间竞态
2. 无法处理核间竞态和核内进程与中断间竞态
① 在SMP场景下,禁止内核抢占仅作用于当前CPU,而不是整个系统,因此无法处理核间竞态
② 禁止内核抢占并没有屏蔽本地中断,因此也无法处理核内进程与中断间竞态
说明1:核内进程间竞态实例
per-CPU变量仅能被所属CPU上的执行单元访问,如果某个per-CPU变量仅被所属CPU上的进程访问,则可以使用禁止内核抢占的方式保护
说明2:如果禁止内核抢占的进程长期占用CPU,该CPU上的其他进程将无法及时得到调度,因此通过禁止内核抢占保护的临界区应该尽可能短暂
操作API |
功能 |
屏蔽/使能本地中断API |
|
local_irq_disable() |
屏蔽本地中断 |
local_irq_enable() |
打开本地中断 |
local_irq_save(flags) |
保存当前DAIF寄存器状态,并屏蔽本地中断 |
local_irq_restore(flags) |
使用flags的值恢复DAIF寄存器状态 |
判断本地中断屏蔽状态API |
|
irqs_disabled() |
判断当前本地中断是否已屏蔽 |
1. CPU一般都具备屏蔽中断和打开中断的功能,具体操作与体系结构相关。在ARM64体系结构中,通过操作DAIF寄存器中的I位,可以屏蔽和打开中断
2. local_irq_disable函数分析
3. local_irq_enable函数分析
4. local_irq_save函数分析
5. local_irq_restore函数分析
说明:通过local_irq_save和local_irq_restore函数可以实现嵌套关中断,要点就是local_irq_restore函数是使用匹配的local_irq_save函数保存的状态恢复DAIF寄存器
6. irqs_disabled函数分析
说明:禁止中断底半部
如上文所述,还可以单独禁止中断底半部,相关操作API如下,
操作API |
功能 |
local_bh_disable() |
将禁止软中断计数加1,从而禁止基于软中断实现的中断底半部 |
local_bh_enable() |
将禁止软中断计数减1,如果计数变为0,则开启中断底半部 如果当前满足处理软中断的条件,会处理软中断 如果当前满足内核抢占条件,还会触发调度 |
1. 屏蔽本地中断不会导致睡眠,而且有支持嵌套关中断的版本,因此可以在原子上下文中使用
2. 通过屏蔽本地中断构成的临界区属于原子上下文,因此不能在其中进行可能导致睡眠的操作
说明1:和在禁止内核抢占的情况下进行可能导致睡眠的操作类似,如果切换后的进程也要访问通过屏蔽中断保护的临界区,也会破坏临界区的原子性(当然,还会导致其他问题)
说明2:验证在屏蔽本地中断的情况下调用schedule函数
① 产生该验证想法是因为在in_atomic宏的判断条件中并不包括屏蔽本地中断的情况,而might_sleep函数的判断标准中包括了屏蔽本地中断的情况。也就是说,在屏蔽本地中断的情况下,根据in_atomic宏判断不属于原子上下文(这里还需要thread_info.preempt.count字段为0);但是根据might_sleep函数的判断标准,不能进行可能导致睡眠的操作
② 经过验证,由于schedule函数本身不检查中断是否关闭,因此在调用local_irq_disable函数屏蔽本地中断后仍可以调用schedule函数,而且实验所用的驱动程序"貌似"可以工作
③ 为了进一步验证上述现象,在屏蔽和使能本地中断的操作前后打印当前中断屏蔽状态。可见当再次切换回globalmem_open函数所在进程执行时,本地中断已经被打开
④ 之所以在屏蔽本地中断并调用schedule函数之后中断会被打开,是因为在schedule函数中有屏蔽和使能本地中断的操作,而且使用的是非嵌套版本,这就是实验驱动程序在屏蔽本地中断的情况下可以调用schedule函数并且没有导致系统崩溃的原因
但是从语义上说,schedule函数返回后的中断屏蔽状态已经被修改,并不是驱动程序期望的屏蔽状态,因此不应在屏蔽本地中断的情况下进程可能导致睡眠的操作
⑤ 需要特别注意的是,上述验证是在进程上下文中屏蔽中断然后调用schedule函数触发进程调度,如果是在中断顶半部(此时处于中断屏蔽状态)中进行可能导致睡眠的操作,还会导致更为复杂的问题
1. 可以处理核内进程与中断间竞态
屏蔽本地中断后,本地CPU不再响应中断,也就不会运行中断处理函数,因此可以处理核内进程与中断间竞态
2. 无法处理核间竞态和核内进程间竞态
① 在SMP场景下,屏蔽本地中断仅作用于当前CPU,而不是整个系统,因此无法处理核间竞态
② 屏蔽本地中断会导致部分内核抢占点不被执行(e.g. preemptible函数的判断条件中要求中断没有被屏蔽时才能触发抢占调度),但是无法禁止所有内核抢占点,所以也无法处理核内进程间竞态
说明1:核内进程与中断间竞态的2个对象并不是对等的,中断本身是可以打断进程的。所以一般都是在进程中屏蔽本地中断,从而确保进程在内核中的执行路径不被中断处理程序打断
对于中断处理程序而言,中断顶半部本身就是在关中断的环境中运行,所以无需屏蔽本地中断;而中断底半部是在开中断的环境中运行,但是中断底半部可能运行在中断上下文(e.g. 软中断softirq)也可能运行在进程上下文(e.g. 工作队列workqueue),此时需要根据根据实际情况决定是否需要屏蔽本地中断,详解后文自旋锁章节的讨论
说明2:屏蔽本地中断并不是安全的禁止内核抢占的方法
如上文所述,屏蔽本地中断虽然会影响部分内核抢占点,但是无法禁止所有内核抢占点。在屏蔽本地中断的情况下,只要执行路径中调用到cond_resched函数,则依然会触发内核抢占,详情可参考如下链接
Why is irqs_disabled() an unsafe way of disabling preemption?
Why disabling interrupts disables kernel preemption and how spin lock disables preemption
说明3:中断对于内核的运行非常重要(e.g. 进程调度就依赖于中断),在屏蔽中断期间所有中断都无法得到处理。如果长时间屏蔽中断,可能造成数据丢失乃至系统崩溃等后果,因此通过屏蔽本地中断保护的临界区也应该尽可能短暂
原子操作依靠体系结构的原子操作指令实现,在ARM64体系结构中有2种实现机制,
1. Load/Store Exclusive机制
这是ARMv8体系结构支持的基础原子内存访问机制,该机制通过ldxr和stxr指令对实现原子操作。ldxr和stxr指令配对使用,可以让总线监控ldxr和stxr指令之间有无其他执行单元存取该地址。如果有其他并发访问,则执行stxr指令时第1个寄存器的值会被设置为1,并且存储的行为也会失败。当独占访问失败时,可以通过条件跳转指令重新尝试
2. LSE(Large System Extension)机制
① LSE机制是ARMv8.1引入的一组新的原子操作指令,用于提高大型系统(e.g. 多核处理器和多处理器系统)中的同步和并发性能。相较于传统的Load/Store Exclusive指令,LSE指令在高争用场景中有更好的性能表现
② LSE指令集包括原子交换(SWAP)、原子比较交换(CAS)、原子加载和操作(LDADD)和原子存储和操作(STADD)等,这些指令可以用于实现各种高效的原子操作和同步原语
1. int型原子操作数据类型如下,
2. int型原子操作API如下,
操作API |
功能 |
获取原子变量值API |
|
int atomic_read(const atomic_t *v) |
读取原子变量的值,即原子地读取v->counter |
设置原子变量值API |
|
void atomic_set(atomic_t *v, int i) |
设置原子变量的值,即原子地设置v->count = i |
原子变量自增/自减API |
|
void atomic_inc(atomic_t *v) |
将原子变量的值加1,即原子地v->counter++ 相应的int atomic_inc_return(atomic_t *v)函数还会返回原子变量加1后的值 |
void atomic_dec(atomic_t *v) |
将原子变量的值减1,即原子地v->counter-- 相应的int atomic_dec_return(atomic_t *v)函数还会返回原子变量减1后的值 |
原子变量加/减API |
|
void atomic_add(int i, atomic_t *v) |
将原子变量的值加i,即原子地v->counter += i 相应的int atomic_add_return(int i, atomic_t *v)函数还会返回原子变量加i后的值 |
void atomic_sub(int i, atomic_t *v) |
将原子变量的值减i,即原子地v->counter -= i 相应的int atomic_sub_returen(int i, atomic_t *v)函数还会返回原子变量减i后的值 |
原子变量操作并测试API |
|
bool atomic_inc_and_test(atomic_t *v) |
先将原子变量的值加1,再判断新值是否为0,如果新值为0,则返回1 |
bool atomic_dec_and_test(atomic_t *v) |
先将原子变量的值减1,再判断新值是否为0,如果新值为0,则返回1 |
说明:可以通过ATOMIC_INIT宏设置int型原子变量的初始值
1. long型原子操作数据类型如下,可见与int型原子操作相比数据长度从4B扩展为8B
2. long型原子操作API如下,可见与int型原子操作基本相同
操作API |
功能 |
获取原子变量值API |
|
s64 atomic64_read(const atomic64_t *v) |
读取原子变量的值,即原子地读取v->counter |
设置原子变量值API |
|
void atomic64_set(atomic64_t *v, int i) |
设置原子变量的值,即原子地设置v->count = i |
原子变量自增/自减API |
|
void atomic64_inc(atomic64_t *v) |
将原子变量的值加1,即原子地v->counter++ 相应的s64 atomic64_inc_return(atomic64_t *v)函数还会返回原子变量加1后的值 |
void atomic64_dec(atomic64_t *v) |
将原子变量的值减1,即原子地v->counter-- 相应的s64 atomic64_dec_return(atomic64_t *v)函数还会返回原子变量减1后的值 |
原子变量加/减API |
|
void atomic64_add(int i, atomic64_t *v) |
将原子变量的值加i,即原子地v->counter += i 相应的s64 atomic64_add_return(int i, atomic64_t *v)函数还会返回原子变量加i后的值 |
void atomic64_sub(int i, atomic64_t *v) |
将原子变量的值减i,即原子地v->counter -= i 相应的s64 atomic64_sub_returen(int i, atomic64_t *v)函数还会返回原子变量减i后的值 |
原子变量操作并测试API |
|
bool atomic64_inc_and_test(atomic64_t *v) |
先将原子变量的值加1,再判断新值是否为0,如果新值为0,则返回1 |
bool atomic64_dec_and_test(atomic64_t *v) |
先将原子变量的值减1,再判断新值是否为0,如果新值为0,则返回1 |
说明1:可以通过ATOMIC64_INIT宏设置long型原子变量的初始值,可见在ARM64体系结构体中ATOMIC64_INIT宏与ATOMIC_INIT宏相同
说明2:int型原子操作和long型原子操作均通过体系结构的原子操作指令实现,差别主要在于操作的数据长度
bit型原子操作没有定义特殊的数据类型,而是直接操作整型数据中的位,相关操作API如下,
操作API |
功能 |
设置位API |
|
void set_bit(unsigned int nr, volatile unsigned long *p) |
将(*p)的bit[nr]置为1 |
清除位API |
|
void clear_bit(unsigned int nr, volatile unsigned long *p) |
将(*p)的bit[nr]置为0 |
改变位API |
|
void change_bit(unsigned int nr, volatile unsigned long *p) |
翻转(*p)中bit[nr]的值 |
测试位API |
|
bool test_bit(unsigned int nr, const volatile unsigned long *p) |
测试(*p)的bit[nr]是否置位 |
测试并操作位API |
|
int test_and_set_bit(unsigned int nr, volatile unsigned long *p) |
将(*p)的bit[nr]置为1,并且返回该位的旧值 |
int test_and_clear_bit(unsigned int nr, volatile unsigned long *p) |
将(*p)的bit[nr]置为0,并且返回该位的旧值 |
int test_and_change_bit(unsigned int nr, volatile unsigned long *p) |
翻转(*p)中bit[nr]的值,并且返回该位的旧值 |
说明1:bit型原子操作基于整型原子操作实现,以set_bit函数为例,
说明2:虽然Linux内核中的bitmap系列函数(以bitmap_开头,e.g. bitmap_and,定义在include/linux/bitmap.h文件)也可以进行位操作,但是他们不是原子的,相关内容可参考Atomic bitops — The Linux Kernel documentation
说明3:根据Atomic bitops — The Linux Kernel documentation,内核中以__开头的位操作也不是原子的(e.g. __set_bit)
1. 原子操作不会导致睡眠,因此可以在原子上下文中使用
2. 原子操作构成的临界区局限于一个原子操作的范围,没有上下文的概念
原子操作可以确保对一个整型数据的修改是排他性的,通过将临界区局限在一个原子操作的范围,就可以处理核间竞态、核内进程间竞态和核内进程与中断间竞态
1. 自旋锁数据类型如下,可见在Linux 5.4.70内核中自旋锁以队列自旋锁的方式实现
2. 自旋锁操作基础API
操作API |
功能 |
初始化自旋锁API |
|
void spin_lock_init(spinlock_t *lock) |
初始化已定义的自旋锁,且初始状态为unlock |
DEFINE_SPINLOCK(name) |
定义并初始化一个自旋锁,且初始状态为unlock |
获取自旋锁API |
|
void spin_lock(spinlock_t *lock) |
获取自旋锁,如果无法获取,执行单元忙等待;该函数返回时说明肯定获取了自旋锁 |
int spin_trylock(spinlock_t *lock) |
尝试获取自旋锁,成功获取返回1;否则返回0,执行单元不会忙等 |
释放自旋锁API |
|
void spin_unlock(spinlock_t *lock) |
释放自旋锁 |
判断自旋锁锁定状态API |
|
int spin_is_locked(spinlock_t *lock) |
返回自旋锁状态,已加锁返回1,否则返回0 |
3. 自旋锁操作衍生API
如上文所述,持有自旋锁的执行单元还可能被中断或中断底半部打断,此时需要使用衍生的自旋锁API,在获取自旋锁的同时屏蔽本地中断或禁止中断底半部
操作API后缀 |
功能 |
_bh(spinlock_t *lock) |
加锁时禁止中断底半部,解锁时使能中断底半部 相当于spin_lock + local_bh_disable |
_irq(spinlock_t *lock) |
加锁时屏蔽本地中断,解锁时使能本地中断 相当于spin_lock + local_irq_disable |
_irqsave(spinlock_t *lock, flags) _restore(spinlock_t *lock, flags) |
加锁时保存当前DAIF寄存器状态并屏蔽本地中断,解锁时使用flags值恢复DAIF寄存器状态 相当于spin_lock + local_irq_save |
说明:自旋锁API使用原则
根据互斥对象的不同,自旋锁API的使用原则如下表所示。表格中将执行单元分为线程、软中断和硬中断,其中后者可以打断前者,前者不可以打断后者。因此如果互斥对象存在打断关系时,需要在前者中禁用后者才能确保临界区安全且不会发生死锁
说明:本节通过原始自旋锁来说明自旋锁的实现原理,以Linux 2.6.35内核基于ARMv7体系结构的实现为例
1. 自旋锁基于原子操作指令实现,具体的实现方式则经历了原始自旋锁、ticket自旋锁、MCS自旋锁和队列自旋锁的阶段
2. 自旋锁数据类型如下,可见其本质上是一个整型变量。自旋锁就是通过对该变量的标记,来确定当前执行单元是否成功持有锁,而这个标记的过程必须是原子的,这就需要依靠体系结构提供的原子操作指令
1. spin_lock_init宏分析
spin_lock_init宏对已定义的自旋锁进行初始化后,数据结构中的整型变量值为0,即表示unlock状态
2. DEFINE_SPINLOCK宏分析
通过DEFINE_SPINLOCK宏可以定义并且初始化一个自旋锁,可见自旋锁的初始状态为unlock
spin_lock函数上锁操作流程如下,可见核心是依靠ldrex和strex原子操作指令
说明1:arch_spin_lock函数中当上锁未成功时的重试操作,就是自旋锁名称的由来。也就是说自旋锁是一种忙等锁,执行单元在等待获取自旋锁的过程中会一直循环操作等待
如果配置使能执行wfe指令,则CPU可以进入low power模式而不是循环操作等待
说明2:从spin_lock函数的实现分析可见,自旋锁和原子变量的实现方式是相同的,都是基于原子操作指令
说明3:为什么spin_lock函数在实际的上锁操作之前需要禁止内核抢占?
① 对于SMP系统,spin_lock函数禁止内核抢占是为了避免死锁
spin_lock函数的上锁操作本身可以处理核间竞态,但是如果不禁止本地CPU的内核抢占,则当前持有锁的执行单元可能被抢占。如果抢占的执行单元也要获取相同的自旋锁,则会导致死锁
② 对于单核CPU(也称作UP系统,Uni-Processor)且运行支持内核抢占的Linux内核,spin_lock函数的实现就是禁止内核抢占。仍以Linux 2.6.35内核基于ARMv7体系结构的实现为例,spin_lock函数实现如下,
③ 对于单核CPU且运行不支持内核抢占的Linux内核,spin_lock函数的实现退化为空操作(本质上是此时preempt_disable函数实现为空操作)
说明4:如果持有自旋锁的执行单元可以被中断打断,且中断处理函数也要获取相同的自旋锁,则也可能导致死锁,此时在执行单元中需要使用spin_lock_irq函数同时屏蔽本地中断
需要注意的是,此处的被中断打断,是指被执行单元所在CPU的中断打断。如果中断处理函数在SMP系统的其他CPU上执行,互斥锁上锁操作本身即可处理;但是如果中断处理函数在本地CPU执行,则可能导致死锁
spin_unlock函数解锁操作流程如下,
1. 在设置自旋锁整型值进行解锁操作时使用str指令即可,因为地址和长度对齐(即地址按要操作的数据长度对齐)的str指令操作本身就是原子的
2. 解锁过程中的sev指令用于唤醒上锁过程中因wfe指令进入low power状态的CPU
1. 自旋锁不会导致睡眠,因此可以在原子上下文中使用
2. 通过自旋锁构成的临界区属于原子上下文,因此不能在其中进行可能导致睡眠的操作
说明:临界区原子性实验验证
① 在实验驱动程序的open回调函数中增加自旋锁上锁操作,并且在临界区内调用schedule函数触发调度
② 经过验证,在schedule函数中会检测出在原子上下文中触发调度的错误
1. 在SMP环境中,自旋锁基于原子操作指令实现,因此可以处理核间竞态
2. 在获取自旋锁的spin_lock函数中,会在真正上锁前调用preempt_disable函数禁用内核抢占,因此还可以处理核内进程间竞态
3. spin_lock函数还有衍生的关中断版本,即spin_lock_irq和spin_lock_irqsave函数。由于是在spin_lock函数的基础上增加了屏蔽本地中断的操作, 因此该版本还可以处理核内进程与中断间竞态
说明:自旋锁是一种忙等锁,在执行单元获取到锁之前不会放弃CPU(无论是循环操作等待还是执行wfe指令进入low power模式),在此期间CPU不会做任何有用的工作,因此通过自旋锁保护的临界区应该尽可能短暂
操作API |
功能 |
初始化互斥锁API |
|
void mutex_init(struct mutex *lock) |
初始化已定义的互斥锁,且初始状态为unlock |
DEFINE_MUTEX(mutexname) |
定义并初始化一个互斥锁,且初始状态为unlock |
获取互斥锁API |
|
void mutex_lock(struct mutex *lock) |
获取互斥锁,如果无法获取,执行单元睡眠;该函数返回时说明肯定获取了互斥锁 mutex_lock函数在睡眠过程中不会被信号唤醒 |
int mutex_lock_interruptible(struct mutex *lock) |
获取互斥锁,如果无法获取,执行单元睡眠,但是可以被任意信号唤醒,函数返回值如下, 0:成功获取互斥锁 -EINTR:被信号唤醒 |
int mutex_lock_killable(struct mutex *lock) |
获取互斥锁,如果无法获取,执行单元睡眠,但是可以被fatal signal(即SIGKILL信号)唤醒,函数返回值如下, 0:成功获取互斥锁 -ENTR:被信号唤醒 |
int mutex_trylock(struct mutex *lock) |
尝试获取互斥锁,成功获取返回1;否则返回0,执行单元不会睡眠 |
释放互斥锁API |
|
void mutex_unlock(struct mutex *lock) |
释放互斥锁,如有其他进程在该互斥锁上睡眠等待,则将其唤醒 实际是会唤醒等待队列中的首个进程 |
判断互斥锁锁定状态API |
|
bool mutex_is_locked(struct mutex *lock) |
判断互斥锁状态,已加锁返回1,否则返回0 |
说明:获取互斥锁的操作没有设置等待超时时间的版本(也就是没有mutex_lock_timeout版本)
1. 互斥锁基于自旋锁和等待队列实现,其中自旋锁用于保护等待队列,而没有成功获取锁的进程在等待队列上进行睡眠和唤醒
2. 互斥锁数据类型如下,通过wait_lock自旋锁的保护,可以确保wait_list等待队列在SMP系统中是安全的
说明1:此处所说的等待队列只是一个链表,并不是下文会介绍的Linux内核等待队列(wait queue)机制
说明2:互斥锁语义
互斥锁语义源自mutex结构体内核注释,具体如下,
① 一次只能有一个进程持有互斥锁
② 只有互斥锁的持有者才能解锁
③ 不允许多次解锁
④ 不允许递归上锁,否则会死锁
⑤ 只能通过API初始化互斥锁,不能使用memset或memcpy之类的函数初始化互斥锁
⑥ 进程在持有互斥锁时不能退出
⑦ 在互斥锁使用期间,互斥锁所在的内存不能释放
⑧ 在互斥锁使用期间,不能对其重新初始化
⑨ 互斥锁不能在硬中断或软中断(e.g. tasklet和定时器)上下文中使用
如果编译内核时配置了CONFIG_DEBUG_MUTEXES选项,内核会对上面的规则进行检查,防止用户对互斥锁的误用
说明3:关于MUTEX_FLAGS
① MUTEX_FLAGS存储在mutex.owner字段的最低3位,之所以可以使用这些比特位,是因为task_struct结构体是L1_CACHE_BYTES对齐的。对于ARMv8体系结构,L1_CACHE_BYTES宏值为2^6(= 64B),因此mutex.owner字段的最低6位均可以用于存储其他信息
② MUTEX_FLAGS相关标志位的含义如下图所示,需要注意的是,这些标志位是以互斥锁为单位,而不是以操作互斥锁的进程为单位,关于这些标志位的具体使用方式详见下文分析,
1. mutex_init宏分析
mutex_init宏对已定义的互斥锁进行初始化后,互斥锁的初始状态为unlock,具体如下,
① owner持有者为0
② wait_lock自旋锁为unlock状态
③ wait_list等待队列为空链表
2. DEFINE_MUTEX宏分析
通过DEFINE_MUTEX宏可以定义并初始化一个互斥锁,且初始状态为unlock
1. 尝试获取锁操作
尝试获取锁的操作是后续上锁操作的核心步骤,因此先予以说明
说明1:atomic_long_cmpxchg_acquire函数原型
/* |
说明2:为什么需要将操作放在for(;;)循环中?
因为__mutex_trylock_or_owner函数中对mutex.owner字段的操作序列并没有采用互斥机制进行保护,所以将相关操作放在for(;;)循环中。这样在操作的最后,如果通过atomic_long_cmpxchg_acquire函数判断出在操作过程中mutex.owner字段被其他进程修改,则进入循环重新来过
说明3:尝试获取锁操作算法小节
进入__mutex_trylock_or_owner函数时有如下3种情况,
① 互斥锁尚未被持有
此时当前进程可以尝试持有,并且通过for(;;)循环可以判断在尝试持有的过程中,是否有其他进程修改了mutex.owner的状态
② 互斥锁被其他进程持有
此时当前进程无法获取互斥锁,直接退出循环
③ 互斥锁被当前进程持有
此时当前进行需要判断是否已经完成了互斥锁的传递(handoff),
2. 上锁操作框架
mutex_lock函数会首先尝试通过fastpath获取互斥锁,如果此时互斥锁尚未被持有,则可以快速获取。如果失败,则通过slowpath获取互斥锁
3. fastpath上锁操作
如果互斥锁尚未被持有,当前进程可以直接通过原子操作快速获取互斥锁。而互斥锁未被持有的标志,就是mutex.owner字段为0
说明:atomic_long_try_cmpxchg_acquire函数原型
/* |
4. slowpath上锁操作
说明1:mutex_waiter结构体用于描述一个需要等待互斥锁的进程,该结构体由mutex.wait_list链表管理
说明2:__mutex_lock_common函数在进行上锁操作的过程中禁止了内核抢占,当需要睡眠时也是在禁止内核抢占的情况下通过schedule_preempt_disabled函数触发调度。这样做的目的是避免上锁操作的核内进程间竞态,保持相关数据结构的原子性(当然,只是处理核内进程间竞态)
说明3:mutex结构体数据原子性讨论
我们来扩展一下,在多个进程争用互斥锁的场景中,mutex结构体就是共享资源,因此需要处理在各种竞态场景下的数据原子性问题
① 核内进程间竞态
通过禁止内核抢占处理
② 核内进程与中断间竞态
互斥锁上锁操作可能导致睡眠,因此不能在中断上下文中使用,从而也就不存在核内进程与中断间竞态
③ 核间竞态
也正是由于互斥锁不能在中断上下文中使用,所以对wait_lock自旋锁上锁只需要spin_lock函数,无需spin_lock_irq函数
说明4:在__mutex_lock_common函数中,在实际进入睡眠之前会多次调用__mutex_trylock函数去尝试获取互斥锁,目的是尽可能避免睡眠导致的进程切换开销
说明5:乐观自旋(optimistic spin)机制简介
① Linux 5.4.70内核中的互斥锁包含了乐观自旋机制,相关实现位于mutex_optimistic_spin函数中。要使能乐观自旋机制,需要配置CONFIG_MUTEX_SPIN_ON_OWNER条件编译宏
② 乐观自旋的基本思路是因为互斥锁可能被其他CPU上正在执行中的进程持有,如果临界区很短,那么有可能互斥锁很快就被释放。此时与其进行一次进程上下文切换,还不如自旋等待,以避免进程上下文切换的开销
③ 乐观自旋机制在底层使用了MCS锁
说明6:__mutex_lock_common函数MUTEX_FLAG标志位的处理
这里再次强调一下,MUTEX_FLAG标志位是设置在mutex结构体中,用于标识互斥锁当前的状态信息
① 在将要等待互斥锁的进程加入mutex.wait_list队列时,如果加入时等待队列为空(也就是加入后的进程是top waiter),则设置MUTEX_FLAG_WAITERS标志位。该操作标识等待队列中有正在等待的进程,互斥锁的持有者在解锁时需要进行唤醒操作
② 当等待互斥锁的进程被唤醒后,如果判断出自己是top waiter,则设置MUTEX_FLAG_HANDOFF标志位。该操作标识等待队列中的top waiter需要进行互斥锁传递,互斥锁的持有者在解锁时需要将互斥锁直接传递给该进程
③ 当进程通过slowpath获取到互斥锁之后,如果等待队列为空,则清除所有MUTEX_FLAG标志
说明7:mutex_lock / mutex_lock_interruptible / mutex_lock_killable函数最终都通过__mutex_lock函数实现功能,只是传递的进程睡眠状态不同
1. 解锁操作框架
mutex_unlock函数会首先尝试通过fastpath释放互斥锁,如果此时没有进程在等待互斥锁,则可以快速释放。如果失败,则通过slowpath释放互斥锁
2. fastpath解锁操作
如果互斥锁的持有者在释放互斥锁时没有进程在wait_list等待队列上睡眠,由于无需进行唤醒操作,当前进程可以直接通过原子操作快速释放互斥锁
说明1:如果互斥锁的释放者不是互斥锁的持有者,fastpath解锁操作会失败并且进入slowpath流程,而在slowpath流程中会判断出这一情况(需要配置CONFIG_DEBUG_MUTEXES条件编译宏)
说明2:atomic_long_cmpxchg_release函数原型
/* |
3. slowpath解锁操作
从总体上说,slowpath的逻辑分为2段,
① 释放互斥锁
② 唤醒mutex.wait_list上的top waiter
说明1:关于解锁过程中的唤醒操作
① 无论是将互斥锁直接传递给top waiter,还是直接唤醒top waiter参加互斥锁的争用,都需要将top waiter唤醒,而且每次只唤醒top waiter一个进程
② 唤醒top waiter的操作通过唤醒队列wake queue实现
③ 唤醒top waiter时并未将其从等待队列中移出,而是要等到该进程获取到互斥锁之后,才会在__mutex_lock_common函数中进行出队操作
说明2:互斥锁释放操作、互斥锁传递(handoff)与乐观自旋
① 当互斥锁的持有者清除mutex.owner字段中的task_struct指针部分后,就相当于已经完成了释放操作,后续要做的就是唤醒操作。此时如果有正在该互斥锁上乐观自旋的进程,则会立即感知到锁被释放,因此可以立即获取该互斥锁。在这种情况下,即使唤醒了top waiter,相应的进程也无法获取到互斥锁
② 如果互斥锁的持有者在解锁过程中发现需要进行互斥锁传递(handoff),则不会在for循环中释放自旋锁,而是会退出循环并在__mutex_handoff函数中直接将互斥锁传递给top waiter,并唤醒相应的进程
说明3:__mutex_handoff函数分析
说明4:可以在原子上下文中调用mutex_unlock函数吗?
① 从mutex_unlock函数的实现来看,其中没有可能导致睡眠的操作,所以从合法性的角度可以在原子上下文中调用
② 但是根据互斥锁的语义,只有持有互斥锁的进程才可以进行解锁操作,因此只能是持有互斥锁的进程才可以在原子上下文中调用mutex_unlock函数解锁(当然,这种情况并不常见)
③ 在中断上下文中一定不能调用mutex_unlock函数,因为互斥锁在实现时排除了在中断上下文中使用的情况,所以在对mutex.wait_lock上锁时并没有屏蔽本地中断。如果在中断上下文中调用mutex_unlock函数,将会破坏mutex结构体中数据的原子性(当然,这种用法本身是不合理的,因为中断上下文中没有依托的task_struct结构体,也就不存在持有互斥锁的概念,此处单纯属于讨论)
其实出于同样的原因,mutex_trylock函数也不能在中断上下文中使用,该要求在mutex_trylock函数的注释中有说明
1. 获取互斥锁的操作可能导致睡眠,因此不可以在原子上下文中使用
2. 通过互斥锁构成的临界区不属于原子上下文,因此可以在其中进行可能导致睡眠的操作
3. 在中断上下文中不能调用mutex_trylock和mutex_unlock函数
说明:获取互斥锁的执行单元只能是不处于原子上下文状态的进程上下文,在实现中就是进程因系统调用陷入内核态执行或者内核线程并且不处于原子上下文(e.g. 禁止内核抢占)
1. 互斥锁只能在进程上下文中调用,不能在原子上下文中调用,因此不能处理进程与中断之间的竞态,包括
① 核内进程与中断间竞态
② 核间进程与中断间竞态
③ 核间中断与中断间竞态
2. 互斥锁中的持有状态通过原子操作实现,互斥锁中的等待队列通过自旋锁保护,因此可以处理
① 核内进程间竞态
② 核间进程间竞态
操作API |
功能 |
初始化信号量API |
|
void sema_init(struct semaphore *sem, int val) |
初始化已定义的信号量,且信号量初始值为val |
DEFINE_SEMAPHORE(name) |
定义并初始化一个信号量,且信号量初始值为1 |
获取信号量API |
|
void down(struct semaphore *sem) |
获取信号量,如果无法获取,执行单元睡眠;该函数返回时说明肯定获取了信号量 down函数在睡眠过程中不会被信号唤醒(因为是将进程睡眠状态设置为TASK_UNINTERRUPTIBLE) |
int down_interruptible(struct semaphore *sem) |
获取信号,如果无法获取,执行单元睡眠,但是可以被任意信号唤醒,函数返回值如下, 0:成功获取信号量 -EINTR:被信号唤醒 |
int down_killable(struct semaphore *sem) |
获取信号量,如果无法获取,执行单元睡眠,但是可以被fatal signal(即SIGKILL信号)唤醒,函数返回值如下, 0:成功获取信号量 -ENTR:被信号唤醒 |
int down_trylock(struct semaphore *sem) |
尝试获取信号量,成功获取返回0;否则返回1,执行单元不会睡眠 |
int down_timeout(struct semaphore *sem, long timeout) |
带超时获取信号量,如果无法获取,执行单元睡眠,其中超时时间以jiffies为单位。如果到达超时时间仍无法获取,则将执行单元唤醒,函数返回值如下, 0:成功获取信号量 -ETIME:等待超时 down_timeout函数在睡眠过程中不会被信号唤醒(因为是将进程睡眠状态设置为TASK_UNINTERRUPTIBLE) |
释放信号量API |
|
void up(struct semaphore *sem) |
释放信号量,如有其他进程在该信号量上睡眠等待,则将其唤醒 实际是唤醒等待队列中的首个进程 |
信号量数据类型如下,信号量也是基于自旋锁和等待队列实现,其中自旋锁用于保护count信号量计数值和wait_list等待队列,确保他们在SMP系统中是安全的
说明1:信号量没有持有者的概念
说明2:semaphore结构体中的count信号量计数值表示资源的数量
1. sema_init函数分析
sema_init函数对已定义的信号量进行初始化后,信号量的初始状态如下,
① 信号量初始值为val
② lock自旋锁为unlock状态
③ wait_list等待队列为空链表
2. DEFINE_SEMAPHORE宏分析
通过DEFINE_SEMAPHORE宏定义并初始化一个信号量,可见信号量的初始状态如下,
① 信号量初始值为1
② lock自旋锁为unlock状态
③ wait_list等待队列为空链表
1. __down_common函数分析
__down_common函数是各种获取信号量操作的核心步骤,因此先予以说明
说明1:semaphore_wait结构体用于描述一个需要等待信号量的进程,该结构体由semaphore.wait_list链表管理
说明2:schedule_timeout函数原型
/* |
2. down函数分析
说明1:down / down_interruptible / down_killable / down_timeou函数最终都通过__down_common函数实现功能,只是传递的进程睡眠状态和超时时间不同
说明2:根据down函数注释,内核不推荐使用down函数,而是代之以down_interruptible或down_killable函数
说明3:关于MAX_SCHEDULE_TIMEOUT宏
① 将说明的超时时间设置为MAX_SCHEDULE_TIMEOUT宏,表示睡眠的进程不会被超时唤醒,相当于不设置超时时间限制
② MAX_SCHEDULE_TIMEOUT宏定义如下,即long类型的最大值
③ schedule_timeout函数在处理MAX_SCHEDULE_TIMEOUT宏时,不会启动用于唤醒睡眠进程的定时器,而是直接触发调度
说明4:down_trylock函数分析
根据函数注释,该函数可以在中断上下文中使用,关于信号量操作在中断上下文中的使用详见下文分析
释放信号量通过up函数实现,
说明1:信号量操作在中断上下文的使用
由于信号量操作在实现中使用raw_spin_lock_irqsave函数保护semaphore结构体的数据原子性,因此可以处理核间竞态、核内进程间竞态和核内进程与中断间竞态
所以在中断上下文中可以调用down_trylock函数尝试获取信号量,也可以调用up函数释放信号量
说明2:信号量没有持有者的概念,所以在各种上下文中均可调用up函数释放信号量
说明3:如果将信号量的初始值设置为1并且配对使用down和up函数,也可以将信号量用于互斥目的。但是如上文所示,Linux内核对互斥锁进行了大量优化,所以更适合用于互斥目的
说明:对于内核同步机制,不存在构成临界区的问题,因此只讨论该机制是否可以在原子上下文中使用
1. down / down_interruptible / down_killable / down_timeout函数可能导致睡眠,因此不可以在原子上下文中使用
2. down_trylock函数不会导致睡眠且实现中处理了各种竞态场景,因此可以在原子上下文中使用
up函数不会导致睡眠且实现中处理了各种竞态场景,因此可以在原子上下文中使用
操作API |
功能 |
初始化等待队列头部API |
|
void init_waitqueue_head(wait_queue_head_t *wq_head) |
初始化已定义的等待队列头部 |
DECLARE_WAIT_QUEUE_HEAD(name) |
定义并初始化一个等待队列头部 |
初始化等待队列节点API |
|
void init_waitqueue_entry(struct wait_queue_entry *wq_entry, struct task_struct *p) |
初始化已定义的等待队列节点,其中,
|
void init_waitqueue_func_entry(struct wait_queue_entry *wq_entry, wait_queue_func_t func) |
初始化已定义的等待队列节点,其中,
|
DECLARE_WAITQUEUE(name, tsk) |
定义并初始化一个等待队列节点,其中,
|
加入等待队列API |
|
void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry) |
以非互斥方式(非exclusive)将等待队列节点加入等待队列 |
void add_wait_queue_exclusive(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry) |
以互斥方式(exclusive)将等待队列节点加入等待队列 |
移出等待队列API |
|
void remove_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry) |
将指定的等待队列节点移出等待队列 |
在等待队列上唤醒API |
|
void wake_up(struct wait_queue_head *wq_head) |
唤醒等待队列中处于TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE状态的所有非exclusive类型进程和至多1个exclusive类型进程 说明:在等待队列中处于睡眠状态的进程都会带有TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE状态标识 |
void wake_up_nr(struct wait_queue_head *wq_head, int nr_exclusive) |
唤醒等待队列中处于TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE状态的所有非exclusive类型进程和至多nr_exclusive个exclusive类型进程 |
void wake_up_all(struct wait_queue_head *wq_head) |
唤醒等待队列中处于TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE状态的所有非exclusive和exclusive类型进程 |
void wake_up_locked(struct wait_queue_head *wq_head) |
与wake_up函数功能相同,但是需要在已获取等待队列自旋锁的情况下调用,该函数本身不会再进行获取自旋锁的操作 |
void wake_up_all_locked(struct wait_queue_head *wq_head) |
与wake_up_all函数功能相同,但是需要在已获取等待队列自旋锁的情况下调用,该函数本身不会再进行获取自旋锁的操作 |
void wake_up_interruptible(struct wait_queue_head *wq_head) |
唤醒等待队列中处于TASK_INTERRUPTIBLE状态的所有非exclusive类型进程和至多1个exclusive类型进程 |
void wake_up_interruptible_nr(struct wait_queue_head *wq_head, int nr_exclusive) |
唤醒等待队列中处于TASK_INTERRUPTIBLE状态的所有非exclusive类型进程和至多nr_exclusive个exclusive类型进程 |
void wake_up_interruptible_all(struct wait_queue_head *wq_head) |
唤醒等待队列中处于TASK_INTERRUPTIBLE状态的所有非exclusive和exclusive类型进程 |
void wake_up_interruptible_sync(struct wait_queue_head *wq_head) |
以同步方式唤醒等待队列中处于TASK_INTERRUPTIBLE状态的所有非exclusive类型进程和至多1个exclusive类型进程,也就是wake_up_interruptible + sync语义 |
说明1:关于非exclusive和exclusive类型进程
① 本文中,
② 关于等待队列中对这两类进程的处理,详见下文分析
说明2:关于wake_up_interruptible_sync函数的同步语义
如果进行唤醒操作的进程在完成唤醒操作后将进入阻塞状态(也就是会触发调度),那么wake_up_interruptible_sync函数可以让调度器将被唤醒进程就加入当前CPU的运行队列,而不是迁移到其他CPU,如此一来被唤醒的进程可能更快得到执行
说明:等待队列是等待事件(wait event)、完成量(completion)和多路复用机制(poll / select系统调用)的实现基础
1. 等待队列头部
2. 等待队列节点
说明:关于等待队列节点标志
Linux 5.4.70内核中支持的等待队列节点标志如下,具体用法详见下文分析
1. init_waitqueue_head宏分析
init_waitqueue_head宏对已定义的等待队列头部进行初始化后,等待队列头部的初始状态如下,
① lock自旋锁为unlock状态
② head等待队列为空链表
2. DECLARE_WAIT_QUEUE_HEAD宏分析
通过DECLARE_WAIT_QUEUE_HEAD宏定义并初始化一个等待队列头部,可见等待队列头部的初始状态如下,
① lock自旋锁为unlock状态
② head等待队列为空链表
说明:关于DECLARE_WAIT_QUEUE_HEAD_ONSTACK宏
Linux内核中除了DECLARE_WAIT_QUEUE_HEAD宏,还提供了DECLARE_WAIT_QUEUE_HEAD_ONSTACK宏。如果不使能lockdep功能,二者实现相同;如果使能lockdep功能,则会调用init_waitqueue_head宏使用lockdep机制相关功能
1. init_waitqueue_entry函数分析
init_waitqueue_entry函数对已定义的等待队列节点进行初始化后,等待队列节点的初始状态如下,
① flags标志位清零
② private关联的进程设置为参数p
③ func唤醒时回调函数为default_wake_function
说明:default_wake_function函数的作用是唤醒与等待队列节点关联的进程,也就是通过该等待队列节点在等待队列中睡眠的进程。需要特别注意的是default_wake_function
2. DECLARE_WAITQUEUE宏分析
DECLARE_WAITQUEUE宏定义并初始化一个等待队列节点,可见等待队列节点的初始状态如下,
① private关联的进程设置为参数tsk
② func唤醒时回调函数为default_wake_function
③ entry链表节点状态为尚未加入链表
3. init_waitqueue_func_entry函数分析
和init_waitqueue_entry函数相比,init_waitqueue_func_entry函数主要是用于设置等待队列节点的唤醒时回调函数
1. add_wait_queue函数分析
2. add_wait_queue_exclusive函数分析
说明:根据上文分析可见,
① 非exclusive类型进程对应的等待队列节点没有WQ_FLAG_EXCLUSIVE标志,并且加入等待队列队首
② exclusive类型进程对应的等待队列节点带有WQ_FLAG_EXCLUSIVE标志,并且加入等待队列队尾
1. __wake_up函数分析
__wake_up函数是wake_up系列函数操作的核心步骤,因此先予以说明
说明1:通过bookmark机制实现分批次唤醒
① 在__wake_up_common_lock函数中,是在持有自旋锁且关闭中断的情况下遍历等待队列进行唤醒操作。如果在等待队列中睡眠的进程过多,就会导致持有自旋锁和关闭中断的时间过长,从而影响系统性能
② 通过加入bookmark等待队列节点记录当前遍历节点的位置,可以确保每唤醒至多64个进程就会释放自旋锁且使能中断一次,从而减低对性能的影响
③ 关于引入bookmark机制的相关讨论,可参考sched/wait: Break up long wake list walk
说明2:通过exclusive机制避免惊群(thundering herd)
① 如上文所述,__wake_up函数每次会唤醒等待队列中所有符合指定睡眠状态的非exclusive类型进程和至多nr_exclusive个exclusive类型进程。如果有多个进程在等待队列上等待某个资源,而该资源却只能被一个进程持有,那么每次被唤醒的进程中除了获取到资源的一个进程,其余进程需要重新进入睡眠。这就导致了不必要的进程上下文切换,从而降低了系统性能,这种情况就被称为惊群
② 通过设置等待队列节点的WQ_FLAG_EXCLUSIVE标志并以exclusive类型进程加入等待队列,并在唤醒时传递合适的nr_exclusive参数,就可以限制每次唤醒的进程数量,从而避免惊群
③ 关于通过exclusive机制避免惊群的典型示例就是select / poll / epoll系统调用,他们都用于实现多路复用IO模型,但是在内核态对等待队列的使用却有所不同
说明3:__wake_up_locked函数分析
① __wake_up_locked函数的后缀就表示调用到该函数时,调用者已经持有了wait_queue_head.lock自旋锁,所以在该函数中不会再有去持有锁的操作
② 提供__wake_up_locked函数的目的,是使得调用者可以使用等待队列头部的自旋锁保护更多的资源。例如下文要介绍的完成量,就使用等待队列头部的自旋锁保护了完成量计数值
③ 类型__wake_up_locked函数这种命名方式还可见于file_operations结构体中的unlocked_ioctl回调函数,此处的unlocked就是表示在调用到unlocked_ioctl回调函数时,不会持有锁,相关调用流程可参考Linux设备驱动基础03:Linux字符设备驱动 chapter 3.6
说明4:__wake_up_sync函数分析
关于wake_up_interruptible_sync函数的同步语义在上文中已有说明,在实现上就是当至多只唤醒一个exclusive类型进程时将唤醒标志WF_SYNC传递给等待队列节点唤醒后回调函数,以指导后续的进程唤醒操作
说明5:关于sleep_on函数
① 在早期的内核版本中,在等待队列上有sleep_on系列函数,以Linux 2.6内核为例,具体函数如下,
② 但是因为sleep_on系列函数存在竞态问题,从Linux 3.15版本开始,内核移除了sleep_on系列函数并推荐使用等待事件(wait event)机制代替
原先内核中对sleep_on系列函数的使用方法一般如下,
// 等待端 |
假设唤醒端条件满足和唤醒操作在等待端判断条件和通过sleep_on函数实际进入睡眠之间发生,则会导致等待端错过此次唤醒。如果后续不再有唤醒操作,则进入睡眠的等待端进程将无法被唤醒,此时具体场景如下,
③ 当时解决该问题的唯一方法是使用大内核锁(Big Kernel Lock,BKL)保护等待端和唤醒端的操作原子性,BKL是一个名为kernel_flag的自旋锁,持有该锁的进程仍可以睡眠,睡眠时持有的锁将被自动释放;当该进程被唤醒时会重新持有该锁。但是当时BKL也将要被移除,所以sleep_on系列函数自然就被淘汰了
关于移除sleep_on系列函数和BKL的相关内容,可参考如下资料,
sleep_on() in 2.6.
[PATCH] sched: remove sleep_on() and friends
sleep_on removal [LWN.net]
BKL(Big Kernel Lock) - linux内核锁机制
1. add_wait_queue / add_wait_queue_exclusive函数不会导致睡眠且实现中处理了各种竞态场景,因此可以在原子上下文使用(但是通常不会这么操作,因为加入等待队列后还需要进行睡眠操作,而睡眠操作是不能在原子上下文中进行的)
2. remove_wait_queue函数不会导致睡眠且实现中处理了各种竞态场景,因此可以在原子上下文使用(一般配合唤醒操作进行)
3. wake_up系列函数不会导致睡眠且实现中处理了各种竞态场景,因此可以在原子上下文使用
下面以drivers/char/rtc.c驱动程序为例,说明驱动程序中如何直接使用等待队列机制实现同步。此处强调直接,是相对于等待事件(wait event)这个封装了等待队列的机制
需要特别注意的是,等待队列操作本身只包含加入队列、移出队列和在队列上进行唤醒,并不包括睡眠操作。因此,如果在驱动程序中直接使用等待队列机制,需要驱动程序自己处理等待进程的睡眠操作
1. 定义并初始化全局的等待队列头部
2. 在read系统调用回调函数中使用等待队列机制实现等待端逻辑
3. 在中断处理函数中读取数据,并在等待队列上进行唤醒操作
说明:上述实例中对于等待队列的用法存在类似sleep_on函数的竞态问题吗?让我们展开分析一下,下图的具体场景对函数实现进行了裁剪
① 在等待端加入等待队列之前已读取数据并进行唤醒操作
② 在等待端加入等待队列之后设置进程睡眠状态之前已读取数据并进行唤醒操作
③ 在等待端完成加入等待队列的相关操作之后触发调度之前读取到数据并进行唤醒操作
④ 在等待端进入睡眠之后读取到数据并进行唤醒操作
可见对于上述各种情况,实例均没有竞态问题。相较于sleep_on函数的使用场景,此处的关键就是
具体场景如下,后续将看到,等待事件机制就是采取该流程避免竞态问题
回顾一下上文分析的信号量机制实现,其实也是遵循了类似的处理流程,
操作API |
功能 |
void wait_event(struct wait_queue_head *wq_head, condition) |
在等待队列上睡眠,直到条件满足,睡眠过程不会被信号唤醒 |
int wait_event_interruptible(struct wait_queue_head *wq_head, condition) |
在等待队列上睡眠,直到条件满足,睡眠过程可以被任意信号唤醒。函数返回值如下, 0:因条件满足被唤醒 -ERESTARTSYS:被信号唤醒 |
int wait_event_interruptible_exclusive(struct wait_queue_head *wq_head, condition) |
和wait_event_interruptible函数功能相同,但是进程以exclusive属性加入等待队列 |
long wait_event_timeout(struct wait_queue_head *wq_head, condition, long timeout) |
在等待队列上睡眠,直到条件满足或者等待超时(超时时间以jiffies为单位),睡眠过程不会被信号唤醒。函数返回值如下, 0:等待已超时,但是条件仍未满足 1:等待已超时,但是条件已满足 > 1:在等待超时前条件即满足 |
long wait_event_interruptible_timeout(struct wait_queue_head *wq_head, condition, long timeout) |
在等待队列上睡眠,直到条件满足或者等待超时(超时时间以jiffies为单位),睡眠过程可以被任意信号唤醒。函数返回值如下, 0:等待已超时,但是条件仍未满足 1:等待已超时,但是条件已满足 > 1:在等待超时前条件即满足 -ERESTARTSYS:被信号唤醒 |
int wait_event_killable(struct wait_queue_head *wq_head, condition) |
在等待队列上睡眠,直到条件满足,睡眠过程可以被fatal signal(即SIGKILL信号)唤醒。函数返回值如下, 0:因条件满足被唤醒 -ERESTARTSYS:被信号唤醒 |
long wait_event_killable_timeout(struct wait_queue_head *wq_head, condition, long timeout) |
在等待队列上睡眠,直到条件满足或者等待超时(超时时间以jiffies为单位),睡眠过程可以被fatal signal(即SIGKILL信号)信号唤醒。函数返回值如下, 0:等待已超时,但是条件仍未满足 1:等待已超时,但是条件已满足 > 1:在等待超时前条件即满足 -ERESTARTSYS:被信号唤醒 |
int wait_event_hrtimeout(struct wait_queue_head *wq_head, condition, ktime_t timeout) |
在等待队列上睡眠,直到条件满足或者等待超时(超时时间为ktime_t类型),睡眠过程不会被信号唤醒。函数返回值如下, 0:在等待超时前条件即满足 -ETIME:等待已超时,但是条件仍未满足 该函数相较于wait_event_timeout函数,使用的是内核中的高精度定时器(hrtimer) |
long wait_event_interruptible_hrtimeout(struct wait_queue_head *wq_head, condition, ktime_t timeout) |
在等待队列上睡眠,直到条件满足或者等待超时(超时时间为ktime_t类型),睡眠过程可以被任意信号唤醒。函数返回值如下, 0:在等待超时前条件即满足 -ERESTARTSYS:被信号唤醒 -ETIME:等待已超时,但是条件仍未满足 该函数相较于wait_event_interruptble_timeout函数,使用的是内核中的高精度定时器(hrtimer) |
说明1:等待事件基于等待队列实现,相当于在等待队列的基础上增加了一个判断条件
说明2:wait_event系列函数中的condition是一个C语言表达式(C expression),wait_event系列函数通过该表达式的值判断条件是否满足
说明3:通过wait_event系列函数进入睡眠的进程,可以被wake_up系列函数唤醒,习惯上按进程睡眠状态匹配使用,
① wake_up函数应该与wait_event或wait_event_timeout函数匹配使用
② wake_up_interruptible函数应该与wait_event_interrruptible或wait_event_interruptible_timeout函数匹配使用
1. ___wait_event函数分析
___wait_event函数为wait_event系列函数提供了操作框架,因此先予以说明。可见该框架也通过正确处理加入等待队列、判断条件是否满足和进程进入睡眠的顺序解决了竞态问题
更正:对于prepare_to_wait_event函数中需要判断等待队列节点是否已经加入等待队列的原因,之前的理解有误,并不是等待队列节点只需要加入等待队列一次,而是只要等待队列节点尚未加入等待队列,此处就要将其加入等待队列
例如等待事件的进程被唤醒后,会继续执行for(;;)循环,此时会先调用prepare_to_wait_event函数,再判断条件是否满足。而被唤醒的进程,其对应的等待队列节点可能仍然在等待队列中(e.g. 等待队列节点唤醒时回调函数为default_wake_function),也可能已经被移出等待队列(e.g. 等待队列节点唤醒时回调函数为autoremove_wake_function),所以在prepare_to_wait_event函数中需要根据当前情况进行处理
说明1:关于条件condition的原子性
① 在___wait_event函数的框架中,只是通过简单的if语句判断condition是否满足。如果condition比较复杂,需要确保原子性,则由驱动程序负责
② 以drivers/media/v4l2-core/videobuf-core.c文件中的videobuf_waiton函数为例,该函数通过自旋锁确保了condition的原子性
说明2:autoremove_wake_function函数分析
在等待事件机制中,注册的等待队列节点唤醒时回调函数是autoremove_wake_function,该函数在唤醒进程成功时,会将相应的等待队列节点移出等待队列,这也是后续调用的finish_wait函数在删除等待队列节点时需要先判断该节点是否仍在等待队列中的原因
说明3:在___wait_event函数的实现中,通过prepare_to_wait_event + 循环结构 + finish_wait的方式构成对等待队列的使用框架,结构如下,
// 定义并初始化,或者只是初始化等待队列节点,我们以只是初始化为例, |
下面以fs/pipe.c文件中中的pipe_read函数为例,说明使用上述框架的实例,
2. wait_event函数分析
3. wait_event_interruptible函数分析
4. wait_event_timeout函数分析
5. wait_event_interruptible_timeout函数分析
可见wait_event_interruptible_timeout函数与wait_event_timeout函数实现的唯一差别就是进程睡眠状态不同,此时睡眠过程可以被任意信号唤醒
wait_event系列函数可能导致睡眠,因此不可以在原子上下文中使用
在等待队列章节的使用实例讨论(chapter 4.2.4)中,rtc_read函数是直接使用等待队列机制实现同步,下面将其修改为使用等待事件机制实现同步,可见实现更加简洁
操作API |
功能 |
初始化完成量API |
|
void init_completion(struct completion *x) |
初始化已定义的完成量,且完成量初始值为0 |
void reinit_completion(struct completion *x) |
将完成量计数值重新初始化为0 |
DECLARE_COMPLETION(work) |
定义并初始化一个完成量,且完成量初始值为0 |
等待完成量API |
|
void wait_for_completion(struct completion *x) |
等待完成量,如果尚未完成,则执行单元睡眠直至其完成,睡眠过程不会被信号唤醒 |
int wait_for_completion_interruptible(struct completion *x) |
等待完成量,如果尚未完成,则执行单元睡眠直至其完成,睡眠过程可以被任意信号唤醒。函数返回值如下, 0:因完成量完成被唤醒 -ERESTARTSYS:被信号唤醒 |
int wait_for_completion_killable(struct completion *x) |
等待完成量,如果尚未完成,则执行单元睡眠直至其完成,睡眠过程可以被fatal signal(即SIGKILL)信号唤醒。函数返回值如下, 0:因完成量完成被唤醒 -ERESTARTSYS:被信号唤醒 |
long wait_for_completion_timeout(struct completion *x, unsigned long timeout) |
等待完成量,如果尚未完成,则执行单元睡眠直至其完成或者超时(超时时间以jiffies为单位),睡眠过程不会被信号唤醒。函数返回值如下, 0:等待已超时,但是完成量仍未完成 > 0:在等待超时前完成量即完成 |
long wait_for_completion_interruptible_timeout(struct completion *x, unsigned long timeout) |
等待完成量,如果尚未完成,则执行单元睡眠直至其完成或者超时(超时时间以jiffies为单位),睡眠过程可以被任意信号唤醒。函数返回值如下, 0:等待已超时,但是完成量仍未完成 > 0:在等待超时前完成量即完成 -ERESTARTSYS:被信号唤醒 |
long wait_for_completion_killable_timeout(struct completion *x, unsigned long timeout) |
等待完成量,如果尚未完成,则执行单元睡眠直至其完成或者超时(超时时间以jiffies为单位),睡眠过程可以被atal signal(即SIGKILL)信号唤醒。函数返回值如下, 0:等待已超时,但是完成量仍未完成 > 0:在等待超时前完成量即完成 -ERESTARTSYS:被信号唤醒 |
bool try_wait_for_completion(struct completion *x) |
尝试等待完成量,成功返回true;否则返回false,执行单元不会睡眠 |
释放完成量API |
|
void complete(struct completion *x) |
释放完成量,如果有其他进程在等待该完成量,则唤醒等待队列中的首个进程 |
void complete_all(struct completion *x) |
释放完成量,如果有其他进程在等待该完成量,则唤醒等待队列中的所有进程 |
完成量状态检查API |
|
bool completion_done(struct completion *x) |
判断是否有进程在完成量上等待,如果有,则返回0;否则返回1 |
说明:关于DECLARE_COMPLETION_ONSTACK和DECLARE_COMPLETION_ONSTACK_MAP宏
Linux内核中除了DECLARE_COMPLETION宏,还提供了DECLARE_COMPLETION_ONSTACK和DECLARE_COMPLETION_ONSTACK_MAP宏。如果不使能lockdep功能,二者实现相同;如果使能lockdep功能,则会增加传递相关参数
完成量数据结构如下,完成量基于等待队列实现,并且引入计数值实现计数完成量(counting completion)语义,整个结构体则是通过wait_queue_head_t.lock自旋锁确保原子性
1. init_completion宏分析
init_completion宏对已定义的完成量进行初始化后,完成量的初始状态如下,
① 完成量计数值为0
② 等待队列为空
2. reinit_completion函数分析
① 可见reinit_completion函数只是将完成量计数值清零,并没有操作等待队列
② 根据内核注释,reinit_completion函数需要在complete_all函数之后调用
3. DECLARE_COMPLETION宏分析
通过DECLARE_COMPLETION宏定义并初始化一个完成量,可见完成量的初始状态如下,
① 完成量计数值为0
② 等待队列为空
1. wait_for_common函数分析
wait_for_common函数是wait_for_completion系列函数操作的核心步骤,因此先予以说明
说明1:传递给__wait_for_common函数的action回调函数是schedule_timeout或io_schedule_timeout
说明2:当退出do-while循环时,其实不存在睡眠过程被信号打断但是同时完成量也就绪的情况(即timeout = -ERESTARTSYS且x->done > 0),也就是不存在上图中的1.c场景,这是因为,
① 如果满足上面的条件在进程睡眠的过程中满足,那么在调度返回后的while语句判断中,就会退出do-while循环,而此时timeout变量中保存的是睡眠timeout的剩余时间,并不是-ERESTARTSYS
② 也就是说,为了不退出do-while循环,再次通过signal_pending_state函数处理进程接收到的信号,就需要在while语句判断时完成量未就绪(x->done == 0)且等待未超时(timeout != 0)。但是当再次进入循环时,自旋锁会上锁,虽然可以处理进程接收到的信号,但是其他进程已无法修改x->done的值
综上所述,当通过signal_pending_state函数判断出睡眠被信号唤醒时,完成量一定是未就绪的
说明3:因等待完成量而睡眠的进程,都是以exclusive属性加入等待队列,那么在后续唤醒时就可以通过指定每次至多唤醒exclusive类型进程的个数来避免惊群
说明4:wait_for_common函数也是通过处理好加入等待队列、判断条件和触发调度的时序关系,来解决竞态问题
说明5:关于return timeout ? : 1
① timeout ? : 1表达式的含义就是,
相当于timeout ? timeout : 1
② 使用该表达式的目的,是处理等待超时但是完成量也已就绪的情况,此时会返回1
说明6:wait_for_completion系列函数最终通过调用wait_for_common函数实现功能,差别在于传递不同的等待超时时间和进程睡眠状态
2. try_wait_for_completion函数分析
说明:try_wait_for_completion函数中会先在自旋锁不上锁的情况下读取一次完成量计数值,这样做的目的是在完成量未就绪的情况下函数可以尽快返回,而无需进行自旋锁的竞争
3. completion_done函数分析
根据内核注释,completion_done函数是用于判断是否有进程在完成量上等待。但是由于判断时包含了可能正在进行中的wait_for_completion操作(wait_for_completion in progress),所以在实现上只是判断完成量计数值是否为0,并没有检查等待队列
1. complete函数分析
complete函数会递增完成量计数值,并唤醒至多1个在等待队列上睡眠的进程
说明:关于进程在完成量上的睡眠和唤醒流程
① 进程在完成量上睡眠时,都是以exclusive属性加入等待队列队尾
② complete函数在等待队列上进行唤醒操作时,通过将nr_exclusive参数设置为1,每次至多唤醒1个等待队列上的exclusive类型进程。需要注意的是,complete函数在递增完成量计数值之后,无论等待队列上是否有正在睡眠的进程都会调用__wake_up_locked函数
③ __wake_up_locked函数是从头到尾遍历等待队列,所以唤醒进程的顺序是和他们的睡眠顺序相同的
2. complete_all函数分析
complete_all函数会将完成量计数值直接修改为UINT_MAX,然后唤醒等所有在等待队列上睡眠的进程
说明1:根据内核注释,在调用complete_all函数之后,完成量相当于进入不可用状态。根据上文分析,一旦将完成量计数值设置为UINT_MAX,wait_for_completion系列函数和complete函数将不会处理done字段
如果想重新使用该完成量,需要调用reinit_completion函数将done字段清零
说明2:对比信号量和完成量机制
① 信号量和完成量都是基于计数值 + 等待队列的同步机制,只不过信号量使用的是自己通过链表维护的等待队列,而完成量使用的wait_queue等待队列
② 二者等待和唤醒的语义和流程也是相似的,唤醒顺序都是与进入等待队列睡眠的顺序一致。对于up和complete函数,每次最多唤醒一个在等待队列上睡眠的进程
③ 但是完成量有一次性将睡眠进程全部唤醒的complete_all函数,并且可以通过reinit_completion函数重新初始化完成量,而信号量则不具备该功能
1. wait_for_completion系列函数可能导致睡眠,因此不可以在原子上下文中使用
2. try_wait_for_completion / complete / complete_all / complete_done函数不会导致睡眠且实现中处理了各种竞态场景,因此可以在原子上下文中使用
1. 在选择互斥机制之前,首先要确定需要保护的共享资源。我们以Linux设备驱动基础03:Linux字符设备驱动 中实现的字符设备驱动为例,此处的共享资源就是字符设备持有的共享内存
2. 字符设备驱动程序中的read / write / ioctl系统调用均会操作该共享资源,当多个进程都对共享内存进行操作时,如果没有适当的互斥机制,共享内存中的数据将出现错乱
1. 在选择互斥机制时需要考虑原子性问题,即该互斥机制是否可以在原子上下文中使用,以及该互斥机制构成的临界段是否是原子上下文
2. 示例字符设备驱动程序原子性讨论,
① 在示例字符设备驱动程序中,需要进行互斥的操作是read / write / ioctl系统调用,也就是进程上下文,因此即可以选择自旋锁,也可以选择互斥锁
② 在示例字符设备驱动的read和write系统调用中,会使用copy_to_user和copy_from_user函数操作共享内存,而这2个函数可能导致睡眠,因此所选用的互斥机制不能构成原子上下文
综上所述,我们选择互斥锁作为互斥机制
1. 定义互斥锁
在设备驱动程序中,一般将互斥机制也定义在设备结构体中
2. 初始化互斥锁
在内核模块的初始化函数中初始化互斥锁
3. 在访问共享资源时使用互斥锁
① read系统调用
② write系统调用
③ ioctl系统调用