奔跑吧Linux内核卷2笔记

358 阅读14分钟

并发与同步

并发:并发访问是指多个内核代码路径同时访问和操作数据,这可能发生相互覆盖共享数据的情况,造成被访问数据的不一致。内核代码路径可以是一个内核执行路径,中断处理程序或者内核线程等。 在早期不支持对称多对称处理器的Linux内核访问的因素是中断服务程序,只有中断发生时,或者内核代码路径显式地要求重新调度并执行另外一个进程时,才可能发生并发访问。 在支持SMP的linux内核中,在不同CPU中并发执行的内核线程完全可能在同一时刻并发访问共享数据,并发访问随时可能发生。

在内核中产生并发访问的并发源主要有如下4种:

  • 中断和异常:中断发生后,中断处理程序和被中断的进程之间可能产生并发访问。
  • 软中断和tasklet:软中断和tasklet随时可能会被调度,执行,从而打断当前正在执行的进程上下文。
  • 内核抢占:调度器支持可抢占特效,会导致进程和进程之间的并发访问。
  • 多处理器并发执行:多处理器可以同时执行多个进程。

原子操作

Linux内核提供了很多操作原子变量的函数

基本原子操作函数

Linux内核提供最基本的原始操作函数包括atomic_readatomic_set

<include/asm-generic/atomic.h>

#define ATOMIC_INIT(i)  //原子变量初始化为i
#define atomic_read(v)  //读取原子变量的值
#define atomic_set(v,i) //设置变量v的值为i

image.png

不带返回值的原子操作函数
atomic_inc(v) :原子地给v加1
atomic_dec(v) :原子的给v减1
atomic_add(i, v): 原子的给v加i
atomic_and(i, v):原子的给v和i做 与 操作
atomic_or(i, v):原子的给v和i做 或 操作
atomic_xor(i, v):原子的给v和i做 异或 操作
带返回值的原子操作函数
// 返回原子变量新值的原子操作函数如下。
atomic_add_return(int i, atomic_t *v):原子地给v加i并且返回v的新值。
atomic_sub_return(int i, atomic_t *v):原子地给v减i并且返回v的新值。
atomic_inc_return(v):原子地给v加1并且返回v的新值。
atomic_dec_return(v):原子地给v减1并且返回v的新值。
// 返回原子变量旧值的原子操作函数如下。
atomic_fetch_add(int i, atomic_t *v):原子地给v加i并且返回v的旧值。
atomic_fetch_sub(int i, atomic_t *v):原子地给v减i并且返回v的旧值。
atomic_fetch_and(int i, atomic_t *v):原子地给v和i做与操作并且返回v的旧值。
atomic_fetch_or(int i, atomic_t *v):原子地给v和i做或操作并且返回v的旧值。
atomic_fetch_xor(int i, atomic_t *v):原子地给v和i做异或操作并且返回v的旧值。
原子交换函数
atomic_cmpxchg(ptr, old, new):原子地比较ptr的值是否与old的值相等,若相等,则把new的值设置到ptr地址中,返回old的值。
atomic_xchg(ptr, new):原子地把new的值设置到ptr地址中并返回ptr的原值。
atomic_try_cmpxchg(ptr, old, new):与atomic_cmpxchg()函数类似,但是返回值发生变化,返回一个bool值,以判断cmpxchg()函数的返回值是否和old的值相等。
处理引用计数的原子操作函数
atomic_add_unless(atomic_t *v, int a, int u):比较v的值是否等于u。
atomic_inc_not_zero(v):比较v的值是否等于0。
atomic_inc_and_test(v):原子地给v加1,然后判断v的新值是否等于0。
atomic_dec_and_test(v):原子地给v减1,然后判断v的新值是否等于0

上述原子操作函数在内核代码志平很常见,特别是对一些引用计数进行操作,如page的_refcount和_mapcount。

内嵌内存屏障源语的原子操作函数
{}_relaxed:不内嵌内存屏障原语。
{}_acquire:内置了加载-获取内存屏障原语。
{}_release:内置了存储-释放内存屏障原语。

内存屏障

  1. 数据存储屏障(Data Memory Barrier, DMB)
  2. 数据同步屏障(Data Synchronization Barrier, DSB)
  3. 指令同步屏障(Instruction synchrionization Barrier, ISB)
接口函数描述
barrier()编译优化屏障,组织编译器为了性能优化而进行指令重排
mb()内存屏障(包括读和写),用与SMP和UP
rmb()读内存屏障,用于SMP和UP
wbm()写内存屏障,用于SMP和UP
smp_mb()用于SMP的内存屏障,对于UP不存在内存一致性的问题(对汇编指令),在UP上就是一个优化屏障,确保汇编代码和C代码的内存一致性
smp_rmb()用于SMP的读内存屏障
smp_wmb()用于SMP的写内存屏障
smp_read_barrier_depends读依赖屏障
smp_mb__before_atomic/smp_mb__after_atomic用于在原子操作中插入一个通用内存屏障

经典自旋锁

如果临界区中只有一个变量,那么原子变量可以解决问题,但是大多数情况下临界区有一个数据操作的集合,整个执行过程需要保证原子性,在数据更新完毕之前,不能从其他内核代码路径访问和改写这些数据,这个过程使用原子变量不合适,需要使用锁机制来完成,自旋锁是Linux内核中最常见的锁机制。 自旋锁的特性如下:

  1. 忙等待的锁机制。操作系统中的机制分为两类,一类是忙等待,另一类是睡眠等待,自旋锁属于前者,当无法获取自旋锁时会不断尝试,直到获取锁为止。
  2. 同一时刻只能有一个内核代码路径可以获得该锁。
  3. 自旋锁可以在中断上下文中使用。

信号量

struct semaphore {
    raw_spinlock_t lock; //自旋锁变量,用户保护semaphore数据结构里的count和wait_list成员
    unsigned int count; // 用于表示允许进入临界区的内核执行路径个数
    struct list_head wait_list; // 用于管理所有在该信号量上睡眠的进程,没有成功获取的进程会在这个链表上睡眠
};

通常使用sema_init函数进行信号量的初始化,val值通常设置为1。 void sema_init(struct semaphore *sem, int val)

信号量有一个有趣的特点,它可以允许任意数量的锁持有者,当count大于1时,表示允许在同一时刻至多有count个锁持有者,这种信号量叫做计数信号量;当count等于1时,同一时刻仅允许一个CPU持有锁,这种信号量叫做互斥信号量或者二进制信号量。 在Linux内核中,大多使用count值为1的信号量,相比自旋锁,信号量是一个允许睡眠的锁,信号量适用于一些情况复杂,加锁时间比较长的应用场景,如内核与用户空间复杂的交互行为等。

互斥锁(mutex)

信号量是在并行处理环境中对多个处理器访问某个公共资源进行保护的机制,互斥锁用于互斥操作。其实互斥锁相当于count值为1的信号量,但是互斥锁相比于信号量要简单轻便一些。

  1. 在锁争用激烈的测试场景下,互斥锁比信号量执行速度更快,可扩展性更好。
  2. mutex数据结构定义比信号量小。
struct mutex {
     atomic_long_t          owner; // 0表示锁没有未被持有,非零值则表示锁持有者的task_struct指针的值
     spinlock_t          wait_lock;// 用于管理所有在互斥锁上睡眠的进程,没有成功获取锁的进程会在此链表上睡眠。
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
     struct optimistic_spin_queue osq; // 用于实现MCS锁机制。
#endif
     struct list_head     wait_list;
};

乐观自旋

自旋等待机制的核心原理是当发现锁持有者正在临界区执行并且没有其他优先级高的进程要调度时,当前进程坚信锁持有者会很快离开临界区并释放锁,因此与其睡眠等待,不如乐观地自旋等待,以减少睡眠唤醒的开销。

image.png 该函数只需要返回owner->on_cpu即可,若返回值为1,说明锁持有者正在临界区执行,当前进程适合进行乐观自旋等待。

互斥锁案例

假设系统有4个CPU(每个CPU一个线程)同时争用一个互斥锁。

image.png

  1. T0时刻,CPU0率先获得了互斥锁,进入了临界区,CPU0是锁的持有者。
  2. T1时刻,CPU1也开始申请互斥锁,因为互斥锁已经被CPU0上的线程持有,CPU1发现锁持有者(CPU0)正在临界区执行,所以它采用乐观自旋等待机制。
  3. T2时刻,CPU2也开始申请同一个锁,同理,CPU2也采用乐观自旋等待机制。
  4. T3时刻,CPU0退出临界区,释放了互斥锁,CPU1察觉到锁持有者已经退出,很快申请到了锁,这时锁持有者变成了CPU1,CPU1进入了临界区。
  5. T4时刻,CPU1在临界区里被抢占调度了或者自己主动睡眠了,若在采用乐观自旋等待机制时发现锁持有者没有在临界区里执行,那只好取消乐观等待机制,进入睡眠模式。
  6. T5时刻,CPU3也开始申请互斥锁,他发现锁持有者没有在临界区里执行,不能采用乐观自旋等待机制,只好进入睡眠模式。
  7. T6时刻,CPU1的线程被唤醒,重新进入临界区。
  8. T7时刻,CPU1退出临界区,释放了锁,这时CPU2也退出睡眠模式,获得了锁。
  9. T8时刻,CPU2退出临界区,释放了锁,这时CPU3也退出了睡眠模式,获得了锁。

读写锁

信号量有一个明显的缺点,没有区分临界区的读写属性。读写锁通常允许多个线程并发地读访问临界区,但是写访问只限制一个线程,读写锁能有效地提高并发性。读写锁具有如下特效:

  1. 允许多个读者同时进入临界区,但是统一时刻写者不能进去。
  2. 同一时刻只允许一个写者进入临界区。
  3. 读者和写者不能同时进入临界区。

读写锁常用函数

rwlock_init():初始化rwlock。
write_lock():申请写者锁。
write_unlock():释放写者锁。
read_lock():申请读者锁。
read_unlock():释放读者锁。
read_lock_irq():关闭中断并且申请读者锁。
write_lock_irq():关闭中断并且申请写者锁。
write_unlock_irq():打开中断并且释放写者锁。

读写信号量的重要特性:

  1. down_read():如果一个进程持有读者锁,那么允许继续申请多个读者锁,申请写者锁则要等待。
  2. down_write:如果一个进程持有写者锁,那么第二个进程申请该写者锁要自旋等待,申请读者锁则要等待。
  3. up_write/up_read():如果等待队列中的第一个成员是写者,那么唤醒该写者;否则,唤醒排在等待队列中最前面连续的几个读者。

RCU

RCU的全称Read-Copy-Update,它是Linux内核中一种重要的同步机制。RCU机制的原理可以概括为RCU记录了所有指向共享数据的指针的使用者,当要修改共享数据时,首先创建一个副本,在副本志平修改,所有读者线程离开临界区之后,指针指向修改后的副本,并且删除旧数据。 RCU提供的接口如下:

  1. rcu_read_lock/rcu_read_unlock:组成一个RCU读者临界区
  2. rcu_derefernce():用户获取被RCU保护的指针,读者线程要访问RCU保护的共享数据,需要使用该函数创建一个新的指针,并且指向被RCU保护的指针。
  3. rcu_assign_pointer:通常用于写者现场。在写者线程完成新数据的修改后,调用该接口可以让被RCU保护的指针指向新创建的数据,用RCU的术语是发布了更新后的数据。
  4. synchronize_rcu:同步等待所有现存的读访问完成。
  5. call_rcu():注册一个回调函数,当所有现存的读访问完成后,调用这个回调函数销毁旧数据。

Linux内核中锁机制的特点和使用规则

锁机制特点使用规则
原子操作使用处理器的原子指令,开销小临界区中的数据是变量、位等简单的数据结构
内存屏障使用处理器内存屏障指令或GC的屏蔽指令读写指令时许的调整
自旋锁自旋等待中断上下文,短期持有锁,不可递归,临界区不可睡眠
信号量可睡眠的锁可长时间持有锁
读写信号量可睡眠的锁,多个读者可以同时持有锁,同一时刻只能有一个写者,读者和写者不能同时存在程序员界定出临界区后读/写属性才有用
互斥锁可睡眠的互斥锁,比信号量快速和简洁,实现自旋等待机制同一时刻只有一个线程可以持有互斥锁,由锁持有者负责解锁,即在同一个上下文中解锁,不能递归持有锁,不适合内核和用户空间复杂的同步场景
RCU读者持有锁没有开销,多个读者和写者可以同时共存,写者必须等待所有读者离开临界区后才能销毁相关数据受保护资源必须通过指针访问,如链表等

中断管理

Linux内核的中断管理可以分为如下4层:

  1. 硬件层,如CPU和中断控制器的连接。
  2. 处理器架构管理层,如CPU中断异常处理。
  3. 中断控制管理器,如IRQ号的映射。
  4. Linux内核通用中断处理器层,如中断注册和中断处理。

中断号

不同的架构对中断控制器有着不同的设计理念,如ARM公司提供了一个通用中断控制器(Generic Interrupt Controller,GIC),x86架构则采用高级可编程中断控制器(Advanced Programmable Interrupt Controller,APIC)。

中断类型中断号范围
软件触发中断(SGI)0-15
私有外设中断(PPI)16 ~ 31
共享外设中断(SPI)32 ~ 1019

软中断和tasklet

中断上半部有一个很重要的原则-硬件中断处理函数应该执行饿的越快越好,即希望它尽快离开并从硬件中断返回,这么做的原因如下:

  1. 硬件中断处理程序以异步方式执行,他会中断其他重要代码的执行,因此为了避免中断的程序停止时间太长,硬件中断处理程序必须尽快执行完。
  2. 硬件中断处理程序通常在关中断的情况下执行。所谓的关中断是指关闭了本地CPU的所有中断响应。关中断之后,本地CPU不能再响应中断,因此硬件中断处理程序必须尽快执行完。

软中断

软中断是预留给系统中对时间要求较严格和重要的下半部使用的,而且目前驱动中只有块设备和网络子系统使用了软中断。而且目前驱动中只有块设备和网络子系统使用了软中断,系统静态定义了若干种软中断类型,并且Linux内核开发者不希望用户再扩充新的软中断类型,如有需要,建议使用tasklet机制。 已经定义好的软中断: HI_SOFTIRQ,优先级为0,是最高优先级的软中断类型。 TIMER_SOFTIRQ,优先级为1,定时器的软中断。 NET_TX_SOFTIRQ,优先级为2,发送网络数据包的软中断。 NET_RX_SOFTIRQ,优先级为3,接收网络数据包的软中断。 BLOCK_SOFTIRQ和BLOCK_IOPOLL_SOFTIRQ,优先级分别是4和5,用于块设备的软中断。 TASKLET_SOFTIRQ,优先级为6,专门为tasklet机制准备的软中断。 SCHED_SOFTIRQ,优先级为7,用于进程调度和负载均衡。 HRTIMER_SOFTIRQ,优先级为8,用于高精度定时器。 RCU_SOFTIRQ,优先级为9,专门为RCU服务的软中断。

工作队列

工作队列是除了软中断和tasklet以外,最常用的一种下半部机制。工作队列的基本原理是把work(需要推迟执行的函数)交由内核线程来执行,它总是在进程上下文中执行。工作队列的优点是利用进程上下文来执行中断下半部操作,因此工作队列允许重新调度和睡眠,是异步执行的进程上下文。

  • 普通优先级BOUND类型的工作队列system_wq,名称为"events",可以理解为默认工作队列。
  • 高优先级BOUND类型的工作队列system_highpri_wq,名称为"events_highpri"。
  • UNBOUND类型的工作队列system_unbound_wq,名称为"system_unbound_wq"。
  • Freezeable类型的工作队列system_freezable_wq,名称为"events_freezable"。
  • 省电类型的工作队列system_power_efficient_wq,名称为"events_power_efficient"。

内核调试和性能优化

GCC编译器有多个优化等级,如O0表示关闭所有优化,O1表示最基本的优化等级,O2是从O1进阶的优化等级。

书签

p536