文章目录

在进行多线程编程时,我们要特意留意共享数据的保护,防止并发访问时多个线程同时操作 导致的结果不一致性。 Linux 2.0 开始内核开始支持 SMP. 所以内核可以在同一时刻运行 多个内核线程。因此内核的共享数据的保护显得尤为重要。

基本概念

  1. 临界区:访问和操作共享数据的代码段
  2. 竞争:两个或多个线程对临界区的同时访问
  3. 同步:避免并发和防止竞争

同步方法

原子操作

原子指不可分割的执行序列,原子操作可以保证指令以原子的方式执行。

原子整型操作

只能对类型为 atomic_t 的数据进行处理。不使用 int 原因如下:

  1. 确保只与这种特殊类型的数据一起使用,这样保证该类型的数据不会被传递给任何非原 子函数
  2. 确保编译器不对相应的值进行访问优化,这使得原子操作最终接收到正确的内存地址, 而不是一个别名。
  3. 在不同体系结构下实现原子操作时可以屏蔽其间的差异
typedef struct {
  volatile int counter;
} atomic_t;

内核提供的接口在 <asm/atomic.h> 中: {{ figure src= “/images/截图_2019-06-12_21-08-15.png” >}}

能使用原子操作时尽量使用,相比于其它的加锁机制。原子操作与复杂的同步方法系统开 销更小,对高速缓存的影响也很小。原子操作大部分是内联函数,内嵌汇编代码。

64位原子操作

typedef struct{
  volatile long counter;
}atomic64_t;

使用方式和 atomic_t 一样,只是将名称都改为 atomic64_t.

原子位操作

位操作是对普通内存地址进行操作。参数是一个地址和位号。只要指针指向目标数据,就可 以对其操作。 {{ figure src= “/images/截图_2019-06-12_21-23-14.png” >}}

内核同时还提供了非原子位操作,函数名称是上述函数前前面加上双下划线。如 test_bit() 的非原子形式为 __test_bit(). 它通常比原子操作快些。

自旋锁

Linux 内核中最常见的锁。最多只能被一个可执行线程持有。如果一个线程试图获取一个被 占用的自旋锁,那么就会陷入忙循环-旋转-等待锁重新可用。

使用:

DEFINE_SPINLOCK(mr_lock);
spin_lock(&mr_lock);
/* 临界区 */
spin_unlock(&mr_lock);

Linux 内核实现的自旋锁是不可递归的。

可以用在中断处理程序中(不能使用信号量,因为会导致睡眠)。在获取自旋锁之前,必须 禁止本地中断(当前处理器),否则中断处理程序就会打断有锁的内核代码,有可能试图去争取这个已经被 占用的自旋锁,这样中断处理程序就会自旋等待该锁重新可用;但锁的持有者在这个中断程 序处理完之前不可能运行。

读写自旋锁

信号量

Linux 中的信号量是一种睡眠锁,如果一个任务试图获取不可用(被占用)的信号量时,信 号量会将其推进一个等待队列,然后让其睡眠。当持有信号量的任务释放信号量后,处于等 待队列中的任务将被唤醒并获得信号量。

  1. 争用时会睡眠,所以适用于锁会被长时间持有的情况
  2. 相反,短时间持有时,就不适合了,因为睡眠、维护等待队列以及唤醒的开销可能比锁 占用的时间还长
  3. 由于执行线程会睡眠,所以只能在进程上下文中获取信号量锁,因为在中断上下文是不 能调度的
  4. 在持有信号量时睡眠,因为其它等待进程不会因此死锁
  5. 占用信号锁的同时不能占用自旋锁,因为等待信号量时有可能睡眠,而持 有自旋锁不能睡眠

计数信号量和二值信号量

二值信号量又称互斥信号量,表示同一时刻只能有一个线程持有该锁。

读写信号量

和读写自旋锁一样,将信号量细分为读写两个步骤进行加锁

互斥体

是一种比互斥信号量简洁高效的互斥锁;行为和互斥信号量类似,但接口比互斥信号量简单;

互斥体的使用场景相对而言更严格、定向:

  • 任何时刻只有一个任务持有 mutex,即 mutex 的计数永远为 1
  • mutex 上锁者必须负责解锁,即不能在一个上下文加锁,而在另一个上下文解锁。 这使得 mutex 不适合内核同用户空间复杂的同步场景
  • 不允许递归的上锁和解锁
  • 当持有一个 mutex 时,进程不可以退出
  • mutex 只能通过官方 API 管理,不可拷贝、手动初始化或者重复初始化

最有用的特色是:通过一个特殊的调试模式,内核可以采用编程方式检查和警告任何践踏其 约束法则的行为。

对于信号量和互斥体的选择:首选互斥体,除非它的约束条件不够

自旋锁和互斥体的选择:

完成变量

用于一个任务发送给另一个任务发生某个特定事件的信号。他是信号量的简单替代解决方案, 如子进程执行或退出时使用完成变量唤醒父进程。

通常的用法是:将完成变量作为数据结构中的一项动态创建,而完成数据结构初始化工作的 内核代码将调用 wait_for_completion() 进行等待。初始化完成后,初始化函数调用 completion() 唤醒等待的内核任务。

大内核锁(BKL)

是一个全局自旋锁,主要是为了方便实现从 Linux 最初的 SMP 过渡到细粒度加锁机制。 BKL 多数情况下像是在保护代码而不是数据。这也是使用自旋锁代替 BKL 的困难之处。

  • 持有 BKL 的任务可以睡眠。因为当任务无法调度时,所加锁会被自动丢弃;当任务被调 度时,又会重新获得。
  • BKL 是一种递归锁。可以多次请求一个锁,不会像自旋锁那样产生死锁
  • 只可以用在进程上下文,这点不同与自旋锁
  • 新的用户不允许使用 BKL, 现在已经很少有驱动和子系统依赖与 BKL 了

顺序锁

简称 seq 锁,2.6 版引入的新型锁。是一种用于保护读写共享数据的简单机制。实现主 要依靠一个序列计数器,当有疑义的数据被写入时,会得到一个锁,并且序列会增加。在读 取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读操作进行的过程 中没有被写操作打断过。此外,如果读取的值是偶数,就表明写操作没有发生(即初始值是 0,写锁变为奇数,释放再变为偶数)

应用场景:

  • 存在很多的读者
  • 写者很少
  • 虽然写者少,但希望写优于读,且不允许读者让写者饥饿
  • 数据很简单。

禁止抢占

使用一个自旋锁可以避免内核抢占,但是这会使全部的处理器均不可抢占该临界区。但有时 候我们只想保护某个处理器上的数据,那么我们可以使用在某个处理上禁止内核抢占即可。

preempt_disable() 可以嵌套调用,可以调用任意次,但必须有相应的 preempt_enable() 调用。抢占计数存放着被持有锁的数量和 preempt_disable() 的调 用次数,如果计数是 0, 表示内核可抢占。

还有一种简单的方法是使用 get_cpu()put_cpu(), 在 get_cpu() 返回前会关闭 内核抢占,使用 put_cpu() 开启内核抢占。

int cpu;
cpu = get_cpu();
/* 对处理器上的数据进行操作 */
put_cpu(); /* 开启内核抢占 */

顺序和屏障