《linux内核设计与实现》读书笔记-内核同步方法(代码片段)

贺二公子 贺二公子     2023-02-21     188

关键词:

原文地址:https://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html


文章目录


内核中提供了多种方法来防止竞争条件,理解了这些方法的使用场景有助于我们在编写内核代码时选用合适的同步方法,

从而即可保证代码中临界区的安全,同时也让性能的损失降到最低。

1. 原子操作

原子操作是由编译器来保证的,保证一个线程对数据的操作不会被其他线程打断。

原子操作有2类:

  1. 原子整数操作,有32位和64位。头文件分别为<asm/atomic.h>和<asm/atomic64.h>
  2. 原子位操作。头文件 <asm/bitops.h>

原子操作的api很简单,参见相应的头文件即可。
原子操作头文件与具体的体系结构有关,比如x86架构的相关头文件在 arch/x86/include/asm/*.h

2. 自旋锁

原子操作只能用于临界区只有一个变量的情况,实际应用中,临界区的情况要复杂的多。
对于复杂的临界区,linux内核中也提供了多种同步方法,自旋锁就是其中一种。

自旋锁的特点就是当一个线程获取了锁之后,其他试图获取这个锁的线程一直在循环等待获取这个锁,直至锁重新可用。
由于线程实在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能很快处理完的临界区。

自旋锁的实现与体系结构有关,所以相应的头文件 <asm/spinlock.h> 位于相关体系结构的代码中。

自旋锁使用时有2点需要注意:

  1. 自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。
  2. 线程获取自旋锁之前,要禁止当前处理器上的中断。(防止获取锁的线程和中断形成竞争条件)
    比如:当前线程获取自旋锁后,在临界区中被中断处理程序打断,中断处理程序正好也要获取这个锁, 于是中断处理程序会等待当前线程释放锁,而当前线程也在等待中断执行完后再执行临界区和释放锁的代码。

中断处理下半部的操作中使用自旋锁尤其需要小心:

  1. 下半部处理和进程上下文共享数据时,由于下半部的处理可以抢占进程上下文的代码,所以进程上下文在对共享数据加锁前要禁止下半部的执行,解锁时再允许下半部的执行。
  2. 中断处理程序(上半部)和下半部处理共享数据时,由于中断处理(上半部)可以抢占下半部的执行,所以下半部在对共享数据加锁前要禁止中断处理(上半部),解锁时再允许中断的执行。
  3. 同一种tasklet不能同时运行,所以同类tasklet中的共享数据不需要保护。
  4. 不同类tasklet中共享数据时,其中一个tasklet获得锁后,不用禁止其他tasklet的执行,因为同一个处理器上不会有tasklet相互抢占的情况
  5. 同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,因为同一个处理器上不会有软中断互相抢占的情况

自旋锁方法列表如下:

方法描述
spin_lock()获取指定的自旋锁
spin_lock_irq()禁止本地中断并获取指定的锁
spin_lock_irqsave()保存本地中断的当前状态,禁止本地中断,并获取指定的锁
spin_unlock()释放指定的锁
spin_unlock_irq()释放指定的锁,并激活本地中断
spin_unlock_irqstore()释放指定的锁,并让本地中断恢复到以前状态
spin_lock_init()动态初始化指定的spinlock_t
spin_trylock()试图获取指定的锁,如果未获取,则返回0
spin_is_locked()如果指定的锁当前正在被获取,则返回非0,否则返回0

3. 读写自旋锁

读写自旋锁除了和普通自旋锁一样有自旋特性以外,还有以下特点:

  1. 读锁之间是共享的
    即一个线程持有了读锁之后,其他线程也可以以读的方式持有这个锁
  2. 写锁之间是互斥的
    即一个线程持有了写锁之后,其他线程不能以读或者写的方式持有这个锁
  3. 读写锁之间是互斥的
    即一个线程持有了读锁之后,其他线程不能以写的方式持有这个锁

:读写锁要分别使用,不能混合使用,否则会造成死锁。

正常的使用方法:

DEFINE_RWLOCK(mr_rwlock);

read_lock(&mr_rwlock);
/* 临界区(只读).... */
read_unlock(&mr_rwlock);

write_lock(&mr_lock);
/* 临界区(读写)... */
write_unlock(&mr_lock);

混合使用时:

/* 获取一个读锁 */
read_lock(&mr_lock);
/* 在获取写锁的时候,由于读写锁之间是互斥的,
 * 所以写锁会一直自旋等待读锁的释放,
 * 而此时读锁也在等待写锁获取完成后继续下面的代码。
 * 因此造成了读写锁的互相等待,形成了死锁。
 */
write_lock(&mr_lock);

读写锁相关文件参照 各个体系结构中的 <asm/rwlock.h>
读写锁的相关函数如下:

方法描述
read_lock()获取指定的读锁
read_lock_irq()禁止本地中断并获得指定读锁
read_lock_irqsave()存储本地中断的当前状态,禁止本地中断并获得指定读锁
read_unlock()释放指定的读锁
read_unlock_irq()释放指定的读锁并激活本地中断
read_unlock_irqrestore()释放指定的读锁并将本地中断恢复到指定前的状态
write_lock()获得指定的写锁
write_lock_irq()禁止本地中断并获得指定写锁
write_lock_irqsave()存储本地中断的当前状态,禁止本地中断并获得指定写锁
write_unlock()释放指定的写锁
write_unlock_irq()释放指定的写锁并激活本地中断
write_unlock_irqrestore()释放指定的写锁并将本地中断恢复到指定前的状态
write_trylock()试图获得指定的写锁;如果写锁不可用,返回非0值
rwlock_init()初始化指定的rwlock_t

4. 信号量

信号量也是一种锁,和自旋锁不同的是,线程获取不到信号量的时候,不会像自旋锁一样循环的去试图获取锁,而是进入睡眠,直至有信号量释放出来时,才会唤醒睡眠的线程,进入临界区执行。

由于使用信号量时,线程会睡眠,所以等待的过程不会占用CPU时间。所以信号量适用于等待时间较长的临界区。

信号量消耗的CPU时间的地方在于使线程睡眠和唤醒线程,如果 (使线程睡眠 + 唤醒线程)的CPU时间 > 线程自旋等待的CPU时间,那么可以考虑使用自旋锁。

信号量有二值信号量和计数信号量2种,其中二值信号量比较常用。

  • 二值信号量表示信号量只有2个值,即0和1。信号量为1时,表示临界区可用,信号量为0时,表示临界区不可访问。
    二值信号量表面看和自旋锁很相似,区别在于争用自旋锁的线程会一直循环尝试获取自旋锁,而争用信号量的线程在信号量为0时,会进入睡眠,信号量可用时再被唤醒。
  • 计数信号量有个计数值,比如计数值为5,表示同时可以有5个线程访问临界区。

信号量相关函数参照: <linux/semaphore.h> 实现方法参照:kernel/semaphore.c
使用信号量的方法如下:

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

/* 试图获取信号量...., 信号未获取成功时,进入睡眠
 * 此时,线程状态为 TASK_INTERRUPTIBLE
 */
down_interruptible(&mr_sem);
/* 这里也可以用:
 * down(&mr_sem);
 * 这个方法把线程状态置为 TASK_UNINTERRUPTIBLE 后睡眠
 */

/* 临界区 ... */

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

一般用的比较多的是down_interruptible()方法,因为以 TASK_UNINTERRUPTIBLE 方式睡眠无法被信号唤醒。

对于 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 补充说明一下:

  • TASK_INTERRUPTIBLE - 可打断睡眠,可以接受信号并被唤醒,也可以在等待条件全部达成后被显式唤醒(比如wake_up()函数)。
  • TASK_UNINTERRUPTIBLE - 不可打断睡眠,只能在等待条件全部达成后被显式唤醒(比如wake_up()函数)。

信号量方法如下:

方法描述
sema_init(struct semaphore *, int)以指定的计数值初始化动态创建的信号量
init_MUTEX(struct semaphore *)以计数值1初始化动态创建的信号量
init_MUTEX_LOCKED(struct semaphore *)以计数值0初始化动态创建的信号量(初始为加锁状态)
down_interruptible(struct semaphore *)以试图获得指定的信号量,如果信号量已被争用,则进入可中断睡眠状态
down(struct semaphore *)以试图获得指定的信号量,如果信号量已被争用,则进入不可中断睡眠状态
down_trylock(struct semaphore *)以试图获得指定的信号量,如果信号量已被争用,则立即返回非0值
up(struct semaphore *)以释放指定的信号量,如果睡眠队列不空,则唤醒其中一个任务

信号量结构体具体如下:

/* Please don't access any members of this structure directly */
struct semaphore 
    spinlock_t        lock;
    unsigned int        count;
    struct list_head    wait_list;
;

可以发现信号量结构体中有个自旋锁,这个自旋锁的作用是保证信号量的down和up等操作不会被中断处理程序打断。

5. 读写信号量

读写信号量和信号量之间的关系 与 读写自旋锁和普通自旋锁之间的关系 差不多。
读写信号量都是二值信号量,即计数值最大为1,增加读者时,计数器不变,增加写者,计数器才减一。
也就是说读写信号量保护的临界区,最多只有一个写者,但可以有多个读者。

读写信号量的相关内容参见:<asm/rwsem.h> 具体实现与硬件体系结构有关。

6. 互斥体

互斥体也是一种可以睡眠的锁,相当于二值信号量,只是提供的API更加简单,使用的场景也更严格一些,如下所示:

  1. mutex的计数值只能为1,也就是最多只允许一个线程访问临界区
  2. 在同一个上下文中上锁和解锁
  3. 不能递归的上锁和解锁
  4. 持有个mutex时,进程不能退出
  5. mutex不能在中断或者下半部中使用,也就是mutex只能在进程上下文中使用
  6. mutex只能通过官方API来管理,不能自己写代码操作它

在面对互斥体和信号量的选择时,只要满足互斥体的使用场景就尽量优先使用互斥体。

在面对互斥体和自旋锁的选择时,参见下表:

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

互斥体头文件:<linux/mutex.h>
常用的互斥体方法如下:

方法描述
mutex_lock(struct mutex *)为指定的mutex上锁,如果锁不可用则睡眠
mutex_unlock(struct mutex *)为指定的mutex解锁
mutex_trylock(struct mutex *)试图获取指定的mutex,如果成功则返回1;否则锁被获取,返回0
mutex_is_locked(struct mutex *)如果锁已被争用,则返回1;否则返回0

7. 完成变量

完成变量的机制类似于信号量,比如一个线程A进入临界区之后,另一个线程B会在完成变量上等待,线程A完成了任务出了临界区之后,使用完成变量来唤醒线程B。

完成变量的头文件:<linux/completion.h>
完成变量的API也很简单:

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

使用完成变量的例子可以参考:kernel/sched.c 和 kernel/fork.c

一般在2个任务需要简单同步的情况下,可以考虑使用完成变量。

8. 大内核锁

大内核锁已经不再使用,只存在与一些遗留的代码中。

9. 顺序锁

顺序锁为读写共享数据提供了一种简单的实现机制。
之前提到的读写自旋锁和读写信号量,在读锁被获取之后,写锁是不能再被获取的,也就是说,必须等所有的读锁释放后,才能对临界区进行写入操作。

顺序锁则与之不同,读锁被获取的情况下,写锁仍然可以被获取。
使用顺序锁的读操作在读之前和读之后都会检查顺序锁的序列值,如果前后值不符,则说明在读的过程中有写的操作发生,那么读操作会重新执行一次,直至读前后的序列值是一样的。

do

    /* 读之前获取 顺序锁foo 的序列值 */
    seq = read_seqbegin(&foo);
...
 while(read_seqretry(&foo, seq)); /* 顺序锁foo此时的序列值!=seq 时返回true,反之返回false */

顺序锁优先保证写锁的可用,所以适用于那些读者很多,写者很少,且写优于读的场景。

顺序锁的使用例子可以参考:kernel/timer.c和kernel/time/tick-common.c文件

10. 禁止抢占

其实使用自旋锁已经可以防止内核抢占了,但是有时候仅仅需要禁止内核抢占,不需要像自旋锁那样连中断都屏蔽掉。

这时候就需要使用禁止内核抢占的方法了:

方法描述
preempt_disable()增加抢占计数值,从而禁止内核抢占
preempt_enable()减少抢占计算,并当该值降为0时检查和执行被挂起的需调度的任务
preempt_enable_no_resched()激活内核抢占但不再检查任何被挂起的需调度的任务
preempt_count()返回抢占计数

这里的preempt_disable()和preempt_enable()是可以嵌套调用的,disable和enable的次数最终应该是一样的。

禁止抢占的头文件参见:<linux/preempt.h>

11. 顺序和屏障

对于一段代码,编译器或者处理器在编译和执行时可能会对执行顺序进行一些优化,从而使得代码的执行顺序和我们写的代码有些区别。

一般情况下,这没有什么问题,但是在并发条件下,可能会出现取得的值与预期不一致的情况,比如下面的代码:

/* 
 * 线程A和线程B共享的变量 a和b
 * 初始值 a=1, b=2
 */
int a = 1, b = 2;

/*
 * 假设线程A 中对 a和b的操作
 */
void Thread_A()

    a = 5;
    b = 4;


/*
 * 假设线程B 中对 a和b的操作
 */
void Thread_B()

    if (b == 4)
        printf("a = %d\\n", a);

由于编译器或者处理器的优化,线程A中的赋值顺序可能是b先赋值后,a才被赋值。
所以如果线程A中 b=4; 执行完,a=5; 还没有执行的时候,线程B开始执行,那么线程B打印的是a的初始值1。
这就与我们预期的不一致了,我们预期的是a在b之前赋值,所以线程B要么不打印内容,如果打印的话,a的值应该是5。

在某些并发情况下,为了保证代码的执行顺序,引入了一系列屏障方法来阻止编译器和处理器的优化。

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

为了使得上面的小例子能正确执行,用上表中的函数修改线程A的函数即可:

/*
 * 假设线程A 中对 a和b的操作
 */
void Thread_A()

    a = 5;
    mb(); 
    /* 
     * mb()保证在对b进行载入和存储值(值就是4)的操作之前
     * mb()代码之前的所有载入和存储值的操作全部完成(即 a = 5;已经完成)
     * 只要保证a的赋值在b的赋值之前进行,那么线程B的执行结果就和预期一样了
     */
    b = 4;

12. 总结

本节讨论了大约11种内核同步方法,除了大内核锁已经不再推荐使用之外,其他各种锁都有其适用的场景。
了解了各种同步方法的适用场景,才能正确的使用它们,使我们的代码在安全的保障下达到最优的性能。

同步的目的就是为了保障数据的安全,其实就是保障各个线程之间共享资源的安全,下面根据共享资源的情况来讨论一下10种同步方法的选择。
10种同步方法在图中分别用蓝色框标出。

《内核设计与实现》第一章读书笔记

《内核设计与实现》第一章读书笔记第一章:Linux内核简介1.1Unix的历史Unix强大的特点A.简洁,几百个系统调用,明确的设计目的B.文件对待所有东西C.移植性强(C语言)D.进程创建快,使用fork()系统调用。E.进程间通信元语,进程... 查看详情

《linux内核设计与实现》读书笔记从内核出发(代码片段)

内核源码获取①可以直接登录linux内核官方网站http://www.kernel.org,可以随时获取当前版本的linux源代码②也可以使用git工具从远程仓库下载,地址:LinuxKernel:Linux内核源码镜像如:gitclonegit@gitee.com:mirrors/linux_old1.git这... 查看详情

《linux内核设计与实现》读书笔记linux内核简介

Unix的历史①Unix诞生于1969年,至今仍然被认为是现存操作系统中最强大和最优秀的系统。②Unix起源于一个失败的多用户操作系统Multics,Multics终止而Unix萌生。③1973年整个Unix操作系统用C语言进行了重写,为后面各种... 查看详情

《linux内核设计与实现》笔记——内核同步简介

相关概念竞争条件多个执行线程(进程/线程/中断处理程序)并发(并行)访问共享资源,因为执行顺序不一样造成结果不一样的情况,称为竞争条件(racecondition)举例说明#include<thread>usingnamespacestd;inti=0;voidthread1(){//for(intx=0;x&... 查看详情

《内核设计与实现》读书笔记-进程管理

...程序以及相关的资源的总称。线程是进程中活动的对象。内核调度的对象是线程,而不是进程。进程和线程的管理操作(比如创建和销毁)都是由内核来实现的。Linux中的进程于Windows相比是很轻量级的,而且不严格区分进程和线... 查看详情

《linux内核设计与实现》读书笔记linux进程管理(代码片段)

...于执行期的程序,通常进程还包含挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程,还包含存放全局变量的数据段等。②线程是进程中活动的对象ÿ... 查看详情

读薄《linux内核设计与实现》-中断与同步

这篇文章是《读薄「Linux内核设计与实现」》系列文章的第IV篇,本文主要讲了以下问题:中断和中断处理程序的概念与实现原理、Linux中的下半部以及内核同步方法。0x00中断和中断处理程序I中断中断是一种特殊的电信号,由硬... 查看详情

《linux内核设计与实现》读书笔记linux进程管理(代码片段)

...于执行期的程序,通常进程还包含挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程,还包含存放全局变量的数据段等。②线程是进程中活动的对象ÿ... 查看详情

《linux内核设计与实现》笔记——vfs

关于VFS有一篇很好的博客http://www.ibm.com/developerworks/cn/linux/l-vfs/建议先阅读本文为基础,然后继续阅读该文章。VFS,虚拟文件系统,为用户提供了文件和文件系统相关的接口。这些接口可以跨越各种文件系统和不同介质执行。VFS... 查看详情

《linux内核设计与实现》学习笔记——中断中断处理程序

...线,每个irq线关联一个数值。中断处理程序响应中断时,内核会执行一个函数,中断处理程序/中断服务例程ISR,一个设备的中断处理程序是他的设备驱动的一部分。IO资源包括:中断,I/O端口,共享RAM,DMA。驱动程序需要管理注... 查看详情

《linux内核设计与实现》学习笔记——i/o调度算法

I/O调度子系统用于调度来自多个进程对块设备的I/O请求。电梯调度首先,如果队列中已存在一个对相邻磁盘扇区操作的请求,那么新请求将和这个已经存在的请求合并为一个请求。2.如果队列中存在一个驻留时间过长的请求,那... 查看详情

linux内核设计与实现的目录

参考技术A译者序序言前言作者简介第1章 Linux内核简介11.1 Unix的历史11.2 追寻Linus足迹:Linux简介21.3 操作系统和内核简介31.4 Linux内核和传统Unix内核的比较51.5 Linux内核版本71.6 Linux内核开发者社区81.7 小结8第2章 从内... 查看详情

《linux内核设计与实现》知识整合与讲解-第一章

Linux内核简介第一章主要对Linux的内核进行一个大致的介绍,让大家对Linux的内核有一个比较全面的印象。众所周知Linux起源于unix系统,它们之间有着千丝万缕的联系,伟大的linux之父linus不满于当时unix对于源码更改的限制,花费... 查看详情

《linux设计与实现》笔记——系统调用工作原理添加系统调用的过程

系统调用的意义为了和用户空间上的进程进行交互,内核提供的提供的一组接口。应用程序通过这组接口访问硬件和其他操作系统资源。完成对硬件和资源访问的控制。安全、可靠,多任务、虚拟必须硬件设备的抽象(提供设备... 查看详情

android深度探索——第十章读书笔记及心得

...学习了解了printk函数。该函数与printf函数类似,用于打印内核调试信息。只是前者运行在内核空间,后者运行在用户空间。即linux驱动这样的linux内核程序只能使用printk函数输出调试信息。Printk函数的原型:asmLinkKageintprintk(constcha... 查看详情

android深度探索(卷1)hal与驱动开发第十章 嵌入式linux的调试技术读书笔记

...开发板、Android模拟器以及一些函数、工具调试嵌入式Linux内核模块、可执行程序和共享库。1.打印内核调试信息:printk该函数的用法和printf函数类似,只不过printk函数运行在内核空间,printf函数运行在用户空间。函数原型:asmlinka 查看详情

第十章读书笔记

...习一些这方面的技术无疑更有助于开发强大而稳定的Linux内核模块。对于嵌入式Linux内核模块、可执行程序、共享库的调试可能要更复杂一些。 P 查看详情

ssl读书笔记

...全的  在linux系统上,网络上的通信实现由用户空间和内核空间共同实现,内核以模块的方式(tcpip协议栈)提供通信子网,而资源子网由用户程序实现。  TCP/IP是可能被窃听的网络,而HTTP是不加密的传输,可以用一些例如Packe... 查看详情