linux内核设计与实现---内核同步方法(10)

一、原子操作—其他同步方法的基石。

  • 原子操作可以保证指令以原子的的方式执行—-执行过程不被打断。
  • 内核提供了两组原子操作接口:一组针对整数进行操作、一组针对单独的位进行操作。
  • 原子整数操作

    • 针对整数的原子操作只能对atomic_t类型的数据进行处理。

      • 采用新类型的原因:让原子操作只接受atomic_t类型的操作数。保证该数据类型不会被传递给任何非原子函数。使用atomic _t类型确保编译器不对相应的值进行访问优化。不同体系结构,可以屏蔽差异。定义在< linux/ types.h>

        typedef struct{
        volatile int counter;
        } atomic_t

      • 使用原子整型操作需要声明在< asm /atomic.h >中。体系结构不同,原子操作方法不同,但是所有的体系结构都能保证内核使用到的所有操作的最小集。
        例如: atomic_t v ; atomic_t u=ATOMIC_INIT(0) ; atomic_set(&v,4);

      • 原子整数操作最常见的用途就是实现计数器。
      • 还可以用原子整数操作原子地执行一个操作并检测结果。例:int atomic_dec_and_test(atomic _t *v)—-减1,若为0则返回真,否则返回假。
      • 原子操作通常是内联函数,通过内联汇编指令来实现。如果函数本来就是原子的,一般被定义成宏。
      • 原子性与顺序性的比较:
        • 原子性:确保指令执行期间不被打断,要么全部执行完,要么根本不执行。(原子操作只保证原子性)
    • 顺序性:确保即使两条或者多条指令出现在独立的执行线程中,甚至独立的处理器上,他们本该的执行顺序却依然不变。(顺序性通过barrier指令来实现的)
    • 64位原子操作:与32位原子操作无异,只是整型变量大小为64位,前缀位atomic64_t
  • 原子位操作
    • 内核提供了一组针对位这一级数据进行操作的函数。与体系结构相关,定义在< asm/ bitops.h>
    • 位操作函数是对普通的内存地址进行操作的。它的参数是一个指针和位号(0-31 32位机)。
    • 原子位操作是对普通的指针进行操作没有特殊的数据类型。例如:
      unsigned long word = 0;
      set_bit(0, &word); 第零位被设置(原子地)
      set_bit(1,&word); 第1位被置位(原子地)
      clear_bit(1,&word); 清空
      change_bit(1,&word); 反转第0位
    • 非原子位操作与原子位操作完全相容,但不保证原子性。且其名字前缀多加两个下划线。__test_bit() 如果已经用锁保护了数据,那就可以用非原子位操作,它们更快一些。
    • 非原子位操作:原子位操作保证操作确实执行,指令要么成功执行,不被打断,要么根本不执行。保证中间结果都正确无误。非原子操作,做不到。
    • 如果你的代码已经避免了竞争条件, 课采用非原子位操作,否则最好用原子位操作。

二、自旋锁:(spin_lock)自旋锁最多只能被一个可执行的线程持有。申请的进程只能:忙循环–旋转—等待锁重新可用。

  • 一个被争用的自旋锁是的请求它的进程在等待锁重新可用时自旋(特别浪费处理器时间)。因此自旋锁不能长时间持有—短时间内进行轻量级加锁。
  • 自旋锁方法:自旋锁的基本使用形式
    DEFINE _ SPINLOCK (mr_lock);
    spin_lock(&mr_lock);
    /*临界区*/
    spin_unlock(&mr_lock);

    • 注意:在单处理器机器上,编译的时候并不会加入自旋锁。它仅仅被当做一个设置内核抢占机制是否被启用的开关。如果禁止内核抢占,那么在编译时自旋锁会完全被剔除内核。(自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)
    • 警告: 自旋锁是不可递归的!!
      • linux内核实现的自旋锁是不可递归的(有别于其他操作系统。)如果试图得到一个你正在持有的锁,必须自旋,但永远没有机会得到,造成死锁。
      • 自旋锁可以使用在中断处理程序中(此处不能使用信号量,因为它会导致睡眠。为什么? 首先睡眠的含义是将进程置于“睡眠”状态,在这个状态的进程不能被调度执行。然后,在一定的时机,这个进程可能会被重新置为“运行”状态,从而可能被调度执行。 可见,“睡眠”与“运行”是针对进程而言的,代表进程的task_struct结构记录着进程的状态。内核中的“调度器”通过task_struct对进程进行调度。但是,中断上下文却不是一个进程,它并不存在task_struct,所以它是不可调度的。所以,在中断上下文就不能睡眠。因此如果中断申请信号量,同时信号量被其他进程占有,那中断进程就会睡眠,而中断进程并不能被内核调度(调度程序不能调度中断进程),因此中断会一直睡眠下去。自旋锁可以被使用的原因在下一段
      • 在中断处理程序中使用自旋锁时,一定要在获取锁之前首先禁用本地中断(在当前处理器上的中断请求),否则,中断处理程序就会打断正持有锁的内核代码,有可能会试图去争用这个已经被持有的自旋锁。这样以来,中断处理程序就会自旋,等待该锁重新可用,但是所得持有者在这个中断处理执行完毕前不可能运行。这就会产生双重请求死锁。(在普通内核线程中,同样会存在该问题。因此,在使用自旋锁时,要明确知道该锁是否会被中断处理程序使用) (1)处理器正在运行的进程在获取自旋锁之前首先禁止了中断,在释放锁时打开中断。因此,此处不存在中断和进程争夺自旋锁的问题,也不会造成死锁。(2)同样中断处理程序在获取该锁之前,也需要把中断禁止,防止更高优先级的中断再次产生中断,同时又要与该中断共享资源获取自旋锁,从而产生死锁。)
      • 注意:需要关闭的只是当前处理器上的中断。如果中断发生在不同的处理器上,即使中断处理程序在同一锁上自旋,也不会妨碍锁的持有者最终释放锁。
      • 内核提供的禁止中断同时请求锁的接口:

        DEFINE_SPINLOCK(mr_lock);
        unsigned long flags;
        spin_lock_irqsave(&mr_lock,flag);
        /*.....临界区.....*/
        spin_unlock_irqrestore(&mr_lock,flag);

        spin_lock_irqsave(&mr_lock,flag);保存中断的当前状态,并禁止本地中断,然后再去获取指定的锁。 spin_unlock_irqrestore(&mr_lock,flag);对指定的锁解锁,然后让终端恢复到加锁前的状态。
        spin_lock_irq(&mr_lock); spin_unlock_irq(&mr_lock);这两个函数对中断进行关闭和打开操作,而不关心它原来的状态。
        注意:flags变量看起来像是由数值传递的,这是因为这些锁函数有些部分是通过宏的方式实现的。另外在单处理器系统上,虽然在编译时抛弃掉了锁机制,但是仍然需要关闭中断,以禁止中断处理程序访问共享数据。加锁和解锁分别可以禁止和允许内核抢占。
      • 大原则:针对代码加锁会使程序难以理解,并且容易引发竞争条件,正确的做法是对数据加锁。
      • 调试自旋锁:配置选项CONFIG_DEBUG_SPINLOCK—-激活该选项加入调试检测手段。未初始化锁,加锁是否成对。进一步调试可以打开CONFIG_DEBUG_LOCK_ALLOC。
    • 其他针对自旋锁的操作
      • spin_lock_init() 初始化动态创建的自旋锁
      • spin_try_lock()试图获取某个特定的自旋锁,如果该所已被争用,返回非0值
      • spin_is_locked()检测所是否已被占用。
    • 自旋锁和下半部分
      • spin_lock_bh()用于获取指定的锁,同时会禁止所有下半部分执行。
      • 由于下半部分可以抢占进程上下文中的代码,所以当下半部分和进程上下文共享数据时,加锁同时禁止下半部分执行。
      • 由于中断处理程序可以抢占下半部分,所以中断处理程序和下半部分共享数据时,获取恰当的锁同时禁止中断。
      • 同类的tasklet(是linux中断处理机制中的软中断延迟机制,当linux接收到硬件中断之后,通过tasklet函数来设定软中断被执行的优先程度从而导致软中断处理函数被优先执行的差异性。)不可能同时运行(即使在多处理器上),所以同类tasklet共享数据不需要保护。不同种类的tasklet共享数据时,需要获取普通自旋锁。同一处理器上绝不会有tasklet相互抢占的情况,不需要禁止下半部分。
      • 对于软中断,无论是否同种类型,如果数据被软中断共享,那么他必须得到锁的保护。因为即使同种类型的两个软中断,也可以运行在一个系统的多个处理器上。但同一处理器上的一个软中断绝对不会抢占另一个软中断,因此没有必要禁止下半部分。
    • 补充tasklet知识:
      • 1、前言
        对于中断处理而言,linux将其分成了两个部分,一个叫做中断handler(top half),属于不那么紧急需要处理的事情被推迟执行,我们称之deferable task,或者叫做bottom half,。具体如何推迟执行分成下面几种情况:
        1)推迟到top half执行完毕
        2)推迟到某个指定的时间片(例如40ms)之后执行
        3)推迟到某个内核线程被调度的时候执行
        对于第一种情况,内核中的机制包括softirq机制和tasklet机制。第二种情况是属于softirq机制的一种应用场景(timer类型的softirq),在本站的时间子系统的系列文档中会描述。第三种情况主要包括threaded irq handler以及通用的workqueue机制,当然也包括自己创建该驱动专属kernel thread(不推荐使用)。
      • 2、为什么需要tasklet?
        1)基本的思考
        我们的驱动程序或者内核模块真的需要tasklet吗?每个人都有自己的看法。我们先抛开linux kernel中的机制,首先进行一番逻辑思考。将中断处理分成top half(cpu和外设之间的交互,获取状态,ack状态,收发数据等)和bottom half(后段的数据处理)已经深入人心,对于任何的OS都一样,将不那么紧急的事情推迟到bottom half中执行是OK的,具体如何推迟执行分成两种类型:有具体时间要求的(对应linux kernel中的低精度timer和高精度timer)和没有具体时间要求的。对于没有具体时间要求的又可以分成两种:
        (1)越快越好型,这种实际上是有性能要求的,除了中断top half可以抢占其执行,其他的进程上下文(无论该进程的优先级多么的高)是不会影响其执行的,一言以蔽之,在不影响中断延迟的情况下,OS会尽快处理。
        (2)随遇而安型。这种属于那种没有性能需求的,其调度执行依赖系统的调度器。
        本质上讲,越快越好型的bottom half不应该太多,而且tasklet的callback函数不能执行时间过长,否则会产生进程调度延迟过大的现象,甚至是非常长而且不确定的延迟,对real time的系统会产生很坏的影响。
        2、对linux中的bottom half机制的思考
        在linux kernel中,“越快越好型”有两种,softirq和tasklet,“随遇而安型”也有两种,workqueue和threaded irq handler。“越快越好型”能否只留下一个softirq呢?对于崇尚简单就是美的程序员当然希望如此。为了回答这个问题,我们先看看tasklet对于softirq而言有哪些好处:
        (1)tasklet可以动态分配,也可以静态分配,数量不限。
        (2)同一种tasklet在多个cpu上也不会并行执行,这使得程序员在撰写tasklet function的时候比较方便,减少了对并发的考虑(当然损失了性能)。
        对于第一种好处,其实也就是为乱用tasklet打开了方便之门,很多撰写驱动的软件工程师不会仔细考量其driver是否有性能需求就直接使用了tasklet机制。对于第二种好处,本身考虑并发就是软件工程师的职责。因此,看起来tasklet并没有引入特别的好处,而且和softirq一样,都不能sleep,限制了handler撰写的方便性,看起来其实并没有存在的必要。在4.0 kernel的代码中,grep一下tasklet的使用,实际上是一个很长的列表,只要对这些使用进行简单的归类就可以删除对tasklet的使用。对于那些有性能需求的,可以考虑并入softirq,其他的可以考虑使用workqueue来取代。Steven Rostedt试图进行这方面的尝试(http://lwn.net/Articles/239484/),不过这个patch始终未能进入main line。
  • 读-写自旋锁—一个或多个读任务可以并发的持有读者锁,而写的锁最多只能被一个写任务持有,而且此时不能有并发的读操作。又叫做共享/排斥锁或并发/排斥锁。
    • 使用方法:初始化 DEFINE_RWLOCK(my_rwlock); 读锁 read_lock(&mr_rwlock);写锁 write_lock(&mr_rwlock);
    • 读锁和写锁通常是位于完全分割的代码中。因此read_lock(&mr_rwlock); write_lock(&mr_lock);同时执行会死锁,因为写锁会不断自旋等待读锁释放,也包括他自己。所以当确定需要写操作时,要在一开始就请求写锁,当不能把读写清晰地区分开来,那就需要使用一般的自旋锁,而不能使用读-写自旋锁。
    • 多个读操作可以递归的获取读锁。因此在中断处理程序中(进程上下文也适用—我认为)只有读操作没有写操作,那么,就可以混合使用“中断禁止锁”,使用read_lock()而不是read_lock_irqsave(),但是需要用write_lock_irqsave()禁止有写操作的中断,否则会死锁。
    • 几种常见的读写锁:获取锁–read_lock(); 禁止/使能本地中断并获取/释放锁—read_lock/unlock_irq(); 存储/恢复本地中断当前状态,禁止/恢复本地中断并获取/释放锁—read_lock/unlock_irqrestore(); 试图获取指定的写锁,若不可用,返回非0—-write_trylock()(不是成对锁);初始化指定的rwlock_t—rwlock_init();
  • 利用自旋锁时,加锁时间不能太长或者代码在持有锁时睡眠,那最好用信号量。

三、 信号量:linux中的一种睡眠锁。任务试图获取被占用的信号量,则会被推入等待队列,睡眠,处理器可以重获自由,去执行其他代码。 当信号量释放,唤醒该任务,执行。

  • 适用于锁会长时间持有情况

    • 睡眠,维护等待队列,唤醒可能花费大开销
    • 只能在进程上下文获取信号量,因为中断上下文中不能进行调度。
    • 可以持有信号量时去睡眠,因为信号量不用关闭中断,其他进程试图获取该锁时会同样睡眠,不会引起死锁。(而在获取自旋锁之前,需要关闭中断,同样关闭了时钟中断,调度器通过时钟中断判断何时唤醒任务,关了中断,调度器再也无法收到时钟中断了,所以就无法唤醒任务了。因此等待自旋锁的一直在死等,获取自旋锁的不能被执行,无法释放自旋锁,造成死锁。在开发者眼中,关中断与关调度是等同的概念。
      - 注意:所以操作系统中,关中断时不能睡眠,睡眠就会睡死了。

    • 在占用信号量的同时不能占用自旋锁。因为在你等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。

    • 往往在需要和用户空间同步时,你的代码需要睡眠,此时使用信号量时唯一的选择。
    • 信号量不同于自旋锁,不会禁止内核抢占,所以持有信号量的代码可以被抢占。这意味着信号量不会对调度的等待时间带来负面影响。
  • 计数信号量和二值信号量
    • 信号量同时允许的持有者数量可以再声明信号量时指定。这个值称为使用者数量(usage count)或简单的交数量(count)。在一个时刻只允许一个锁持有者。这时计数等于1,这样的信号量称为二值信号量或者互斥信号量。计数值被设置成大于1的非0值。称为计数信号量(counting semaphone)
    • 信号量的两个原子操作P()和V(),也就是down()和up()。down()通过对信号量计数减一来请求获得一个信号量。如果结果是0或者大于0,则获得信号量。up()操作用来增加计数值并释放信号量的。
  • 使用信号量
    • 函书down_interruptible()试图获取指定的信号量,如果不可用,他将把调用进程置成TASK_INTERRUPTABLE状态—进入睡眠。函数down_interruptable()会返回-ENTER。down()会进程在TASK_UNINTERRUPTIBLE状态下睡眠(不建议)。
    • 使用down_trylock()函数,以堵塞方式来获取指定的信号量。信号量占用返回非0,否则,返回0,并得到信号量锁。
    • 释放信号量用up()
  • 读-写信号量
    • 所有的读-写信号量都是互斥信号量。他们只对写者互斥,不对读者互斥。
    • 所有读-写锁的睡眠都不会被信号打断,所以他们只有一个版本的down()。
    • down_read_trylock()和down_write_trylock()
    • downgrade_write() 动态的将获得的写锁转换成读锁。
    • 除非读写代码可以明白无误地分割开来,否则最好不要使用(读写自旋锁同样)

四、互斥体(mutex):任何可以睡眠的强制互斥锁。最近称为现在也用于一种实现互斥的特定睡眠锁。也就是互斥体是一种互斥信号。实际就是一个简化了的信号量,不存在信号量计数值。

  • 只有一盒任务可以持有mutex。
  • 在同一上下文中上锁和解锁
  • 不允许递归地上锁和解锁
  • 当持有一个mutex时,进程不可退出
  • 不能在中断或者下半部分中使用。
  • mutex只能通过官方API管理
  • 最有用的特色是:可以配置CONFIG_DEBUG_MUTEXES来检查约束规则。
  • 中断上下文只能使用自旋锁,任务睡眠时只能使用互斥体。其他大多数情况下不会太多考虑

五、完成变量(completion variable):如果内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量,是使两个任务得以同步的简单方法。

  • 宏静态创建完成变量并初始化:DECLARE_COMPLETION(mr_comp);
  • init_completion()动态创建并初始化完成变量
  • 在一个指定的完成变量上,需要等待的任务调用wait_for_completion()来等待特定事件。当特定事件发生后,产生事件的任务调用complete()来发送信号唤醒正在等待的任务。
  • 完成变量的通常用法是:将完成变量作为数据结构中的一项动态创建,而完成数据结构初始化工作的内核代码将调用wait_for_complete()进行等待。初始化完成后,初始化函数调用completion()唤醒在等待的内核任务。

六、大内核锁(BKL):全局自旋锁,主要是SMP过渡到细粒度加锁机制

  • 持有BKL的任务仍然可以睡眠,无法调度时,抛弃锁,可以被调度时,重新获得
  • BKL是一种递归锁。
  • 只可用于进程上下文中。
  • 新的用户不允许使用。
  • lock_kernel(); unlock_kernel();

七、顺序锁:简称seq锁,用于读写共享数据

  • 实现这种锁主要依靠一个序列计数器。当有异议的数据被写入时,会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读操作过程中没有被写操作打断过。此外,如果读取的值是偶数,那么就表明写操作没有发生(锁初始值为0,写锁会成奇数,释放变为偶数)
    • 你的数据存在很多读者。
    • 你的数据写着很少。
    • 虽然写者很少,但是你希望写优先于读,而且不允许读者让写着饥饿。
    • 你的数据很简单。

八、禁止抢占

  • 内核抢占代码使用自旋锁作为非抢占区域的标记。
  • 如果一个自旋锁被持有,内核便不能进行抢占。
  • 有些情况并不需要自旋锁,但是仍然需要关闭内核抢占。
  • 如果数据对每个处理器是唯一的,那么这样的数据就不需要使用锁来保护,因为数据只能被一个处理器访问。
  • 如果自旋锁没有被持有,内核又是抢占式的,那么新调度的任务就可能访问同一个变量。
  • preempt_disable()/preempt_enable() 一个可以嵌套调用的函数,可以调用任意次。
  • 抢占计数存放着被持有锁的数量和preempt_disable的调用次数,如果计数是0,那么内核可以进行抢占,如果为不为0,那么,内核就会进行抢占。它是对原子操作和睡眠操作很有效的调试方法。preempt_count()返回抢占计数值。
  • 更简洁的方法解决每个处理器上的数据访问问题—通过get_cpu()获得处理器编号,这个函数在返回当前处理器编号前首先会关闭内核抢占。

九、顺序和屏障

  • 当处理多器之间或硬件设备之间的同步问题时,有时需要在你的程序代码中以指定的顺序发出读内存和写内存指令。这些确保顺序的指令称作屏障(barriers)。
  • rmb()方法提供了一个“读”内存屏障—-确保跨越(前后)rmb()的载入动作不会发生重新排序。
  • wmb()>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>存储>>>>>>>>>>>>>>
  • mb() 读写屏障
  • read_barrier_depends()
  • barrer() 阻止编译器跨屏障对载入或存储操作进行优化。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值