linux 内核同步

同步原语

Linux内核使用的同步技术:

-技术-                -说明-                                    -适用范围-
每CPU变量            在CPU之间复制数据结构                      所有CPU
原子操作                对一个计数器原子地"读-修改-写"的指令            所有CPU
内存屏障                避免指令重新排序                            本地CPU或所有CPU
自旋锁               加锁时忙等                             所有CPU
信号量               加锁时阻塞等待(睡眠)                       所有CPU
顺序锁               基于访问计数器的锁                         所有CPU
本地中断的禁止       禁止单个CPU上的中断处理                   本地CPU
本地软中断的禁止        禁止单个CPU上的可延迟函数处理              本地CPU
读-拷贝-更新(RCU)      通过指针而不是锁来访问共享数据结构         所有CPU

"适用范围"表示该同步技术是适用于系统中所有的CPU还是单个CPU.


每CPU变量

per-cpu variables是Linux 2.6中一个有趣的特性. 当创建一个per-cpu variable时, 每个processor都创建自己对变量的一个拷贝. 这么做看起来有些奇怪, 但实际上很有优势, 访问per-cpu variable几乎(almost)不需要加锁, 因为每个processor都有自己的工作副本. 并且per-cpu variables能够驻留在对应processor的cache中, 对频繁访问的变量能带来显著的性能提升.

虽然per-cpu var为来自不同CPU的并发访问提供保护, 但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护, 这种情况下需要另外的同步原语. 此外在单处理器和多处理器系统中, 内核抢占都可能使per-cpu var产生竞争条件, 总的原则是内核控制路径应该在禁用抢占的情况下访问per-cpu var.
例子: 一个内核路径获得了它的per-cpu var本地副本的地址, 然后它因被抢占而转移到另外一个CPU上, 但仍然引用原来CPU元素的地址.


原子操作

很多汇编指令具有"读-修改-写"类型的操作, 也就是说, 它们访问存储器单元两次, 第一次读原值, 第二次写新值.

假如运行在两个CPU上的两个内核控制路径试图通过执行非原子操作来同时"读-修改-写"同一存储单元.
1. 两个CPU都试图读同一单元, 但是存储器仲裁器(对访问RAM芯片的操作进行串行化的硬件电路)插手, 只允许其中一个访问, 而让另一个延迟.
2. 第一个读操作完成后, 延迟的第二个读操作读到同一个旧值.
3. 两个CPU都试图向那个存储器单元写入新值, 总线存储器访问再一次被存储仲裁器串行化, 一次只能有一个写, 但最终两次写操作都成功.

这就是典型的竞争场景.
避免"读-修改-写"指令引起的竞争条件最容易的办法, 就是确保这样的操作在芯片级是原子的, 任何一个这样的操作都必须以单个指令执行, 中间不能中断, 且避免其他CPU访问同一存储器单元.

80x86的指令中原子和非原子的指令:
- 进行零次或一次对齐内存访问的汇编指令是原子的.
- 在读操作之后, 写操作之前如果没有其他处理器占用内存总线, 那么从内存中读取数据, 更新数据并把更新后的数据写回内存中的这些"读-修改-写"汇编语言指令是原子的.
- 操作码前缀是lock字节的"读-修改-写"汇编指令即使在多处理器系统中也是原子的, 控制单元检测到这个前缀时, 就"锁定"内存总线, 直到指令执行完成.
- 操作码前缀是rep字节的汇编指令不是原子的, 这条指令让控制单元多次重复执行相同的指令, 在执行新的循环之前要检查挂起的中断.

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)          把1加到*v, 如果结果为0, 则返回1, 否则返回0
atomic_add_negative(i, v)       把i加到*v, 如果结果为负, 则返回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的所有位

优化和内存屏障

程序在经过编译器编译成可执行程序之后, 我们千万不要认为指令会严格按照它们在源代码中出现的顺序执行. 因为编译器可能重新安排汇编指令以使寄存器以最优的方式使用, 并且现代CPU通常并行执行多条指令, 于是内存访问可能被重排以加速程序执行.

优化屏障(optimization barrier)原语保证编译程序不会重排原语操作之前和之后的汇编指令. 在Linux中, 优化屏障就是barrier()宏.

但是优化屏障并不保证当前CPU顺序执行汇编指令, 即使用优化屏障阻止不了CPU把汇编指令混在一起执行--这是内存屏障的工作.

内存屏障(memory barrier)原语确保, 在原语之后的操作开始之前, 原语之前的操作已经完成. 可见优化屏障控制指令的生成, 内存屏障控制指令的执行.

Linux使用六个内存屏障原语(mb(),rmb(),wmb(),smp_mb(),smp_rmb(),smp_wmb()), 这些原语也被当作优化屏障, 因为我们必须保证编译程序不在屏障前后移动汇编指令. "读内存屏障"仅仅作用于从内存读的指令, 而"写内存屏障"仅仅作用于写内存的指令. 内存屏障原语的实现依赖于系统的体系结构.

Q: pthreads是否有对应优化屏障和内存屏障的函数?


自旋锁

加锁是一种应用广泛的同步技术. 自旋锁(spin lock)是用来在多处理器环境中工作的一种特殊的锁, 如果内核控制路径发现自旋锁"开着", 就获取锁并继续执行; 相反, 如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径"锁着", 就在周围"旋转", 反复执行一条紧凑的循环指令, 直到锁被释放, 这称为"忙等", 即使等待的内核控制路径无事可做, 它也在CPU上保持运行.

一般来说, 由自旋锁保护的每个临界区都是禁止内核抢占的. 在单处理器系统上, 这种锁本身并不起锁的作用(猜想: 这个锁是CPU级别的, 是从CPU角度去看的, 而不是从进程的角度去看), 这时, 自旋锁原语仅仅是禁止或启用内核抢占. 不过需要注意, 在自旋锁忙等期间, 内核抢占还是有效的, 因此, 等待自旋锁释放的进程有可能被更高优先级的进程替代.

具有内核抢占的spin_lock宏

spin_lock宏用来请求自旋锁, 它使用自旋锁的地址slp作为参数, 执行如下操作:
1. 调用preempt_disable()禁用内核抢占.
2. 调用函数_raw_spin_trylock(), 对自旋锁的slock字段执行原子性的测试和设置操作.
3. 如果自旋锁旧值是正数, 宏结束, 内核控制路径获得自旋锁.
4. 否则, 内核控制路径无法获得自旋锁, 因此宏必须执行循环一直等到其他CPU上运行的内核控制路径释放自旋锁. 调用preempt_enable()递减在第一步中递增的抢占计数器.
5. 如果break_lock字段等于0, 则设置为1. 通过检测该字段, 拥有锁并在其他CPU上运行的进程可以知道是否有其他进程在等待这个锁, 如果进程把持某个自旋锁的时间太长, 它可以提前释放.
6. 执行等待循环: while (spin_is_locked(slp) && slp->break_lock) cpu_relax();
7. 跳转到第一步, 再次试图获得自旋锁.

读写自旋锁

读写自旋锁(rwlock_t)的引入是为了增加内核的并发能力. 如大家想象一样, 读锁可以共享访问, 而写锁独占访问资源.


顺序锁

顺序锁(seqlock)与读写自旋锁非常类似, 只是它为writer赋予了较高的优先级: 即使reader正在读的时候也允许writer继续执行. 这种策略的好处是, 除非另一个writer正在写, writer永远不会等待; 缺点是有时候reader不得不反复读相同的数据, 直到获得有效副本. 每个reader都必须在读数据前后两次读顺序计数器, 并检查两次读到的值是否相同, 如果不同, 说明新的writer已经开始写并增加了顺序计数器, 暗示reader刚读到的数据是无效的.


读-拷贝-更新(RCU)

RCU是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术, 是Linux 2.6中新加的功能, 用在网络层和虚拟文件系统中. RCU允许多个reader和writer并发执行, 这相比只允许一个writer的顺序锁有了改进. 而且RCU是不是用锁的, 就是说, 它不使用被所有CPU共享的锁或计数器, 从这点来看, RCU比自旋锁和顺序锁有更大的优势.

RCU是如何做到如此神奇的? 其关键思想是限制使用范围:
- RCU只保护被动态分配并通过指针引用的数据结构.
- 在被RCU保护的临界区中, 任何内核控制路径都不能睡眠.

当内核控制路径要读取被RCU保护的数据结构时, 执行宏rcu_read_lock(), 它等同于preempt_disable(), 接着reader间接引用数据结构指针所对应的内存单元并开始读这个数据结构, reader在读完之前是不能睡眠的, 读完后用等同于preempt_enable()的宏rcu_read_unlock()标记临界区的结束.

可以看出, reader是没做什么事情来防止竞争条件的出现的, 相反writer要做很多工作. 当writer要更新数据结构时, 它间接引用指针并生成整个数据结构的副本, 接下来writer修改这个副本. 一旦修改完毕, writer改变指向数据结构的指针, 使它指向修改后的副本. 由于修改指针值的操作是一个原子操作, 所以旧副本和新副本对每个reader或writer都是可见的, 不会出现数据错乱. 但尽管如此, 还是需要内存屏障来保证: 只有数据结构被修改之后, 已更新的指针对其他CPU才是可见的. 如果把自旋锁与RCU结合起来, 以禁止writer的并发执行, 就隐含地引入了这样的内存屏障.

然而使用RCU技术的真正困难在于: writer修改指针时不能立即释放数据结构的旧副本, 实际上writer开始修改时, 正在访问数据结构的读者可能还在读旧副本, 只有在CPU上所有reader都执行完宏rcu_read_unlock()之后, 才可以释放旧副本. 内核要求每个潜在的reader在下面的操作之前执行rcu_read_unlock()宏:
- CPU执行进程切换.
- CPU开始在用户态执行.
- CPU执行空循环.

对上述每种情况, 我们说CPU已经经过了静止状态(quiescent state). writer调用函数call_rcu()来释放数据结构的旧副本, 当所有CPU都通过静止状态之后, call_rcu()接受rcu_head的地址和回调函数地址, 一旦回调函数被执行, 它通常释放数据结构的旧副本.


信号量

广泛使用的一种同步机制是信号量(semaphore), 它在单处理器和多处理器系统上都有效. 信号量仅仅是与一个数据结构相关的计数器, 所有内核线程在试图访问这个数据结构之前, 都要检查这个信号量. 可以把每个信号量看成一个对象, 其组成如下:
- 一个整数变量.
- 一个等待进程的链表.
- 两个原子方法: down()和up(). down()对信号量的值减1, 如果新值小于0, 该方法就把正在运行的进程加入这个信号量链表, 然后阻塞该进程(即调用调度程序). up()对信号量的值加1, 如果新值大于或等于0, 则激活这个信号量链表中的一个或多个进程. 每个要保护的数据结构都有它自己的信号量, 初值为1.

Linux提供两种信号量:
- 内核信号量, 供内核控制路径使用.
- System V IPC信号量, 由用户态进程使用.

内核信号量类似于自旋锁, 内核控制路径试图获得内核信号量锁保护的资源时, 相应的进程被挂起. 只有在资源被释放时, 进程才再次变为可运行. 因此, 只有可睡眠的函数才能获取内核信号量, 中断处理程序和可延迟函数都不能使用内核信号量.

内核信号量的数据结构:

/* Please don't access any members of this structure directly */
struct semaphore {
    spinlock_t      lock;
    unsigned int        count;
    struct list_head    wait_list;
};

count存放atomic_t类型的一个值, 如果>0, 那么资源是空闲的; 如果等于0, 表示信号量是忙的, 但没有进程等待这个被保护的资源; 如果<0, 则资源是不可用的, 并至少有一个进程等待资源.
wait_list用来存放等待队列链表的地址.

读写信号量

类似于读写自旋锁, 但在信号量再次变为打开之前, 等待进程挂起而不是自旋. 内核用rw_semaphore结构表示读写信号量.


补充原语

Linux 2.6还是用另一种类似于信号量的原语: 完成量(completion). 引入它的目的是为了解决多处理器系统上发生的一种微妙的竞争关系: 进程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(), 这个函数把current作为一个互斥进程加到等待队列的末尾, 并把current置为TASK_UNINTERRUPTIBLE让其睡眠.


禁止本地中断

禁止本地中断(local interrupt disabling), 使得即使硬件设备产生一个IRQ信号时, 内核控制路径也能继续执行, 从而保护了中断处理程序也访问的数据结构. 然而禁止中断并不能阻止运行在另一个CPU上的中断处理程序对数据结构的并发访问, 因此在多处理器系统上, 禁止本地中断经常与自旋锁结合使用.


同步访问内核数据结构

可以使用前面提到的同步原语保护共享数据结构, 避免竞争条件. 系统性能可能随选择的同步原语而有很大变化. 一般地, 内核开发者采用经验法则: 把系统中的并发度保持在尽可能高的程度.

系统的并发度取决于两个主要因素:
- 同时运转的IO设备数.
- 进行有效工作的CPU数.

为使IO吞吐量最大化, 应该使中断禁止保持尽短的时间. 为了有效利用CPU, 应该尽可能避免使用基于自旋锁的同步原语, 因为CPU执行紧指令循环等待自旋锁时, 浪费了宝贵的机器周期, 同时自旋锁对硬件高速缓存的影响对系统整体性能不利.

举例, 在下面的例子中, 可以维持较高的并发度, 也可以达到同步.
- 共享的数据结构是一个单独的整数值, 此时可声明为atomic_t, 并使用原子操作.
- 把一个元素插入到共享链表的操作不是原子的, 因为至少涉及两个指针赋值.

讨论第二个例子, 在C语言中, 插入是通过如下指针赋值实现的:

new->next = list_element->next;
list_element->next = new;

在汇编中, 插入简化为两个连续的原子指令. 第一条指令创建中间结果, 不修改链表. 因此, 如果中断处理程序在第一条和第二条指令执行之间查看链表, 那么看到的是没有新元素的链表, 在第二天指令之后查看到的就是包含新元素的链表. 这两种情况下的链表都是一致的, 处于未损坏状态. 然而, 开发者必须确保两个赋值操作的顺序不被编译器或CPU打乱, 因此需要一个写内存屏障.

new->next = list_element->next;
wmb();
list_element->next = new;    

more

禁止和激活可延迟函数

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值