并发:(Concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(RaceConditions)。例如,对于globalmem 设备,假设一个执行单元A对其写人3000个字符“a”,而另一个执行单元B对其写入4000个“b",第三个执行单元C读取globalmem的所有字符。如果执行单元A、B的写操作按图7.1那样顺序发生,执行单元C的读操作当然不会有什么问题。但是,如果执行单元A、B按图7.2那样被执行,而执行单元C又“不合时宜”地读,则会读出3000个“b”。
竟态发生的几种情况:
1.对称多处理器的多个CPU。
两个核(CPU0 CPU1)的竟态可能发生在两个进程之间,当CPU0进程与CPU1的中断之间或两个中断之间。
2.单CPU内进程与抢占它的进程
一个进程在内核执行的时候可能被另一个高优先级进程打断。
3.中断(硬中断 软中断 Tasklet 底半部)与进程之间
中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则竟态会发生。
中断屏蔽
在单CPU范围内避免竞态的一种简单而有效的方法是在进人临界区之前屏蔽系统的中断,但是在驱动编程中不值得推荐,驱动通常需要考虑跨平台特点而不假定自己在单核上运行。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免了。
中断一般不单独使用,中断一般结合自旋锁或其他的配合使用。
原子操作
原子操作可以保证对一个整型数据的修改是排他性的。Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。位和整型变量的原子操作都依赖于底层CPU的原子操作,因此所有这些函数都与CPU架构密切相关。
自旋锁
自旋锁(Spin Lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。为了获得一个自旋锁,在某 CPU上运行的代码需先执行一个原子操作,该操作测试并设置(Test-And-Set)某个内存变量。由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行;如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置”操作,即进行所谓的“自旋”,通俗地说就是“在原地打转”。
理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区标记为“我当前在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁;当B执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。
使用自旋锁,而且在使用中还要特别注意如下几个问题。
1)自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
2)自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。
3)在自旋锁锁定期间不能调用可能引起进程调度的函数。如果进程获得自旋锁之后再阻塞,如调用copy_from_user(),copy_to_user(),kmalloc()和 msleep()等函数,则可能导致内核的崩溃。
4)在单核情况下编程的时候,也应该认为自己的CPU是多核的,驱动特别强调跨平台的概念。比如,在单CPU的情况下,若中断和进程可能访问同一临界区,进程里调用spin_lock_irqsave()是安全的,在中断里其实不调用spin_lock()也没有问题,因为 spin_lock_irqsave()可以保证这个CPU的中断服务程序不可能执行。但是,若CPU变成多核,spin_lock_irqsave()不能屏蔽另外一个核的中断,所以另外一个核就可能造成并发问题。因此,无论如何,我们在中断服务程序里也应该调用spin_lock()。
读写自旋锁
自旋锁不关心锁定的临界区究竞在进行什么操作,不管是读还是写,它都一视同仁。即便多个执行单元同时读取临界资源也会被锁住。实际上,对共享资源并发访问时,多个执行单元同时读取它是不会有问题的,自旋锁的衍生锁读写自旋锁(rwlock)可允许读的并发。读写自旋锁是一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多有1个写进程,在读操作方面,同时可以有多个读执行单元。当然,读和写也不能同时进行。
顺序锁
顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元不会被写执行单元阻塞,也就是说,读执行单元在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其他写执行单元必须自旋在那里,直到写执行单元释放了顺序锁。
信号量是操作系统中最典型的用于同步和互斥的手段。
信号量(semaphore):与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。信号量的值可以是0、1或者n。信号量与操作系统中的经典概念PV操作对应。
P(S):①将信号量S的值减1,即S= S-1;
②如果S=0,则该进程继续执行;否则该进程置为等待状态,排入等待队列。
v (S):①将信号量S的值加1,即S=SH1;
②如果S>0,唤醒队列中等待信号量的进程。
特点
1、信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
2、信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
3、每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
例子:就像一个房间,信号量相当于开门的钥匙,房间相当于连接资源(一次仅允许一个进程使用的资源;如:A进程正在使用临界资源,B进程就无法使用,等A进程完成后才行。)
在Linux中有许多的信号量集,
P操作:拿锁;V操作:放回锁;组。
使用PV操作和信号量可以实现进程间的同步和互斥。
互斥体
互斥体和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者。在互斥体本身的实现上,为了保证互斥体结构存取的原子性,需要自旋锁来互斥。所以自旋锁属于更底层的手段。
总结自旋锁和互斥体选用的3项原则。
l)当锁不能被获取到时,使用互斥体的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)。若临界区比较小,宜使用自旋锁,若临界区很大,应使用互斥体。
2)互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
3)互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁。当然,如果一定要使用互斥体,则只能通过mutex_trylock(方式进行,不能获取就立即返回以避免阻塞。
总结:并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁和互斥体都是解决并发问题的机制。中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和互斥体应用最为广泛。
自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。互斥体允许临界区阻塞,可以适用于临界区大的情况。