Linux(内核剖析):33—内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

释放双眼,带上耳机,听听看~!

一、完成变量

  • 如果在内核中一个任务需要发出信号通知另一任务发生了某个特定事件,

利用完成变量(completion variable)是使两个任务得以同步的简单方法
如果一个任务要执行一些工作时,另 一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。这听起来很像一个信号量,的确如此——思想是一样的。事实上,完成变量仅仅提供了代替信号量的一个简单的解决方法。例如,当子进程执行或者退出时,vfork()系统调用使用完成变量唤醒父进程

  • 完成变量由结构completion表示,定义在<linux/completion.h>
  • 通过以下宏

静态地创建完成变量并初始化它:


1
2
1DECLARE_COMPLETION(mr_comp);
2
  • 通过init_completion()

动态创建并初始化完成变量

  • 在一个指定的完成变景上,需要等待的任务调用wait_dor_completion()来等待特定事件。当特定事件发生后,产生事件的任务调用complete()

来发送信号唤醒正在等待的任务

  • 下表列出了完成变量的方法:

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 使用完成变量的例子可以参考kernel/sched.c和kernel/fork.c。完成变量的通常用法是,将完成变量作为数据结构中的一项动态创建,而完成数据结构初始化工作的内核代码将调用wait_ for_completion()进行等待。初始化完成后,初始化函数调用completion()唤醒在等待的内核任务

二、BLK:大内核锁

  • 欢迎来到内核的原始混沌使其。BKL(大内核锁)是一个全局自旋锁,使用它主要是为了方便实现从Linux最初的SMP过渡到细粒度加锁机制

  • 我们下面来介绍BKL的一些有趣的特性:

  • **持有BKL的任务仍然可以睡眠。**因为当任务无法被调度时,所加锁会自动被丢弃;当任务被调度时,锁又会被重新获得。当然,这并不是说,当任务持有BKL时 ,睡眠是安全的,仅仅是可以这样做,因为睡眠不会造成任务死锁

    • BKL是一种递归锁。 一个进程可以多次请求一个锁,并不会像自旋锁那样产生死锁现象
    • BKL只可以用在进程上下文中。和自旋锁不同,你不能在中断上下文中申请BKL
    • 新的用户不允许使用BKL。随着内核版本的不断前进,越来越少的驱动和子系统再依赖于BKL了
  • 这些特性有助于2.0版本的内核向2.2版本过渡。

在SMP支持被引入到2.0版本时,内核中一个时刻上只能有一个任务运行(当然,经过长期发展,现在内核已经被很好地线程化了)。2.2版本的目标是允许多处理器在内核中并发执行程序。引入BKL是为了使到细粒度加锁机制的过渡更容易些,虽然当时BKL对内核过渡很有帮助,但是目前它已成为内核可扩展性的障碍了

  • 在内核中不鼓励使用BKL。事实上,

**新代码中不再使用BKL,**但是这种锁仍然在部分内核代码中得到沿用,所以我们仍然需要理解BKL以及它的接口。除了前面提到的以外,BKL的使用方式和自旋锁类似。函数lock_kernel()请求锁, unlock_kernel释放锁。一个执行线程可以递归的请求锁,但是,释放锁时也必须调用同样次数的unlock_kernel()操作,在最后一个解锁操作完成后,锁才会被释放。函数kernel_locked()检测锁当前是否被持有,如果被持有,返回一个非0值,否则返回0。

  • 这些接口被声明在文件<linux/smp_lock.h>,简单的用法如下:

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • BKL在被持

有时同样会禁止内核抢占。在单一处理器内核中,BKL并不执行实际的加锁操作

  • 下标列出了所有BKL函数

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 对于BKL最主要的问题是确定BKL锁保护的到底是什么。多数情况下,

BKL更像是保护代码(如“它保护对foo()函数的调用者进行同步”)
而不保护数据(如“保护结构foo”)。这个问题给利用自旋锁取代BKL造成了很大困难,因为难以判断BKL到底锁的是什么,更难的是,发现所有使用BKL的用户之间的关系

三、顺序锁

  • 顺序,通常简称seq锁,是在2.6版本内核中才引入的一种新型锁。这种锁提供了一种很简单的机制,

用于读写共享数据

  • 实现这种锁

主要依靠一个序列计数器:

  • 当有疑义的数据

被写入时,会得到一个锁,并且序列值会增加
* 在读取数据之前和之后,序列号都被读取

  • 如果读取的序列号值相同,说明在读操作进行的过程中没有被写操作打断过
    * 此外,**如果读取的值是偶数,**那么就表明写操作没有发生(要明白因为锁的初值是0,所以写锁会使值成奇数,释放的时候变成偶数)

基本使用

  • 定义一个seq锁:


1
2
1sqlock_t mr_seq_lock=DEFINE_SEQLOCK(mr_seq_lock);
2

 

  • 然后,写锁的方法如下:


1
2
3
4
5
1write_seq_lock(&amp;mr_seq_lock);
2
3//写锁被释放
4write_sequnlock(&amp;mr_seq_lock);
5

 

  • 和普通的自旋锁类似。不同的情况发生在读时,并且与自旋锁有很大不同


1
2
3
4
5
1unsigned long seq;
2do{
3    seq=read_seqbegin(&amp;mr_seq_lock);
4}while(read_seqretry(&amp;mr_seq_lock,seq));
5
  • 在多个读者和少数写者共享一把锁的时候,seq锁有助于提供一种非常轻量级和具有可扩展性的外观。但是seq锁对写者更有利。只要没有其他写者,写锁总是能够被成功获得。读者不会影响写锁,这点和读-写自旋锁及信号量一样。另外,挂起的写者会不断地使得读操作循环(前一个例子),直到不再有任何写者持有锁为止

  • Seq锁在你遇到如下需求时将是最理想的选择:

  • 你的数据存在很多读者

    • 你的数据写者很少
    • 虽然写者很少,但是你希望写优先于读,而且不允许读者让歇着饥饿
    • 你的数据很简单,如简单结构,甚至是简单的整型——在某些场合,你是不能使用原子量的
  • 使用seq锁中最有说服力的是jiffies。该变量存储了Linux机器启动到当前的时间(参见后面文章。jiffies是使用一个64位的变量,记录了自系统启动以来的时钟节拍累加数。对于那些能自动读取全部64位jiffies_64变量的机器来说,需要用get_jiffies_64()方法完成,而该方法的实现就是用了seq锁:

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 定时器中断会更新Jiffies的值,此刻,也需要使用seq锁变量:

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 若要进一步了解jiffies和内核时间管理 ,请看后面的文章和内核源码树中的kernel/time.c与kernel/time/tick-common.c文件

四、禁止内核抢占

  • 由于

内核是抢占性的,内核中的进程
在任何时刻都可能停下来以便另一个具有更高优先权的进程运行。这意味着一个任务与被抢占的任务可能会在同一个临界区内运行

  • 为了上面这种情况,内核抢占代码使用自旋锁作为非抢占区域的标记。如果一个自旋锁被持有,内核便不能进行抢占。因为内核抢占和SMP面对相同的并发问题,并且内核已经是SMP安全的(SMP-safe),所以,这种简单的变化使得内核也是抢占安全的(preempt-safe)

单处理器/多处理器下抢占

  • 或许这就是我们希望的。实际中,

某些情况并不需要自旋锁,但是仍然需要关闭内核抢占

  • 最频繁出现的情况就是每个处理器上的数据。

如果数据对每个处理器是唯一的,那么,这样的数据可能就不需要使用锁来保护,因为数据只能被一个处理器访问

  • 如果自旋锁没有被持有,内核又是抢占式的,那么一个新调度的任务就可能访问同一个变量,如下所示:

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 这样,即使这是一个单处理器计算机,变量foo也会被多个进程以伪并发的方式访问。通常

,这个变量会请求得到一个自旋锁(防止多处理器机器上的真并发)。但是
如果这是每个处理器上独立的变量,可能就不需要锁

使用方法

  • 为了解决这个问题,可以通过

preempt_disable()禁止内核抢占。这是一个可以嵌套调用的函数,可以调用任意次。每次调用都必须有一个相应的preempt_enable()调用。当最后一次preempt_enable()调用后,内核抢占才重新启用。例如:

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 抢占计数存放着被持有锁的数量和preempt_disable()的调用次数,如果计数0,那么内核可以进行抢占;如果为1或更大的值,那么,内核就不会进行抢占。这个计数非常有用——它是一种对原子操作和睡眠很有效的调试方法。

函数preempt_count()返回这个值

  • 下表列出了内核抢占相关的函数

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 为了用更简洁的方法解决每个处理器上的数据访问问题,可以通过get_cpu()获得处理器编号(假定是用这种编号来对每个处理器的数 进行索引的)。这个函数在返回当前处理器号前首先会关闭内核抢占

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

五、顺序和屏障

顺序和屏障概述

  • 当处理多处理器之间或硬件设备之间的同步问题时,有时需要在你的程序代码中以指定的顺序发出读内存(读入)和写内存(存储)指令。在和硬件交互时,时常需要确保一个给定的读操作发生在其他读或写操作之前。另外,在多处理器上,可能需要按写数据的顺序读数据(通常确保后来以同样的顺序进行读取)
  • 但是

**编译器和处理器为了提高效率,可能对读和写重新排序,**这样无疑使问题复杂化了

  • 幸好,所有可能重新排序和写的处理器提供了机器指令来确保顺序要 求。同样也可以

指示编译器不要对给定点周围的指令序列进行重新排序。这些确保顺序的指
令称作屏障(barriers)

演示说明

  • 在某些处理器上有以下代码


1
2
3
1a=1;
2b=2;
3
  •  有可能会在a中存放新值之前就在b中存放新值

  • 编译器和处理器都看不出a和b之间的关系:

  • **编译器会在编译时按这种顺序编译,**这种顺序会是静态的,编译的目标代码就只把a放在b之前

    • 但是,**处理器会重新动态排序,**因为处理器在执行指令期间,会在取指令和分派时,把表面上看似无关的指令按自认为最好的顺序排列。大多数情况下,这样的排序是最佳的,因为a和b之间没有明显关系
  • 处理器和编译器可能会对上面的代码进行重新排序,但

绝不会对下面的代码进行重新排序:


1
2
3
1a=1;
2b=a;
3
  • 此时a和b均为全局变量,因为a与b之间有明确的数据依赖关系
  • 但是不管是编译器还是处理器都不知道其他上下文中的相关代码。偶然情况下,有必要让写操作被其他代码识别,也让所期望的指定顺序之外的代码识別。这种情况常常发生在硬件设备上,但是在多处理器机器上也很常见

使用方法

  • rmb()方法:提供了一个“读”内存屏障,它确保跨越rmb()的载入动作不会发生重排序。也就是说,在rmb()之前的载入操作不会被重新排在该调用之后,同理,在rmb()之后的载入操作 不会被重新排在该调用之前
  • **wmb()方法:**提供了一个“写”内存屏障,这个函数的功能和rmb()类似,区别仅仅是它是针对存储而非载入——它确保跨越屏障的存储不发生重排序
  • **mb()方法:**既提供了读屏障也提供了写屏障。载入和存储动作都不会跨越屏障重新排序。这是因为一条单独的指令(通常和rmb()使用同一个指令)既可以提供载入屏障,也可以提供存储屏障
  • **read_barrier_depends():**是rmb()的变种,它提供了一个读屏障,但是仅仅是针对后续读操作所依靠的那些载入。因为屏障后的读操作依赖于屏障前的读操作,因此,该屏障确保屏障前的 读操作在屏障后的读操作之前完成。基本上说,该函数设置一个读屏障,如rmb(),但是只针对特定的读——也就是那些相互依赖的读操作。在有些体系结构上,read_barrier_ depends()比rmb()执行得快,因为它仅仅是个空操作,实际并不需要
  • 看看使用了mb()和rmb()的一个例子,其中a的初始值是1,b的初始值是2

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 如果不使用内存屏障,在某些处理器上,c可能接收了b的新值,而d接收了a原来的值。 比如c可能等于4(正是我们希望的),然而d可能等于1(不是我们希望的)。使用mb()能确保a和b按照预定的顺序写入,而rmb()确保c和d按照预定的顺序读取
  • 这种重排序的发生是因为现代处理器为了优化其传送管道,打乱了分派和提交指令的顺序。如果上例中读a、b时的顺序被打乱话,又会发生什么情况呢?rmb()或wmb()函数相当于指令,它们告诉处理器在继续执行前提交所有尚未处理的载入或存储指令
  • 看一个类似的例子,但是其中一个线程用read_barrier_depends()代替了rmb()。例子中a的初始值是1,b是2,p是&b

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 再一次声明,如果没有内存屏障,有可能在pp被设置成p前,b就被设置为pp了。由于载入*PP依靠载入o,所以read_barrier_depends()提供了一个有效的屏障。虽然使用rmb()同样有效,但是因为读是数据相关的,所以我们使用read_barrier_depends()可能更快。注意,不管在哪种情况下,左边的线程都需要mb()操作来确保预定的载入或存储顺序
  • 宏smp_rmb()、smp_wmb()、smp_mb()和smp_read_barrier_depends()提供 了一个有用的优化。在SMP内核中它们被定义成常用的内存屏障,而在单处理机内核中,它们被定义成编译器的屏障。对于SMP系统,在有顺序限定要求时,可以使用SMP的变种
  • barrier()方法可以防止编译器跨屏障对载入或存储操作进行优化。编译器不会重新组织存储或载入操作,而防止改变C代码的效果和现有数据的依赖关系。但是,它不知道在当前上下文之外会发生什么事。例如,编译器不可能知道有中断发生,这个中断有可能在读取正在被写入的数据。这时就要求存储操作发生在读取操作前。前面讨论的内存屏障可以完成编译器屏障的功能,但是编译器屏障要比内存屏障轻量(它实际上是轻快的)得多。实际上,编译器屏障几乎是空闲的,因为它只防止编译器可能重排指令
  • 下表给出了内核中所有体系结构提供的完整的内存和编译器屏障方法

Linux(内核剖析):33---内核同步之(完成变量(completion)、大内核锁(BLK)、顺序所(seqlock)、禁止抢占、顺序和屏障(barriers))

  • 注意,对于不同体系结构,屏障的实际效果差别很大。例如,如果一个体系结构不执行打乱存储(如Intel x86芯片就不会),那么wmb()就什么也不做。但应该为最坏的情况(即排序能力最弱的处理器)使用恰当的内存屏蔽,这样代码才能在编译时执行针对体系结构的优化

给TA打赏
共{{data.count}}人
人已打赏
安全运维

WordPress网站专用docker容器环境带Waf

2020-7-18 20:04:44

安全运维

运维安全-Gitlab管理员权限安全思考

2021-9-19 9:16:14

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索