Linux内核设计与实现 第9 10章 内核同步介绍 和 内核同步方法

9 内核同步介绍

临界区和竞争条件

临界区(段)就是访问和操作共享数据的代码段。多个执行线程都处于同一临界区中同时执行,则产生竞争条件。
避免并发和防止竞争条件称 同步(synchronization)。

造成并发执行的原因

锁是采用原子操作实现。

用户空间之所以需要同步,是因为用户程序会被调度程序抢占和重新调度。

内核中造成并发执行的原因:

  1. 中断,几乎在任何时刻异步发生,打断当前代码
  2. 软中断和tasklet,内核能在任何时刻唤醒和调度软中断和tasklet
  3. 内核抢占,内核具有抢占性,内核中的任何可能会被另一个任务抢占
  4. 睡眠及与用户空间同步,内核执行的进程可能休眠,这会唤醒调度程序,导致调度一个新的用户进程执行
  5. SMP,多处理器同时执行代码

用锁保护共享数据并不困难,辨认出真正需要保护的共享数据和相应的临界区,才是有挑战的地方。

中断安全代码(interrupt-safe):在中断处理程序中能避免并发访问的代码。
SMP安全代码(SMP-safe):在对称多处理器中能避免并发访问的代码。
抢占安全代码(preempt-safe):在内核抢占时能避免并发访问的代码。

要保护些什么,哪些数据

大多数内核数据结构都需要加锁。有其他执行线程可以访问的数据。
在编写内核代码时,以下问题很重要:

  1. 此数据是不是全局,其他线程能不能访问?
  2. 此数据会在进程上下文中、中断上下文中共享吗?会在两个不同的中断处理程序中共享?
  3. 进程在访问时此数据时可不可能被抢占,被调度的新进程会不会访问此数据?
  4. 当前进程会不会阻塞在此数据上,会让此数据处于何种状态?
  5. 怎样防止数据失控?
  6. 此函数在另一个处理器上被调度会发生什么?

对于处理并发的配置选项

Linux内核可在编译时配置,所以可以针对指定机器进行裁剪。
CONFIG_SMP选项控制内核是否支持SMP, 很多加锁问题在单处理器上不存在,可以避免自旋锁的开销。CONFIG_PREEMPT运行内核抢占的配置选项。

死锁

都在相互等待,但都不释放已占有的资源,都无法继续执行。

最简单的死锁是自死锁:一个执行线程试图去获取自己已持有的锁。递归锁可以解决自死锁,但是Linux不提供,因为递归锁会使逻辑杂乱无章。
典型的两个线程两把锁,也称ABBA死锁:
在这里插入图片描述

避免死锁的方法:

1. 按顺序加锁,最有用

使用嵌套锁时保证以相同顺序获取锁。

2. 防止饥饿发生

假设本代码不会结束,加跳出的代码

3. 不重复请求同一锁
4. 设计求简单,加锁越复杂,死锁越容易出现

争用和扩展性

锁的争用(lock contention),当锁被占据时,其他线程试图获得该锁。
高度争用状态是指有多个其他线程等待获取该锁。

细粒度: 锁太粗造成系统性能瓶颈,锁太细增大系统开销。

10 内核同步方法

Linux内核提供了相当完备的同步方法,这里讨论它们的接口、行为和用途。

原子操作

原子操作可以保证指令执行过程不被打断。多个原子操作绝不可能并发的访问同一变量,不会引起竞争。
内核提供了两组原子操作接口,一组针对整数的操作,一组针对单独的位操作。

1. 原子整数操作

常见用途是 实现计数器,用上锁机制就大炮打蚊子了。
原子整数操作只能对atomic_t类型数据进行操作,为什么不直接用c语言里的int表示,两个原因:

  • 让原子函数只能接收atomic_t类型的操作数,保证原子操作函数只与这种特殊类型一起使用,保证atomic_t数据类型不会被传递到非原子函数
  • 确保编译器不对相应的值进行访问优化,使得原子操作接收正确的内存地址也不是一个别名

atomic_t在linux/types.h中定义,

typedef struct {
	volatile int counter;
} atomic_t;

在老的Linux版本,atomic_t实际只有24位长,低8位用来做锁。这是由于Linux是一个跨平台的实现,可以运行在多种 CPU上,有些类型的CPU比如SPARC并没有原生的atomic指令支持,所以只能在32位int使用8位来做同步锁,避免多个线程同时访问。

老版本中关于原子整数的操作函数在asm/atomic.h中,尽管不同体系结构可能有额外的实现方法,但是Linux提供的是都已经实现了的,原子操作函数通常是内联函数,往往通过内嵌汇编指令实现,

atomic_t v; /* define v */
atomic_t u = ATOMIC_INIT(0); /* define u and initialize it to zero */

// 操作也很简单
atomic_set(&v, 4); /* v = 4 (atomically) */
atomic_add(2, &v); /* v = v + 2 = 6 (atomically) */
atomic_inc(&v); /* v = v + 1 = 7 (atomically) */

// 将atomic_t类型转换成int
printk(%d\n”, atomic_read(&v)); /* will print “7” */

// 检查原子整数并返回结果,减1后判断结构为0则返回true,否则返回false
int atomic_dec_and_test(atomic_t *v)

在编写代码时,能使用原子操作就不用锁机制,原子操作开销很小,对处理器高速缓冲影响也小。
原子变量是32位的,也有64位的实现,atomic_t代表32位的,atomic_t64代表64位的。但多数32位机器不支持64位的原子整数。

2. 原子位操作

与体系结构相关,2.6版本定义在文件asm/bitops.h中,位操作函数是对普通内存地址进行操作的。参数是一个指针和一个位号。 对位号范围没有限制。并没有像原子整数操作有专门的数据结构,原子位操作直接提供指针和位号就行。

unsigned long word = 0;
set_bit(0, &word); /* bit zero is now set (atomically) */
set_bit(1, &word); /* bit one is now set (atomically) */
printk(%ul\n”, word); /* will print “3” */
clear_bit(1, &word); /* bit one is now unset (atomically) */
change_bit(0, &word); /* bit zero is flipped; now it is unset (atomically) */

// 语句合法,知识不是原子
word = 7;

除此之外内核还提供对应的非原子操作,前面多加两条下划线,test_bit() 对应 __test_bit()。通常非原子操作执行更快。如果代码已经有避免竞争的锁,非原子位操作更好。

自旋锁(spin lock)

很多情况是通过原子操作解决不了的,如从一个数据结构中取出数据,然后操作,然后放入另一个数据结构。内核提供更复杂的同步方法—锁。

Linux内核最常见的锁时自旋锁,它最多只能被一个可执行线程拥有,如果该锁已被一个线程持有,那么其他线程就要自旋等待(循环等待,特别浪费处理器时间)锁重新可用。
所以自旋锁不应该被长时间持有,初衷就是短时间轻量加锁。通常持有自旋锁的时间要短于完成两次上下文切换时间。
信号量就是在发生争用时,等待的线程进入睡眠,直到锁重新可用时再唤醒它。有睡眠所以有明显的两次上下文切换,较耗时。

自旋锁方法

自旋锁的实现与体系结构密切相关,汇编实现。代码在asm/spinlock.h,实际接口在linux/spinlock.h中定义,基本使用形式,

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

自旋锁同一时刻只能有一个线程持有锁,所以同一时刻只有一个线程位于临界区,为多处理器机器提供了防止并发访问的所需的保护机制。在单处理器上,编译时并不会加入自旋锁,如果禁止内核抢占则在编译时自旋锁就完全被踢出内核。

自旋锁可能导致自锁(不递归)和双重请求锁(中断中)原因

在Linux内核中实现的自旋锁不可递归,与其他操作系统不同。获取已持被自己有的锁将死锁。
自旋锁使用在中断处理程序中,自旋锁不会引起调用者睡眠。而信号量可以导致睡眠。
在中断处理程序中使用自旋锁时,一定要在获取所之前禁止本地中断(当前处理器上的中断请求),否则可能导致双重请求死锁ABBA锁。解释:中断可能打断内核持有锁的代码,在中断处理程序中去争用那个锁而自旋,但本中断不结束内核代码不能执行就不能释放锁,死锁产生。
??????解释不通啊,我认为应该是内核代码在请求锁前都要禁止本地中断,否则被中断后中断中再去请求已被其他代码持有的锁,那中断就自旋了,死锁了。

因此内核提供禁止本地中断(当前处理器上)同时请求锁的代码。在单处理器上虽然编译时抛弃了锁机制,但禁止中断访问共享数据,对应加锁和解锁分别可以禁止和允许内核抢占。

DEFINE_SPINLOCK(mr_lock);
unsigned long flags;

spin_lock_irqsave(&mr_lock, flags);      // 保存中断当前状态 并 禁止本地中断,再获取锁
/* critical region ... */
spin_unlock_irqrestore(&mr_lock, flags); // 解锁,恢复中断到之前状态 

如果加锁前已知中断是激活状态,那解锁后就不需要恢复中断以前样子,无条件地在解锁时激活中断,不提倡这种方法,

DEFINE_SPINLOCK(mr_lock);
spin_lock_irq(&mr_lock);
/* critical section ... */
spin_unlock_irq(&mr_lock);
调试自旋锁

配置选项CONFIG_DEBUG_SPINLOCK,为使用自旋锁的代码加入了很多调试检测手段,如内核是否使用了未初始化的锁,在未加锁前就就要解锁等操作,测试代码时应该打开这个选项。如果要进一步全程调试锁,还应该打开CONFIG_DEBUG_LOCK_ALLOC。

试图获取锁 和 查看锁的状态
// 初始化动态创建的自旋锁
spin_lock_init()
// 试图获取自旋锁,如果已被争用则返回非0而不自旋,成功则获得了该锁
spin_try_lock()
// 检查锁的状态,已被占用返回非0,否则返回0
spin_is_locked()

自旋锁和下半部

在与下半部配合时,必须小心锁机制。
函数spin_lock_bh()用于获取指定锁,同时禁止所有下半部执行,spin_unlock_bh()相反操作。
在这里插入图片描述
下半部可以抢占进程上下文中的代码,当下半部和进程上下文共享数据时,必须保护进程上下文的共享数据,所以进程上下文中和下半部共享数据时, 进程上下文中加锁的同时还要禁止下半部执行。

中断处理程序可以抢占下半部,中断处理程序和下半部共享数据时 下半部在加锁的同时还要禁止中断。

同类的tasklet不可能同时运行,所以同类tasklet中共享数据不需要保护。 不同类tasklet之间共享数据时 需要在访问数据前先获得一个自旋锁。因为同一处理器上绝不会出现tasklet相互抢占。

软中断共享数据必须得到锁的保护,同种类型软中断也能同时在多处理器上运行。同一处理器上的软中断绝不会抢占另一个软中断,所以无需禁止下半部。

linux上的自旋锁有三种实现:
1. 在单cpu,不可抢占内核中,自旋锁为空操作。
2. 在单cpu,可抢占内核中,自旋锁实现为“禁止内核抢占”,并不实现“自旋”。
3. 在多cpu,可抢占内核中,自旋锁实现为“禁止内核抢占” + “自旋”。

读写自旋锁(共享/排斥锁,并发/排斥锁)

对于共享数据,只要没有写操作则可以并发读取,一旦有写则不能并发。比如任务链表就是通过读写自旋锁获得保护。
对于读/写 或 生产者/消费者类型数据结构,可以用读写自旋锁。
Linux专门提供了读写自旋锁,这种锁为 读 和 写分别提供了不同的锁,一个和多个读任务可以并发持有读者锁,用于写的锁只能被一个写任务持有而且此时不能有并发的读操作。

// 初始化读写自旋锁
DEFINE_RWLOCK(mr_rwlock);

// 读者代码中这样
read_lock(&mr_rwlock);
/* critical section (read only) ... */
read_unlock(&mr_rwlock);

// 写者代码中这样
write_lock(&mr_rwlock);
/* critical section (read and write) ... */
write_unlock(&mr_lock);

这样会死锁,读者持有锁,写者自旋等待读者释放,

read_lock(&mr_rwlock);
write_lock(&mr_rwlock);

在中断处理程序中只有读没有写操作,使用read_lock()而不是read_lock_irqsave(),但是还是要用write_lock_irqsave()禁止包含写操作的中断。
在中断中有写的操作,可能导致死锁。若其他地方有读者在操作,而中断中需要获取锁之后才能写进而自旋等待,死锁。
?????没搞明白
在这里插入图片描述
读写自旋锁 照顾读比写要多,大量的读者会使写处于饥饿状态,编程需要注意。

信号量(Semaphores)

Linux中的信号量是一种睡眠锁。若有任务试图获取已被占用的信号量,信号量会使该任务进入一个等待队列,然后让其睡眠。处理器重获自由去执行其他代码。当持有信号量的任务释放锁后,处于等待队列的那个任务将被唤醒。
信号量比自旋锁有更好的CPU利用率,但是开销更大。

信号量与自旋锁使用差异

  • 争用信号量的进程可能睡眠,所以信号量适用于锁被长时间持有的情况。使用信号量不太合适短时间持有锁的情况,因为睡眠、维护等待队列、唤醒所花费的开销可能比锁占用时间还长。
  • 由于争用信号量锁的进程会睡眠,所有只能在进程上下文中获取信号量锁,因为中断上下文不能调度。
  • 持有信号量再去睡眠不会导致死锁,因为其他争用锁任务也只是睡眠,而本锁迟早释放。
  • 持有信号量的同时不能持有自旋锁,因为等待信号量可能睡眠,而持有自旋锁不允许睡眠。

往往在需要和用户空间同步时,代码会需要睡眠,信号量是唯一选择。信号量不受睡眠限制,使用信号量更容易一些。

自旋锁会禁止内核抢占,而信号量代码可以被抢占,意味着信号量不会负面影响系统调用时间。

计数信号量 和 二值信号量(互斥信号量)

信号量有个特性,允许任意多个数量的持有者,具体数量(使用者数量,usage count)可以在声明信号量时指定。自旋锁在同一时刻只能允许一个锁持有者。
计数信号量,不能用来强制互斥,它允许多个执行线程同时访问临界区,内核使用不多。
二值信号量,也称互斥信号量,计数等于1的信号量,强制互斥。

信号量支持两个原子操作,P()V(),后来系统叫down()up()
down()对信号计数量减1表示请求信号量,up()表示释放信号量。

创建和初始化信号量

信号量实现与体系结构相关,在asm/semaphore.h中。用struct semaphore类型表示信号量。
以下方式静态声明信号量,name信号量名称,count使用数量。
更为普遍的互斥量用DECLARE_MUTEX定义。
常见情况是信号量作为大数据结构的一部分动态创建,所以需要初始化指针,

struct semaphore name;
sema_init(&name, count);

// 互斥量
static DECLARE_MUTEX(name);

// 初始化动态创建的信号量指针
sema_init(sem, count);
// 初始化动态创建的互斥量指针
init_MUTEX(sem);

使用信号量

争用信号量使调用它的进程进入睡眠,既可以是interruptible也可以是uninterruptible,所以有不同的获取信号量的函数,

// 获取不可用信号量锁时睡眠,睡眠中可被信号唤醒,返回-EINTR
down_interruptible();
// uninterruptible睡眠,不再响应信号
down();

// 以非阻塞方式获取信号量,获取到了返回0,否则立即返回非0值
down_trylock();

在这里插入图片描述

读写信号量(都是互斥信号量)

struct rw_semaphore结构表示,在linux/rwsem.h中,静态创建读写信号量,

static DECLARE_RWSEM(name);

// 初始化动态创建的信号量
init_rwsem(struct rw_semaphore *sem);

只对写者互斥,读者不是。只要没有写者,并发读者数量不限。
只有唯一写者在没有读者时可以获取锁,所有读写锁的睡眠都不会被信号打断,因此只有一个版本down,
例操作,

static DECLARE_RWSEM(mr_rwsem);
/* attempt to acquire the semaphore for reading ... */
down_read(&mr_rwsem);
/* critical region (read only) ... */
/* release the semaphore */
up_read(&mr_rwsem);

/* attempt to acquire the semaphore for writing ... */
down_write(&mr_rwsem);
/* critical region (read and write) ... */
/* release the semaphore */
up_write(&mr_sem);

也提供了down_read_trylock()down_write_trylock()操作。但是小心获取成功返回非0,失败返回0,与普通信号量相反。
读写信号量比读写自旋锁多一个操作downgrade_write(),他可以动态将获取的写锁转化为读锁。

互斥体(Mutexes)

互斥体指任何可以睡眠的强制互斥锁。如计数为1的信号量。
信号量的用途更通用,没有多少限制,适用于那些较复杂、未明情况下的互斥访问,如内核与用户空间复杂的交互行为。
互斥体操作接口更简单,实现更高效,使用限制更强,(因为受限制所以简洁高效)。

常用方法:

// 静态定义
DEFINE_MUTEX(name);
// 初始化动态指针
mutex_init(&mutex);

// 锁定和解锁
mutex_lock(&mutex);
/* critical region ... */
mutex_unlock(&mutex);

在这里插入图片描述

  • 任何时刻只有一个任务可以持有mutex
  • 上锁者必须负责解锁,不能再一个地方上锁在另一个地方解锁,使得mutex不能用在内核与用户空间同步
  • 不支持递归
  • 持有mutex的进程不允许退出
  • 不能在中断 或 下半部使用
  • 只能通过官方API管理

自旋锁支持调试,信号量不支持调试,而互斥体mutex支持,需要在内核配置选项打开CONFIG_DEBUG_MUTEXES。

优先使用mutex,再考虑信号量(在很底层的代码中才用)。
中断上下文中只能使用自旋锁,而在任务睡眠时只能使用互斥体。

完成变量(Completion Variables)

一个任务发出信号通知另一个任务已完成特定任务,用完成变量来同步。
与信号量思想一致。例如当子进程执行或者退出时,vfork()系统调用通过完成变量唤醒父进程。
struct completion表示,在linux/completion.h中,通过宏静态创建并初始化,

DECLARE_COMPLETION(mr_comp);

// 初始化动态创建的完成变量
init_completion();

需要等待的任务调用wait_for_completion(),完成工作后的任务调用complete()唤醒正在等待的任务。完成变量通常用法是 将完成变量作为数据结构中的一项动态创建。
在这里插入图片描述

BKL 大内核锁

BKL是一个全局的自旋锁。它的存在是为Linux从最初的SMP过渡到细粒度加锁机制。

顺序锁(Sequential Locks)

常称seq锁。在2.6版中引入的新锁。
主要依靠序列计数器。对于多个读者少数写者的情况,seq锁对写者有利,不会让写者饥饿。没有其他写者情况下写锁总是能被获取。

最有说服力的例子是jiffies变量。该64位变量存储了Linux机器启动到当前的时间。获取jiffies的值用到get_jiffies_64(),该方法用到了seq锁,

u64 get_jiffies_64(void)
{
unsigned long seq;
u64 ret;
do {
		seq = read_seqbegin(&xtime_lock);
		ret = jiffies_64;
} while (read_seqretry(&xtime_lock, seq));
return ret;
}

// 定时器中断更新该值
write_seqlock(&xtime_lock);
jiffies_64 += 1;
write_sequnlock(&xtime_lock);

禁止抢占

内核是抢占性的,内核进程在任何时刻都可能停下来以便更高优先级的进程运行。
这可能导致两个任务访问同一临界区,为避免这种情况,内核用自旋锁作为非抢占区域的标记。如果一个自旋锁被持有,内核便不能进行抢占。
内核本身已经是SMP安全的,加上这个小变化使得内核也是抢占安全的(preempt-safe)。

可以通过preempt_disable();禁止内核抢占,可以调用任意次,但是要与preempt_enable();对应调用。

preempt_disable();
/* 抢占被禁止,preemption is disabled ... */
preempt_enable();

抢占计数存放着被持有锁的数量和preempt_disable();的调用次数,如果是0则可以抢占,大于等于1是内核就不会抢占。preempt_count()返回这个值。
在这里插入图片描述

顺序和屏障

编译器和处理器为了提高效率,可能对读和写重新排序,而多处理器时读数据可能需要按照写的顺序进行,所以有确保顺序的指令,叫做屏障(barriers)

处理器在执行指令期间,会在取指令和分派时,把表面看似无关的指令按照自认为最好的顺序执行。现代处理器为了优化其传送管道( pipelines)而发生指令重排。

但是不管是编译器还是处理器都不知道其他上下文中的代码,所以有时保证一下执行顺序是有必要的,特别是在硬件设备上的这种情况最多,多处理器机器上也常见。

rmb()提供“读”内存屏障,它确保跨越rmb()的读入动作不被重排,rmb()之前的操作不会排在之后,之后不排在之前。
wmb()提供“写”内存屏障。
mb提供“读写”内存屏障。载入和存储动作都不会发生重排。
read_barrier_depends()rmb()的变种,提供读屏障,但是只针对那些相互依赖的读操作,在有些体系结构上, read_barrier_depends()rmb()快,可能仅仅是个空操作。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值