• Linux内核设计与实现 第十章 内核同步


    在这里插入图片描述

    10.1原子操作

    1)32位原子整数操作

    针对整数的原子操作只能引入一个特殊数据类型atomic_t,让原子函数只接收atomic_t类型的操作数,可以确保原子操作只与这种特殊类型数据一起使用。也确保了atomic_t类型的数据不会chuandi-给任何非原子函数。
    使用原子整数类型atomic_t,编译器会知道不对原子整数类型的数据进行访问优化。
    使用原子整数类型atomic_t,在不同体系结构上实现原子操作,可以屏蔽其间的差异。

    typedef struct{
                  volatile int counter;//注意是int
    } atomic_t;
    
    • 1
    • 2
    • 3

    在这里插入图片描述
    在这里插入图片描述
    有些体系结构会提供一些只能在该体系结构上使用的额外原子操作方法,但是所有的体系结构都能保证支持原子操作方法的最小集。

    2)64位原子整数操作

    若要使用64位的原子变量,则要使用atomic64_t类型,atomic64_t与atomic_t使用方法完全相同,只是整型变量的位数·不同。

    typedef struct{
                  volatile long counter;//注意是long
    } atomic64_t;
    
    • 1
    • 2
    • 3

    在这里插入图片描述
    为了保持Linux能支持在各种体系结构之间移植代码,开发者应该使用32位的atomic_t

    3)原子位操作

    除了原子整数操作外,内核也提供了一组针对位这一级别数据进行操作的函数。
    原子位操作没有像原子整数那样对应的atomic_t类型,只要指针指向了任何你希望的数据,你都可以对它进行操作。
    在这里插入图片描述

    原子位操作函数有其对应的非原子操作函数,且只是在原子操作函数名字前加两个下划线,如set_bit()与__set_bit()。

    用原子操作是为了所有中间结果都正确。因为例如:
    使用非原子操作函数,当置为操作和清除操作同时发生时,可能置位操作没有成功,清除操作成功了。可以发现位的值,任何时候不一定是某一条指令执行后的结果(同时是相对的,就看实际情况能容忍多大的时间相差值)
    使用原子操作函数,当置为操作和清除操作同时发生时,可能置位操作正真发生,接着清除操作正真发生。可以发现位的值,任何时候都是某一条指令执行后的结果(同时是相对的,就看实际情况能容忍多大的时间相差值)

    位操作函数是唯一的、具有可移植性的设置特定位方法,就是使用非原子操作函数和原子操作函数设置特定位在所有体系结构上都能正常运行。
    大多数体系结构上,非原子操作函数比原子操作函数执行更快。

    10.2锁机制1:自旋锁

    临界区:访问和操作共享数据的代码段

    我们经常会碰到无法用硬件的原子操作保护临界区的情况:
    先从一个数据结构中移出数据,对其进行格式转换和解析,最后再把它加入另一个数据结构中。整个执行过程必须是原子的,在数据被更新完毕前,不能有其他代码读取这些数据。
    这种整个执行过程比较复杂的临界区,需要用软件机制保护:自旋锁(信号量也是软件机制)。

    如果一个执行线程A试图获取一个已经被其他线程持有的自旋锁,那么线程A一直忙循环,即旋转等待锁重新可用
    旋转等待是没办法做事产生的无畏开销。所以要让持有自旋锁的时间尽量的短,以减少无畏开销。

    1)自旋锁方法

    多处理器机器上保护代码量多的临界区,真的需要自旋锁。

    单处理器机器上保护代码量多的临界区,编译的时候不会加入自旋锁,它仅仅被当做一个设置内核抢占机制是否被启用的开关。
    如果系统禁止内核抢占,这就不会发生多个线程并发访问一个资源的情况,那么在编译在禁止内核抢占的系统上运行的程序时自旋锁会完全剔除出内核。

    自旋锁不可递归:
    本地中断:当前处理器上的中断请求。
    在中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁止本地中断,
    否则,当前处理器A的中断处理程序就可能打断正持有锁的内核代码,有可能会试图争用这个已经被处理器A上运行线程持有的自旋锁。
    这样一来,处理器A的中断处理程序,等待处理器A上运行线程,这就死锁了。

    DEFINS_SPINLOCK(mr_lock);//该宏声明一个自旋锁x并初始化它。
    unsigned long flags;
    spin_lock_irqsave(&mr_lock,flags);//保存中断的当前状态,并禁止本地中断,然后再去获取指定的锁。
    spin_unlock_irqrestore(&mr_lock,flags);//解开指定的锁,然后让中断恢复到加锁前的状态。
    
    • 1
    • 2
    • 3
    • 4

    在单处理器系统上,虽然在编译时抛弃掉了锁机制,但在上面例子中仍需要关闭中断,以禁止中断处理程序访问共享数据。加锁和解锁分别可以禁止和允许内核抢占。

    尽管本章的例子讲的都是保护临界区的重要性,但是要知道需要保护的是数据而不是代码。

    在这里插入图片描述

    2)其针对自旋锁的操作

    在这里插入图片描述

    3)自旋锁和下半部

    spin_lock_bh()//获取指定的锁,同时它会禁止所有下半部的执行。
    spin_unlock_bh()//解开指定的锁,同时它会允许所有下半部的执行。
    
    • 1
    • 2

    由于下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中的共享数据进行 保护,所以需要加锁的同时还要禁止下半部执行。

    同类的tasklet不可能同时运行,所以对于同类tasklet中的共享数据不需要保护,因为同类tasklet之间是依次执行没有抢占。
    但是不同类的tasklet共享数据时,就需要在访问下半部中的数据前先获得一个普通的自旋锁。但是不用禁止下半部,因为同类tasklet之间是依次执行没有抢占。

    多处理器上不同的俩软中断可以并行,如果数据被软中断共享,那么它必须得到锁的保护,因为一个软中断不会抢占另一个软中断。唯一可以抢占软中断的是中断处理程序。不过,其他的软中断可以在其他处理器上同时执行。

    10.3读-写自旋锁

    a)读-写自旋锁:
    为读和写分别提供不同的锁,一个或多个读任务可以并发地持有读者锁,只有一个任务持有写者锁,持有写者锁的任务不能与持有读者锁的任务并发。
    此锁机制照顾读比照顾写多一些,当有读者任务持有锁时,写者任务自旋等待,直至所有读者任务释放读者锁。
    在这里插入图片描述

    b)不能把一个读锁升级为写锁:

    read_lock(&mr_rwlock)//获得指定的读锁
    write_lock(&mr_rwlock)//获得指定的写锁
    
    • 1
    • 2

    执行read_lock(&mr_rwlock)和write_lock(&mr_rwlock)会带来死锁,因为写锁任务会不断自旋,等待所有读者释放锁,其中也包括它自己。

    c)自旋锁与信号量的不同使用场景:
    在这里插入图片描述

    10.4锁机制2:信号量

    在这里插入图片描述
    在这里插入图片描述

    虽然用信号量实现同步比用自旋锁实现同步的开销大,但是信号量允许睡眠(即允许抢占),自旋锁不允许睡眠。
    信号量不同于自旋锁,它不会禁止内核抢占,所以持有信号量的代码可以被抢占。这意味着信号量不会对调度的等待时间带来负面影响。

    1)计数信号量和二值信号量

    信号量的特性:信号量可以同时允许任意数量的锁持有者,而自旋锁只允许一个锁持有者。
    信号量同时允许的持有者数量需要在声明信号量时指定。

    二值信号量:指定同时允许1个持有者的信号量。也叫互斥信号量。内核基本上用的都是互斥信号量。
    计数信号量:指定同时允许1个以上持有者的信号量。

    down()//试图获得信号量锁,试图减少信号量的计数值。如果结果是0或大于0,成功获得信号量锁,任务就可以进入临界区;如果结果是负数,获得信号量锁失败,任务被哄睡在等待队列,处理器执行其他任务。
    up()//释放信号量锁,增加信号量的计数值。如果信号量的等待队列不为空,那么处于等待队列中的任务在被唤醒的同时会获得该信号量。
    
    • 1
    • 2

    2)创建和初始化信号量

    struct semaphore name;            //定义信号量变量名name
    sema_init(&name,count);          //静态地创建使用数量为count的信号量name。
    
    • 1
    • 2
    static DECLARE_MUTEX(name);//创建常用的互斥信号量name
    
    • 1
    sema_init(sem,count);//动态地创建使用数量为count的信号量,sem是指针。
    
    • 1
    init_MUTEX(sem);//初始化一个动态创建互斥信号量,sem是指针。
    
    • 1

    3)使用信号量

    在这里插入图片描述
    处于不可中断睡眠态的进程:可以由 wake_up直接唤醒
    处于可中断睡眠态的进程:不光可以由 wake_up直接唤醒,还可以由信号唤醒。

    10.5读-写信号量

    static DECLARE_RWSEM(name);//该宏创建静态声明的读-写信号量
    static init_rwsem(struct rw_semaphore *sem);//动态创建读-写信号量
    
    • 1
    • 2

    所有的读-写信号量都是互斥信号量。
    只要没有写者,看并发任意个数的读者任务。
    只要没有写者,只有唯一的写者获得写锁。
    在这里插入图片描述

    读-写锁的睡眠都是不可中断睡眠。
    读-写信号量可以用函数downgrade_write()动态地将获取的写锁转化为读锁。
    当代码中的读和写可以明白无误的分割开来时才用读-写信号量,否则最好别用。

    10.6化简版的信号量:互斥体

    a)
    Linux2.6中,互斥体是一种互斥的特定睡眠锁。即互斥体是一种互斥信号。
    根据互斥体含于信号量可以发现,信号量用途比较通用,互斥体比信号量更有针对性。
    互斥体针对的对象,使用互斥体更加简洁方便。
    互斥体是一个简化版的信号量,因为不再需要管理任何使用计数。

    b)静态地定义mutex
    在这里插入图片描述
    在这里插入图片描述

    c)mutex使用场景
    在这里插入图片描述

    1)信号量与互斥体

    针对互斥体和信号量的使用规范:
    除非mutex的某个约束妨碍你使用,否者相比信号量要优先使用mutex。

    2)自旋锁与互斥体

    根据需求判断,何时用化简版的信号量:互斥体,何时用自旋锁:
    在这里插入图片描述

    10.7化简版的信号量:完成变量

    如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用变量取唤醒在等待的任务。这就是一个信号量,完成变量仅仅提供了代替信号量的一个简单解决方法
    在这里插入图片描述
    可以根据信号量猜测完成变量的三个操作函数,可以怎么简单的实现。

    10.8 BLK:大内核锁

    SMP一般指对称多处理
    欢迎来到内核的原始混沌使其。BKL(大内核锁)是一个全局自旋锁,使用它主要是为了方便实现从Linux最初的SMP(对称多处理)过渡到细粒度加锁机制

    ​我们下面来介绍BKL的一些有趣的特性:​
    ​持有BKL的任务仍然可以睡眠。​因为当任务无法被调度时,所加锁会自动被丢弃;当任务被调度时,锁又会被重新获得。当然,这并不是说,当任务持有BKL时 ,睡眠是安全的,仅仅是可以这样做,因为睡眠不会造成任务死锁
    ​BKL是一种递归锁​。 一个进程可以多次请求一个锁,并不会像自旋锁那样产生死锁现象
    BKL​只可以用在进程上下文中​。和自旋锁不同,你不能在中断上下文中申请BKL
    ​新代码中不再使用BKL,但是这种锁仍然在部分内核代码中得到沿用

    大内核锁的简单用法如下(可以看出大内核锁的核心是保证执行时不被抢占,范围应该是内核全局):
    在这里插入图片描述

    BKL在被持​有时同样会禁止内核抢占​。在单一处理器内核中,BKL并不执行实际的加锁操作
    下标列出了所有BKL函数
    在这里插入图片描述
    对于BKL最主要的问题是确定BKL锁保护的到底是什么。
    多数情况下,​BKL更像是保护代码​(如“它保护对foo()函数的调用者进行同步”)​而不保护数据​(如“保护结构foo”)。这个问题给利用自旋锁取代BKL造成了很大困难,因为难以判断BKL到底锁的是什么,更难的是,发现所有使用BKL的用户之间的关系

    10.9顺序锁

    顺序,通常简称seq锁,是在2.6版本内核中才引入的一种新型锁。这种锁提供了一种很简单的机制,​用于读写共享数据​。

    实现这种锁​主要依靠一个序列计数器:​
    当有疑义的数据​被写入时,会得到一个锁,并且序列值会增加​
    ​在读取数据之前和之后,序列号都被读取​
    如果读取的​序列号值相同​,说明在读操作进行的过程中​没有被写操作打断过​
    此外,​如果读取的值是偶数,​那么就表明写操作没有正在发生(要明白因为锁的初值是0,所以写锁会使值成奇数,释放的时候变成偶数)

    基本使用:

    //定义一个seq锁:
    sqlock_t mr_seq_lock=DEFINE_SEQLOCK(mr_seq_lock);
    
    然后,写锁的方法如下:
    write_seq_lock(&mr_seq_lock); //写锁被释放 write_sequnlock(&mr_seq_lock);
    
    //和普通的自旋锁类似。不同的情况发生在读时,并且与自旋锁有很大不同
    unsigned long seq; do{ seq=read_seqbegin(&mr_seq_lock); }while(read_seqretry(&mr_seq_lock,seq));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在多个读者和少数写者共享一把锁的时候,seq锁有助于提供一种非常轻量级和具有可扩展性的外观。但是seq锁对写者更有利。只要没有其他写者,写锁总是能够被成功获得。读者不会影响写锁,这点和读-写自旋锁及信号量一样。另外,挂起的写者会不断地使得读操作循环(前一个例子),直到不再有任何写者持有锁为止

    Seq锁在你遇到如下需求时将是最理想的选择:​
    你的数据存在很多读者
    你的数据写者很少
    虽然写者很少,但是你希望写优先于读,而且不允许读者让歇着饥饿
    你的数据很简单,如简单结构,甚至是简单的整型——在某些场合,你是不能使用原子量的

    使用seq锁中最有说服力的是jiffies。该变量存储了Linux机器启动到当前的时间(参见后面文章。jiffies是使用一个64位的变量,记录了自系统启动以来的时钟节拍累加数。对于那些能自动读取全部64位jiffies_64变量的机器来说,需要用get_jiffies_64()方法完成,而该方法的实现就是用了seq锁:

    在这里插入图片描述

    定时器中断会更新Jiffies的值,此刻,也需要使用seq锁变量:
    在这里插入图片描述

    若要进一步了解jiffies和内核时间管理 ,请看后面的文章和内核源码树中的kernel/time.c与kernel/time/tick-common.c文件

    10.10禁止抢占

    由于​内核是抢占性的​,内核中的进程​在任何时刻都可能停下来以便另一个具有更高优先权的进程运行​。这意味着一个任务与被抢占的任务可能会在同一个临界区内运行
    ​为了上面这种情况,内核抢占代码使用自旋锁作为非抢占区域的标记​。如果一个自旋锁被持有,内核便不能进行抢占。因为内核抢占和SMP面对相同的并发问题,并且内核已经是SMP安全的(SMP-safe),所以,这种简单的变化使得内核也是抢占安全的(preempt-safe)

    单处理器/多处理器下抢占
    或许这就是我们希望的。实际中,​某些情况并不需要自旋锁,但是仍然需要关闭内核抢占​
    最频繁出现的情况就是每个处理器上的数据。​如果数据对每个处理器是唯一的​,那么,这样的数据可能就不需要使用锁来保护,因为数据只能被一个处理器访问
    如果自旋锁没有被持有,内核又是抢占式的,那么一个新调度的任务就可能访问同一个变量

    关闭内核抢占​使用方法:
    为了解决这个问题,可以通过​preempt_disable()禁止内核抢占​。这是一个可以嵌套调用的函数,可以调用任意次。每次调用都必须有一个​相应的preempt_enable()调用​。当最后一次preempt_enable()调用后,内核抢占才重新启用。

    抢占计数​存放着被持有锁的数量和preempt_disable()的调用次数,如果计数0,那么内核可以进行抢占;如果为1或更大的值,那么,内核就不会进行抢占。这个计数非常有用——它是一种对原子操作和睡眠很有效的调试方法。​函数preempt_count()返回这个值​
    ​下表列出了内核抢占相关的函数​
    在这里插入图片描述

    为了用更简洁的方法解决每个处理器上的数据访问问题,可以通过get_cpu()获得处理器编号(假定是用这种编号来对每个处理器的数 进行索引的)。这个函数在返回当前处理器号前首先会关闭内核抢占
    在这里插入图片描述

    10.11顺序和屏障

    a)顺序和屏障概述
    当处理多处理器之间或硬件设备之间的同步问题时,有时需要在你的程序代码中以指定的顺序发出读内存(读入)和写内存(存储)指令。在和硬件交互时,时常需要确保一个给定的读操作发生在其他读或写操作之前。另外,在多处理器上,可能需要按写数据的顺序读数据(通常确保后来以同样的顺序进行读取)
    但是​编译器和处理器为了提高效率,可能对读和写重新排序,​这样无疑使问题复杂化了
    幸好,所有可能重新排序和写的处理器提供了机器指令来确保顺序要 求。同样也可以​指示编译器不要对给定点周围的指令序列进行重新排序​。这些确保顺序的指​令称作屏障(barriers)

    b) 有可能会在a中存放新值之前就在b中存放新值
    ​编译器和处理器都看不出a和b之间的关系:​
    ​编译器会在编译时按这种顺序编译,​这种顺序会是静态的,编译的目标代码就只把a放在b之前
    但是,​处理器会重新动态排序,​因为处理器在执行指令期间,会在取指令和分派时,把表面上看似无关的指令按自认为最好的顺序排列。大多数情况下,这样的排序是最佳的,因为a和b之间没有明显关系
    处理器和编译器可能会对上面的代码进行重新排序,但​绝不会对下面的代码进行重新排序:​
    a=1; b=a;
    ​此时a和b均为全局变量,因为a与b之间有明确的数据依赖关系​
    但是不管是编译器还是处理器都不知道其他上下文中的相关代码。偶然情况下,有必要让写操作被其他代码识别,也让所期望的指定顺序之外的代码识別。这种情况常常发生在硬件设备上,但是在多处理器机器上也很常见

    c)下表给出了内核中所有体系结构提供的完整的内存和编译器屏障方法
    在这里插入图片描述
    注意,对于不同体系结构,屏障的实际效果差别很大。例如,如果一个体系结构不执行打乱存储(如Intel x86芯片就不会),那么wmb()就什么也不做。但应该为最坏的情况(即排序能力最弱的处理器)使用恰当的内存屏蔽,这样代码才能在编译时执行针对体系结构的优化

  • 相关阅读:
    【微服务】Docker-Compose
    yolo v5 与 yolo v7 在一个项目中混合使用是否可行?
    yum 安装的 nginx 添加自定义模块后重新编译安装
    1.3 vue ui框架-element-ui框架
    Windows超级管理器
    ECU简介
    Python特征分析重要性的常用方法
    瑞吉外卖实战项目全攻略——优化篇第三天
    Java中的IO流
    git能pink成功,为什么一直克隆超时啊
  • 原文地址:https://blog.csdn.net/weixin_55255438/article/details/126835865