Linux内核笔记 – 内核同步2

Linux内核提供一组同步方法,用于避免共享数据之间的竞争:

  • 内核态进程的非抢占性
  • 原子操作
  • 关中断

[======]

内核态进程的非抢占性

Linux内核是非抢占的:正在运行的进程处于内核态时,不会被抢占,即使来了优先级更高进程。
以下断言,在Linux中总是成立:

  • 内核态运行的进程不会被其他进程取代,除非它主动放弃CPU控制权。
  • 中断或异常处理可以中断在内核态中运行的进程。但是,在中断处理程序结束时,该进程的内核控制路径被恢复。
  • 执行中断或异常处理的内核控制路径,只能被执行中断或异常处理的其他内核控制路径中断。(一个内核控制路径被另一个内核控制路径中断)

内核控制路径

内核控制路径:内核用来处理系统调用,异常或中断所执行的指令序列。
内核控制路径类似于进程的角色,不过前者更原始:1)没有任何描述符与内核控制路径相关;2)内核控制路径不是通过单个函数进行调度的,而是通过把停止和恢复的指令序列插入到内核代码中进行调度的。

最简单情况,CPU从第一条指令到最后一条指令顺序执行内核控制路径。但在以下事件之一发生时,CPU会交错执行内核控制路径:

  • 发生上下文切换。进程调度、上下文切换,都只有在schedule()函数被调用时发生。
  • 中断发生在当CPU正在执行开中断的内核控制路径时。此时,第一个内核控制路径还没完成,CPU就开始执行另一个内核控制路径来处理该中断。

[======]

原子操作

所谓原子操作,可以保证指令以原子的方式执行,执行过程不被打断。
内核提供两组原子操作接口:1)针对整数进行操作;2)针对单独的位进行操作。
Linux支持的大多数体系结构都实现了这两组接口,一般有这几种实现方式:本来就支持简单的原子操作;为单步执行提供锁内存总线的指令,确保其他读写操作不能同时发生;SPARC体系结构的原子指令LDSTUB。

  • 原子操作整数
    虽然原子操作针对的atomic_t类型数据是32位整数,没有用int,原因在于1)原子函数只接受atomic_t类型操作数;2)使用atomic_t类型确保编译器不对相应的值进行访问优化;3)不同体系结构上实现原子操作的时候,用atomic_t可以屏蔽其间差异。
    SPARC体系结构,32位atomic_t的低8位用于内嵌锁。Linux为了兼容SPARC体系结构,使用atomic_t的代码只能将该类型的数据当24bit来用。
    原子整型操作相关声明位于<asm/atomic.h>

定义一个atomic_t类型数据,并操作:

atomic_t v; /* 定义原子整数v */
atomic_t u = ATOMIC_INIT(0);

atomic_set(&v, 4); /* v = 4(原子地) */
atomic_add(2, &v); /* v = v + 2 = 6(原子地) */
atomic_inc(&v);    /* v = v + 1 = 7(原子地) */

// 将atomic_t转型为int,可用atomic_read()
printk("%dn", atomic_read(&v)); /* 打印7 */

除了上面的原子自增atomic_inc()和原子自减atomic_dec(),还有一类原子操作:用原子整数操作原子地执行一个操作并检查结果,如原子减操作和检查:

// 将给定的原子变量-1,如果结果为0,就返回真;否则,返回假
int atomic_dec_and_test(atomic_t *v);

注:原子操作通常是内联函数,通过内联汇编指令来实现,因为内核开发者喜欢使用内联函数。

所有的原子整数操作:
Linux内核笔记 - 内核同步2

原子操作使用原则:
能用原子操作,就尽量不要用复杂加锁机制。对多数体系结构来讲,原子操作给系统带来的开销更小,对高速缓存行(cache-line)的影响也更小。

  • 原子位操作
    内核提供一组针对位进行操作的函数,与体系结构相关,位于<asm/bitops.h>。
    位操作函数是对普通的内存地址进行的,其参数是一个指针 + 一个位号。第0位是给定地址的最低有效位,最高有效位通常是31(32位机)或63(64位机)。不过,对位号范围并没有限制。

所有原子位操作:
Linux内核笔记 - 内核同步2

对应非原子位操作,函数的形式是在原子微操作版本前面加上2个下划线。如test_bit()的非原子形式是__test_bit()。非原子操作执行速度更快。

[======]

自旋锁

自旋锁(spin lock)常用于Linux内核。自旋锁最多只能被一个可执行线程持有。如果一个执行线程试图获得一个被争用(已被持有)的自旋锁,那么该线程就会一直循环等待锁重新可用;如果锁未被争用,请求锁的执行线程能立刻得到它继续执行。
自旋锁特点:等待锁的时候,不会因此主动放弃CPU。因此自旋锁不应该被长时间持有,否则会浪费大量CPU时间。

也有其他的锁,如互斥锁,能让线程阻塞时主动放弃CPU,那为什么会有自旋锁呢?
因为互斥锁阻塞线程,会带来2次明显的上下文切换(阻塞 + 唤醒),从而带来一定开销,而自旋锁是没有上下文切换的。当自旋锁时间小于两次上下文切换耗时时,使用自旋锁可能更合适。

自旋锁的基本使用形式:

#include <linux/spinlock.h>

spinlock_t mr_lock=SPIN_LOCK_UNLOCKED;

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

注意:
1)单处理器机器上,编译时不会加入自旋锁。它仅被当做一个设置内核抢占机制是否被启用的开关。如果禁止内核抢占,那么编译时自旋锁会被完全剔除出内核。
2)自旋锁不可递归。如果试图取得自己正持有的锁,必须自旋,等待自己释放该锁。而自己却处于自旋忙等待中,导致永远无法释放锁,从而导致死锁。
3)调试时,应该激活选项CONFIG_DEBUG_SPINLOCK,以便于调试、检测自旋锁,内核会检查是否使用未初始化的锁,是否还没加入锁的时候就开始执行解锁操作。

自旋锁可用在中断处理程序中,但不能使用信号量,因为信号量会导致睡眠。中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁用本地中断(当前处理器上的中断请求);否则,中断处理程序可能会打断正持有锁的内核代码,有可能会试图争用这个已经被持有的自旋锁,而中断处理程序不返回,锁的持有者就无法释放锁。

内核提供禁止中断,同时请求锁的接口:

spinlock_t mr_lock = SPIN_LOCK_UNLOCKED;
unsigned long flags;

spin_lock_irqsave(&mr_lock, flags); /* 保存中断当前状态, 并禁止本地中断, 再去获取指定锁 */
/* 临界区... */
spin_unlock_irqrestore(&mr_lock, flags); /* 对指定的锁解锁, 然后让中断恢复到加锁前的状态 */

其他自旋锁操作

spin_lock_init() 初始化动态创建的自旋锁(调用者只有一个指向spinlock_t的指针,没有其实体);
spin_try_lock() 试图获取某个特定的自旋锁,如果该锁已被争用,那么立刻返回非0值,而不会自旋等待;如果成功获取锁,则返回0。
spin_is_locked() 用于检测指定锁当前是否已经被占用,如果已被占用,返回非0;否则,返回0。

所有自旋锁方法:
Linux内核笔记 - 内核同步2

自旋锁和下半部

与下半部配合使用时,必须小心使用锁机制。
spin_lock_bh() 用于获取指定锁,同时禁止所有下半部的执行;
spin_unlock_bh() 释放指定锁,同时使能所有下半部的执行。

为什么要对进程上下文和中断处理程序中的共享数据进行保护?
对于进程上下文,因为下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中的共享数据进行保护,加锁的同时还要禁止下半部执行。
对于中断处理程序,由于中断处理程序可以抢占下半部,所以如果中断处理程序和下半部共享数据,那么就必须在获取恰当的锁的同时禁止中断。
简而言之,为保护共享数据,所以需要用锁进行保护;如果在不能取得锁的地方,如中断处理程序,就要禁用中断,等用完数据后再恢复中断。

[======]

读-写自旋锁

有时根据锁的用途,可以明确地分为读取和写入。Linux提供写读-写自旋锁,为读和写分别提供不同的锁。一个或多个读任务可以并发持有读者锁;而写锁最多只能被一个写任务持有,同时不能被其他读任务持有。
因此,读/写锁也叫,共享/排斥锁,或者并发/排斥锁。

读写自旋锁用法类似于普通自旋锁

rwlock_t my_rwlock = RW_LOCK_UNLOCKED; /* 初始化 */

read_lock(&mr_rwlock);    /* 取得读锁 */
/* 临界区(只读) */
read_unlock(&mr_rwlock);  /* 释放读锁 */

write_lock(&mr_rwlock);   /* 取得写锁 */
/* 临界区(读写) */
write_unlock(&mr_rwlock); /* 释放写锁 */

不能把一个读锁“升级”为写锁,例如:

/* 错误示例:将带来死锁 */
read_lock(&mr_rwlock);    /* 取得读锁 */
write_lock(&mr_rwlock);   /* 取得写锁 */

所有读-写自旋锁方法:
Linux内核笔记 - 内核同步2

[======]

信号量

Linux信号量是一种睡眠锁。如果有一个任务试图获得一个已被占用的信号量,信号量会将其推进一个等待队列,然后让其睡眠(放弃CPU)。当持有信号量的进程将新信号量释放后,等待队列中的那个任务将被唤醒,并获得该信号量。

有关信号量的睡眠特性的结论:

  • 由于争用信号量的进程在等待锁可用时,会睡眠,因此信号量适用于锁会被长时间持有的情况。
  • 如果锁被短时间持有,使用信号量就不太适合。因为睡眠、维护等待队列以及唤醒的开销,可能比锁被占用的时间还要长。
  • 由于执行线程在锁争用时可能会睡眠,所以只能在进程上下文中才能获取信号量锁,因为中断上下文中不能调度,以让中断睡眠。
  • 在持有信号量时,可以去睡眠,因为其他进程试图获得同一信号量时不会因此而死锁(因为该进程只是去睡眠,最终会继续执行)。
  • 占用信号量的同时不能占用自旋锁。因为在等待信号量时可能会睡眠,而持有自旋锁时不允许睡眠。

信号量与自旋锁异同点

信号量不同于自旋锁,不会禁止内核抢占,所以持有信号量的代码可以被抢占。也就是说,信号量不会对调度的等待时间带来负面影响。

另外,信号量可用同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有它。信号量同时允许的持有者数量,可在声明信号量是指定,该数值称为使用者数量(usage count)或简单叫数量(count)。

  • 二值信号量
    信号量和只允许一个锁持有者时,使用者数量为1,这样的信号量被称为二值信号量,或者互斥信号量(互斥锁)。
  • 计数信号量(counting semaphore)
    当运行同一个时刻有多个信号量锁的持有者时,这样的信号量被称为计数信号量。内核中很少使用。

创建和初始化信号量

信号量实现与体系结构相关,具体实现定在<asm/semaphore.h>。

  • 信号量
    1)静态声明信号量
/* name 信号量变量名
   count 信号量的使用者数量
 */
static DECLARE_SEMAPHORE_GENERIC(name, count)

2)初始化动态创建的信号量

/* sem 指向动态创建的信号量的指针
   count 信号量的使用者数量
 */
sema_init(sem, count); 
  • 互斥信号量
    1)静态互斥信号量
    声明静态互斥信号量DECLARE_MUTEX,在Linux 2.6.36以后改成了DEFINE_SEMAPHORE
static DECLARE_MUTEX(name); /* 内核2.6以后已废弃 */

static DEFINE_SEMAPHORE(name); /* 内核2.6以后 */

2)动态互斥信号量

init_MUTEX(sem);

使用信号量

几个典型的获取信号量锁的方法:
down_interruptible() 试图获取指定信号量,如果获取失败,它将以TASK_INTERRUPTIBLE状态进入睡眠。
down_trylock() 试图获取指定信号量,如果已被占用,则立刻返回非0;否则,返回0,且成功持有信号量锁。
up() 释放信号量锁。

典型示例:

/* 定义并声明一个信号量,名为mr_sen,用于信号量计数 */
static DECLARE_MUTEX(mr_sem);

/* 试图获取信号量... */
if (down_interruptible(&mr_sem)) {
    /* 信号被接收,信号量还未获取 */
}

/* 临界区... */

/* 释放给定的信号量 */
up(&mr_sem);

其他信号量操作:
Linux内核笔记 - 内核同步2

读-写信号量

跟自旋锁一样,信号量也区分读-写访问的功能。
头文件:<linux/rwsem.h>

基本操作
创建静态声明的读-写信号量:

/* name 新信号量名
 */
static DECLARE_RWSEM(name);

动态创建的读-写信号量,初始化:

init_rwsem(struct rw_semaphore *sem);

读-写信号量类似于读-写锁,都是互斥信号量,其引用计数为1。只要没有写者,并发持有读锁的读者数不限。相反,只有唯一的写者(在没有读者时),可以获得写锁。所有读-写锁的睡眠都不会被信号打断,所以它只有一个版本的down()操作。

static DECLARE_RWSEM(mr_rwsem);

/* 试图获取信号量用于读... */
down_read(&mr_rwsem);

/* 临界区(只读)... */
/* 释放信号量 */

up_read(&mr_rwsem);

/* ... */

/* 试图获取信号量用于写... */
down_write(&mr_rwsem);

/* 临界区(读和写)... */

/* 释放信号量 */
up_write(&mr_sem);

读-写信号量也提供了trylock版本:down_read_trylock()和down_write_trylock(),分别试图获取信号量读锁和写锁。

注意:
1)读-写信号量比读-写自旋锁多一种特有操作:downgrade_write(),可以动态将读取的写锁转换为读锁。
2)只有代码中读和写操作,能明确无误地分割开,否则最好不使用。

[======]

自旋锁与信号量

自旋锁与信号量的比较:

需求 建议的加锁方法
低开销加锁 优先使用自旋锁
短期锁定 优先使用自旋锁
长期锁定 优先使用信号量
中断上下文中加锁 使用自旋锁
持有锁需要睡眠 使用信号量

注意:中断上下文中只能用自旋锁,任务睡眠时只能用信号量

[======]

完成变量

如果在内核中一个任务需要发出信号通知另一个任务发生了某个特定时间,利用完成变量(completion variable)是使2个任务得以同步的简单方法。
如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当该任务完成后,就会用完成变量唤醒在等待的任务。
完成变量仅提供替代信号量的一个简单解决方案。

典型的例子是,当子进程执行或退出时,vfork()系统调用使用完成变量唤醒父进程。

头文件:<linux/completion.h>

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

DELCARE_COMPLETION(mr_comp);

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

init_completion(mr_comp);

完成变量方法:

int_completion(struct completion*);      /* 初始化指定的动态创建的完成变量 */
wait_for_completion(struct completion*); /* 等待指定的完成变量接受信号 */
complete(struct completion*);            /* 发信号唤醒任何等待任务 */

[======]

BKL

BKL(大内核锁)是一个全局自旋锁,主要为了方便从Linux最初的SMP过渡到细粒度的加锁机制。

BKL特性:

  • 持有BKL的任务可以睡眠,睡眠不会造成任务死锁。
  • BKL是一种递归锁,一个进程可以多次请求同一个锁,不会像自旋锁那样产生死锁。
  • BKL可以用在进程上下文。
  • BKL是有害的。

内核中不鼓励使用BKL,理解旧的代码即可。
头文件:<linux/smp_lock.h>

lock_kernel();

/* 临界区,对所有其他的BLK用户进行同步 
   注意:你可以安全地在此睡眠,锁会悄无声息地被释放;
   当你的任务被重新调度时,锁又会悄无声息地获取;
   意味着你不会处于死锁状态,但是,如果你需要锁保护这里的数据,你还是不需要睡眠
 */

unlock_kernel();

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

所有BKL函数:

lock_kernel();   /* 获得BKL */
unlock_kernel(); /* 释放BKL */
kernel_locked(); /* 如果锁被持有返回非0值,否则返回0(UP总是返回非0) */

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

[======]

Seq锁

内核2.6引入新型锁Seq锁,提供一种很简单的机制,用于读写共享数据。
其实现主要依靠一个序列计数器:当有数据被写入时,会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读取操作进行的过程中没有被写操作打断过。此外,如果读取的值是偶数,那么就表明写操作没有发生。因为锁初值0,写锁会使值成奇数,释放锁会变成偶数。

seq锁是写优先的读写锁。

seq锁典型应用模式:
1)seq写锁

seqlock_t mr_seq_lock = SEQLOCK_UNLOCKED; /* 定义并初始化一个Seq锁 */

/* 请求获取写锁 */
write_seqlock(&mr_seq_lock);

/* 写锁被获取... */

write_sequnlock(&mr_seq_lock);

2)seq读锁

unsigned long seq;
do {
    seq = read_seqbegin(&mr_seq_lock);
    /* 读这里的数据... */
} while (read_seqretry(&mr_seq_lock, seq));

[======]

禁止抢占

Linux内核是抢占性的,自旋锁保护的区域,会阻止内核抢占。

但有时,我们可能不需要自旋锁,但仍需要关闭内核抢占。比如,每个处理器上的数据,如果数据对每个处理器是唯一的,那么这样的数据可能不需要使用锁来保护,因为数据只能被一个处理器访问,但是可能被同一个CPU上不同的调度任务访问(抢占)。

  • preempt_disable()
    为解决这个问题,可以通过preempt_disable()禁止内核抢占。可嵌套调用任意次数。
preempt_disable(); /* 抢占计数+1 */
/* 内核抢占被禁止... */
preempt_enable();  /* 抢占计数-1 */
/* 此时,内核抢占重新启用 */

只有抢占计数为0时,内核可以抢占;如果>=1,内核不进行抢占。

preempt_disable(); /* 增加抢占计数值,从而禁止内核抢占 */
preempt_enable();  /* 减少抢占计数,当该值降为0时检查和执行被挂起的需调度的任务 */
preempt_enable_no_resched(); /* 激活内核抢占但不再检查任何被挂起的需调度任务 */
preempt_count(); /* 返回抢占计数 */
  • get_cpu()
    还有另一种更简洁方法解决每个处理器上的数据访问问题:用get_cpu()获得处理器编号。
int cpu;

/* 禁止内核抢占,并将cpu设置为当前处理器 */
cpu = get_cpu();
/* 对每个处理器的数据进行操作... */

/* 再给予内核抢占性 */
put_cpu();

[======]

顺序和屏障

多处理器(SMP)环境下,有时需要在代码中以指定的顺序发出读内存、写内存指令。和硬件交互时,经常需要确保一个给定的读操作发生在其他读或写操作之前。而编译器和处理器为了提高效率,可能对读和写重新排序(指令排序)。

如何确保这些指令的顺序?
可以使用屏障(barrier)指令。

  • rmb() 提供“读”内存屏障,确保跨越rmb()的载入(读内存)动作不会发生重排序。也就是说,在rmb()之前的载入操作,不会被重新排在该调用之后。同理,在rmb()之后载入操作不会被重新排在该调用之前。
  • wmb() 提供一个“写”内存屏障,功能类似于rmb(),区别在于仅针对存储(写内存)而非载入。
  • mb() 既提供读屏障也提供写屏障。载入和存储动作都不会跨越屏障重新排序。

一个用mb()和rmb()的例子,其中,a初值1,b初值2

线程1                      线程2
a=3;                       -
mb();                      -
b=4;                       c=b;
-                          rmb();
-                          d=a;

如果不用内存屏障,在某些处理器上,线程2中,c可能接收b新值4,也可能没有(接收b初值2);d可能接收a新值3,也可能接收初值1。
而使用内存屏障:线程1中使用mb(),可以确保a和b按预定顺序写入,rmb()确保c和d按预定顺序读取。

重排序的发生是因为现代处理器为了优化其传送管道(pipeline),打乱了分派和提交指令的顺序。

有些体系结构上,read_barrier_depends()比rmb()执行更快,因为它仅仅是个空操作,实际并不需要。

所有体系结构提供的完整的内存和编译器屏障方法:

rmb();   /* 阻止跨越屏障的载入动作发生重排序 */
read_barrier_depends(); /* 阻止跨越屏障的具有数据依赖关系的载入动作重排序 */
wmb();  /* 阻止跨越屏障的存储动作发生重排序 */
mb();    /* 阻止跨越屏障的载入和存储动作重排序 */
smp_rmb(); /* 在SMP上提供rmb()功能,在UP上提供barrier()功能 */
smp_read_barrier_depends(); /* 在SMP上提供wmb()功能,在UP上提供barrier()功能 */
smp_wmb(); /* 在SMP上提供wmb()功能,在UP上提供barrier()功能 */
smp_mb();   /* 在SMP上提供mb()功能,在UP上提供barrier()功能 */
barrier();      /* 阻止编译器跨屏障对载入或存储操作进行优化 */

对于不同体系结构,屏障的实际效果差别很大。例如,如果一个体系结构不执行打乱存储(如Intel x86),那么wmb()就什么也不做。但应该为最坏的情况,使用恰当的内存屏障。

[======]

原文链接: https://www.cnblogs.com/fortunely/p/15837924.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    Linux内核笔记 - 内核同步2

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/402767

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月21日 上午11:12
下一篇 2023年4月21日 上午11:12

相关推荐