目录
1. 内核锁机制 2. 同步与互斥 3. 锁定内存总线原子操作 4. 信号量 5. 自旋锁 6. RCU机制 7. PERCPU变量 8. 内存和优化屏障 9. 读者/写者锁 10. 大内核锁 11. 互斥量
1. 内核锁机制
内核可以不受限制地访问整个地址空间,在多处理器系统上(或类似地,在启用了内核抢占的单处理器上),这会引起一些问题,如果几个处理器同时处于核心态(即CPU正在执行内核区代码)。则理论上它们可以同时访问同一个数据结构,这会造成竞态条件
为了解决这个问题,内核使用了由锁组成的细粒度网络,来明确地保护各个数据结构,如果处理器A在操作数据结构X,则处理器B可以执行任何其他内核操作,但不能操作X,内核为此提供了各种锁选项,分别优化不同的内核数据使用模式
1. 原子操作 这是最简单的锁操作,它们保证简单的操作,诸如计数器加1之类,赋值操作,可以不中断地原子进行,即使操作由几个汇编语句组成,也可以保证 2. 自旋锁 这是最常用的锁选项,它们用于短期保护某段代码,以防止其他处理器的访问,在内核等待自旋锁释放时,会重复检查是否能获取锁,而不会进入睡眠状态(忙等待),当然,如果等待时间较长,则效率不高 3. 信号量 这是用经典方法实现的,在等待信号量释放时,内核进入睡眠状态,直至被唤醒,唤醒后,内核才重新尝试获取信号量。互斥量是信号量的特例(1元信号量),互斥量保护的临界区,每次只能有1个用户进入 4. 读者/写着锁 这些锁会区分对数据结构的两种不同类型的访问,任意数目的处理器都可以对数据结构进行并发访问,但只有一个处理器能进行写访问,在进行写访问时,读访问是无法进行的
这些锁已经成为内核开发的一个非常重要的方面,无论是基础的核心内核代码还是设备驱动程序
2. 同步与互斥
在多任务操作系统中,多个进程按照不可预测的顺序进行,因为多个进程之间常常存在相互制约或者相互依赖的关系,这些关系可以被划分为同步和互斥的关系
从本质上来说,同步和互斥也可以理解为进程/线程间同步通信的一种机制,只是这里传递的是一种"争用关系",关于Linux进程间通信和同步、以及不同和互斥的相关知识,请参阅另一篇文章
http://www.cnblogs.com/LittleHann/p/3867214.html
0x1: 在内核编程中如果不使用锁可能会导致的问题
假设使用一个全局变量printer表示可用的打印机数量,这个变量被初始设置为1,进程按照如下的方法申请使用打印机
int request_printer() { while (printer == 0) wait(); printer--; ... {
这段代码在单线程(在linux下就是单进程,因为linux没有严格的线程概念,linux只有进程)下运行是没有问题的,但是在多线程情况下,会包含着一个隐含的问题,这个问题需要从"--printer"的汇编代码才能看出,在x86平台上,"--printer"指令可能会被编译成如下的汇编指令
# printer-- movl printer %eax decl %eax movl %eax printer
由于CPU在每条指令流水阶段(CPU的指令集中的任意一条指令)的最后会进行"中断检查",因此上面的3条汇编指令之间都有可能发生中断,如果将这3条指令看成一个整体,当执行到其中一半的时候发生了中断,就会导致最后的结果不一致性。从本质上来说,要解决这个问题,就需要建立一个"原子操作"的概念,将需要强制一致性的操作(一条指令、或者多条指令)都"封装"到一个原子操作中。
0x2: 内核编程中涉及到的互斥锁机制
1. 锁定内存总线原子操作 2. 信号量 3. 自旋锁 4. RCU机制 5. PERCPU变量 6. 内存和优化屏障 7. 读者/写者锁 8. 大内核锁 9. 互斥量
Relevant Link:
深入linux内核架构(中文版).pdf 2.3章 同步与互斥 http://blog.csdn.net/lucien_cc/article/details/7440225
3. 锁定内存总线原子操作
我们继续回到之前提出的那个存在同步问题的case中,这个问题的根源是"printer--"不是原子操作引起的,为了解决这个问题,x86平台提供了dec和inc指令,这两条指令可以直接对内存进行减一或加一的操作而不产生中途的中断
因此上面的指令可以直接编译为decl printer,这样在单CPU系统上,这就是一个原子操作了,但是在多处理器系统上,上述问题依旧存在,因为在CPU内部,decl printer还是由几条微指令组成的,它还是需要首先把printer的值从内存读取到CPU中的寄存器中,然后执行减一操作,最后再写入内存,只不过在这个过程中,CPU不会进行中断检查,因此对单CPU来说,它是原子操作,但是对多CPU系统来说,CPU之间依然存在不同步的问题,脏读、幻读的现象依旧存在。
为此,x86提供了lock前缀,来避免这个问题
# printer--
lock decl printer
lock前缀告诉CPU,在执行当前指令期间锁住内存总线,这样在decl操作的微指令执行期间,如果另外的CPU访问printer,由于得不到总线仲裁的许可,在decl操作完成之前,不会访问到printer内存变量,因此它保证了在多处理器上的原子性
0x1: 对整数的原子操作
内核定义了atomic_t数据类型,用作对整数计数器的原子操作的基础,从内核的角度来说,这些操作的执行相当于是一条汇编语句
\linux-2.6.32.63\arch\x86\include\asm\atomic_32.h
/** * atomic_add - add integer to atomic variable * @i: integer value to add * @v: pointer of type atomic_t * * Atomically adds @i to @v. */ static inline void atomic_add(int i, atomic_t *v) { asm volatile(LOCK_PREFIX "addl %1,%0" : "+m" (v->counter) : "ir" (i)); }
从源代码上可以看出,atomic_add就是对addl、和lock前缀的一层封装,实现了修改的原子操作以及实现在执行期间锁住内存总线
为使得内核中"平*立"的部分能够使用原子操作,特定于体系结构的代码必须"实现"下列用于操作atomic_t类型变量的操作,在某些系统上,这些操作与C语言中对应的普通操作相比要慢得多,因此只有在确实必要的情况下才能使用这些操作
1. int atomic_read(const atomic_t *v): 读取原子变量的值 2. void atomic_set(atomic_t *v, int i): 将v设置为i 3. void atomic_add(int i, atomic_t *v): 将i加到v 4. int atomic_add_return(int i, atomic_t *v): 将i加到v,并返回结果 5. void atomic_sub(int i, atomic_t *v): 从v减去i 6. int atomic_sub_return(int i, atomic_t *v): 从v减去i,并返回结果 7. int atomic_sub_and_test(int i, atomic_t *v): 从v减去i,如果结果为0则返回true,否则返回false 8. void atomic_inc(atomic_t *v): 将v加1 9. int atomic_inc_and_test(atomic_t *v): 将v加1,如果结果为0则返回true,否则返回false 10. void atomic_dec(atomic_t *v): 从v减去1 11. int atomic_dec_and_test(atomic_t *v): 从v减去1,如果结果为0则返回true,否则返回false 12. int atomic_add_negative(int i, atomic_t *v):将i加到v,如果结果小于0则返回true,否则返回false
值得注意的是,混合普通和原子操作是不可能的,atomic原子操作不适用于标准数据类型(int、long),反过来,标准运算符(++)也不适用于atomic_t变量
同时,原子操作只能借助于ATOMIC_INIT宏初始化,因为原子数据类型最终是用普通的C语言类型实现的,内核将标准类型的变量封装在结构中,它们不能再用普通运算符处理
typedef struct { volatile int counter; } atomic_t;
如果内核编译时未启用SMP支持,则上述操作(atomic_t操作)的实现与普通变量一样(,即只遵循了atomic_t的封装,没有其他额外的原子操作,因为在非SMP情况下,没有其他CPU的干扰
内核为SMP系统提供了local_t数据类型,该类型允许在单个CPU上的原子操作,为修改此类型变量,内核提供了基本上与atomic_t数据类型相同的一组函数,只是将atomic替换为local
0x2: 内核实现代码分析
/source/arch/x86/include/asm/atomic.h
static inline void atomic_dec(atomic_t *v) { asm volatile(LOCK_PREFIX "decl %0" : "+m" (v->counter)); }
0x3: 使用方法
1. 在多CPU系统中,使用lock前缀保证CPU间的数据一致性 2. 使用由CPU微指令组成的原子操作进行读取、增、减
Relevant Link:
4. 信号量(semaphore)
信号量(semaphore)其实是建立在原子操作的基础上的。从数据结构上角度来看,信号量实际上是一个整数型变量,有两个最基本的原子操作:
1. P(Prolagen)操作:对应于内核中的down() 2. V(Verhogen)操作:对应于内核中的up() //它们统称PV原语
0x1: 数据结构
/source/include/linux/semaphore.h
struct semaphore { //1. 信号量,保护count的原子增减 raw_spinlock_t lock; /* 2. 等待该信号量的进程个数,竞争的信号量(PV操作的核心) 等待的进程会进入睡眠状态,直至信号量释放才会被唤醒,这意味着相关的CPU在同时可以同时执行其他任务(等待进程会被挂起,CPU重新调度) */ unsigned int count; //3. 该信号量的等待队列,保存所有在该信号量上睡眠的进程的task_struct struct list_head wait_list; };
0x2: 使用方式
1. 初始化
对于这一类工具类使用较多的机制,包括
1. 用于同步互斥的信号量 2. 锁 3. completion 4. 用于进程等待的等待队列 5. 用于Per-CPU的变量等等
内核都提供了两种初始化方法,静态与动态方式
1. 静态初始化 define __SEMAPHORE_INITIALIZER(name, n) \ { \ .lock = __SPIN_LOCK_UNLOCKED((name).lock), \ .count = n, \ .wait_list = LIST_HEAD_INIT((name).wait_list), \ } #define DECLARE_MUTEX(name) \ struct semaphore name = __SEMAPHORE_INITIALIZER(name, 1) //这种初始化使我们在编程的时候直接用一条语句DECLARE_MUTEX(name);就可以完成申明与初始化 2. 动态初始化方法 define init_MUTEX(sem) sema_init(sem, 1) #define init_MUTEX_LOCKED(sem) sema_init(sem, 0) static inline void sema_init(struct semaphore *sem, int val) { static struct lock_class_key __key; *sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val); lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0); } //静态初始化时信号量的count值初始化为1,当我们需要初始化为0时需要用动态初始化方法
2. down()
/source/kernel/locking/semaphore.c
void down(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(sem->count > 0)) sem->count--; else __down(sem); raw_spin_unlock_irqrestore(&sem->lock, flags); }
除了down操作之外,还有两种其他的操作用于获取信号量(不同于自旋锁,在退出信号量保护的临界区时,只有up函数可用)
1. down_interruptible: 工作方式与down相同,但如果无法获得信号量,则将进程置于TASK_INTERRUPTIBLE状态,因此,在进程睡眠时可以通过信号唤醒 2. down_trylock: 试图获取信号量,如果失败,则进程不会进入睡眠等待信号量,而是继续正常执行,如果获取了信号量,则该函数返回false值,否则返回true
3. up()
void up(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(list_empty(&sem->wait_list))) sem->count++; else __up(sem); raw_spin_unlock_irqrestore(&sem->lock, flags); }
大多数情况下,不需要使用信号量的所有功能,只是将其用作互斥量,这是一种"二值信号量",为简化代码书写,内核提供了DECLARE_MUTEX宏,可以声明一个二值信号量,初始情况下未锁定,count = 1
.. DECLARE_MUTEX(mutex); .. /* 在进入临界区时,用down将使用计数器count减1,在计数器为0时,其他进程不能进入临界区 1. 在试图用down获取已经分配的信号量时,当前进程进入睡眠,并放置再与该信号量关联的的等待队列上,同时,该进程被置于TASK_UNINTERRUPTIBLE状态,在等待进入临界区的过程中无法接收信号 2. 如果信号量没有被分配,则该进程可以立即获得信号量并进入到临界区,而不会进入睡眠 */ down(&mutex); /* 临界区 */ /* 在退出临界区时,必须调用up,该例程负责唤醒在信号量睡眠的某个进程,该进程然后允许进入临界区,而所有其他等待的进程继续睡眠 */ up(&mutex);
需要注意的是,与自旋锁相比,信号量适合于保护更长的临界区,以防止并行访问,但它们不应该用于保护较短的代码范围,因为竞争信号量时需要使进程睡眠和再次唤醒,这是一个代价很高的操作
0x3: 流程
1. 将信号量的值减1 2. 如果信号量的值小于0,则进入等待状态,否则继续执行。访问完资源之后,线程释放信号量,进行如下操作 3. 将信号量的值加1 4. 如果信号量的值小于1,唤醒一个等待中的线程
除了只能用于内核的互斥量之外,Linux也提供了futex(快速用户空间互斥量 fast userspace mutex),由和心态和用户状态组合而成,它为用户空间进程提供了互斥量功能,但必须确保其使用和操作尽可能快速并高效
Relevant Link:
http://blog.csdn.net/bullbat/article/details/7376424
5. 自旋锁
当需要对成块的代码进行"原子化串行操作",即保证一个代码块的原子性,最好的办法就是在一个代码块执行前关闭中断,在代码块结束之后再开启中断,在x86中
1. 关闭中断:通过cli指令清除标志寄存器中的中断允许位,即关闭中断 2. 开启中断:通过sti指令开启中断
关闭中断会带来其他的副作用
1. 在关闭中断期间,CPU每执行完一条指令,不会进行中断检查,由于关闭中断后,系统的外部设备(网卡,硬盘)可能得不到响应,因此这就要求进程关中断的时间必须是短暂的 2. 并且在关闭中断期间,不能使系统进入睡眠状态,因为如果进程因为等待某个资源而进入睡眠状态(例如等待硬盘外设完成swap页调入),但是这个时候硬盘的外设中断无法得到CPU响应,则这个进程就会一直无限等待下去
通过关中断的方式可以使CPU在执行某些"临界"代码块中免受干扰,但是cli只能关闭当前CPU的中断,但是在多CPU系统中,还是无法避免CPU间脏读、幻读的问题,虽然可以通过信号量的方式来防止其他CPU在同时访问同一个数据,但是由于以下原因,信号量显得不太合适
1. 信号量引起的进程切换消耗相对较大,由于这类"临界"代码的特征是执行时间非常短暂,也就是说CPU执行信号量进程切换的消耗远远大于"临界"代码本身执行的消耗。 所以,在这种情况下,让另一个CPU进入"忙等待"状态,直到临界代码操作完成 2. 由于信号量可能引起进程切换,但是在某些环境下,是不允许进程切换的,例如 1) 中断环境中是不允许进程切换的,否则会引起panic
因此在这种情况下,当一个进程不能进入信号量包裹的临界区(PV)时,最好的办法是让CPU进入忙等待状态,即使用自旋锁
1. 设置一个锁变量,用来保护临界区代码 2. 当CPU进入临界区之前,检查锁的状态 3. 如果已经上锁,则当前CPU(或者是单CPU模拟出的多线程)执行一个"空循环"反复检测锁的状态,直到其他的CPU(或者是单CPU模拟出的其他线程) 4. 由于在测试锁的期间,CPU处于忙等待状态的"自旋"状态,因此把这种机制称为自旋锁
自旋锁用于保护短的代码段,其中只包含少量C语句,因此会很快执行完毕,大多数内核数据结构都有自身的自旋锁,在处理结构中的关键成员时,必须获得相应的自旋锁
0x1: 数据结构
自旋锁通过spinlock_t数据结构实现
\linux-3.15.5\include\linux\spinlock_types.h
typedef struct raw_spinlock { arch_spinlock_t raw_lock; #ifdef CONFIG_GENERIC_LOCKBREAK unsigned int break_lock; #endif #ifdef CONFIG_DEBUG_SPINLOCK unsigned int magic, owner_cpu; void *owner; #endif #ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map; #endif } raw_spinlock_t;
0x2: 自旋锁使用方法
自旋锁的实现几乎完全是汇编语言,与体系结构非常相关
1. 初始化自旋锁
在使用自旋锁的时候,首先需要使用spin_lock_init来初始化,spin_lock_init是一个宏
\linux-3.15.5\include\linux\spinlock.h
# define raw_spin_lock_init(lock) \ do { *(lock) = __RAW_SPIN_LOCK_UNLOCKED(lock); } while (0)
初始化自旋锁时,必须使用SPIN_LOCK_UNLOCKED将其设置为未锁定状态,spin_lock会考虑下列情况
1. 如果内核中其他地方尚未获得lock,则由当前处理器获取,其他处理器不能再进入lock保护的代码范围 2. 如果lock已经由另一个处理器获得,spin_lock进入一个"无限循环",重复检查lock是否已经由spin_unlock释放(自旋锁因此得名),如果已经释放,则获取lock,并进入临界区
2. 获取自旋锁
1. spin_lock: 获取自旋锁 //spin_lock定义了一个原子操作,在获得自旋锁的情况下可以防止竞态条件出现 2. spin_trylock: 尝试获取锁,但在锁无法立即获取时不会阻塞,而是返回0,表示代码没有被锁保护 3. spin_lock_irqsave: 不仅获得自旋锁,还停用本地CPU的中断 4. spin_lock_bh: 停用softIRQ(软中断) //用这两个操作获得的自旋锁必须用对应的接口释放,分别是 5. spin_trylock_bh
在使用自旋锁时必须要注意下面几点
1. 如果获得锁之后不释放,系统将变得不可用,所有的处理器(包括获得锁的那个CPU在内),迟早会需要进入锁对应的临界区,它们会进入无限循环等待锁释放,如果一直等不到,这就产生了死锁(deadlock) 2. 自旋锁决不应该长期持有,因为所有等待锁释放的处理器都处于不可用状态,无法用于其他工作,它们会不断重复地去检测是否锁可用 3. 由自旋锁保护的代码不能进入睡眠状态,除了避免直接进入睡眠状态,还必须保证在自旋锁保护的代码所调用的函数也不会进入睡眠状态,一个特定的例子是kmalloc函数,通常情况是立即返回请求申请的内存,但在内存短缺时,该函数可以进入睡眠状态 4. 自旋锁当前的持有者无法多次获得同一个自旋锁,如果在函数中调用了其他子函数,而在子函数中包含试图获取对和父函数相同的自旋锁,也同样会发生死锁,即处理器等待自身释放持有的锁
3. 释放自旋锁
1. spin_unlock_irqsave 2. spin_unlock_bh 3. spin_unlock
申请和释放锁分别由spin_lock_irq()、spin_unlock_irq()完成
/source/include/linux/spinlock_api_smp.h
static inline void __raw_spin_lock_irq(raw_spinlock_t *lock) { //关闭本地CPU的IRQ中断响应 local_irq_disable(); /* 关闭进程抢占,由于中断或系统调用之后,可能会调度其他的进程运行(例如当前进程的时间片用完,或者有一个拥有更高优先级的进程已经进入就绪状态),preempt_disable关闭调度器这个功能,从而保证当前进程在执行临界区代码的过程中不会被其他进程干扰 */ preempt_disable(); spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); /* 关闭中断,这是一个宏,经过层层翻译后最终调用一条汇编指令 /source/arch/x86/include/asm/irqflags.h static inline void native_irq_disable(void) { asm volatile("cli": : :"memory"); } */ LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock); }
在单处理器系统上,自旋锁定义为空操作,因为不存在几个CPU同时进入临界区的情况,对于单CPU来说只需要解决多个进程的互斥问题即可,所以在单CPU系统中,进入临界区代码只需要关闭中断就可以了(即不允许其他进程的抢占)
但如果启用了内核抢占,这种说法就不适用了,如果内核在临界区中被中断,而此时另一个进程进入临界区,这与SMP系统上两个处理器同时在临界区执行的情况是等效的,通过一个简单的方法可以防止这种情况的发生,内核进入到自旋锁保护的临界区时,就停用内核抢占,在启动了内核抢占的单处理器内核中,spin_lock(基本上)等价于preempt_disable,而spin_unlock则(基本上)等价于preempt_enable
但是在多CPU(或者在单CPU模拟的多线程)的系统中,则需要测试自旋锁的状态
/source/arch/x86/include/asm/spinlock.h
static __always_inline void __ticket_spin_lock(raw_spinlock_t *lock) { short inc = 0x0100; asm volatile ( //锁住内存总线 LOCK_PREFIX "xaddw %w0, %1\n" "1:\t" //如果lock->lock == 0,则说明成功获取到了这个自旋锁,跳转到标号2,成功退出 "cmpb %h0, %b0\n\t" "je 2f\n\t" //否则进入"自旋状态",执行nop指令,并测试lock->slock是否为0 "rep ; nop\n\t" "movb %1, %b0\n\t" /* don't need lfence here, because loads are in-order */ "jmp 1b\n" "2:" : "+Q" (inc), "+m" (lock->slock) : : "memory", "cc"); } /* 在单CPU下该函数展开为一个空操作 */
从内核代码中可以看出,当一个CPU获取自旋锁失败时,这个CPU就在循环中做"无用功",等待其他CPU(或者单CPU模拟的多线程)释放自旋锁,所以,要求持有自旋锁的时间必须尽可能短暂,并且持有自旋锁时不能进入睡眠状态
在单CPU系统中,spin_unlock_irq()只需要打开中断就可以了,在多CPU系统中,spin_unlock_irq()还需要把spinlock设置为开锁状态(即释放自旋锁的持有)
0x2: 使用方法
在使用自旋锁的时候,spin_lock_irq()和spin_unlock_irq()保护临界区代码不受干扰,即在申请自旋锁的时候需要关闭中断,释放自旋锁的时候又要开启中断,这里存在一个潜在的问题
1. 某段代码在调用spin_lock_irq()之前需要关闭中断,之后获取了某个自旋锁,退出临界区后,再释放自旋锁 2. 在释放自旋锁时,spin_unlock_irq()开启了中断,然而该段代码此时可能根本不允许开启中断 3. 因此我们需要在调用申请自旋锁时保存当时的中断许可状态,在释放自旋锁时恢复之前的状态,而不是盲目地开启中断
spin_lock_irqsave、spin_unlock_irqrestore就是用来完成这个工作的
/source/include/linux/spinlock.h
/* 由于中断许可位位于CPU的标志寄存器中,因此spin_lock_irqsave()在获取自旋锁之后,把标志寄存器的值保存到flags中,而spin_unlock_irqrestore()在释放自旋锁之后,根据flags恢复标志寄存器 */ #define spin_lock_irqsave(lock, flags) \ do { \ raw_spin_lock_irqsave(spinlock_check(lock), flags); \ } while (0) static inline void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags) { raw_spin_unlock_irqrestore(&lock->rlock, flags); }
0x3: 代码示例
spin_lock_irqsave(...); //some code to do somethings spin_unlock_irqrestore(...);
Relevant Link:
http://blog.csdn.net/hustyangju/article/details/40391815 http://www.ibm.com/developerworks/cn/linux/l-cn-spinlock/ http://www.searchtb.com/2011/01/pthreads-mutex-vs-pthread-spinlock.html http://blog.csdn.net/zhanglei4214/article/details/6837697
6. RCU机制
在实际应用中,对于某些关键数据结构而言,读取操作的次数远远超过修改操作的次数,典型的例子就是内核中的路由表。在读取操作次数远大于写入操作的情况下,自旋锁带来了不必要的消耗,为了解决这个问题,Linux内核专门设计了RCU机制,它的基本原理就是读操作不加锁,写操作必须加锁
RCU(read-copy update)是一个比较新的同步机制,在内核版本2.5被加入内核主干,RCU的性能很好,不够对内存有一定的开销,但大多数情况下可以忽略,RCU对潜在的使用者提出了一些约束
1. 对共享资源的访问在大部分时间应该只是只读的,写访问应该相对很少 2. 在RCU保护的代码范围内,内核不能进入睡眠状态 3. 受保护资源必须通过指针访问
0x1: 原理
RCU的原理很简单: 该机制记录了指向共享数据结构的指针的所有使用者,在该数据结构将要改变时,则首先创建一个副本(或一个新的实例,填充适当的内容),所有的操作都在副本中修改。在所有进行读访问的使用者结束对旧副本的读取之后,指针可以替换为指向新的、修改后的副本的指针。这种机制允许读写并发进行
0x2: 核心API
假定指针ptr指向一个被RCU保护的数据结构,直接反引用指针是禁止的,首先必须调用rcu_dereference(ptr),然后反引用返回的结果,此外,反引用指针并其结果的代码,需要用rcu_read_lock和rcu_read_unlock调用保护起来
rcu_read_lock(); p = rcu_dereference(ptr); if(p != NULL) awesome_function(p); //被反引用的指针不能在rcu_read_lock、rcu_read_unlock保护的代码范围之外使用,也不能用于写访问 rcu_read_unlock();
如果要修改ptr指向的对象,则需要使用rcu_assign_pointer;
struct super_duper *new_ptr = kmalloc(); new_ptr->meaning = xyz; new_ptr->of = 42; new_ptr->life = 23; rcu_assign_pointer(ptr, new_ptr); //按RCU的术语定于,该操作"公布"了这个指针,"后续"的读取操作将看到新的结构数值,而不是原来的
如果更新可能来自内核中许多地方,那么必须使用普通的同步原语防止并发的写操作,如自旋锁,尽管RCU能保护读访问不受写访问的干扰,但RCU不能对写访问之间的相互干扰提供给保护
在新值"公布"之后,内核可以释放旧的结构实例,在所有的读访问结束之后,但内核必须知道何时释放内存是安全的,为此,RCU提供了另外两个函数
1. synchronize_rcu(): 等待所有现存的读访问完成,在该函数完成之后,释放与原指针关联的内核是安全的 2. call_rcu(): 可用于注册一个函数,在所有针对共享资源的读访问完成之后调用(回调机制),这要求将一个rcu_head实例嵌入(不能通过指针)到RCU保护的数据结构 struct super_duper { struct rcu_head head; int meaning, of, life; } 该回调函数可以通过参数访问对象的rcu_head成员,进而使用container_of机制访问对象本身 /source/include/linux/rcupdate.h void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head));
0x3: 链表操作
RCU不仅能保护一般的指针,内核也提供了标准函数,使得能通过RCU机制保护双链表,这是RCU机制在内核内部最重要的应用。此外,由struct hlist_head、strcut hlist_node组成的散列表也可以通过RCU保护
通过RCU保护的链表,仍然可以使用标准的链表元素,只有在遍历链表、修改、删除链表元素时,必须调用标准函数的RCU变体
/source/include/linux/rculist.h
1. static inline void list_add_rcu(struct list_head *new, struct list_head *head) list_add_rcu将新的链表元素new添加到表头为head的链表头部 2. static inline void list_add_tail_rcu(struct list_head *new, struct list_head *head) list_add_tail_rcu将新的链表元素new添加到表头为head的链表尾部 3. static inline void list_del_rcu(struct list_head *entry) list_del_rcu从链表删除链表元素entry 4. static inline void list_replace_rcu(struct list_head *old, struct list_head *new) list_replace_rcu将链表元素old替换为new 5. list_for_each_rcu: 允许遍历链表的所有元素 6. list_for_each_rcu_safe: 在遍历过程中删除链表元素也是安全的 //这两个操作都必须通过一堆rcu_read_lock、rcu_read_unlock包围
Relevant Link:
深入linux内核架构(中文版).pdf 35 page
7. PERCPU变量
如果系统安装有大量CPU,计数器可能成为瓶颈,每次只有一个CPU可以修改其值,所有其他CPU都必须等待操作结束,才能再次访问计数器,如果计数器频繁访问,则会严重影响系统性能
对于某些计数器,没有必要实时了解其准确的数值,这种计数器的近似值与实时准确值,作用几乎一样,这种情况下,引入per-CPU计数器,来加速SMP系统上计数器的操作,基本思想是: 计数器的准确值存储在内存中某处,准确值所在内存位置之后是一个数组,每个数组项对应于系统中的一个CPU
per-CPU变量是Linux2.6内核中的一个特性
1. 当创建一个per-CPU变量时,系统中的每个处理器都会获得它自己对这个变量的拷贝(副本) 2. 存取per-CPU变量时几乎不需要加锁,因为每个处理器使用的都是它自己的拷贝 3. per-CPU变量也可以存在于它们各自的处理器缓存中,这样对于频繁更新的量子来说,带来了显著的、更好的性能 4. 任何处理器读取计数器值时,都不是完全准确的,很有可能得到一个近似的值,偏移的程度取决于"修改阈值"的大小,即如果某个特定于CPU的数组元素修改后的绝对值超出了"修改阈值",则认为这种修改有问题,将随之修改计数器的值(即per-CPU变量同步),在这种情况下,内核需要确保通过适当的锁机制来保护这次访问,由于这种改变很少发生,因此锁操作的代价将不再那么重要 5. 只要计数器改变适度,这种方案中读操作得到的平均值会相当接近于计数器的准确值
内核借助下列数据结构来实现per-CPU计数器
\linux-2.6.32.63\include\linux\percpu_counter.h
struct percpu_counter { //lock是一个自旋锁,用于在需要准确值时保护计数器 spinlock_t lock; //count是计数器的准确值 s64 count; #ifdef CONFIG_HOTPLUG_CPU struct list_head list; /* All percpu_counters are on a list */ #endif //counters数组中各数组项是特定于CPU的,该数组缓存了对计数器的操作 s32 *counters; };
触发计数器修改的阈值依赖于系统中CPU的数目
/source/include/linux/percpu_counter.h
#if NR_CPUS >= 16 #define FBC_BATCH (NR_CPUS*2) #else #define FBC_BATCH (NR_CPUS*4) #endif
下列函数可以用来修改近似per-CPU计数器
1. static inline void percpu_counter_add(struct percpu_counter *fbc, s64 amount) 用于对计数器加或减少指定的值,如果积累的改变超过FBC_BATCH给出的阈值,则修改会传播到计数器的准确值(即触发同步操作) 2. static inline void percpu_counter_dec(struct percpu_counter *fbc) 3. static inline s64 percpu_counter_sum(struct percpu_counter *fbc) 计算计数器的准确值 4. static inline void percpu_counter_set(struct percpu_counter *fbc, s64 amount) 5. static inline void percpu_counter_inc(struct percpu_counter *fbc) 6. static inline s64 percpu_counter_read(struct percpu_counter *fbc) 读取计数器的当前值,而不考虑各个CPU所进行的改动
Relevant Link:
深入linux内核架构(中文版).pdf 39 page http://bdxnote.blog.163.com/blog/static/844423520126173729156
8. 内存和优化屏障
现代编译器和处理器试图从代码中"压榨"出每一点性能,一个有利于提高性能的技术是"指令重排",只要结果不变,这完全没有问题,但编译器或处理器很难判定重排的结果是否确实与代码原本的意图匹配,特别是需要考虑"副作用效应"的时候。尽管锁足以确保原子性,但对编译器和处理器优化过的代码,锁不能保证时序正确,与竞态条件相比,这个问题不仅影响SMP系统,也影响单处理器计算机,内核提供了下面几个函数,用于阻止处理器和编译器进行代码重排
1. mb()、rmb()、wmb()将硬件内存屏障插入到代码流程中 1) rmb()是读访问内存屏障,它保证在屏障之后发出的任何读取操作执行之前,屏障之前发出所有读取操作都已经完成 2) wmb()适用于写访问,语义与rmb类似 3) mb()合并了二者的语义 2. barrier() 插入一个优化屏障,该指令告知编译器,保存在CPU寄存器中、在屏障之前有效的所有内存地址,在屏障之后都将失效。本质上,这意味着编译器在屏障之前发出的读写请求完成之前,不会处理屏障之后的任何读写请求 3. smb_mb()、smp_rmb()、smp_wmb()相当于硬件内存屏障,但只用于SMP系统,它们在单处理器系统上产生的是软件屏障 4. read_barrier_depends() 是一种特殊的形式的读访问屏障,它会考虑读操作之间的依赖性,如果屏障之后的读请求,依赖于屏障之前的读请求的数据,那么编译器和硬件都不能重排这些请求
优化屏障的一个特定应用是内核抢占机制,为了防止因为指令重排导致的语义错误,preempt_disable在抢占计数器加1后插入一个内存屏障
\linux-2.6.32.63\include\linux\preempt.h
#define preempt_disable() \ do { \ inc_preempt_count(); \ barrier(); \ } while (0) //这防止了编译器将inc_preempt_count()与后续的语句交换位置
同样,preempt_enable必须在再次启用抢占之前插入一个优化屏障
#define preempt_enable() \ do { \ preempt_enable_no_resched(); \ barrier(); \ preempt_check_resched(); \ } while (0)
9. 读者/写者锁
通常,任意数目的进程都可以并发读取数据结构,而写访问只能限于一个进程,因此内核提供了额外的信号量和自旋锁版本,分别称之为
1. 读者/写者自旋锁 2. 读者/写者信号量
1. 读者/写者自旋锁
读者/写着自旋锁定义为rwlock_t数据类型,必须根据读写访问,以不同的方法获取锁
1. 进程对临界区进行读访问时,在进入和离开时需要分别执行read_lock和read_unlock,内核会允许任意数目的读进程并发访问临界区 2. write_lock和write_unlock用于写访问,内核保证只有一个写进程(此时没有读进程)能够处于临界区中
2. 读者/写者信号量
读/写信号量的用法类似,所用的数据结构是struct rw_semaphore,down_read和up_read用于获取对临界区的读访问。写访问借助于down_write、up_write进行
10. 大内核锁
大内核锁(big kernel lock BKL)内核遗迹之一,它可以锁定整个内核,确保没有处理器在和心态并行运行
使用lock_kernel可以锁定整个内核,对应的解锁使用unlock_kernel,BKL的一个特性是,它的锁深度也会进行计数,这意味着在内核已经锁定时,仍然可以调用lock_kernel,对应的解锁操作(unlock_kernel)必须调用同样的次数,以解锁内核,使其他处理器能够进入
\linux-2.6.32.63\lib\kernel_lock.c
/* * lib/kernel_lock.c * * This is the traditional BKL - big kernel lock. Largely * relegated to obsolescence, but used by various less * important (or lazy) subsystems. */
11. 互斥量
尽管信号量可用于实现互斥量的功能(即二元信号量),但信号量的通用性导致的开销通常是不必要的,因此,内核提供了一个专门互斥量的独立实现,它们不依赖于信号量,或确切地说,内核包含互斥量的两种实现
0x1: 经典的互斥量
经典互斥量的基本数据结构定义如下
\linux-2.6.32.63\include\linux\mutex.h
struct mutex { /* 1: unlocked, 2. 0: locked 3. negative: locked, possible waiters */ atomic_t count; spinlock_t wait_lock; struct list_head wait_list; #if defined(CONFIG_DEBUG_MUTEXES) || defined(CONFIG_SMP) struct thread_info *owner; #endif #ifdef CONFIG_DEBUG_MUTEXES const char *name; void *magic; #endif #ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map; #endif };
这种特殊处理有助于加快代码的执行速度,因为在通常情况下,不会有进程在互斥量上等待
有两种方法定义新的互斥量
1. 静态互斥量可以在编译时通过使用DEFINE_MUTEX产生(注意和DECLARE_MUTEX区分,后者是基于信号量的互斥量) 2. mutex_init在运行时动态初始化一个新的互斥量
mutex_lock、mutex_unlock分别用于锁定和解锁互斥量
0x2: 实时互斥量(用于解决优先级反转问题)
实时互斥量是内核支持的另一种形式的互斥量,它们需要编译时通过配置选项CONFIG_TR_MUTEX显示启用,与普通的互斥量相比,它们实现了"优先级继承(priority inheritance)",该特性可用于解决(或者在最低限度上缓解)优先级反转的影响
该问题可以通过优先级继承解决,如果高优先级进程阻塞在互斥量上,该互斥量当前由低优先级持有,那么当前持有互斥量的进程将临时提高到被阻塞进程的优先级,从而理顺了优先级的问题
实时互斥量的定义非常接近于普通互斥量
\linux-2.6.32.63\include\linux\rtmutex.h
struct rt_mutex { //提供实际的互斥量保护 spinlock_t wait_lock; //所有等待的进程都在wait_list中排队 struct plist_head wait_list; //owner表明互斥量的所有者 struct task_struct *owner; #ifdef CONFIG_DEBUG_RT_MUTEXES int save_state; const char *name, *file; int line; void *magic; #endif };
与普通互斥量相比,决定性的改变是等待列表中的进程按优先级排序,在等待列表改变时,内核可相应地校正锁持有者的优先级,这需要用到调度器的一个接口,可由函数rt_mutex_setprio提供,该函数更新动态优先级task_struct->prio,而普通优先级task_struct->normal_priority不变
Copyright (c) 2014 LittleHann All rights reserved