内核同步
你可以把内核看作是不断对请求响应的服务器,这些请求可能来自在CPU上执行的进程,也可能来自发出中断请求的外部设备。
内核如何为不同的请求提供服务
为了更好的理解内核代码是如何执行的,我们把内核看作必须满足两种请求的侍者:一种请求来自于顾客,另一种请求来自数量有限的几个不同的老板。
对不同的请求,侍者采用如下的策略:
1、老板提出请求时,如果侍者正空闲,则侍者开始为老板服务。
2、如果老板提出请求时侍者正在为顾客服务,那么侍者停止为顾客服务,转而去为老板服务。
3、如果一个老板提出请求时侍者正在为另一个老板服务,那么侍者停止为第一个老板服务,而开始为第二个老板服务,服务完毕再继续为第一个老板服务。
4、一个老板可能命令侍者停止正在为顾客提供的服务。侍者在完成对老板最近请求的服务之后,可能会暂时不理会原来的顾客而去为新选中的顾客服务。
侍者提供的服务对应于CPU处理内核态时执行的代码。如果CPU在用户态执行,则侍者被认为处于空闲状态。
老板的请求相当于中断,而顾客的请求相当于用户态进程发出的系统调用或异常。
内核抢占
如果进程正在执行内核函数时,即它在内核态运行时,允许发生内核切换,这个内核就是抢占的。遗憾的是在Linux中,情况要复杂的多。
无论在抢占内核还是非抢占内核中,允许在内核态的进程都可以自动放弃CPU,比如,其原因可能是,进程由于等待资源而不得不转入睡眠状态。我们把这种进程切换称为计划性进程切换。但是抢占式内核在相应引起进程切换的异步事件的方式上与非抢占的内核有差别的,我们把这种进程切换称为强制性进程切换。
所有进程的切换都由宏switch_to来完成。在抢占内核和非抢占内核中,当进程执行某些具有内核功能的线程,而且调度程序被调用后,就发生进程的切换。不过在非抢占内核中,当前进程是不可能被替换的,除非它打算切换到用户态。
因此抢占内核的主要特点是:一个在内核态运行的进程,可能在执行内核函数期间被另一个进程取代。
什么时候同步是必需的
交叉内核控制路径使内核开发者的工作变得复杂:他们必须特别小心地识别出异常处理程序、中断处理程序、可延迟函数和内核线程中的临界区。一旦临界区被确定,就必须对其采用适当地保护措施,以确保在任意时刻只有一个内核控制路径处于临界区。
什么时候不需要同步
中断处理程序和tasklet不必编写成可重入的函数。
仅被软中断和tasklet访问的cpu变量不需要同步
仅被一种tasklet访问的数据结构不需要同步
同步原语
内核使用的各种同步技术
技术 说明 使用范围
每CPU变量 在CPU之间复制数据结构 所有CPU
原子操作 对一个计数器原子地“读-修改-写”的指令 所有CPU
内存屏障 避免指令重新排序 本地CPU或所有CPU
自旋锁 加锁时忙等 所有CPU
信号量 枷锁时阻塞等待(睡眠) 所有CPU
顺序锁 基于访问计数器的锁 所有CPU
本地中断禁止 禁止单个CPU上的中断处理 本地CPU
本地软中断禁止 禁止单个CPU上的可延迟函数处理 本地CPU
读-拷贝-更新(RCU) 通过指针而不是锁来访问共享数据结构 所有CPU
每CPU变量
事实上每一种显示的同步原语都是不容忽视的性能开销。
最简单也是最重要的同步技术包括把内核变量声明为每CPU变量(per-cpu variable)。每CPU变量主要是数据结构的数组,系统的每个CPU对应数组的一个元素。
一个CPU不应该访问其他CPU对应的数组元素,另外,它可以随意读或者修改它自己的元素而不必担心出现竞争条件,因为它是唯一有资格这么做的CPU。这也意味着没CPU变量基本上只能在特殊情况下使用,也就是当他确定在系统的CPU上的数据在逻辑上是独立的时候。
在单处理器和多处理器系统中,内核抢占都可能使每CPU变量产生竞争条件。总的原则是内核控制路径应该在禁用抢占的情况下访问每CPU变量。
为每CPU变量提供的函数和宏
DEFINE_PER_CPU(type,name) 静态分配一个每CPU数组,数组名为name,结构类型为type
per_cpu(name,cpu) 为CPU选择一个每CPU数组元素,CPU由参数cpu指定,数组名称为name
__get_cpu_var(name) 选择每cpu数组name的本地CPU元素
get_cpu_var(name) 先禁用内核抢占,然后在每CPU数组name中,为本地CPU选择元素
put_cpu_var(name) 启动内核抢占(不使用name)
alloc_percpu(type) 动态分配type类型数据结构的每CPU数组,并返回它的地址
free_percpu(pointer) 释放被动态分配的每CPU数组,pointer指示其地址
per_cpu_ptr(pointer,cpu) 返回每CPU数组中与参数cpu对应的COU元素地址,参数pointer给出数组地址
原子操作
Linux中的原子操作
atomic_read(v) 返回*v
atomic_set(v,i) 把*v置位i
atomic_add(i,v) 把*v增加i
atomic_sub(i,v) 把*v减去i
atomic_sub_and_test(i,v)从*v中减去i,如果结果为0,返回1,否则返回0
atomic_inc(v) 把1加到*v
atomic_dec(v) 把*v减1
atomic_dec_and_test(v) 从*v中减去1,如果结果为0,返回1,否则返回0
atomic_inc_and_test(v) 从*v中加上1,如果结果为0,返回1,否则返回0
atomic_add_negative(i,v)从*v中加上i,如果结果为负,返回1,否则返回0
atomic_inc_return(v) 把1加到*v,返回*v的新值
atomic_dec_return(v) 把*v减去1,返回*v的新值
atomic_add_return(i,v) 把i加到*v,返回*v的新值
atomic_sub_return(i,v) 把*v减去i,返回*v的新值
另一类原子函数操作作用于为掩码
test_bit(nr,addr) 返回*addr的第nr位的值
set_bit(nr,addr) 设置*addr的第nr位的值
clear_bit(nr,addr) 清*addr的第nr位的值
change_bit(nr,addr) 转换*addr的第nr位的值
test_and_set_bit(nr,addr) 设置*addr的第nr位的值
test_and_clear_bit(nr,addr) 请*addr的第nr位的值,并返回原值
test_and_change_bit(nr,addr) 转换*addr的第nr位的值,并返回原值
atomic_clear_mask(mask,addr) 清mask指定的*addr的所有位
atomic_set_mask(mask,addr) 设置mask指定的*addr的所有位
优化和内存屏障
当使用优化的编译器时,你千万不要认为指令会严格按照它们在源代码中出现的顺序执行。
优化屏障(optimization barrier)原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编语言指令,这些汇编语言质量在C中都由对应的语言。
优化屏障就是barrier()宏
内存屏障(memory barrier)原语确保,在源于之后的操作开始执行之前,原语之前的操作已经完成。因此,内存屏蔽类似于防火墙,让任何汇编于语言指令都不能通过。
内存屏障宏
mb() 适用于MP和UP的内存屏蔽
rmb() 适用于MP和UP的读内存屏蔽
wmb() 适用于MP和UP的写内存屏蔽
smp_mb() 仅适用于MP的内存屏蔽
smp_rmb() 仅适用于MP的读内存屏蔽
smp_wmb() 仅适用于MP的写内存屏蔽
自旋锁
当内核控制路径必须访问共享数据结构或临界区时,就需要为自己获取一把“锁”。
自旋锁(spin lock)是用来在多处理器环境中工作的一种特殊的锁。如果内核控制路径发现自旋锁是“开着”,就获取锁并继续自己的执行。相反,如果内核控制路径发现锁由运行着的另一个CPU上的内核控制路径“锁着”,就在周围”旋转“,反复执行一条紧凑地循环指令,知道锁被释放。
一般来说,由自旋锁所保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,自旋锁原语仅仅是禁止或启动内核抢占。
在Linux中,每个自旋锁都用spinlock_t结构表示,其中包含两个字段:
slock:该字段表示自旋锁的状态:值为1表示“未加锁”状态,而任何负数和0都表示“加锁”状态。
break_lock:表示进程正在忙等自旋锁(只在内核支持SMP和内核抢占的情况下使用该标志)。
自旋锁宏:
spin_lock_init():把自旋锁置为1(未锁)
spin_lock():循环,直到自旋锁变为1(未锁),然后把自旋锁置为0(锁上)
spin_unlock():把自旋锁置为1(未锁)
spin_unlock_wait():等待,直到自旋锁变为1(未锁)
spin_is_locked():如果自旋锁被置为1(未锁),返回0,否则,返回1
spin_trylock():把自旋锁置为0(锁上),如果原来锁的值是1,就返回1;否则,返回0;
读/写自旋锁
读/写自旋锁的引入是为了增加内核的并发能力。只要没有内核控制路径对数据结构进行修改,读/写自旋锁允许多个内核控制路径同时读同一个数据结构。如果一个内核控制路径相对这个结构进行写操作,那么它必须首先获取读/写锁的写锁,写锁授权独占访问资源。
每个读/写自旋锁都有一个rwlock_t结构,其中lock字段是一个32位的字段,分为两个不同的部分:
24位计数器:表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。
“未锁”标志字段:当没有内核控制路径在读或者写时设置该位,否则清0。
break_lock:表示进程正在忙等自旋锁(只在内核支持SMP和内核抢占的情况下使用该标志)。
顺序锁
顺序锁和读写自旋锁非常相似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续执行。
这种策略的好处是写者永远不会等待(除非另外一个写者正在写),缺点是有时候读者不得不反复多次读相同的数据知道它获得有效的副本。
每个顺序锁都是包含两个字段的seqlock_t结构:一个类型为spinlock_t的lock字段和一个整型的sequence字段,第二个字段是一个顺序计数器。
每个读者必须在读数据前后两次读顺序计数器,并检查两次读到的值是否相同,如果不相同,说明新的写者已经开始在写并且增加了顺序计数器,因此暗示读者刚刚读到的数据是无效的。
并不是每一种资源都可以使用自旋锁来保护的。一般来说,必须满足下述条件才能使用顺序锁:
被保存的数据结构不包含被写者修改和被读者间接引用的指针。(否则,写者可能在读者的眼鼻下就修改指针)
读者的临界区代码没有副作用。(否则,多个读者的操作会与单独的读操作有不同的结果)
此外,读者的临界区代码应该简短,而且读者应该不常获取顺序锁,否则,反复的读访问会引起严重的开销。
在Linux2.6中,使用顺序锁的典型例子保护一些与系统时间处理相关的数据结构。
读-拷贝-更新(RCU)
读-拷贝-更新(RCU)是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术。RCU允许多个读者和写者并发执行(相对于只允许一个写者执行的顺序锁有了改进)。而且RCU是不使用锁的,就是说,它不使用被所有CPU共享的锁或计数器,在这一点上与读写自旋锁和顺序锁相比,RCU具有更大的优势。
RCU关键思想包括限制RCP的范围,
1、RCU只保护被动态分配并且通过指针引用的数据结构。
2、在被RCU保护的临界区中,任何内核控制路径都不能睡眠。
RCU是Linux2.6中新加的功能,用在网络层和虚拟文件系统中。
信号量
从本质上说,它们实现了一个加锁原语,即让等待者睡眠,知道等待的资源变为空闲。
实际上Linux提供两种信号量:
内核信号量,由内核控制路径使用。
System V IPC信号量,由用户态进程使用。
内核信号量类似于自旋锁,因为当锁关闭时,它不允许内核控制路径继续执行。然而,当内核控制路径试图获取内核信号量所保护的忙资源时,相应的进程被挂起。只有进程被释放时,进程才再次变为可运行的。因此,只有可以睡眠的函数才能获取内核信号量;中断处理程序和可延迟函数都不能使用内核信号量。
struct semaphore {
atomic_t count; 如果大于0,那么资源时空闲的,也就是资源可用,相反,如果等于0,那么信号量是忙的,但没有进程等待这个被保护的资源,如果为负数,则资源不可用的,并且至少有一个进程等待资源。
int sleepers; 存放一个标志,表示是否有一个进程在信号量上睡眠。
wait_queue_head_t wait; 存放等待队列链表的地址,当前等待资源的所有睡眠进程都放在这个链表中
};
可以用init_MUTEX()和init_MUTEX_LOCKED()函数来初始化互斥访问所需的信号量;
释放信号量锁,调用up()函数。(简单)
获取信号量锁,调用down()函数。(复杂)
读/写信号量
读/写信号量类似于前面的“读/写自旋锁”,有一点不同:在信号量再次变为打开前,等待进程挂起而不是自旋。
每个读/写信号量都由rw_semaphore结构描述的,它包含下列字段:
struct rw_semaphore {
signed long count; 存放两个16位的计数器。其中最高16位计数器以二进制补码形式存放非等待写者进程的总数和等待的写内核控制路径数。
低16位计数器存放非等待的读者和写者进程总数。
spinlock_t wait_lock; 一个自旋锁,用来保护等待队列链表和rw_semaphore结构本身。
struct list_head wait_list; 指向等待进程的链表
};
init_resem()函数初始化rw_semaphore结构,即把count字段置为0,wait_lock自旋锁置为未锁,wait_list置为空链表。
down_read()和down_write()函数分别为读或写获取读/写信号量。同样up_read()和up_write()函数为读或写释放以前获取的读/写信号量。
补充原语
Linux2.6还使用了另一种类似于信号量的原语:补充。引入这种原语是为了解决多处理器系统上发生的一种微妙的竞争条件,当进程A分配了一个临时信号量变量,把它初始化为关闭的MUTEX,并把其地址传递给进程B,然后在A纸上调用down(),进程A打算一旦被唤醒就撤销信号量。随后允许在不同CPU上的进程B在同一信号量上调用up()。然而,up()和down()的目前实现还允许这两个函数在同一信号量上并发执行。因此,进程A可以被唤醒并撤销临时信号量,而进程B还在允许up()函数,结果up()可能试图访问一个不存在的数据结构。
补充是专门设计来解决以上问题的同步原语。
completion数据结构包含了一个等待队列头和标志:
struct completion{
unsigned int done;
wait_queue_head_t wait;
};
与up()对应的函数叫做complete()。
与down()对应的函数叫做wait_for_completion()。
补充原语和信号量之间的真正差别在于如何使用等待队列中包含的自旋锁。在补充原语中,自旋锁用来确保complete()和wait_for_completion()不会并发执行。在信号量中,自旋锁用于避免并发执行的down()函数弄乱信号量的数据结构。
禁止本地中断
确保一组内核已经被当作一个临界区处理的主要机制之一就是中断禁止。即使当硬件设备参数一个IRQ信号时,中断禁止也让内核控制路径继续执行,因此,这就提供了一种有效的方法,确保中断处理程序访问的数据结构也受到保护。然而,禁止本地中断并不保护允许在另一个CPU上的中断处理程序对数据结构的并发访问,因此,在多处理器系统上禁止本地中断经常与自旋锁结合使用。
宏local_irq_disable()使用cli汇编语言指令关闭本地CPU上的中断,宏local_irq_enable()使用sti汇编语言指令打开被关闭的中断。
禁止和激活可延迟函数
在软中断一节中,我们说明了可延迟函数可能在不可预知的时间执行(实际上是在硬件中断处理程序结束时)。因此,必须保护可延迟函数数据结构使其避免竞争条件。
禁止可延迟函数在一个CPU上执行的一种简单方式就是禁止在这个CPU上的中断。因为没有中断处理程序被激活,因此,软中断操作就不能异步的开始了。
对内核数据结构的同步访问
可以使用前面所述的同步语句保护共享数据结构避免竞争条件。通常情况下,内核开发者采用下述由经验得到的法则:把系统中的并发度保存在尽可能搞的程度。
系统中的并发度又取决于两个主要因素:
同时运转的I/O设备数。
进行有效工作的CPU数。
为了使I/O吞吐量最大化,应该使中断禁止保持在很短的时间。
为了有效的利用CPU,应该尽可能避免使用及与自旋锁的同步原语。
在自旋锁、信号量及中断禁止之间选择
遗憾的是,对大多数内核数据结构的访问模式非常复杂,远不像想的那么简单。
一般来说,同步原语的选取取决于访问数据结构的内核控制路径的种类。记住,只要内核控制路径获得自旋锁(还有读写锁,顺序锁,RCU“读锁”),就禁止本地中断或本地软中断,自动禁止内核抢占。
内核控制路径访问的数据结构所需要的保护
访问数据结构的内核控制路径 单处理器保护 多处理器保护
异常 信号量 无
中断 本地中断禁止 自旋锁
可延迟函数 无 无或自旋锁
异常与中断 本地中断禁止 自旋锁
异常与可延迟函数 本地软中断禁止 自旋锁
中断与可延迟函数 本地中断禁止 自旋锁
异常、中断与可延迟函数 本地中断禁止 自旋锁
避免竞争条件的实例
为了直观的认识内核内部到底是什么样子,需要提及本章所定义同步原语的集中典型用法。
引用计数器:
引用计数器广泛地用在内核中以避免由于资源的并发分配和释放而产生的竞争条件,引用计数器只不过是一个atomic_t计数器,与特定的资源,如内存页、模块或文件相关。
当内核开始使用资源时,原子地减少计数器的值,当内核使用完资源时,原子地增加计数器的值。当引用计数器为0时,说明该资源未被使用,如果有必要就是否该资源。
大内核锁
从内核版本2.6.11开始,用一个叫做kernel_sem的信号量来实现大内核锁。但是大内核锁比简单的信号量还是要复制一些。
每个进程描述符都含有lock_depth字段,这个字段允许同一个进程几次获取大内核锁。
内存描述符读/写信号量
mm_struct类型的每个内存描述符在mmap_sem字段都包含了自己的信号量。由于几个轻量级进程之间可以共享一个内存描述符,因此,信号量保护这个描述符以避免可能产生的竞争条件。
slab高速缓存链表的信号量
slab高速缓存描述符链表是通过cache_chain_sem信号量保护的,这个信号量允许互斥地访问和修改该链表。
所有节点的信号量
Linux把磁盘文件的信息存放在一种叫做索引节点(inode)的内存对象中。相应的数据结构也包含有自己的信号量,存放在i_sem字段。