4.内核中的锁

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/wangdapao12138/article/details/82078918

1.为什么要加锁?

假设我们需要处理一个队列上的所有请求。我们假定该队列通过链表得以实现,链表中每个节点就代表一个请求。有两个函数来操作此队列:一个函数将新请求添加到队列尾部,另一个函数从队列头删除请求,然后处理它。内核各个部分都会调用这两个函数,所以内核会不断地在队列中加入请求,从队列中删除和处理请求。对请求队列的操作无疑要用到多条指令。如果一个线程试图读取队列,而这时正好另一个线程正在处理该队列,那么读取线程就会发现队列此刻正处于不一致状态,很明显,如果允许并发访问队列,就会产生危害。当共享资源是一个复杂的数据结构时,竞争条件往往会使该数据结构遭到破坏。

2.并发的原因

  1. 中断-------中断几乎可以在任何时刻异步发生,也就是随时打断当前执行的代码;
  2. 软中断和tasklet------内核能在任何时刻唤醒或者调度软中断和tasklet,打断当前正在执行的代码;
  3. 内核抢占--------因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占;
  4. 睡眠及与用户空间的同步---------在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而导致调度一个新的用户进程执行;
  5. 对称多处理----两个或多个处理器可以同时执行代码。

3.需要保护些什么?

任何可能被并发的代码都几乎需要保护。一般执行线程的局部数据仅仅被他本身访问,显然不需要保护,比如局部变量不需要任何形式的锁。

到底什么数据需要加锁?大多数内核数据结构需要加锁!

经验:如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁;如果任何其他什么东西都能看到它,那么就要锁住它。记住:要给数据加锁而不是给代码加锁。

4.死锁

条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。所有线程都相互等待,但他们永远都不会释放已经占有的资源。

  1. 一个线程:一个已经获取此锁的线程,再去访问等待此锁,会造成死锁。

  1. 多个线程:如两个线程两把锁,他们通常被叫做ABBA死锁。

每个线程都在等待其他线程持有的锁,但绝没有一个线程会释放他们一开始就持有的锁,所以没有任何锁会在释放后被其他线程使用。

5.防止死锁发生的手段

  1. 按顺序加锁;
  2. 防止发生饥饿;
  3. 不要重复请求同一个锁;
  4. 设计应力求简单。

6.内核锁机制

内核可以不受限制的访问整个地址空间。 在多处理器系统上,这会引起一些问题。如果几个处理器同时处于核心态,则理论上他们可以同时访问同一个数据结构,这刚好在临界区引起一些竟态。

现在内核使用了由锁组成的细粒度网络,来明确地保护各个数据结构。也就是让多处理器不能同时访问一个数据结构。内核提供了各种锁,分别优化不同的啮合数据使用模式。

  1. 原子操作最简单的锁操作。他们保证简单的操作,诸如计数器加1,可以不中断地原子执行。即使操作几个由汇编语句组成,也可以保证。
  2. 自旋锁:最常用的锁机制。他们用于短期的保护某段代码,以防止其他处理器的访问,在内核等待自旋锁释放时,会重复检查是否能获取锁,而不会进入睡眠状态,但如果等待时间太长,效率显然不高。
  3. 信号量:经典方法实现。等待信号量释放时,内核进入睡眠状态,直至被唤醒。唤醒后才重新尝试获取信号量。互斥量是信号量的特例,互斥量保护临界区,每次只能有一个用户进入。
  4. 读者/写者锁:这些锁会区分对数据结构的两种不同类型的访问。任意数目的处理器都可以对数据结构进行并发读访问。 但只有一个处理器能进行写访问。写访问时,读访问是无法进行的。

这些锁的部署遍及内核源代码各处。下面开始详细介绍这几种锁。

7.对整数的原子操作

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。

原子操作的实现必须需要硬件的支持,操作系统仅仅是在硬件指令的基础之上进行一次封装。对于没有实现原子操作的硬件,则需要操作系统从软件算法层面进行支持。

7.1数据结构

linux下原子操作的数据结构是atomic_t,其定义放在<linux/types.h>下:

00190: typedef struct {
00191: int counter;
00192: } atomic_t;

可以看出原子操作仅支持int整形变量,为了与正常的整形区分,采用atomic_t来定义这个原子操作。

7.2使用方法:整型原子操作和位原子操作

原子整数操作的使用:

    常见的用途是计数器,因为计数器是一个很简单的操作,所以无需复杂的锁机制;

能使用原子操作的地方,尽量不使用复杂的锁机制;

原子整数操作最常见的用途就是实现计数器。使用复杂的锁机制来保护一个单纯的计数器是很笨拙的,所以,开发者最好使用atomic_inc()和atomic_dec()这两个相对来说轻便一点的操作。

还可以用原子整数操作原子地执行一个操作并检查结果。一个常见的例子是原子的减操作和检查。

int atomic_dec_and_test(atomic_t *v)

这个函数让给定的原子变量减1,如果结果为0,就返回1;否则返回0

原子位操作:操作函数的参数是一个指针和一个位号

8.自旋锁

自旋锁用于保护短的代码段,其中包含少量的C语句,因此会很快执行完毕。大多数内核数据结构都有自己的自旋锁,在处理结构中的关键成员时,必须获得相应的自旋锁。

8.1数据结构

自旋锁通过spinlock_t实现,基本上可使用spin_lock和spin_unlock操作。

00064: typedef struct spinlock {
00065: union {
00066:           struct raw_spinlock rlock;
00075: };
00076: } spinlock_t;

自旋锁(spin lock)是一个典型的对临界资源的互斥手段,它的名称来源于它的特性。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设置(test-and-set)某个内存变量,由于它是原子操作,所以在该操作完成之前其它CPU不可能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行。如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置(test-and-set)”操作,即开始“自旋”。最后,锁的所有者通过重置该变量释放这个自旋锁,于是,某个等待的test-and-set操作向其调用者报告锁已释放。

8.2使用方法

理解自旋锁最简单的方法是把它作为一个变量看待,这个变量把一个临界区或者标记为“我当前在另一个CPU上运行,请稍等一会”,或者标记为“我当前不在运行,可以被使用”。如果1号CPU首先进入该例程,它就获取该自旋锁;当2号CPU试图进入同一个例程时,该自旋锁告诉它自己已为1号CPU所持有,需等到1号CPU释放自己后才能进入。一个简单的自旋锁实现结构如下:

/ /定义一个自旋锁

spinlock_tmylock = SPIN_LOCK_UNLOCKED;

spin_lock (&mylock) ; / /将临界区锁住

. . .

critical section / /临界区

. . .

spin_unlock (&mylock) ; / /解锁

这是自旋锁的一种常用方法,注意它没有开关中断,也没有保存状态字,因为开关中断对SMP系统来说,开销是比较大的

自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置(test-and-set)”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。这说明只有在占用锁的时间极短的情况下,使用自旋锁是合理的,因为此时某个CPU可能正在等待这个自旋锁。当临界区较为短小时,如只是为了保证对数据修改的原子性,常用自旋锁;当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁就不是一个很好的选择,会降低CPU的效率。

自旋锁也存在死锁(deadlock)问题。引发这个问题最常见的情况是要求递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU中的代码第二次获得这个自旋锁,则该CPU将死锁。自旋锁没有与其关联的“使用计数器”或“所有者标识”;锁或者被占用或者空闲。如果你在锁被占用时获取它,你将等待到该锁被释放。如果碰巧你的CPU已经拥有了该锁,那么用于释放锁的代码将得不到运行,因为你使CPU永远处于“测试并设置”某个内存变量的自旋状态。另外,如果进程获得自旋锁之后再阻塞,也有可能导致死锁的发生。由于自旋锁造成的死锁,会使整个系统挂起,影响非常大。

自旋锁一定是由系统内核调用的。不可能在用户程序中由用户请求自旋锁。当一个用户进程拥有自旋锁期间,内核是把代码提升到管态的级别上运行。在内部,内核能获取自旋锁,但任何用户都做不到这一点

自旋锁保护的代码不能进入休眠状态。包括代码调用的函数也不能进入休眠。如kmalloc,如果内存不够,函数会进入休眠,造成死锁。

在单处理器系统上上,自旋锁定义为空操作,因为不存在几个CPU同时进入临界区的情况。但如果开启了内核抢占,也出出现如多核CPU同样的现象。因此,必须保证,内核进入临界区时候,开启自旋锁并停用抢占机制。

9.互斥锁

9.1数据结构

00048: struct mutex {
00049: /*
00049: 1: 未锁定, 0: 锁定, 负值: 锁定, 可能有等待者*/
00050: atomic_t count;
00051: spinlock_t wait_lock;
00052: struct list_head wait_list;
00063: };

互斥锁就用到了原子操作。原子操作在操作系统中的使用。在<linux/mutex.h>中,互斥体mutex就用到了atomic_t。

在这里,上锁标记位的操作必须是原子的,因此采用atomic_t类型的变量。

除此之外,在devices、nfs等很多地方都用到了atomic_t,都是用来保证操作的原子性。下面介绍的自旋锁也同样用到了原子操作。在<linux/Spinlock.h>中:

extern int _atomic_dec_and_lock(atomic_t *atomic,spinlock_t *lock);

该函数的作用是将atomic原子地递减1,如果结果为0,将lock上锁并返回true,否则返回false。

解析:如果互斥量未锁定,则count为1.锁定分为两种情况。如果只有一个进程在使用互斥量,则count为0.如果互斥量被锁定,而且有进程在等待互斥量解锁(在解锁时需要唤醒等待进程),则count为负值。这种特殊处理有助于加快代码的执行速度。因为在通常情况下,不会有进程在互斥量上等待。

9.2.使用方法

实现原理上来讲,Mutex属于sleep-waiting类型的锁。例如在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0 Core1上。假设线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞 (blocking)Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中,此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待Spin lock则不然,它属于busy-waiting类型的锁,如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。

自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。虽然它的效率比互斥锁高,但是它也有些不足之处:

    1、自旋锁一直占用CPU,他在未获得锁的情况下,一直运行--自旋,所以占用着CPU,如果不能在很短的时 间内获得锁,这无疑会使CPU效率降低。

    2、在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()copy_from_user()kmalloc()等。

因此我们要慎重使用自旋锁,自旋锁只有在内核可抢占式或SMP(对称多处理器)的情况下才真正需要,在单CPU且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况下。

两种锁的加锁原理

互斥锁:线程会从sleep(加锁)——>running(解锁),过程中有上下文的切换,cpu的抢占,信号的发送等开销。

自旋锁:线程一直是running(加锁——>解锁),死循环检测锁的标志位,机制不复杂。

两种锁的区别

互斥锁的起始原始开销要高于自旋锁,但是基本是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁,但是随着持锁时间,加锁的开销是线性增长。

两种锁的应用

互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑

1 临界区有IO操作

临界区代码复杂或者循环量大

临界区竞争非常激烈

4 单核处理器

至于自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下,自旋锁一般用于多核的服务器。

10.信号量

信号量又称为信号灯,它是用来协调不同进程间的数据对象的,而最主要的应用是共享内存方式的进程间通信。本质上,信号量是一个计数器,它用来记录对某个资源(如共享内存)的存取状况。一般说来,为了获得共享资源,进程需要执行下列操作:

   (1) 测试控制该资源的信号量。

   (2) 若此信号量的值为正,则允许进行使用该资源。进程将信号量减1。

   (3) 若此信号量为0,则该资源目前不可用,进程进入睡眠状态,直至信号量值大于0,进程被唤醒,转入步骤(1)。

   (4) 当进程不再使用一个信号量控制的资源时,信号量值加1。如果此时有进程正在睡眠等待此信号量,则唤醒此进程。

10.1数据结构

维护信号量状态的是Linux内核操作系统而不是用户进程。我们可以从头文件/usr/src/linux/include/linux/sem.h 中看到内核用来维护信号量状态的各个结构的定义。信号量是一个数据集合,用户可以单独使用这一集合的每个元素。要调用的第一个函数是semget,用以获得一个信号量ID。Linux2.6.26下定义的信号量结构体:

00016: struct semaphore {
00017: spinlock_t lock;
00018: unsigned int count;
00019: struct list_head wait_list;
00020: };

从以上信号量的定义中,可以看到信号量底层使用到了spin lock的锁定机制,这个spinlock主要用来确保对count成员的原子性的操作(count--)和测试(count > 0)

10.2使用方法

11.自旋锁与信号量比较
自旋锁和信号量是解决互斥问题的基本手段,无论是单处理系统还是多处理系统,它们可以不需修改代码地进行移植。那么,这两个手段应该如何选择呢?这就要考虑临界区的性质和系统处理的要求。
从严格意义上说,信号量和自旋锁属于不同层次的互斥手段,前者的实现有赖于后者。
信号量是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争不上,会有上下文切换,进程可以去睡眠,但CPU不会停,会接着运行其他的执行路径。从概念上说,这与单CPU或多CPU没有直接的关系,只是在信号量本身的实现上,为了保证信号量结构存取的原子性,在多CPU中需要自旋锁来互斥。但是值得注意的是上下文切换需要一定时间,并且会使高速缓冲失效,对系统性能影响是很大的。因此,只有当进程占用资源很长时间时,用信号量才是不错的选择。
当所要保护的临界区比较短时,用自旋锁是非常方便的,因为它节省上下文切换的时间。但是CPU得不到自旋锁会在那里空转直到锁成功为止,所以要求锁不能在临界区里停留很长时间,否则会降低系统的效率。

综上,自旋锁是一种保护数据结构或代码片段的原始方式,主要用于SMP中,用于CPU同步,在某个时刻只允许一个进程访问临界区内的代码。它的实现是基于CPU锁定数据总线的指令。为保证系统效率,自旋锁锁定的临界区一般比较短。在单CPU系统中,使用自旋锁的意义不大,还容易因为递归调用自旋锁造成死锁。

自旋锁和信号量的使用要点

(1)自旋锁不能递归

(2)自旋锁可以用在中断上下文(信号量不可以,因为可能睡眠),但是在中断上下文中获取自旋锁之前要先禁用本地中断

(3)自旋锁的核心要求是:拥有自旋锁的代码必须不能睡眠,要一直持有CPU直到释放自旋锁

(4)信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共享资源的访问时间非常短,自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作。

 

12.RCU机制

RCURead-Copy Update)是数据同步的一种方式,在当前的Linux内核中发挥着重要的作用。RCU主要针对的数据对象是链表,目的是提高遍历读取数据的效率,为了达到目的使用RCU机制读取数据的时候不对链表进行耗时的加锁操作。这样在同一时间可以有多个线程同时读取该链表,并且允许一个线程对链表进行修改(修改的时候,需要加锁)。RCU适用于需要频繁的读取数据,而相应修改数据并不多的情景,例如在文件系统中,经常需要查找定位目录,而对目录的修改相对来说并不多,这就是RCU发挥作用的最佳场景。

  1. 对共享资源的访问,大部分时间应该是只读的,写访问应该相对较少;
  2. 在RCU保护的代码范围内,内核不能进入睡眠状态;
  3. 受保护的资源必须通过指针访问。

原理:该机制记录了指向共享数据结构的指针的所有使用者。在该数据结构将要改变时,则首先创建一个副本,在副本中修改,在所有进行读写访问的使用者结束对旧副本的读取之后,指针可以替换为指向新的、修改之后副本的指针,请注意,这种机制允许读写并发进行!

在新的值已经公布之后,旧的数据结构在有所的读访问完成之后,内核可以释放该内存,但它需要知道何时释放内存是安全的。为此RCU提供了两个函数:synchronize_rcu和call_rcu。

RCU保护的不仅仅是一般的指针。内核也提供了标准函数,使得能通过RCU机制保护双链表,这是RCU机制在内核内部最重要的应用。

List_add_rcu将新的链表元素添加到表头为head的链表头部。

List_add_tail_rcu将其添加到链表尾部;

List_replace_rcu将链表old替换为new。

猜你喜欢

转载自blog.csdn.net/wangdapao12138/article/details/82078918