Linux(内核剖析):28---内核同步之(临界区竞争条件同步锁常见的内核并发SMNP和UP配置选项锁的争用和扩展性(锁粒度))

【Linux(内核剖析):28---内核同步之(临界区竞争条件同步锁常见的内核并发SMNP和UP配置选项锁的争用和扩展性(锁粒度))】一身转战三千里,一剑曾当百万师。这篇文章主要讲述Linux(内核剖析):28---内核同步之(临界区竞争条件同步锁常见的内核并发SMNP和UP配置选项锁的争用和扩展性(锁粒度))相关的知识,希望能为你提供帮助。
一、Linux内核同步历史

  • 多年之前,在Linux还未支持对称多处理器的时候,避免并发访问数据的方法相对来说比较简单。在单一处理器的时候,只有在中断发生的时候 ,或在内核代码明确地请求重新调度、执行另一个任务的时候,数据才可能被并发访问。 因此早期内核开发工作相比如今要简单许多
  • 但当年的太平日子一去不复返了,从2.0开始,内核就开始支持对称多处理器了,而且从那以后对它的支持不断地加强和完善。支持多处理器意味着内核代码可以同时运行在两个或更多的处理器上。因此,如果不加保护,运行在两个不同处理器上的内核代码完全可能在同一时刻里 并发访问共享数据。随着2.6内核的出现,Linux内核已发展成抢占式内核,这意味着(当然, 还是指不加保护的情况下)调度程序可以在任何时刻抢占正在运行的内核代码,重新调度其他的进程执行。现在,内核代码中有不少部分都能够同步执行,而它们都必须妥善地保护起来
二、临界区、竞争条件、同步概念
  • 所谓临界区(也称为临界段)就是访问和操作共享数据的代码段。多个执行线程并发访问同一个资源通常是不安全的,为了避免在临界区中并发访问,编程者必须保证这些代码原子地执行——也就是说,操作在执行结束前不可被打断,就如同整个临界区是一个不可分割的指令一样
  • 如果两个执行线程有可能处于同一个临界区中同时执行,那么这就是程序包含的一个bug。如果这种情况确实发生了,我们就称它是竞争条件(race conditions),这样命名是因为这里会存在线程竞争。这种情况出现的机会往往非常小——就是因为竞争引起的错误非常不易重现,所以调试这种错误才会非常困难
  • 避免并发和防止竞争条件称为同步 (synchronization)
三、锁
  • 锁有多种多样的形式,而且加锁的粒度范围也各不相同——Linux自身实现了几种不同的锁机制。各种锁机制之间的区別主要在于:当锁已经被其他线程持有,因而不可用时的行为表现——一些锁被争用时会简单地执行忙等待,而另外一些锁会使当前任务睡眠直到锁可用为止
四、常见的内核并发
  • 内核中有类似可能造成并发执行的原因。它们是:
    • 中断——中断几乎可以在任何时刻异步发生,也就可能随时大端当前正在执行的代码
    • 软中断和tasklet——内核能在任何时刻唤醒或调度软中断和tasklet,打断断当前正在执行的代码
    • 内核抢占——因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占
    • 睡眠及与用户空间的同步——在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而导致调度一个新的用户进程执行
    • 对称多处理——两个或多个处理器可以同时执行代码
中断安全代码、SMP安全代码、抢占安全代码
  • 在中断处理程序中能避免并发访问的安全代码称作中断安全代码(interrupt-saft),在对称多处理的机器中能避免并发访问的安全代码称为SMP安全代码(SMP-safe),在内核抢占时能避免并发访问的安全代码称为抢占安全代码(preempt-safe)
五、SMP和UP配置选项
  • 因为Linux内核可在编译时配置,所以你可以针对指定机器进行内核裁剪。更重要的是,CONFIG_SMP配置选项控制内核是否支持SMP。许多加锁问题在单处理器上是不存在的,因而当CONFIG_SMP没被设置时,不必要的代码就不会被编入针对单处理器的内核映像中。这样做可以使单处理器机器避免使用自旋锁带来的开销。同样的技巧也适用于CONFIG_PREEMPT(允许内核抢占的配置选项)。 这种设计真的很优越——内核只用维护一些简洁的基础资源,各种各样的锁机制当需要时可随时被编译进内核使用。在不同的体系结构上, CONFIG_SMP金额CONFIG_PREEMPT设置不同,实际编译时包含的锁就不同
  • 在代码中,要为大多数糟糕的情况提供适当的保护,例如具有内核抢占的SMP, 并且要考虑到所有的情况
六、锁的争用和扩展性
锁的争用
  • 锁的争用,简称争用,是指当锁正在被占用时,有其他线程试图获得该锁。说一个锁处于高度争用状态,就是指有多个其他线程在等待获得该锁
  • 由于锁的作用是使程序以串行方式对资源进行访问,所以使用锁无疑会降低系统的性能。被高度争用(频繁被持有,或者长时间持有——两者都有就更糟糕)的锁会成为系统的瓶颈,严重降低系统性能。即使是这样,相比于被几个相互抢夺共享资源的线程撕成碎片,搞得内核崩溃,还是这种同步保护来得更好一点。当然,如果有办法能解决高度争用问题,就更好不过了
扩展性
  • 扩展性是对系统可扩展程度的一个量度。对于操作系统,我们在谈及可扩展性时就会和大量进程、大量处理器或是大量内存等联系起来。其实任何可以被计量的计算机组件都可以涉及可扩展性。理想情况下,处理器的数量加倍应该会使系统处理性能翻倍。而实际上, 这是不可能达到的
  • 自从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机器上执行得很好,但它在双处理器机器上的表现又会怎样呢?如果在双处理器机器锁争用表现 得并不明显,那么多余的锁会加大系统开销,造成很大的浪费
  • 不管怎么说,可扩展性都是很重要的,需要慎重考虑。关键在于,在设计锁的开始阶段就应该考虑到要保证良好的扩展性。因为即使在小型机器上,如果对重要资源锁得太粗,也很容易造成系统性能瓶颈。锁加得过粗或过细,差别往往只在一线之间。当锁争用严重时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细会加大系统开销,带来浪费,这两种情况都会造成系统性能下降。但要记住:设计初期加锁方案应该力求简单,仅当需要时再进一步细化加锁方案。 精髓在于力求简单

    推荐阅读