现在的位置: 首页 > 综合 > 正文

【嵌入式Linux学习七步曲之第五篇 Linux内核及驱动编程】全面解析Linux内核的同步与互斥机制–互斥篇

2013年10月04日 ⁄ 综合 ⁄ 共 6227字 ⁄ 字号 评论关闭



全面解析Linux内核的同步与互斥机制--互斥篇

Sailor_forever sailing_9806@163.com 转载请注明

http://blog.csdn.net/sailor_8318/archive/2008/07/01/2600190.aspx

 

【摘要】本文分析了内核的同步及互斥的几种机制:原子运算符(atomic operator)、自旋锁Spinlock、等待队列Waitqueue、事件Eventcompletion、信号量Semaphore及其优化版互斥锁,详细分析了其实现流程。EventSemaphore本质上都是基于Waitqueue和自旋锁实现的。本文还探讨了每种机制最适合应用到哪些地方,以及如何构建安全高效的内核及驱动代码。

【关键词】原子操作;SpinlockWaitqueuecompletionEventSemaphore

 



 

1      内核中的并发与竞态

1.1   并发与竞态概述

当在同一时间段出现两个或更多进程并且这些进程彼此交互时,就存在并发现象,此时必须采取互斥方法。那么什么是内核并发的场景呢?如何防止内核并发?有那些同步方法?以及这些方法的行为有何特点和如何使用它们?

 

对并发的管理是操作系统编程中核心的问题之一 并发产生竞态,竞态导致共享数据的非法访问。因为竞态是一种极端低可能性的事件,因此程序员往往会忽视竞态。但是在计算机世界中,百万分之一的事件可能每几秒就会发生,而其结果是灾难性的。

 

竞态通常是作为对资源的共享访问结果而产生的。在设计自己的驱动程序时,第一个要记住的规则是:只要可能,就应该避免资源的共享。若没有并发访问,就不会有竞态。这种思想的最明显的应用是避免使用全局变量。但是,资源的共享是不可避免的,如硬件资源本质上就是共享、指针传递等等。资源共享的硬性规则:

²      在单个执行线程之外共享硬件或软件资源的任何时候,因为另外一个线程可能产生对该资源的不一致观察,因此必须显示地管理对该资源的访问。访问管理的常见技术成为锁定或者互斥:确保一次只有一个执行线程可操作共享资源。

²      当内核代码创建了一个可能和其他内核部分共享的对象时,该对象必须在还有其他组件引用自己时保持存在(并正确工作)。对象尚不能正确工作时,不能将其对内核可用。

 

1.2   内核并发原因

1)       单处理机器

Ø       2.6版本内核之前

对于单处理机器来说情况相对简单一些。在2.6版本内核之前,Linux内核是非抢占式的——在内核任务没有执行完之前不能被打断,这样的话,内核中程序并发执行的情况很少,准确地讲只有两种可能:

中断发生,因为中断执行是异步的,而且中断是在非抢占式内核中打断当前运行内核代码的唯一方法,所以中断显然是可以和其它内核代码并发执行的。因此如果中断操作和被中断的那内核代码都访问同样的内核数据,那么就会发生竞争。

睡眠和再调度, 处于进程上下文的内核任务可以睡眠(睡眠意味放弃处理器),这时调度程序会调度其它程序去执行(首先执行调度任务队列中的内核任务,然后执行软中断等,最后从运行队列中选择一个高优先级的用户进程运行)。显然这里也会造成内核并发访问,当睡眠的内核任务和新投入运行的内核任务访问同一共享数据时,就发生了竞争。

 

Ø       2.6版本及以后的内核

2.6版本的内核变成了抢占式内核——内核可能在任何时刻抢占正在运行的内核代码。所以内核中发生并发执行的情况大大增加了。内核抢占成为了内核程序并发的又一种可能,所以在开发抢占式内核代码时需要时刻警惕抢占产生的竞争。

 

2)       多处理器SMP

单处理器上的并发是逻辑上的伪并发,事实上所谓并发的内核程序其实是交错地占用处理器。真正的并发执行程序,必须依靠对称多处理器。但无论是逻辑上的并发还是真正的并发,都会产生竞争,而且它们的处理也是相同的。但是对于对称多处理器来说,由于两个或多个处理器可以在同一时刻执行代码,所以会不可避免地给内核带来并发可能,如果分别在不同处理器上执行的内核代码同时访问同一共享数据,竞争就产生了。因此,不用说对称多处理是内核并发的又一种可能。

 

可以看到随着Linux内核不断演化,在内核对系统支持更加全面,对任务调度更加高效的同时,也给内核带来了更多的并发可能,更容易引起竞争。上面提到的各种并发情况在内核中都必须得到有效的处理,才能确保内核有高稳定性。

 

无论是中断产生的并发或是睡眠引起的并发,还是内核抢占引起的并发,要想在内核开发中很好地避免,就必须从本质上了解它们的并发原因。只有在掌握内核任务的调度机制后,才可以真正的达到对并发可能的预测,进而能够采取合适的同步方法——锁——来避免并发。

 

1.3   内核中的任务调度类型

我们这里所说的任务调度不同于常说的进程调度。进程调度是:内核中的调度程序在进程运行队列中选择合适的(优先级高的)进程执行。而我们所说的内核任务调度指的是,内核中的任务获得执行机会。对于内核并发来说,内核任务之间的关系尤为重要。首先我们来看看内核有那些任务,各有什么特点。

 

1)       硬中断操作

硬中断是指那些由处理器以外的外设产生的中断,这些中断被处理器接收后交给内核中的中断处理程序处理。要注意的是:第一,硬中断是异步产生的,中断发生后立刻得到处理,也就是说中断操作可以抢占内核中正在运行的代码。这点非常重要。第二,中断操作是发生在中断上下文中的(所谓中断上下文指的是和任何进程无关的上下文环境)。中断上下文中,不可以使用进程相关的资源,也不能够进行调度。

 

2)       软中断操作

软中断是Linux中为了执行一些硬中断操作来不及完成的任务而采取的推后执行机制。因为硬中断操作期间的中断会被抛弃,所以硬中断是在不安全时间运行的。不安全时间应该尽量短,所以采用软中断来执行大部分任务,它会把硬中断做不完的耗时任务推后到安全时间执行(软中断期间不会丢弃中断信号)。

 

软中断不象硬中断那样时随时都能够被执行,笼统来讲软中断会在内核处理任务处理完毕后返回用户级程序前得到处理机会。具体的讲有三个时刻它将被执行(do_softirq())硬件中断操作完成后(包括时钟tick中断);内核schedule调度程序中;系统调用返回时。需要说明的是软中断的执行也处于中断上下文中,所以中断上下文对它的限制是和硬中断一样的。

 

3)       系统调用

系统调用是用户程序通过门机制来进入内核执行的内核例程,它运行在内核态,处于进程上下文中(进程上下文包括进程的堆栈等等环境),所以系统调用代码可以对进程相关数据进行访问,可以执行调度程序,也可以睡眠。

 

1.4   内核任务之间并发关系

上述内核任务很多情况是可以交错执行的,所以很有可能产生竞争。下面分析这些内核任务之间有那些可能的并发行为。

 

可以抽象出,程序(用户态和内核态一样)并发执行的总原因无非是正在运行中的程序被其它程序抢占,所以我们必须看看内核任务之间的抢占关系:

²      中断处理程序可以抢占内核中的所有程序(当没有锁保护时),包括软中断,taskletbottom half和系统的调用,甚至也包括中断处理程序(Linux内核是否允许中断嵌套??)。也就是说中断处理程序可以和这些所有的内核任务并发执行,如果被抢占的程序和中断处理程序都要访问同一个资源,就产生了竞争。

²      软件中断可以抢占硬中断处理程序以外的内核程序,所以内核代码(比如,系统调用)中有数据和软中断共享,就有会有竞争。此外要注意的是软中断即使是同种类型的也可以并发的运行在不同处理器上,所以它们之间共享数据都会产生竞争。(如果在同一个处理器上软中断是不能相互抢占的,因为软中断此时是顺序执行的,其是原子操作,不可中断)。

²      系统调用这种内核代码可能和各种内核代码并发,除了上面提到的中断(软,硬)抢占它产生并发外,它是有可能自发性地主动睡眠(比如在一些阻塞性的操作中),放弃处理器,重新调度其它任务,所以系统调用中并发情况更普遍,尤其当用户空间需要和内核空间共同操作全局数据时,一定要注意保护。

 

1.5   临界段

临界段是为解决竞态条件问题而产生的。一个临界段是一段不允许多路访问的受保护的代码。这段代码可以操纵共享数据或共享服务(例如硬件外围设备)。临界段操作时坚持互斥锁(mutual exclusion)原则(当一个线程处于临界段中时,其他所有线程都不能进入临界段)。

 

临界段中需要解决的一个问题是死锁条件。考虑两个独立的临界段,各自保护不同的资源。每个资源拥有一个锁,在本例中称为 A B。假设有两个线程需要访问这些资源,线程 X 获取了锁 A,线程 Y 获取了锁 B当这些锁都被持有时,每个线程都试图占有其他线程当前持有的锁(线程 X 想要锁 B,线程 Y 想要锁 A)。这时候线程就被死锁了,因为它们都持有一个锁而且还想要其他锁。一个简单的解决方案就是总是按相同次序获取锁,从而使其中一个线程得以完成。

 

1.6   内核中的锁定机制

Linux使用的锁定机制可以说从2.02.6以来不断发展完善,从最初的原子操作,到后来的信号量,从大内核锁到今天的自旋锁。这些机制的发展伴随Linux单处理器到对称多处理器的过度;伴随着从非抢占内核到抢占内核的过度。锁机制越来越有效,也越来越复杂。

 

Linux 性能非凡,其锁定方法各有特色。原子操作多用来做计数使用,原子锁不仅提供了一种锁定机制,同时也提供了算术或 bitwise 操作。自旋锁提供了一种锁定机制(主要应用于 SMP)。信号量在提供锁机制的情况下对系统影响较小。最后,针对信号量改进的互斥锁是一种新的锁定机制,提供了一种构建在原子之上的简单 API。还有经常用于免锁算法的生产者/消费者任务的数据结构之一循环缓冲区,它在设备驱动程序中相当普遍。不管你需要什么,Linux 都会提供一种锁定方案保护您的数据。

 

要用好Linux锁定机制必须深刻理解内核调度原理,要能清楚区分并发原因。另外Linux内核中锁机制的使用非常普遍,理解和使用锁机制是代码能够可靠运行的关键问题之一,并发产生的错误不可再现,调试困难,往往给开发带来很大麻烦,所以使用锁来保护临界区尤为重要。

 

即使开发环境不存在多处理器,不要求内核抢占,也最好不要放弃使用锁机制,因为使用恰当的锁机制可以方便将开发的代码向其它环境移植。另外要记住,不要指望在代码编写完毕后,再加入锁来保护资源,一定要在设计初期就要考虑竞争问题,设计锁来保护临界区。

 

2      原子操作

2.1   原子概述

Linux 中最简单的互斥方法就是原子操作。原子意味着临界段被包含在 API 函数中。不需要额外的锁定,因为 API 函数已经包含了锁定。完整的锁机制对一个简单的整数来讲显得浪费。由于 C 不能实现原子操作,因此 Linux 依靠底层架构来提供这项功能。各种底层架构存在很大差异,因此原子函数的实现方法也各不相同。一些方法完全通过汇编语言来实现,而另一些方法依靠 c 语言并且使用 local_irq_save local_irq_restore 禁用中断。

 

当需要保护的数据非常简单时,例如一个计数器,原子运算符是种理想的方法。尽管原理简单,原子 API 提供了许多针对不同情形的运算符。

 

2.2   原子API

要声明一个原子变量(atomic variable),首先声明一个 atomic_t 类型的变量。这个结构包含了单个 int 元素。原子变量操作是非常快的,因为它们在任何可能时编译成一条单个机器指令。原子变量使用 ATOMIC_INIT 符号常量在编译时进行初始化,也可以使用 atomic_set在运行时初始化。

atomic_t my_counter = ATOMIC_INIT(0);

atomic_set( &my_counter, 0 );

 

原子 API 支持一个涵盖许多用例的函数集。可以使用 atomic_read 读取原子变量中的内容,也可以使用 atomic_add 为一个变量添加指定值。最常用的操作是使用 atomic_inc 使变量递增。也可用减号运算符,它的作用与相加和递增操作相反。

int atomic_read(atomic_t *v); /*返回 v 的当前值.*/
void atomic_add(int i, atomic_t *v);/*
v 指向的原子变量加 i. 返回值是
void*/
void atomic_sub(int i, atomic_t *v); /*
*v 减去
i.*/
void atomic_inc(atomic_t *v);
void atomic_dec(atomic_t *v); /*
递增或递减一个原子变量.*/

 

API 也支持许多其他常用用例,包括 operate-and-test 例程。这些例程允许对原子变量进行操纵和测试(作为一个原子操作来执行)。

int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i, atomic_t *v);
/*
进行一个特定的操作并且测试结果; 如果, 在操作后, 原子值是 0, 那么返回值是真; 否则, 它是假. 注意没有
atomic_add_and_test.*/
int atomic_add_negative(int i, atomic_t *v);
/*
加整数变量 i v. 如果结果是负值返回值是真, 否则为假。这被内核中一些依赖于架构的信号量函数使用。*/

 

atomic_t 数据项必须通过这些函数存取。 如果你传递一个原子项给一个期望一个整数参数的函数, 你会得到一个编译错误。需要多个 atomic_t 变量的操作仍然需要某种其他种类的加锁。

 

2.3   位操作

内核提供了一套函数来原子地修改或测试单个位。原子位操作非常快, 因为它们使用单个机器指令来进行操作, 而在任何时候低层平台做的时候不用禁止中断。函数是体系依赖的并且在 <asm/bitops.h> 中声明。以下函数中的数据是体系依赖的。 nr 参数(描述要操作哪个位)ARM体系中定义为unsigned int

void set_bit(nr, void *addr); /*设置第 nr 位在 addr 指向的数据项中。*/
void clear_bit(nr, void *addr); /*
清除指定位在 addr 处的无符号长型数据.*/

许多驱动程序使用这些原子操作,使用这些操作前,需要提供一个值和将要进行操作的位掩码。


void change_bit(nr, void *addr);/*
翻转nr.*/
test_bit(nr, void *addr); /*
这个函数是唯一一个不需要是原子的位操作; 它简单地返回这个位的当前值.*/

/*以下原子操作如同前面列出的, 除了它们还返回这个位以前的值.*/

int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);

 

3      自旋锁spin_lock

3.1   自旋锁概述

自旋锁是专为防止对称多处理器SMP并发而引入的一种锁,它在内核中大量应用于中断处理等部分。在单处理器上,对于2.6的抢占式内核,自旋锁仅仅当作一个设置内核抢占的开关。如果2.4

抱歉!评论已关闭.