zoukankan      html  css  js  c++  java
  • Linu内核--同步机制

    前言

    好像很久没有更博客了,ctfer的flag倒了吗?iot环境搭了我一周,555

    承这周的计算机操作系统这门课这周说到的进程同步这概念,这里简单了解一下Linux内核的同步机制
    这里说明一点,Linux内核具有近三千万行代码,内容极其多,本人只是借助大学期间学习的计算机操作系统这门课的方便,去简单了解一下其中的一点点内容,希望为以后的安全研究生涯积累一点点基础。

    同步的基本概念

    在了解同步的基本概念之前,我们先来解决三个问题:

    什么是互斥与同步?

    1. 互斥与同步机制是计算机系统中,用于控制进程对某些特定资源的访问的机制。
    2. 同步是指用于实现控制多个进程按照一定的规则或顺序访问某些系统资源的机制。
    3. 互斥是指用于实现控制某些系统资源在任意时刻只能允许一个进程访问的机制。互斥是同步机制中的一种特殊情况。
    4. 同步机制是linux操作系统可以高效稳定运行的重要机制。

    为什么需要同步机制?

    在操作系统引入了进程概念,进程成为调度实体后,系统就具备了并发执行多个进程的能力,但也导致了系统中各个进程之间的资源竞争和共享。另外,由于中断、异常机制的引入,以及内核态抢占都导致了这些内核执行路径(进程)以交错的方式运行。对于这些交错路径执行的内核路径,如不采取必要的同步措施,将会对一些关键数据结构进行交错访问和修改,从而导致这些数据结构状态的不一致,进而导致系统崩溃。因此,为了确保系统高效稳定有序地运行,linux必须要采用同步机制。

    内核中使用的各种同步技术

    技术 说明 使用范围
    每cpu变量 在cpu之间复制数据结构 所有cpu
    原子操作 对一个计数器原子地“读-修改-写”的指令 所有cpu
    内存屏障 避免指令重新排序 本次cpu或所有cpu
    自旋锁 加锁时忙等 所有cpu
    信号量 加锁时阻塞等待(睡眠) 所有cpu
    顺序锁 基于访问计数器的锁 所有cpu
    本地中断的禁止 禁止单个cpu上的中断处理 本地cpu
    本地软终端的禁止 禁止单个cpu上的可延迟函数处理 本地cpu
    读-拷贝-更新(rcu) 通过指针而不是锁来访问共享数据结构 所有cpu
    这里再补充几点书上讲到的概念:
    1,进程同步和进程异步的区别
    进程同步:这是进程间的一种运行关系。“同”是协同,按照一定的顺序协同进行(有序进行),而不是同时。即一组进程为了协调其推进速度,在某些地方需要相互等待或者唤醒,这种进程间的相互制约就被称作是进程同步。这种合作现象在操作系统和并发式编程中属于经常性事件。具有同步关系的一组并发进程称为合作进程
    进程异步:异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。两个进程同时运行,与并行操作不同,它的两个进程之间是有关系的。即调用者不会一直按照这个顺序傻傻等待着被调方处理完,而是在这段期间做自己的事,直到被调方给他通知再去处理。异步需要内核底层的一些机制,来通知另一个进程去访问临界资源
    2,临界资源
    一次仅允许一个进程使用的资源,许多硬件资源如打印机,磁带机等都属于临界资源。
    3,临界区与并发源
    在linux系统中,我们把对共享的资源进行访问的代码片段称为临界区。把导致出现多个进程对同一共享资源进行访问的原因称为并发源。

    Linux内核的同步原语

    每cpu变量

    首先必须明确最好的同步/互斥技术就是不许要同步/互斥。所有的同步/互斥技术都有性能上的代价。
    最简单也是最重要的同步技术包括把内核变量声明为每cpu变量(per-cpu variable)它实际上是数据结构的数组,系统的每个CPU对应数组中的一个元素。
    使用每CPU变量时,每个CPU只能访问与它相关联的元素,因此每-CPU变量只能在特殊情形下被使用。
    虽然每cpu变量为来自不用cpu的并发访问提供保护,但对于来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下需要另外的同步原语
    每CPU变量会在主存中对其以确保它们会映射到不同的硬件cashe行。这样就可以确保并发访问每-CPU变量不会导致高速缓存的snooping和invalidation(这种操作会带来高昂的系统开销)。
    使用每CPU变量的宏和函数:
    | 宏或函数名 | 说明 |
    | ---- | ---- | ---- |
    | DEFINE_PER_CPU(type, name) | 该宏静态的分配一个名字为name类型为type的每-CPU变量 |
    |per_cpu(name, cpu)|该宏选取名字为name的每CPU变量的对应于指定的cpu的元素|
    |_ _get_cpu_var(name) |该宏选择名字为name的每CPU变量的对应于本地cpu的元素|
    |get_cpu_var(name)|该宏关闭内核抢占,然后选择名字为name的每CPU变量的对应于本地cpu的元素|
    |put_cpu_var(name) |该宏打开内核抢占,未使用name|
    |alloc_percpu(type)|该宏动态分配一个类型为type的每CPU变量并返回其地址
    |free_percpu(pointer)|该宏释放动态分配的每CPU变量,pointer为每CPU变量的地址|
    |per_cpu_ptr(pointer, cpu)|该宏返回存放于地址pointer的每CPU变量对应于cpu的元素的地址|

    原子操作

    所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它的最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。原子操作可以保证指令以原子的方式执行,执行过程不被打断。它通过把读取和修改变量的行为包含在一个单步中执行,从而防止了竞争的发生,保证操作结果总是一致的。
    当你编写C代码程序时,并不能保证编译器会为a=a+1或甚至像a++这样的操作使用一个原子指令。因此,Linux内核提供了一个专门的atomic_t类型(一个原子访问计数器)和一些专门的函数和宏,这些函数和宏作用于atomic_t类型的变量,并当作单独的,原子的汇编语言指令来使用。在多处理器系统中,每条这样的指令都有一个lock字节的前缀
    原子类型定义:

    volatile修饰字段告诉gcc不要对该类型的数据做优化处理,对它的访问都是对内存的访问,而不是对寄存器的访问。
    原子操作API包括:
    atomic_read(atomic_t * v);
    该函数对原子类型的变量进行原子读操作,它返回原子类型的变量v的值。
    atomic_set(atomic_t * v, int i);
    该函数设置原子类型的变量v的值为i。
    void atomic_add(int i, atomic_t *v);
    该函数给原子类型的变量v增加值i。
    atomic_sub(int i, atomic_t *v);
    该函数从原子类型的变量v中减去i。
    int atomic_sub_and_test(int i, atomic_t *v);
    该函数从原子类型的变量v中减去i,并判断结果是否为0,如果为0,返回真,否则返回假。
    void atomic_inc(atomic_t *v);
    该函数对原子类型变量v原子地增加1。
    void atomic_dec(atomic_t *v);
    该函数对原子类型的变量v原子地减1。
    int atomic_dec_and_test(atomic_t *v);
    该函数对原子类型的变量v原子地减1,并判断结果是否为0,如果为0,返回真,否则返回假。
    int atomic_inc_and_test(atomic_t *v);
    该函数对原子类型的变量v原子地增加1,并判断结果是否为0,如果为0,返回真,否则返回假。
    int atomic_add_negative(int i, atomic_t *v);
    该函数对原子类型的变量v原子地增加I,并判断结果是否为负数,如果是,返回真,否则返回假。
    int atomic_add_return(int i, atomic_t *v);
    该函数对原子类型的变量v原子地增加i,并且返回指向v的指针。
    int atomic_sub_return(int i, atomic_t *v);
    该函数从原子类型的变量v中减去i,并且返回指向v的指针。
    int atomic_inc_return(atomic_t * v);
    该函数对原子类型的变量v原子地增加1并且返回指向v的指针。
    int atomic_dec_return(atomic_t * v);
    该函数对原子类型的变量v原子地减1并且返回指向v的指针。
    具体代码实现可看Linux源码下的:/include/asm-generic/atomic.h

    内存屏障和优化屏障

    编译器有时会对代码做一些优化,例如尝试在保证程序执行正确的前提下修改指令顺序或优化ldr/str指令,让程序执行地更快。但是编译器毕竟不能完全猜透人的心思,有时候它做的优化会导致程序运行不符我们的预期。
    因此,内核中提供了一些额外的函数,可以插在某段代码里,告诉编译器不要在这里做指令优化。这些函数分为两种:
    内存屏障:rmb(), wmb(), mb(),可以防止硬件上的指令重排。除了编译器,有的CPU也支持对指令进行重排来优化程序执行效率,这几个函数就是去防止CPU去做这些事情。rmb()是读访问内存屏障,它保证在屏障(调用rmb()的位置处)之后的任何读操作在执行之前,屏障之前的所有读操作都已经完成。wmb()对应写操作,意思同上。mb()就同时包含读和写操作,意思同上。
    优化屏障:barrier(),防止编译器对内存访问的优化,类似volatile关键字对于访问变量的作用。它告诉编译器,在插入barrier()的位置处,内存中的内容都被更新了,你想读变量、映射到内存的寄存器等内容都需要真正到内存里去读,这样就能保证barrier之后的读指令不会被优化掉。

    自旋锁

    加锁(locking)是一种广泛应用的同步技术。当内核控制路径必须访问共享数据结构或进入临界区时,就需要为自己获取一把“锁”。由锁机制保护的资源非常类似于限制于房间内的资源,当某人进入房间时,就把门锁上。如果内核控制路径希望访问资源,就试图获取钥匙“打开门”。当且仅当资源空闲时,它才能成功。然后,只要它还想使用这个资源,门就依然锁着。当内核控制路径释放了锁时,门就打开,另一个内核控制路径就可以进入房间。
    下图显示了锁的使用。5个内核控制路径(P0,PI,P2,P3和P4)试图访问两个临界区(C1和C2)。内核控制路径P0正在C1中,而P2和P4正等待进人C1。同时,P1正在C2中,而P3正在等待进入C2。注意P0和P1可以并行运行。临界区C3的锁现在打开着,因为没有内核控制路径需要进人C3。

    Linux锁的应用之一在多处理器环境中,取名叫自旋锁(spin lock)。如果内核控制路径发现自旋锁“开着”,就获取锁并继续自己的执行。相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径“锁着”,就在周围“旋转”,反复执行一条紧凑的循环指令,直到锁被释放。
    自旋锁的循环指令表示“忙等”。即使等待的内核控制路径无事可做(除了浪费时间),它也在CPU上保持运行。不过,自旋锁通常非常方便,因为很多内核资源只锁1毫秒的时间片段;所以说,等待自旋锁的释放不会消耗太多CPU的时间。
    一般来说,由自旋锁所保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,自旋锁技术仅仅是用来禁止或启用内核抢占。请注意,在自旋锁忙等期间,因为并没有进入临界区,所以内核抢占还是有效的,因此,等待自旋锁释放的进程有可能被更高优先级的所取代。这种设计是合理的,因为不能因为占用CPU太久而使系统死锁。
    在Linux中,每个自旋锁都用spinlock_t结构表示。其中包含两个字段,slock,该字段表示自旋锁的状态:值为1表示“未加锁状态”,而任何负数和0都表示“加锁状态”;break_lock表示进程正在忙等自旋锁。
    自旋锁的几个宏:

    对应实现的源码在于Linux源码的includelinux <spinlock.h>文件中

    读/写锁

    读写自旋锁是一种特殊的自旋锁,它将访问共享资源的线程区分为读者和写者,多个读者可以同时持有锁,因而提高 了线程的并发性。

    读写自旋锁简介

    什么是读写自旋锁

    自旋锁(Spinlock)是一种常用的互斥(Mutual Exclusion)同步原语(Synchronization Primitive),试图进入临界区(Critical Section)的线程使用忙等待(Busy Waiting)的方式检测锁的状态,若锁未被持有则尝试获取。这种忙等待的做法无谓地消耗了处理器资源,故而只适用于临界区非常短小的代码片段,例如 Linux 内核的中断处理函数。

    由于互斥的特点,使用自旋锁的代码毫无线程并发性可言,多处理器系统的性能受到限制。通过观察线程在临界区的访问行为,我们发现有些线程只是 简单地读取信息,并不修改任何东西,那么允许它们同时进入临界区不会有任何危险,反而能大大提高系统的并发性。这种将线程区分为读者和写者、多个读者允许 同时访问共享资源、申请线程在等待期内依然使用忙等待方式的锁,我们称之为读写自旋锁(Reader-Writer Spinlock)。
    读/写锁也叫做共享/排斥锁,或者并发/排斥锁,因为这种锁对读者而言是共享地,对写者以排斥形式获取地。
    在内核代码中,读-写自旋锁用rwlock_t类型表示,

    typedef struct {
    	/**
    	 * 这个锁标志与自旋锁不一样,自旋锁的lock标志只能取0和1两种值。
    	 * 读写自旋锁的lock分两部分:
    	 *     0-23位:表示并发读的数量。数据以补码的形式存放。
    	 *     24位:未锁标志。如果没有读或写时设置该位,否则清0
    	 * 注意:如果自旋锁为空(设置了未锁标志并且无读者),则lock字段为0x01000000
    	 *     如果写者获得了锁,则lock为0x00000000(未锁标志清0,表示已经锁,但是无读者)
    	 *     如果一个或者多个进程获得了读锁,那么lock的值为0x00ffffff,0x00fffffe等(未锁标志清0,后面跟读者数量的补码)
    	 */
    	volatile unsigned int lock;
    #ifdef CONFIG_DEBUG_SPINLOCK
    	unsigned magic;
    #endif
    #ifdef CONFIG_PREEMPT
    	/**
    	 * 表示进程正在忙等待自旋锁。
    	 * 只有内核支持SMP和内核抢占时才使用本标志。
    	 */
    	unsigned int break_lock;
    #endif
    } rwlock_t;
    

    rwlock_t中的锁标志与自旋锁不同,
    注:如果自旋锁为空(设置了未锁标志并且无读者),则锁字节位0x01000000)(自锁锁的锁标志只能取0和1两种值。
    读写自旋锁的锁分为两部分:
    · 0-23位:表示并发读的数量;
    ·第24位:未锁标志如果没有读或写时会设置,否则清0。
    如果写者获得了锁,则锁为0x0000 0000(未锁标志清0,表示已经锁,但无读者);如果一个或多个进程获得了读锁,那么锁的值为0x00ff ffff,0x00ff fffe锁标志清0)
    初始化

    rwlock_init(),初始化指定的rwlock_t。
    

    read_lock

    /**
     * 在没有配置内核抢占时,read_lock的实现。
     */
    void __lockfunc _read_lock(rwlock_t *lock)
    {
    	preempt_disable();
    	_raw_read_lock(lock);
    }
    EXPORT_SYMBOL(_read_lock)
    

    接下来,read_lock调用_raw_read_lock(),其中第一个参数为读写锁指针,第二个为获取读锁失败时的处理函数的函数指针。

    /**
     * 在没有配置内核抢占时,read_lock调用它。
     */
    static inline void _raw_read_lock(rwlock_t *rw)
    {
    #ifdef CONFIG_DEBUG_SPINLOCK
    	BUG_ON(rw->magic != RWLOCK_MAGIC);
    #endif
    	__build_read_lock(rw, "__read_lock_failed");
    }
    

    write_lock()
    获得指定的写锁。

    void __lockfunc _write_lock(rwlock_t *lock)
    {
    	preempt_disable();
    	_raw_write_lock(lock);
    }
    static inline void _raw_write_lock(rwlock_t *rw)
    {
    #ifdef CONFIG_DEBUG_SPINLOCK
    	BUG_ON(rw->magic != RWLOCK_MAGIC);
    #endif
    	__build_write_lock(rw, "__write_lock_failed");
    }
    

    详细代码可参见Linux源码/include/linux/rwlock.h

    信号量

    实际上,Linux提供了两种信号量。
    Linux内核中的信号量使用和用户态的信号量使用有所不同,
    1、内核信号量,由内核控制路径使用。
    2、用户态信号量分为两种,一种为POSIX,另一种为 SYSTEM V
    这里主要讲一下内核信号量
    内核信号量类似于自旋锁,因为当锁关闭着时,它不允许内核控制路径继续进行。然而,当内核控制路径试图获取内核信号量锁保护的忙资源时,相应的进程就被挂起。只有在资源被释放时,进程才再次变为可运行。
    只有可以睡眠的函数才能获取内核信号量;中断处理程序和可延迟函数都不能使用内核信号量。
    内核信号量是struct semaphore类型的对象,它在/include/linux/semaphore.h中定义

    struct semaphore {
       atomic_t count;
       int sleepers;
       wait_queue_head_t wait;
      }
    

    count:相当于信号量的值,大于0,资源空闲;等于0,资源忙,但没有进程等待这个保护的资源;小于0,资源不可用,并至少有一个进程等待资源。
    wait:存放等待队列链表的地址,当前等待资源的所有睡眠进程都会放在这个链表中。当然,如果等待队列链表大于或者等于0,等待队列就为空。
    sleepers:存放一个标志,表示是否有一些进程在信号量上睡眠
    在我们考虑Linux内核的的信号量API之前,我们需要知道如何初始化一个 信号量。事实上, Linux内核提供了两个 信号量 的初始函数。
    分别是静态和动态;
    静态:

    #define DEFINE_SEMAPHORE(name)  
             struct semaphore name = __SEMAPHORE_INITIALIZER(name, 1)
    

    这里的__SEMAPHORE_INITIALIZER其实也是个函数宏定义,详细可以参见源码,或者这一篇文章
    第二种初始化 信号量 的方式是将 信号量 和现有资源数目传送给 sema_init 函数。 这个函数是在 include/linux/semaphore.h 头文件中定义的。

    static inline void sema_init(struct semaphore *sem, int val)
    {
           static struct lock_class_key __key;
           *sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val);
           lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0);
    }
    

    其他API解析可见上面那篇文章

    参考

    http://www.wowotech.net/kernel_synchronization/445.html
    https://e-mailky.github.io/2016-10-13-linux_kernel_sync
    https://blog.csdn.net/fzubbsc/article/details/37736683
    https://cloud.tencent.com/developer/article/1070353
    https://elixir.bootlin.com/linux/v3.18.137/source
    《计算机操作系统》第四版
    《深入理解Linux内核》(第三版)

  • 相关阅读:
    【08月20日】A股滚动市净率PB历史新低排名
    沪深300指数的跟踪基金最近1年收益排名
    基金前15大重仓股持仓股排名
    中证500指数的跟踪基金最近1年收益排名
    【08月14日】A股ROE最高排名
    【08月13日】预分红股息率最高排名
    【08月09日】北上资金持股比例排名
    最近一月研报推荐次数最多的最热股票
    JDK源码阅读-------自学笔记(二十一)(java.util.ArrayList详细版集合类)
    MyBatis-Plus 3.0.3 Sql注入器添加,即全局配置Sql注入器,sqlInjector改写
  • 原文地址:https://www.cnblogs.com/T1e9u/p/13734553.html
Copyright © 2011-2022 走看看