Linux内核之 内核同步

上文咱们介绍过进程调度,Linux内核从2.6版本开始支持内核抢占,因此内核不少代码也须要同步保护。html

 

1、同步介绍

一、临界区与竞争条件

所谓临界区critical regions)就是访问和操做共享数据的代码段。为了不在临界区中并发访问,编程者必须保证这些代码原子地执行——也就是说,代码在执行结束前不可被打断,就如同整个临界区是一个不可分割的指令同样。若是两个执行线程有可能处于同一个临界区中同时执行,那么就是程序包含一个bug,若是这种状况发生了,咱们就称之为竞争条件race conditions,简称竞态),避免并发和防止竞争条件被称为同步(synchronization)linux

 linux中,主要的竞态发生在以下几种状况:算法

(1)对称多处理器(SMP)多个CPU数据库

特色是多个CPU使用共同的系统总线,所以可访问共同的外设和存储器。编程

(2)单CPU内进程与抢占它的进程缓存

(3)中断(硬中断、软中断、Tasklet、中断下半部)与进程之间安全

只要并发的多个执行单元存在对共享资源的访问,竞态就有可能发生。数据结构

若是中断处理程序访问进程正在访问的资源,则竞态也会发生。并发

多个中断之间自己也可能引发并发而致使竞态(中断被更高优先级的中断打断)。函数

二、死锁

死锁的产生须要必定条件:要有一个或多个执行线程和一个或多个资源,每一个线程都在等待其中的一个资源,但全部的资源都已经被占用了,全部线程都在相互等待,但它们永远不会释放已经占有的资源,因而任何线程都没法继续,这便意味着死锁的发生。

最简单的死锁例子是自死锁

  • 得到锁
  • 再次试图得到锁
  • 等待锁从新利用
  • ......

这种状况属于一个线程一把锁,本身等本身,通常是一个函数等另外一个函数,从广义上说就是一种嵌套使用。我曾经的经验总结《踩坑经验总结(四):死锁》就属于这种状况。

最多见的死锁例子是ABBA锁

  • 线程1 
  • 得到锁A
  • 试图得到锁B
  • 等待锁B
  • ......
  • 线程2 
  • 得到锁B
  • 试图得到锁A
  • 等待锁A
  • ......

这种问题确实很常见,在数据库《MySQL InnoDB技术内幕:内存管理、事务和锁》出现的每每也是这种类型的死锁。

三、加锁规则

预防死锁很是重要,那该注意些什么呢?

(1)按顺序加锁。使用嵌套锁是必须保证以正确的顺序获取锁,这样能够阻止致命的拥抱类死锁,即ABBA锁。最好能记录下锁的顺序,后续都按此顺序使用。

(2)防止发生饥饿。特别是在一些大循环中,尽可能将锁移入内部,不然外面等过久。若是发生死循环,就会出现饥饿。

(3)不要重复请求同一把锁。这是针对自死锁的状况,可是一旦出现这种状况,每每不明显,即不是很明显的嵌套,转了几个弯弯,就叫曲线嵌套吧。

(4)设计应力求简单。越复杂的加锁方案越有可能形成死锁。

这里的每一项都很重要,对于应用程序一样适合。再重点说下设计。

在最开始设计代码的时候,就应该考虑加锁;越日后考虑,付出代价越大,效果反而越不理想。那么设计阶段加锁时必定要考虑,为何要加锁,为了保护什么数据?我认为这是一个定位的问题。需求阶段对一个产品的定位,设计阶段对数据的定位,决定了后续一系列的动做好比采用的方案、采用的算法、采用的结构体......开始经验之谈了:)。

那么到底该如何加锁,记住:要给数据而不是给代码加锁。我认为这是一个黄金规则,在《死锁》也这么强调过。

四、争用与扩展性

锁的争用(lock contention),简称争用,是指当锁正在被占用时,有其余线程试图得到该锁。

  • 说一个锁处于高度争用状态,就是指有多个其余线程在等待得到该锁。

  • 因为锁的做用是使程序以串行方式对资源进行访问,因此使用锁无疑会下降系统的性能。被高度争用(频繁被持有,或者长时间持有——二者都有就更糟糕)的锁会成为系统的瓶颈,严重下降系统性能

扩展性(scalability)是对系统可扩展程度的一个量度。

  • 对于操做系统,咱们在谈及可扩展性时就会和大量进程、大量处理器或是大量内存等联系起来。其实任何能够被计量的计算机组件均可以涉及可扩展性。理想状况下,处理器的数量加倍应该会使系统处理性能翻倍。而实际上, 这是不可能达到的。

  • 自从2.0版内核引入多处理支持后,Linux对集群处理器的可扩展性大大提升了。在Linux刚加入对多处理器支持的时候,一个时刻只能有一个任务在内核中执行;在2.2版本中,当加锁机制发展到细粒度加锁后,便取消了这种限制,而在2.4和后续版本中,内核加锁的粒度变得愈来愈精细。现在,在Linux 2.6版内核中,内核加的锁是很是细的粒度,可扩展性也很好.

  • 加锁粒度用来描述加锁保护的数据规模。

  • 一个过粗的锁保护大块数据——好比,一个子系统用到的全部的数据结构:相反,一个过于精细的锁保护很小的一块数据——好比,一个大数据结构中的一个元素。在实际使用中,绝大多数锁的加锁范围都处于上述两种极端之间,保护的既不是一个完整的子系统也不是一个独立元素,而多是一个单独的数据结构。许多锁的设计在开始阶段都很粗,可是当锁的争用问题变得严重时,设计就向更加精细的加锁方向进化

  • 在前面讨论过的运行队列,就是一个锁从粗到精细化的实例。

  • 在2.4版和更早的内核中,调度程序有一个单独的调度队列(回忆一下,调度队列是一个由可调度进程组成的链表),在2.6版内核系列的早期版本中,O(1)调度程序为每一个处理器单独配备一个运行队列,每一个队列拥有本身的锁,因而加锁由一个全局锁精化到了每一个处理器拥有各自的锁。这是一种重要的优化,由于运行队列锁在大型机器上被争着用,本质上就是要在调度程序中每次都把整个调度进程下放到单个处理器上执行。在2.6版内核系列的版本中,CFS调度器进一步提高了锁的可扩展性

  • 通常来讲,提升可扩展性是件好事,由于它能够提升Linux在更大型的、处理能力更强大的系统上的性能。

  • 可是一味地“提升”可扩展性,却会导Linux在小型SMP和UP机器上的性能下降,这是由于小型机器可能用不到特别精细的锁,锁得过细只会增长复杂度,并加大开销

  • 考虑一个链表,最初的加锁方案可能就是用一个锁来保护链表,后来发现,在拥有集群处理器机器上,当各个处理器须要频繁访问该链表的时候,只用单独一个锁却成了扩展性的瓶颈。为解决这个瓶颈,咱们将原来加锁的整个链表变成为链表中的每个结点都加入本身的锁,这样一来, 若是要对结点进行读写,必须先获得这个结点对应的锁。将加锁粒度变细后,多处理器访问同一 个结点时,只会争用一个锁。但是这时锁的争用仍然没有彻底避免,那么,能不能为每一个结点中的每一个元素都提供一个锁呢?(答案是:不能)严格地讲,即便这么细的锁能够在大规模SMP机器上执行得很好,但它在双处理器机器上的表现又会怎样呢?若是在双处理器机器锁争用表现 得并不明显,那么多余的锁会加大系统开销,形成很大的浪费。

  • 无论怎么说,可扩展性都是很重要的,须要慎重考虑。关键在于,在设计锁的开始阶段就应该考虑到要保证良好的扩展性。由于即便在小型机器上,若是对重要资源锁得太粗,也很容易形成系统性能瓶颈。锁加得过粗或过细,差异每每只在一线之间。当锁争用严重时,加锁太粗会下降可扩展性;而锁争用不明显时,加锁过细会加大系统开销,带来浪费,这两种状况都会形成系统性能降低。但要记住:设计初期加锁方案应该力求简单,仅当须要时再进一步细化加锁方案。 精髓在于力求简单

上面这大段话来自书上,分析的很好,介绍了锁的粒度过粗和过细的危害,同时也介绍了内核加锁的一个变化和演进。总之,对于咱们设计软件都有参考意义。也是理解内核后面为何出现了多种同步方法的缘由。

2、同步方法

 一、原子操做

原子操做是其余同步方法的基石。原子操做能够保证指令以原子的方式执行——执行过程不可中断。在数据库事务中这也是基本的要求。

linux内核提供了两组原子操做接口:一组对整数进行操做,一组针对单独的位进行操做。

原子整数操做

针对整数的原子操做只能对atomic_t类型的数据进行处理,在这里之因此引入了一个特殊的数据类型,而没有直接使用C语言的int型,主要是出于两个缘由:

第1、让原子函数只接受atomic_t类型的操做数,能够确保原子操做只与这种特殊类型数据一块儿使用,同时,这也确保了该类型的数据不会被传递给其它任何非原子函数;

第2、使用atomic_t类型确保编译器对相应的值进行访问优化——这点使得原子操做最终接收到正确的内存地址,而不是一个别名,最后就是在不一样体系结构上实现原子操做的时候,使用atomic_t能够屏蔽其间的差别。

atomic_t类型定义在文件<linux/type.h>中:

typedef struct {
    volatile int counter;  
}atomic_t;

原子整数操做最多见的用途就是实现计数器

另外一点须要说明原子操做只能保证操做是原子的,要么完成,要么不完成,不会有操做一半的可能,但原子操做并不能保证操做的顺序性,即它不能保证两个操做是按某个顺序完成的。若是要保证原子操做的顺序性,请使用内存屏障指令。

原子操做与更复杂的同步方法相比较,给系统带来的开销小,对高速缓存行的影响也小。

原子位操做

针对位这一级数据进行操做的函数,是对普通的内存地址进行操做的。它的参数是一个指针和一个位号。

二、自旋锁

Linux内核中最多见的锁是自旋锁(spin lock)。自旋锁最多只能被一个可执行线程持有。若是一个执行线程试图得到一个被争用(已经被持有)的自旋锁,那么该线程就会一直进行忙循环—旋转—等待锁从新可用。要是锁未被争用,请求锁的执行线程便能马上获得它,继续执行。在任意时间,自旋锁均可以防止多于一个的执行线程同时进入临界区。同一个锁能够用在多个位置—例如,对于给定数据的全部访问均可以获得保护和同步。

一个被争用的自旋锁使得请求它的线程在等待锁从新可用时自旋(特别浪费处理器时间),即忙等待,这是自旋锁的要点。因此自旋锁不该该被长时间持有。事实上,这点正是使用自旋锁的初衷,在短时间间内进行轻量级加锁

自旋锁的实现和体系密切相关,代码每每经过汇编实现。实际用到的接口定义在文件中。自旋锁的基本使用形式以下:

DEFINE  SPINLOCK(mr_lock);

spin_lock(&mr_lock);
/*临界区....*/
spin_unlock(&mr_lock);

自旋锁能够使用在中断处理程序中(此处不能使用信号量,由于它们会致使睡眠),在中断处理程序中使用自旋锁时,必定要在获取锁以前,首先禁止本地中断(在当前处理器上的中断请求)。注意,须要关闭的只是当前处理器上的中断,若是中断发生在不一样的处理器上,即便中断处理程序在同一锁上自旋,也不会妨碍锁的持有者(在不一样处理器上)最终释放锁。

三、读写自旋锁

有时,锁的用途能够明确的分为读取和写入两个场景。那么读写能够分开处理,读时能够共享数据,写时进行互斥。为此,Linux内核提供了专门的读写自旋锁。

这种读写自旋锁为读和写分别提供了不一样的锁,因此它具备如下特色:

  1. 读锁之间是共享的,即一个线程持有了读锁以后,其余线程也能够以读的方式持有这个锁。
  2. 写锁之间是互斥的,即一个线程持有了写锁以后,其余线程不能以读或者写的方式持有这个锁。
  3. 读写锁之间是互斥的,即一个线程持有了读锁以后,其余线程不能以写的方式持有这个锁,写锁必须等待读锁的释放。

读写自旋锁的使用用法相似于普通的自旋锁:

DEFINE_RWLOCK(mr_rwlock);

read_lock(&mr_rwlock);
/*critical region, only for read*/
read_unlock(&mr_rwlock);

write_lock(&mr_lock);
/*critical region, only for write*/
write_unlock(&mr_lock);

注意:若是写和读不能清晰地进行分离,那么使用通常的自旋锁就够了,不须要使用读写自旋锁。

四、信号量

信号量也是一种锁,和自旋锁不一样的是,线程获取不到信号量的时候,不会像自旋锁同样循环去试图获取锁,而是进入睡眠,直至有信号量释放出来时,才会唤醒睡眠的线程,进入临界区执行。

因为使用信号量时,线程会睡眠,因此等待的过程不会占用 CPU 时间。因此信号量适用于等待时间较长的临界区

信号量消耗CPU时间的地方在于使线程睡眠和唤醒线程--两次明显的上下文切换。

若是(使线程睡眠 + 唤醒线程)的 CPU 时间 > 线程自旋等待 CPU 时间,那么能够考虑使用自旋锁。 

 

信号量有二值信号量和计数信号量两种,其中二值信号量比较经常使用

二值信号量表示信号量只有2个值,即0和1。信号量为1时,表示临界区可用,信号量为0时,表示临界区不可访问。因此也能够称为互斥信号量。

计数信号量有个计数值,好比计数值为5,表示同时能够有5个线程访问临界区。因此二值信号量就是计数等于1的计数信号量。

五、读写信号量

读写信号量和信号量的关系与读写自旋锁和自旋锁的关系差很少。

读写信号量都是二值信号量,即计数值最大为1,增长读者时,计数器不变,增长写者,计数器才减一。

也就是说读写信号量保护的临界区,最多只有一个写者,但能够有多个读者

六、互斥 

互斥体(mutex)也是一种能够睡眠的锁,至关于二值信号量,只是提供的API更加简单,使用的场景也更严格一些,以下所示:

  1. mutex的计数值只能为1,也就是最多只容许一个线程访问临界区

  2. 同一个上下文中上锁和解锁

  3. 不能递归的上锁和解锁

  4. 持有个mutex时,进程不能退出

  5. mutex不能在中断或者下半部中使用,也就是mutex只能在进程上下文中使用

  6. mutex只能经过官方API来管理,不能本身写代码操做它

在面对互斥体信号量的选择时,只要知足互斥体的使用场景就尽可能优先使用互斥体

在面对互斥体自旋锁的选择时,参见下表:

需求

建议的加锁方法

低开销加锁 优先使用自旋锁
短时间锁定 优先使用自旋锁
长期加锁 优先使用互斥体
中断上下文中加锁 使用自旋锁
持有锁须要睡眠 使用互斥体

七、完成变量

完成变量的机制相似于信号量,好比一个线程A进入临界区以后,另外一个线程B会在完成变量上等待,线程A完成了任务出了临界区以后,使用完成变量来唤醒线程B。

通常在2个任务须要简单同步的状况下,能够考虑使用完成变量。

八、大内核锁

大内核锁已经再也不使用,只存在与一些遗留的代码中。

九、 顺序锁

顺序锁为读写共享数据提供了一种简单的实现机制。以前提到的读写自旋锁和读写信号量,在读锁被获取以后,写锁是不能再被获取的,也就是说,必须等全部的读锁释放后,才能对临界区进行写入操做。

 顺序锁则与之不一样,读锁被获取的状况下,写锁仍然能够被获取。使用顺序锁的读操做在读以前和读以后都会检查顺序锁的序列值,若是先后值不符,则说明在读的过程当中有写的操做发生,那么读操做会从新执行一次,直至读先后的序列值是同样的。

顺序锁优先保证写锁的可用,因此适用于那些读者不少,写者不多,且写优于读的场景。

十、禁止抢占

其实使用自旋锁已经能够防止内核抢占了,可是有时候仅仅须要禁止内核抢占,不须要像自旋锁那样连中断都屏蔽掉。

这时候就须要使用禁止内核抢占的方法了:

方法

描述

preempt_disable() 增长抢占计数值,从而禁止内核抢占
preempt_enable() 减小抢占计算,并当该值降为0时检查和执行被挂起的需调度的任务
preempt_enable_no_resched() 激活内核抢占但再也不检查任何被挂起的需调度的任务
preempt_count() 返回抢占计数

这里的preempt_disable()和preempt_enable()是能够嵌套调用的,disable和enable的次数最终应该是同样的。

十一、顺序和屏障

对于一段代码,编译器或者处理器在编译和执行时可能会对执行顺序进行一些优化,从而使得代码的执行顺序和咱们写的代码有些区别。

通常状况下,这没有什么问题,可是在并发条件下,可能会出现取得的值与预期不一致的状况。

在某些并发状况下,为了保证代码的执行顺序,引入了一系列屏障方法来阻止编译器和处理器的优化。

方法 描述
rmb 阻止跨越屏障的载入动做发生重排序
read_barrier_depends() 阻止跨越屏障的具备数据依赖关系的载入动做重排序
wmb() 阻止跨越屏障的存储动做发生重排序
mb() 阻止跨越屏障的载入和存储动做从新排序
smp_rmb() 在SMP上提供rmb()功能,在UP上提供barrier()功能
smp_read_barrier_depends() 在SMP上提供read_barrier_depends()功能,在UP上提供barrier()功能
smp_wmb() 在SMP上提供wmb()功能,在UP上提供barrier()功能
smp_mb 在SMP上提供mb()功能,在UP上提供barrier()功能
barrier

阻止编译器跨越屏障对载入或存储操做进行优化

举例以下:

void thread_worker()
{
    a = 3;
    mb();
    b = 4;
}

上述用法就会保证 a 的赋值永远在 b 赋值以前,而不会被编译器优化弄反。在某些状况下,弄反了可能带来难以估量的后果。 

十二、总结

本节讨论了大约11种内核同步方法,除了大内核锁已经再也不推荐使用以外,其余各类锁都有其适用的场景。

了解了各类同步方法的适用场景,才能正确的使用它们,使咱们的代码在安全的保障下达到最优的性能。 

同步的目的就是为了保障数据的安全,其实就是保障各个线程之间共享资源的安全,下面根据共享资源的状况来讨论一下10种同步方法的选择。

10种同步方法在图中分别用蓝色框标出。

最后,在此图基础上再作个总结。

上述的10多种锁中,内核中最多见的仍是自旋锁,信号量和互斥锁这三种。其中在第二部分第6节中对这三种如何作出选择已经列出了一个表格,这是全文的重点!

学习内核锁的实现,有助于咱们在程序设计中如何使用锁,使用什么类型的锁以及如何设计锁。

 

 

参考资料:

《Linux内核设计与实现》原书第三版

https://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html

相关文章
相关标签/搜索