浅析Linux内核同步机制


       非常早之前就接触过同步这个概念了,可是一直都非常模糊。没有深入地学习了解过,最近有时间了,就花时间研习了一下《linux内核标准教程》和《深入linux设备驱动程序内核机制》这两本书的相关章节。趁刚看完,就把相关的内容总结一下。为了弄清楚什么事同步机制,必需要弄明确下面三个问题:

  • 什么是相互排斥与同步?
  • 为什么须要同步机制?
  •  Linux内核提供哪些方法用于实现相互排斥与同步的机制?

1、什么是相互排斥与同步?(通俗理解)

  • 相互排斥与同步机制是计算机系统中,用于控制进程对某些特定资源的訪问的机制。
  • 同步是指用于实现控制多个进程依照一定的规则或顺序訪问某些系统资源的机制。
  • 相互排斥是指用于实现控制某些系统资源在随意时刻仅仅能同意一个进程訪问的机制。

    相互排斥是同步机制中的一种特殊情况。

  • 同步机制是linux操作系统能够高效稳定执行的重要机制。

2、Linux为什么须要同步机制?

        在操作系统引入了进程概念,进程成为调度实体后,系统就具备了并发运行多个进程的能力,但也导致了系统中各个进程之间的资源竞争和共享。另外,因为中断、异常机制的引入,以及内核态抢占都导致了这些内核运行路径(进程)以交错的方式运行。

对于这些交错路径运行的内核路径,如不採取必要的同步措施。将会对一些重要数据结构进行交错訪问和改动。从而导致这些数据结构状态的不一致,进而导致系统崩溃。

因此。为了确保系统高效稳定有序地运行,linux必需要採用同步机制。

3、Linux内核提供了哪些同步机制?

        在学习linux内核同步机制之前。先要了解下面预备知识:(临界资源与并发源)
        在linux系统中,我们把对共享的资源进行訪问的代码片段称为临界区。把导致出现多个进程对同一共享资源进行訪问的原因称为并发源。

        Linux系统下并发的主要来源有:

  • 中断处理:比如,当进程在訪问某个临界资源的时候发生了中断。随后进入中断处理程序,假设在中断处理程序中。也訪问了该临界资源。尽管不是严格意义上的并发,可是也会造成了对该资源的竞态。

  • 内核态抢占:比如。当进程在訪问某个临界资源的时候发生内核态抢占,随后进入了高优先级的进程。假设该进程也訪问了同一临界资源,那么就会造成进程与进程之间的并发。
  • 多处理器的并发:多处理器系统上的进程与进程之间是严格意义上的并发,每一个处理器都能够独自调度执行一个进程。在同一时刻有多个进程在同一时候执行 。

如前所述可知:採用同步机制的目的就是避免多个进程并发并发訪问同一临界资源。 

Linux内核同步机制:

(1)禁用中断 (单处理器不可抢占系统)

        由前面能够知道,对于单处理器不可抢占系统来说,系统并发源主要是中断处理。

因此在进行临界资源訪问时,进行禁用/使能中断即能够达到消除异步并发源的目的。

Linux系统中提供了两个宏local_irq_enable与 local_irq_disable来使能和禁用中断。在linux系统中,使用这两个宏来开关中断的方式进行保护时。要确保处于两者之间的代码运行时间不能太长,否则将影响到系统的性能。(不能及时响应外部中断)

(2)自旋锁

应用背景:自旋锁的最初设计目的是在多处理器系统中提供对共享数据的保护。

自旋锁的设计思想:在多处理器之间设置一个全局变量V,表示锁。

并定义当V=1时为锁定状态,V=0时为解锁状态。自旋锁同步机制是针对多处理器设计的,属于忙等机制。自旋锁机制仅仅同意唯一的一个运行路径持有自旋锁。假设处理器A上的代码要进入临界区。就先读取V的值。假设V!=0说明是锁定状态,表明有其它处理器的代码正在对共享数据进行訪问,那么此时处理器A进入忙等状态(自旋)。假设V=0。表明当前没有其它处理器上的代码进入临界区,此时处理器A能够訪问该临界资源。

然后把V设置为1。再进入临界区,訪问完成后离开临界区时将V设置为0。

注意:必需要确保处理器A“读取V,半段V的值与更新V”这一操作是一个原子操作。所谓的原子操作是指。一旦開始运行。就不可中断直至运行结束。

自旋锁的分类:

2.1、普通自旋锁

普通自旋锁由数据结构spinlock_t来表示,该数据结构在文件src/include/linux/spinlock_types.h中定义。

定义例如以下:

typedef struct { raw_spinklock_t   raw_lock;

       #ifdefined(CONFIG_PREEMPT)  &&  defined(CONFIG_SMP)

               unsigned int break_lock;

       #endif

} spinlock_t;

成员raw_lock该成员变量是自旋锁数据类型的核心。它展开后实质上是一个Volatileunsigned类型的变量。

详细的锁定过程与它密切相关,该变量依赖于内核选项CONFIG_SMP。(是否支持多对称处理器)

成员break_lock同一时候依赖于内核选项CONFIG_SMP和CONFIG_PREEMPT(是否支持内核态抢占),该成员变量用于指示当前自旋锁是否被多个内核运行路径同一时候竞争、訪问。

在单处理器系统下:CONFIG_SMP没有选中时。变量类型raw_spinlock_t退化为一个空结构体。对应的接口函数也发生了退化。

对应的加锁函数spin_lock()和解锁函数spin_unlock()退化为仅仅完毕禁止内核态抢占、使能内核态抢占。

在多处理器系统下:选中CONFIG_SMP时。核心变量raw_lock的数据类型raw_lock_t在文件里src/include/asm-i386/spinlock_types.h中定义例如以下:

typedef struct {  volatileunsigned int slock;} raw_spinklock_t;

       从定义中能够看出该数据结构定义了一个内核变量。用于计数工作。当结构中成员变量slock的数值为1时。表示自旋锁处于非锁定状态。能够使用。否则,表示处于锁定状态。不能够使用。

普通自旋锁的接口函数:

spin_lock_init(lock)  //声明自旋锁是,初始化为锁定状态

spin_lock(lock)//锁定自旋锁,成功则返回,否则循环等待自旋锁变为空暇

spin_unlock(lock) //释放自旋锁,又一次设置为未锁定状态

spin_is_locked(lock) //推断当前锁是否处于锁定状态。若是,返回1.

spin_trylock(lock) //尝试锁定自旋锁lock,不成功则返回0,否则返回1

spin_unlock_wait(lock) //循环等待,直到自旋锁lock变为可用状态。

spin_can_lock(lock) //推断该自旋锁是否处于空暇状态。

 

普通自旋锁总结:自旋锁设计用于多处理器系统。

当系统是单处理器系统时,自旋锁的加锁、解锁过程分为别退化为禁止内核态抢占、使能内核态抢占。在多处理器系统中。当锁定一个自旋锁时。须要首先禁止内核态抢占,然后尝试锁定自旋锁,在锁定失败时运行一个死循环等待自旋锁被释放;当解锁一个自旋锁时,首先释放当前自旋锁,然后使能内核态抢占。

2.2、自旋锁的变种

        在前面讨论spin_lock非常好的攻克了多处理器之间的并发问题。可是假设考虑例如以下一个应用场景:处理器上的当前进程A要对某一全局性链表g_list进行操作。所以在操作前调用了spin_lock获取锁。然后再进入临界区。

假设在临界区代码其中。进程A所在的处理器上发生了一个外部硬件中断,那么这个时候系统必须暂停当前进程A的运行转入到中断处理程序其中。

假如中断处理程序其中也要操作g_list,因为它是共享资源,在操作前必需要获取到锁才干进行訪问。因此其中断处理程序试图调用spin_lock获取锁时,因为该锁已经被进程A持有。中断处理程序将会进入忙等状态(自旋)。从而就会出现大问题了:中断程序因为无法获得锁,处于忙等(自旋)状态无法返回。因为中断处理程序无法返回,进程A也处于没有运行完的状态,不会释放锁。因此这样导致了系统的死锁。即spin_lock对存在中断源的情况是存在缺陷的,因此引入了它的变种。

spin_lock_irq(lock) 

spin_unlock_irq(lock)

相比于前面的普通自旋锁,它在上锁前添加了禁用中断的功能,在解锁后,使能了中断。

2.3、读写自旋锁rwlock

应用背景:前面说的普通自旋锁spin_lock类的函数在进入临界区时。对临界区中的操作行为不细分。

仅仅要是訪问共享资源,就运行加锁操作。可是有时候,比方某些临界区的代码仅仅是去读这些共享的数据,并不会改写,假设採用spin_lock()函数,就意味着,随意时刻仅仅能有一个进程能够读取这些共享数据。假设系统中有大量对这些共享资源的读操作,非常明显spin_lock将会减少系统的性能。因此提出了读写自旋锁rwlock的概念。对比普通自旋锁,读写自旋锁同意多个读者进程同一时候进入临界区,交错訪问同一个临界资源,提高了系统的并发能力,提升了系统的吞吐量。

读写自旋锁有数据结构rwlock_t来表示。定义在…/spinlock_types.h中

读写自旋锁的接口函数:

DEFINE_RWLOCK(lock) //声明读写自旋锁lock,并初始化为未锁定状态

write_lock(lock) //以写方式锁定。若成功则返回,否则循环等待

write_unlock(lock) //解除写方式的锁定。重设为未锁定状态

read_lock(lock) //以读方式锁定。若成功则返回,否则循环等待

read_unlock(lock) //解除读方式的锁定,重设为未锁定状态

读写自旋锁的工作原理:

         对于读写自旋锁rwlock,它同意随意数量的读取者同一时候进入临界区,但写入者必须进行相互排斥訪问。

一个进程要进行读,必需要先检查是否有进程正在写入,假设有,则自旋(忙等)。否则获得锁。

一个进程要进程写,必需要先检查是否有进程正在读取或者写入,假设有。则自旋(忙等)否则获得锁。即读写自旋锁的应用规则例如以下:

(1)假设当前有进程正在写,那么其它进程就不能读也不能写。

(2)假设当前有进程正在读,那么其它程序能够读,可是不能写。

2.4、顺序自旋锁seqlock

应用背景:顺序自旋锁主要用于解决自旋锁同步机制中,在拥有大量读者进程时,写进程因为长时间无法持有锁而被饿死的情况,其主要思想是:为写进程提高更高的优先级,在写锁定请求出现时,马上满足写锁定的请求,不管此时是否有读进程正在訪问临界资源。

可是新的写锁定请求不会,也不能抢占已有写进程的写锁定。

顺序锁的设计思想:对某一共享数据读取时不加锁,写的时候加锁。

为了保证读取的过程中不会由于写入者的出现导致该共享数据的更新。须要在读取者和写入者之间引入一个整形变量,称为顺序值sequence。读取者在開始读取前读取该sequence,在读取后再又一次读取该值,假设与之前读取到的值不一致,则说明本次读取操作过程中发生了数据更新。读取操作无效。因此要求写入者在開始写入的时候更新。

顺序自旋锁由数据结构seqlock_t表示,定义在src/include/linux/seqlcok.h

顺序自旋锁訪问接口函数:

seqlock_init(seqlock) //初始化为未锁定状态

read_seqbgin()、read_seqretry() //保证数据的一致性

write_seqlock(lock) //尝试以写锁定方式锁定顺序锁

write_sequnlock(lock) //解除对顺序锁的写方式锁定,重设为未锁定状态。

顺序自旋锁的工作原理:写进程不会被读进程堵塞。也就是,写进程对被顺序自旋锁保护的临界资源进行訪问时,马上锁定并完毕更新工作。而不必等待读进程完毕读訪问。

可是写进程与写进程之间仍是相互排斥的,假设有写进程在进行写操作,其它写进程必须循环等待,直到前一个写进程释放了自旋锁。

顺序自旋锁要求被保护的共享资源不包括有指针。由于写进程可能使得指针失效,假设读进程正要訪问该指针,将会出错。同一时候。假设读者在读操作期间。写进程已经发生了写操作,那么读者必须又一次读取数据。以便确保得到的数据是完整的。

(3)信号量机制(semaphore)

应用背景:前面介绍的自旋锁同步机制是一种“忙等”机制。在临界资源被锁定的时间非常短的情况下非常有效。可是在临界资源被持有时间非常长或者不确定的情况下,忙等机制则会浪费非常多宝贵的处理器时间。针对这样的情况,linux内核中提供了信号量机制,此类型的同步机制在进程无法获取到临界资源的情况下,马上释放处理器的使用权,并睡眠在所訪问的临界资源上相应的等待队列上;在临界资源被释放时,再唤醒堵塞在该临界资源上的进程。另外,信号量机制不会禁用内核态抢占,所以持有信号量的进程一样能够被抢占,这意味着信号量机制不会给系统的响应能力,实时能力带来负面的影响。

信号量设计思想:除了初始化之外。信号量仅仅能通过两个原子操作P()和V()訪问,也称为down()和up()。

down()原子操作通过对信号量的计数器减1。来请求获得一个信号量。假设操作后结果是0或者大于0。获得信号量锁,任务就能够进入临界区。假设操作后结果是负数。任务会放入等待队列,处理器运行其它任务;对临界资源訪问完成后。能够调用原子操作up()来释放信号量,该操作会添加信号量的计数器。

假设该信号量上的等待队列不为空。则唤醒堵塞在该信号量上的进程。

信号量的分类:

3.1、普通信号量

普通信号量由数据结构struct semaphore来表示,定义在src/inlcude/ asm-i386/semaphore.h中.

信号量(semaphore)定义例如以下:

<include/linux/semaphore.h>

struct semaphore{

       spinlock_t       lock; //自旋锁,用于实现对count的原子操作

       unsigned int    count; //表示通过该信号量同意进入临界区的运行路径的个数

       struct list_head      wait_list; //用于管理睡眠在该信号量上的进程

};

普通信号量的接口函数:

sema_init(sem,val)  //初始化信号量计数器的值为val

int_MUTEX(sem) //初始化信号量为一个相互排斥信号量

down(sem)   //锁定信号量。若不成功,则睡眠在等待队列上

up(sem) //释放信号量,并唤醒等待队列上的进程

DOWN操作:linux内核中。对信号量的DOWN操作有例如以下几种:

void down(struct semaphore *sem); //不可中断

int down_interruptible(struct semaphore *sem);//可中断

int down_killable(struct semaphore *sem);//睡眠的进程能够由于受到致命信号而被唤醒,中断获取信号量的操作。

int down_trylock(struct semaphore *sem);//试图获取信号量。若无法获得则直接返回1而不睡眠。返回0则 表示获取到了信号量

int down_timeout(struct semaphore *sem,long jiffies);//表示睡眠时间是有限制的,假设在jiffies指明的时间到期时仍然无法获得信号量,则将返回错误码。

在以上四种函数中,驱动程序使用的最频繁的就是down_interruptible函数

UP操作:LINUX内核仅仅提供了一个up函数

void up(struct semaphore *sem)

加锁处理过程:加锁过程由函数down()完毕。该函数负责測试信号量的状态,在信号量可用的情况下,获取该信号量的使用权,否则将当前进程插入到当前信号量相应的等待队列中。函数调用关系例如以下:down()->__down_failed()->__down.函数说明例如以下:

down()功能介绍:该函数用于对信号量sem进行加锁,在加锁成功即获得信号的使用权是,直接退出,否则,调用函数__down_failed()睡眠到信号量sem的等待队列上。__down()功能介绍:该函数在加锁失败时被调用。负责将进程插入到信号量 sem的等待队列中。然后调用调度器。释放处理器的使用权。

解锁处理过程:普通信号量的解锁过程由函数up()完毕。该函数负责将信号计数器count的值添加1,表示信号量被释放。在有进程堵塞在该信号量的情况下,唤醒等待队列中的睡眠进程。

3.2读写信号量(rwsem)

应用背景:为了提高内核并发运行能力,内核提供了读入者信号量和写入者信号量。

它们的概念和实现机制类似于读写自旋锁。

工作原理:该信号量机制使得全部的读进程能够同一时候訪问信号量保护的临界资源。当进程尝试锁定读写信号量不成功时。则这些进程被插入到一个先进先出的队列中;当一个进程訪问完临界资源,释放相应的读写信号量是。该进程负责将该队列中的进程按一定的规则唤醒。

唤醒规则:唤醒排在该先进先出队列中队首的进程,在被唤醒进程为写进程的情况下,不再唤醒其它进程。在唤醒进程为读进程的情况下。唤醒其它的读进程,直到遇到一个写进程(该写进程不被唤醒)

读写信号量的定义例如以下:

<include/linux/rwsem-spinlock.h>

sturct rw_semaphore{

       __s32      activity; //用于表示读者或写者的数量

       spinlock_t      wait_lock;

       struct list_head      wait_list;

};

读写信号量对应的接口函数

读者up、down操作函数:

void up_read(Sturct rw_semaphore *sem);

void __sched down_read(Sturct rw_semaphore *sem);

Int down_read_trylock(Sturct rw_semaphore *sem);

写入者up、down操作函数:

void up_write(Sturct rw_semaphore *sem);

void __sched down_write(Sturct rw_semaphore *sem);

int down_write_trylock(Sturct rw_semaphore *sem);

3.3、相互排斥信号量

在linux系统中,信号量的一个常见的用途是实现相互排斥机制。这样的情况下,信号量的count值为1,也就是随意时刻仅仅同意一个进程进入临界区。

为此,linux内核源代码提供了一个宏DECLARE_MUTEX,专门用于这样的用途的信号量定义和初始化

<include/linux/semaphore.h>

#define DECLARE_MUTEX(name) 

              structsemaphore name=__SEMAPHORE_INITIALIZER(name,1)

(4)相互排斥锁mutex

Linux内核针对count=1的信号量又一次定义了一个新的数据结构struct mutex,一般都称为相互排斥锁。内核依据使用场景的不同。把用于信号量的downup操作在struct mutex上做了优化与扩展,专门用于这样的新的数据类型。

5RCU

RCU概念:RCU全称是Read-Copy-Update(/-复制-更新),linux内核中提供的一种免锁的同步机制。

RCU与前面讨论过的读写自旋锁rwlock,读写信号量rwsem,顺序锁一样。它也适用于读取者、写入者共存的系统。可是不同的是。RCU中的读取和写入操作无须考虑两者之间的相互排斥问题。可是写入者之间的相互排斥还是要考虑的。

RCU原理:简单地说,是将读取者和写入者要訪问的共享数据放在一个指针p中,读取者通过p来訪问当中的数据,而读取者则通过改动p来更新数据。要实现免锁,读写两方必需要遵守一定的规则。

读取者的操作(RCU临界区)

对于读取者来说,假设要訪问共享数据。

首先要调用rcu_read_lockrcu_read_unlock函数构建读者側的临界区(read-side critical section),然后再临界区中获得指向共享数据区的指针。实际的读取操作就是对该指针的引用。

读取者要遵守的规则是:(1)对指针的引用必需要在临界区中完毕。离开临界区之后不应该出现不论什么形式的对该指针的引用。

2)在临界区内的代码不应该导致不论什么形式的进程切换(一般要关掉内核抢占。中断能够不关)。

写入者的操作

对于写入者来说。要写入数据,首先要又一次分配一个新的内存空间做作为共享数据区。然后将老数据区内的数据拷贝到新数据区,并依据须要改动新数据区。最后用新数据区指针替换掉老数据区的指针。

写入者在替换掉共享区的指针后,老指针指向的共享数据区所在的空间还不能立即释放(原因后面再说明)。

写入者须要和内核共同协作。在确定全部对老指针的引用都结束后才干够释放老指针指向的内存空间。为此,写入者要做的操作是调用call_rcu函数向内核注冊一个回调函数。内核在确定全部对老指针的引用都结束时会调用该回调函数,回调函数的功能主要是释放老指针指向的内存空间。

Call_rcu函数的原型例如以下:

Void call_rcu(struct rcu_head *head,void (*func)(struct rcu_head *rcu));

内核确定没有读取者对老指针的引用是基于下面条件的:系统中全部处理器上都至少发生了一次进程切换。由于全部可能对共享数据区指针的不一致引用一定是发生在读取者的RCU临界区,并且临界区一定不能发生进程切换。所以假设在CPU上发生了一次进程切换切换,那么全部对老指针的引用都会结束,之后读取者再进入RCU临界区看到的都将是新指针。

老指针不能立即释放的原因:这是由于系统中爱可能存在对老指针的引用,者主要发生在下面两种情况:(1)一是在单处理器范围看。如果读取者在进入RCU临界区后,刚获得共享区的指针之后发生了一个中断,如果写入者恰好是中断处理函数中的行为,那么其中断返回后,被中断进程RCU临界区中继续运行时,将会继续引用老指针。(2)还有一个可能是在多处理器系统,当处理器A上的一个读取者进入RCU临界区并获得共享数据区中的指针后,在其还没来得及引用该指针时,处理器B上的一个写入者更新了指向共享数据区的指针。这样处理器A上的读取者也饿将引用到老指针。

RCU特点:由前面的讨论能够知道。RCU实质上是对读取者与写入者自旋锁rwlock的一种优化。RCU的能够让多个读取者和写入者同一时候工作。可是RCU的写入者操作开销就比較大。在驱动程序中一般比較少用。

为了在代码中使用RCU,全部RCU相关的操作都应该使用内核提供的RCU API函数,以确保RCU机制的正确使用,这些API主要集中在指针和链表的操作。

以下是一个RCU的典型使用方法范例:

<span style="font-size:14px;">//<span style="font-size:14px;">如果struct shared_data是一个在读取者和写入者之间共享的受保护数据

Struct shared_data{

Int a;

Int b;

Struct rcu_head rcu;

};

 

//读取者側的代码

Static void demo_reader(struct shared_data *ptr)

{

       Struct shared_data *p=NULL;

       Rcu_read_lock();

       P=rcu_dereference(ptr);

       If(p)

              Do_something_withp(p);

       Rcu_read_unlock();

}

 

//写入者側的代码

 

Static void demo_del_oldptr(struct rcu_head *rh) //回调函数

{

       Struct shared_data *p=container_of(rh,struct shared_data,rcu);

       Kfree(p);

}

Static void demo_writer(struct shared_data *ptr)

{

       Struct shared_data *new_ptr=kmalloc(…);

       …

       New_ptr->a=10;

       New_ptr->b=20;

       Rcu_assign_pointer(ptr,new_ptr);//用新指针更新老指针

       Call_rcu(ptr->rcu,demo_del_oldptr); 向内核注冊回调函数,用于删除老指针指向的内存空间

}

</span></span>

6)完毕接口completion

Linux内核还提供了一个被称为“完毕接口completion”的同步机制,该机制被用来在多个运行路径间作同步使用,也即协调多个运行路径的运行顺序。在此就不展开了。

 

 

 

 

 

   

原文地址:https://www.cnblogs.com/lxjshuju/p/6757683.html