每一种技术的出现必然是因为某种需求。正因为人的本性是贪婪的,所以科技的创新才能日新月异。
前面,我们学习了这么多内核同步技术。那我们该怎么选择呢?选择不同的内核同步技术,可能对系统的性能影响很大。根据经验,基本可以遵守这么一条准则:尽可能高地保证系统的并发性。
而系统的并发水平又依赖于两个关键的因素:
为了最大化I/O的吞吐量,中断禁止的时间应该尽可能短。我们知道,如果中断被禁止,I/O设备发出的IRQ信号会被PIC中断控制器临时性地忽略,也就不会相应I/O设备的请求。
为了使CPU的效率最大化,基于自旋锁的内核同步原语尽可能不用。因为,当CPU忙等待自旋锁被释放的时候,其实浪费了珍贵的机器执行周期。甚至更糟糕的是,自旋锁还会影响硬件Cache,强迫Cache失效,从而从内存中重新读取数据,刷新Cache,这大大降低了系统的整体性能。这就是为什么多核系统不能达到1+1=2的效果的原因。
让我们举几个例子来说明如何在保持高并发水平的同时还能实现同步:
另外,在内核的实现代码中,我们经常需要对列表进行插入操作,通常使用指针赋值的方式实现,如下所示:
new->next = list_element->next;
list_element->next = new;
将上面的代码转换成汇编语言之后,就成为2条连续的原子指令操作。第1条指令建立新元素的next指针,但是不会修改列表。第2条指令将其存入对应的内存位置。假设,在这2条指令执行之间来一个中断信号,则中断处理程序看到的列表没有新元素;如果中断信号在第2条指令执行之后到来,则中断处理程序看到是的已经插入新元素的列表。任何一种情况,列表的数据都是正确的,没有被破坏的。但是,必须保证中断处理程序不会修改这个列表。如果其修改了列表,next指针很可能就会变成非法值。
更重要的是,这两条指令是由时序关系的。只有先创建了next指针,才能给其赋值;否则,操作不合法。所以,对于上面的代码,内核开发者应该保证它们的执行顺序,不会被编译器或者CPU控制单元破坏。否则,在两条赋值语句之间插入进来执行的中断服务程序,会发现一个被破坏了的列表。这时候,往往需要一个写内存屏障原语,如下所示:
new->next = list_element->next;
wmb();
list_element->next = new;
到这儿,很多人可能会纳闷:为什么我在编写内核代码或者驱动程序的时候,怎么几乎不使用wmb()之类的内存屏障呢?那是因为,Linux内核提供的操作函数API已经封装了内存屏障原语。所以,大部分时候我们不需要关心它。
通过上面的分析,我们可以得出的结论就是:尽可能提高系统的并发性,也就是压榨CPU能够有效工作的时间。为此,在保护要访问的数据的同时,尽可能不要选择自旋锁、信号量和关闭中断之类的加锁机制。因为它们往往让CPU处于无效工作时间中,降低系统的性能。
但是,许多时候我们别无选择,只能使用这些降低系统性能的加锁机制。当我们不得不面对的时候,我们又该如何抉择呢?
不幸的是,访问内核数据结构的形式远远比上面的示例复杂多了,迫使内核开发者不得不启动信号量、自旋锁和中断禁止这些锁原语。通常来讲,具体选择哪种加锁机制,取决于访问数据的是哪种内核控制路径,如下表所示。但需要注意的一点是,无论何时,内核控制路径请求一个自旋锁(包括读写锁,seqlock和RCU)时,都会禁止局部中断或者软中断,从而禁止内核抢占。
表5-8 不同内核控制路径访问的数据结构需要的锁
内核控制路径 | 单核系统 | 多核系统 |
---|---|---|
异常处理程序 | 信号量 | 信号量 |
中断处理程序 | 禁止中断 | 自旋锁 |
可延时函数 | 无 | 无/自旋锁 |
异常处理程序+中断处理程序 | 禁止中断 | 自旋锁 |
异常处理程序+可延时函数 | 禁止软中断 | 自旋锁 |
中断处理程序+可延时函数 | 禁止中断 | 自旋锁 |
中断处理程序+可延时函数+异常处理程序 | 禁止中断 | 自旋锁 |
在了解这些不同的内核控制路径访问的数据结构应该如何保护之前,我们先来复习几个概念:
只有异常处理程序访问的数据结构,可能产生的竞态条件简单易懂,也很容易保护。最常见的异常处理程序就是系统调用,因为它可能被多个进程并发调用,从而为用户态的程序提供内核服务。所以说,异常处理程序访问的数据结构就是可以分配给一个或多个进程的一种资源。
避免这种资源可能产生的竞态条件,可以选择信号量,因为大部分情况下,想要访问这个资源的进程如果没有得到资源的使用权的话会选择休眠等待。而恰好,信号量就是这样的一种加锁机制。如果请求信号量失败,进程挂起,让出CPU的使用权给其它进程。这种情况下,自旋锁是不合适的,因为它是忙等待,一直占用CPU。值得一提的是,不论是单核系统还是多核系统,信号量都能工作的很好。
即使是开启内核抢占,也不会产生问题。如果持有信号量的进程被抢占,新进程会尝试申请信号量。但是,这时候申请信号量肯定失败,从而新进程进入休眠,等待旧进程释放信号量。
我们这儿要讨论的数据结构只是被中断程序的顶半部访问,不涉及底半部访问的数据结构,这类数据结构属于可延时函数访问的数据结构的范畴,后面再讨论。我们在学习中断的时候,已经知道,中断处理程序中的处理是串行化的,也就是说不会发生并发访问。所以,也就不需要同步。
但是,当数据结构被多个中断程序访问的时候,就会发生并发访问产生的竞态问题。尤其是在多核系统中,一个数据结构可能被多个不同的中断程序并发访问。这时候就需要同步了。
单核系统,竞态条件很好避免,只要关闭中断即可。其它同步技术也不合适。信号量阻塞进程,而中断万万不能被阻塞。另一方面,自旋锁会冻结系统:如果中断中正在访问的数据结构被中断,它不会释放锁;而新的中断程序一直在忙等待这个锁。其实就是发生了死锁。
多核系统处理更为复杂一些。因为中断都是局部中断,也就是每个CPU独享的。所以,只是简单的关闭中断无法有效避免竞态条件。因为,即使中断被禁止,其它CPU上的中断处理程序还会继续执行。所以,这时候需要关闭中断的同时,再申请一个自旋锁或者读写自旋锁保护数据结构。值得注意的是,这类自旋锁不会冻结系统。首先,因为关闭局部中断,所以同一CPU上的中断程序不会执行,也就不会发生上面所说的死锁。其次,因为是多核系统,中断程序发现锁被占用了,也不会阻止其它CPU上的中断程序释放这个锁。所以,无论哪种情况都不会发生死锁的情况。
为了方便处理多核系统中这种局部中断禁止和自旋锁结合在一起使用的情况,Linux提供了一些宏,如下表所示。单核系统中,这些宏只能禁止中断或者禁止内核抢占。
表5-9 与中断有关的自旋锁宏
宏 | 描述 |
---|---|
spin_lock_irq(l) | local_irq_disable();spin_lock(l) |
unlock_irq(l) | spin_unlock(l);local_irq_enable() |
spin_lock_bh(l) | local_bh_disable();spin_lock(l) |
spin_unlock_bh(l) | spin_unlock(l);local_bh_enable() |
spin_lock_irqsave(l,f) | local_irq_save(f);spin_lock(l) |
spin_unlock_irqrestore(l,f) | spin_unlock(l);local_irq_restore(f) |
read_lock_irq(l) | local_irq_disable( );read_lock(l) |
read_unlock_irq(l) | read_unlock(l);local_irq_enable( ) |
read_lock_bh(l) | local_bh_disable( );read_lock(l) |
read_unlock_bh(l) | read_unlock(l);local_bh_enable( ) |
write_lock_irq(l) | local_irq_disable();write_lock(l) |
write_unlock_irq(l) | write_unlock(l);local_irq_enable( ) |
write_lock_bh(l) | local_bh_disable();write_lock(l) |
write_unlock_bh(l) | write_unlock(l);local_bh_enable( ) |
read_lock_irqsave(l,f) | local_irq_save(f);read_lock(l) |
read_unlock_irqrestore(l,f) | read_unlock(l);local_irq_restore(f) |
write_lock_irqsave(l,f) | local_irq_save(f);write_lock(l) |
write_unlock_irqrestore(l,f) | write_unlock(l);local_irq_restore(f) |
read_seqbegin_irqsave(l,f) | local_irq_save(f);read_seqbegin(l) |
read_seqretry_irqrestore(l,v,f) | read_seqretry(l,v);local_irq_restore(f) |
write_seqlock_irqsave(l,f) | local_irq_save(f);write_seqlock(l) |
write_sequnlock_irqrestore(l,f) | write_sequnlock(l);local_irq_restore(f) |
write_seqlock_irq(l) | local_irq_disable();write_seqlock(l) |
write_sequnlock_irq(l) | write_sequnlock(l);local_irq_enable() |
write_seqlock_bh(l) | local_bh_disable();write_seqlock(l) |
write_sequnlock_bh(l) | write_sequnlock(l);local_bh_enable() |
通过前面软中断、tasklet等概念的梳理,想必你对它们要访问的数据需要的保护方式有了一些初步的理解:采用哪种同步技术保护数据结构,完全取决于是属于哪类可延时函数。接下来,我们详细一一分析。
单核系统,通过上面的分析,不论是哪种机制访问数据结构,都不会产生竞态条件。因为它不会被其它可延时函数中断。也就无需使用同步了。
相反,多核系统就可能发生并发访问所带来的竞态问题。如下表所示,根据可延时函数的类型进行了列举:
延时函数类型 | 保护机制 |
---|---|
软中断 | 自旋锁 |
一个tasklet | 无需锁 |
多个tasklet | 自旋锁 |
如前所述,软中断总是需要自旋锁进行保护,因为即使是同一个软中断也有可能被多个CPU并发访问。相反,一个tasklet不需要锁的保护,因为同一个tasklet不会发生并发访问。但是,如果数据被多个tasklet访问,就需要加锁保护了。
如果数据结构既被异常处理程序(如系统调用)访问,又被中断处理程序访问,那该怎么保护数据呢?
对于这种情况,单核系统的处理非常简单,关闭中断即可。因为中断程序不可重入,也不能被异常处理程序中断。所以只要关闭中断,内核访问数据就不会被中断。
多核系统,我们就不得不考虑多个CPU的并发访问了。所以与中断访问数据一样,采用关闭中断与自旋锁相结合的方式。
但是,有时候使用信号量代替上面的自旋锁可能更好。尤其是异常处理程序等不到锁需要挂起的时候。举例来说,系统调用和中断同时访问某个数据:中断处理程序尝试申请信号量(调用down_trylock()),失败就不断尝试,还是相当于自旋锁的忙等待;另一方面,系统调用如果申请信号量失败,就挂起,让CPU执行其它操作,这完全符合系统调用时的预期行为。这种情况,信号量优于自旋锁,因为它让系统有一个更高的并发性能。
异常和可延时函数同时访问数据时,处理方式与异常和中断同时访问数据时类似。因为可延时函数本质上都是中断激活的,也是运行在中断上下文中的,在运行期间不会被异常中断。也就是说,使用关闭中断和自旋锁相结合的方式就足够了。
实际上,不用关闭硬中断即可,也就是调用local_bh_disable宏,只关闭可延时函数的执行。因为中断处理程序并没有访问数据,所以,只禁止可延时函数比禁止中断更有效率,因为中断可以继续被CPU响应。而在单个CPU上执行可延时函数是串行执行的,没有竞态条件产生。(这儿,禁止可延时函数指的是禁止再激活软中断,tasklet之类的,但是之前已经激活的还是要执行的。)
正如多数情况一样,多核系统中,自旋锁保证任何时候只有一个内核控制路径访问数据。
这种情况与中断和异常同时访问数据相似。单核系统,禁止中断即可。多核系统需要再加上自旋锁。
与上一种情况一样,故不再累述。
本文分享自 嵌入式ARM和Linux 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!