linux内核设计与实现 - 内核同步介绍

第九章 内核同步介绍

小结:
内核同步方法:

  • 原子操作
  • 自旋锁
  • 读写自旋锁
  • 信号量:和mutex不是一个
  • 读写信号量
  • mutex
  • 完成变量
  • BKL
  • 顺序锁

顺序和屏障

文章目录

  • 第九章 内核同步介绍
    • 9.1 临界区和竞争条件
    • 9.2 加锁
    • 9.3 死锁
    • 9.4 争用和扩展性
  • 第10章 内核同步方法
    • 10.1 原子操作
    • 10.2 自旋锁
    • 10.3 读-写自旋锁
    • 10.4 信号量
    • 10.5 读-写信号量
    • 10.6 互斥体
    • 10.7 完成变量
    • 10.8 BKL:大内核锁
    • 10.9 顺序锁
    • 10.10 禁止抢占
    • 10.11 顺序和屏障

9.1 临界区和竞争条件

临界区:访问和操作共享数据的代码。竞争条件:两个执行线程有可能在同一个临界区中同时执行。
同步:避免并发和防止竞争条件。

9.2 加锁

锁的形式和锁的粒度各不相同,各个锁机制之前的主要区别在于:当锁被其他线程持有时,其他的行为表现。

  1. 造成并发执行的原因
    用户空间:因为会被抢占或重新调度。
    信号处理:异步发生
    中断:任何时刻异步发生
    软中断和tasklet:任何时刻唤醒或调度
    内核抢占
    睡眠及与用户空间的同步:内核执行进程可能睡眠,会唤醒调度程序调度一个新的用户进程
    对称多处理

辨识出真正需要共享的数据和相应的临界区才是真正的挑战。
如:一段内核代码操作某资源时产生系统中断,而该中断的处理函数还要访问这个资源。

中断安全代码:在中断处理程序中能避免并发访问的安全代码
SMP安全代码:在SMP中。。。。。。。。。。。。。。。
抢占安全代码:在内核抢占时。。。。。。。。。。。。。

  1. 要保护什么
    要在一开始设计时就要仔细考虑
    不需要保护的:(1). 执行线程的局部数据,如局部自动变量(包括动态分配的数据结构,其地址仅存在栈上);(2).数据只会被特定的进程访问。

需要保护:大多数内核数据结构需要加锁。记住:给数据而不是给代码加锁。
编写内核代码时,要问自己:
(1). 这个数据是不是全局的?除了当前线程外,其他线程能不能访问它?
(2). 这个数据会不会在进程上下文和中断上下文中共享?是不是要在两个不同的中断处理程序中共享?
(3). 进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?
(4). 当前进程是不是会睡眠在某些资源上?如果是,它会让共享数据处于何种状态?
(5). 怎样防止数据失控?
(6). 如果这个函数又在另一个处理器上被调度了将会发生什么?
(7). 如何确保代码远离并发威胁呢?

9.3 死锁

条件
死锁避免:

  • 按顺序加锁:使用嵌套锁时必须保证以相同的顺序获得锁
  • 防止发生饥饿
  • 不要重复请求同一个锁
  • 设计应力求简单 - 越复杂的加锁越有可能死锁。

9.4 争用和扩展性

当锁争用严重时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细会加大系统开销,造成浪费。


第10章 内核同步方法

10.1 原子操作

原子操作:不可分割的指令。
内核提供了2组原子操作接口 - 一组对整数进行操作;另一组针对单独的位进行操作。
大多数系统结构会支持原子操作的简单算术指令,或者通过锁内存总线的方式实现。
最常见用户:实现计数器
特性:开销小

  1. 原子整数操作
    atomic_t:
    atomic_t u = ATOMIC_INIT(0);

    atomic_set()
    atomic_add()
    atomic_inc()
    atomic_read()
    atomic_dec_and_test()

原子性:确保指令执行期间不被打断(通过原子操作等)
顺序性:确保多条指令出现,本该的顺序性依然要保持(通过屏障barrier)

  1. 64位原子操作
    atomic64_t

  2. 原子位操作
    set_bit()
    clear_bit()
    test_and_set_bit()

对应的非原子位函数,多了两个下划线
__test_bit()

  1. 内核提供从指定地址开始搜索第一个被设置(或未设置)的位。
    int find_first_bit(unsigned long *addr, unsigned int size)
    int find_first_zero_bit(unsigned long *addr, unsigned int size)

10.2 自旋锁

等待锁时,一直循环-旋转-等待。
场景:适合短时间内轻量级锁

  1. 自旋锁方法
    自旋锁的实现和体系结构相关,代码往往通过汇编实现。

警告:自旋锁是不可递归的!

  • 自旋锁和中断处理程序:
    自旋锁在中断处理程序中使用时,一定要先禁止本地中断然后获取锁,否则可能打断当前持有锁的进程,而导致其他进程不能获取锁。

    内核提供禁止中断同时请求锁的接口:
    DEFINE_SPINLOCK(mr_lock);
    unsigned long flags;
    spin_lock_irqsave(&mr_lock, flags); //保存中断当前状态,并禁止本地中断,然后获取指定的锁

    spin_unlock_irqrestore(&mr_lock, flags); // 对指定的锁解锁,然后让中断恢复

  • 调试自旋锁
    CONFIG_DEBUG_SPINLOCK选项

  • 其他自旋锁的方法
    spin_lock_init():动态创建自旋锁
    spin_try_lock():试图获得特定的自旋锁,如锁已被争用,立即返回非0,不等待自旋锁被释放
    spin_is_locked():检查锁是否被占用

  1. 自旋锁和下半部
    spin_lock_bh(): 获取指定锁,并禁止所有下半部的执行
    (1). 下半部和进程上下文共享数据时,必须对进程上下文中共享数据保护,需要加锁同时禁止下半部。【下半部可抢占进程】
    (2). 中断处理程序和下半部共享数据时,下半部必须获取恰当的锁的同时禁止中断。【中断可抢占下半部】
    (3). 不同tasklet共享数据时,需要加普通自旋锁,这里不需要禁止下半部。【tasklet不会相互抢占】
    (4). 软中断共享时,需要加普通锁,也不需要禁止下半部【软中断之间不会抢占】

10.3 读-写自旋锁

一个或多个任务可以并发持有读者锁,但写者锁只能有一个。
特点:照顾读。
DEFINE_RWLOCK(mr_rwlock);
read_lock(&mr_rwlock);

read_unlock(&mr_rwlock);

write_lock(&mr_rwlock);
write_unlock(&mr_rwlock);

10.4 信号量

  1. 信号量特征:
    (1). 信号量适用于锁会被长时间持有的情况,睡眠、维护等待队列及唤醒的开销很大。
    (2). 只能在进程上下文中获取信号量锁
    (3). 占用信号量的同时不能占用自旋锁。
    (4). 往往在需要和用户空间同步时,你的代码需要睡眠,此时使用信号量是唯一选择。
    (5). 信号量不同于自旋锁,它不会禁止内核抢占,所有持信号量的代码可以被抢占

  2. 计数信号量和二值信号量
    内核使用信号量时基本用到的都是互斥信号量。

  3. 创建和初始化信号量
    信号量的实现和体系结构相关。
    静态:static DECLARE_MUTEX(name)
    动态:sema_init(sem, count); 或 init_MUTEX(sem);

  4. 使用信号量
    down_interruptible():睡眠时可唤醒。TASK_INTERRUPTIBLE
    down() :睡眠时不可唤醒。TASK_UNINTERRUPTIBLE
    down_trylock():试图获得指定信号量,如果被征用,不等待,直接返回非0值
    up()

10.5 读-写信号量

rw_semaphore,区分读写的信号量。所有读写信号量都是互斥信号量,所有读写锁的睡眠都不会被信号打断
静态初始化:static DECLARE_RWSEM(name);
动态初始化:init_rwsem(sem);
down()
down_read_trylock()
down_write_trylock()
downgradge_write():动态地将写锁转换成读锁。

10.6 互斥体

mutex,类似计数为1的信号量,但操作接口更简单,实现更高效,使用限制更强。
静态:DEFINE_MUTEX(name);
动态:mutex_init(&mutex);
锁定:mutex_lock(&mutex);
解锁:mutex_unlock(&mutex);

mutex相比信号量的场景更严格:

  • mutex上锁者必须负责给其解锁,常用方式:在同一上下文中上锁和解锁。
  • 递归地上锁和解锁是不允许的。
  • 当持有一个mutex时,进程不可以退出。
  • mutex不能在中断或下半部使用,即使使用mutex_trylock()也不行

内核配置:CONFIG_DEBUG_MUTEXES

需求 建议加锁方式
低开销加锁 优先使用自旋锁
短期锁定 优先使用自旋锁
长期加锁 优先使用互斥体
中断上下文加锁 使用自旋锁
持有锁需睡眠 使用互斥体

10.7 完成变量

如果在内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量。如子进程执行或退出时,vfork()系统调用使用完成变量唤醒父进程

  • 用法:
    静态创建并初始化:DECLARE_COMPLETION(mr_comp);
    动态创建并初始化:init_completion()

    在一个指定的完成变量上,需要等待的任务调用wait_for_completion()来等待特定事件。当特定事件发生后,产生事件的任务调用complete()来发送信号唤醒正在等待的任务。

10.8 BKL:大内核锁

BKL是一个全局自旋锁,特性:
(1). 持有BKL的任务仍然可以睡眠。因为当任务无法被调度时,锁会被自动丢弃;当任务被调度时,锁会被重新获得。睡眠不会造成任务死锁。
(2). BKL是一种递归锁。
(3). BKL只能在进程上下文中。
(4). BKL在持有时会禁止内核抢占。

  • 用法:
    lock_kernel()
    unlock_kernel()
    kernel_locked():检测是否被持有

10.9 顺序锁

用于读写共享数据。实现这种锁主要依靠一个序列计数器,当有疑义的数据被写入时会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取,如果序列号相同,说明没有被写操作打断过。此外,如果读数据是偶数,也表明写没发生过。

  • 用法:
    DEFINE_SEQLOCK(mr_seq_lock);
    write_seqlock(&mr_seq_lock);
    write_sequnlock(…)
    读时区别很大:
    do{
    seq = read_seqbegin(&mr_seq_lock);
    }while(read_seqretry(&mr_seq_lock,seq));

  • 特点:
    (1). 多个读者和少数写者时,seq锁提供轻量级访问
    (2). seq锁对写者更有利。

  • 适用场景:
    (1). 数据存在很多读者,数据写者很少
    (2). 写优先于读
    (3). 数据很简单
    如jiffies

10.10 禁止抢占

内核抢占可以使用自旋锁作为非抢占区域的标记。当然,每个处理器上的数据不要锁保护。
禁止内核抢占:preempt_disable()和preempt_enable()
每个处理器上的数据访问问题:get_cpu()和put_cpu():在返回当前处理器号前首先关闭内核抢占。

10.11 顺序和屏障

屏障:保证顺序要求,指示编译器不要对给定点周围的指令进行重新排序。

  • 内存屏障
    rmb():提供一个读内存屏障,确保跨越rmb()的载入动作不会发生重排。
    wmb():提供一个写内存屏障
    mb():提供读写屏障
    read_barrier_depends():rmb的变种,只针对后续读操作锁依靠的那些载入。保证屏障前的读操作在屏障后的读操作之前完成。【只针对特定的读】

    对应的,宏smp_rmb()、smp_wmb()、smp_mb()、smp_read_barrier_depends()提供了 一个有用的优化。

  • 编译器屏障
    barrier():可以防止编译器跨屏障对load和store进行优化。前面的内存屏障可以实现编译器屏障的功能,但编译器屏障更轻量级,它只防止编译器可能重排指令

你可能感兴趣的:(操作系统)