第9章 内核同步介绍

在使用共享内存的应用程序中,必须特别留意保护共享资源,防止共享资源并发访问。内核也不例外。共享资源要防止并发访问,是因为如果多个执行线程同时访问和操作数据,有可能发生各线程之间相互覆盖共享数据的情况,造成被访问数据处于不一致状态。并发访问共享数据是造成系统不稳定的一类隐患,而且这种错误一般难以跟踪和调试——所以首先应该认识到这个问题的重要性。

要做到对共享资源的恰当保护很困难。在Linux未支持SMP时,避免并发访问数据的方法相对来说简单。在单一处理器时,只有在中断发生时,或在内核代码请求重新调度、执行另一个任务时,数据才可能被并发访问。

从2.0版本开始,内核开始支持SMP,而且对它的支持不断地加强和完善。支持多处理器意味着内核代码可以同时运行在两个或更多的处理器上。如果不加保护,运行在两个不同的处理器上的内核代码完全可能在同一个时刻并发访问共享数据。2.6版本内核的出现,Linux内核已发展成为抢占式内核,这意味着调度程序可以在任何时刻抢占正在运行的内核代码,重新调度其他的进程执行。现在,内核代码中有不少部分都能够同步执行,而且它们都必须保护起来。

9.1 临界区和竞争条件

临界区就是访问和操作共享数据的代码段。多个执行线程并发访问同一个资源通常不安全,为避免在临界区中并发访问,编程者必须保证这些代码原子地执行——操作在执行结束前不可被打断。如果两个执行线程有可能处于同一个临界区中同时执行,那么这是程序包含的一个BUG。如果这种情况确实发生了,就称它是竞争条件,这样命名是因为会存在线程竞争。这种情况出现的机会非常小——就是因为竞争引起的错误不易重现,所以调试这种错误才会非常困难。避免并发和防止竞争条件称为同步。

1、为什么需要保护

为了认清同步的必要性,首先要明白临界区无处不在。

2、单个变量

9.2 加锁

假设需要处理一个队列上的所有请求。假定该队列是通过链表实现,链表中的每个结点代表一个请求。有两个函数可以用来操作此队列:一个函数将新请求添加到队列尾部,另一个函数从队列头删除请求,然后处理它。内核各个部分都会调用这两个函数,所以内核会不断地在队列中加入请求,从队列中删除和处理请求。对请求队列的操作要用到多条指令。如果一个线程试图读取队列,而这时正好另一个线程正在处理该队列,那么读取线程会发现队列此刻正处于不一致状态。如果允许并发访问队列,就会产生危害。当共享资源是一个复杂的数据结构时,竞争条件会使该数据结构遭到破坏。

需要一种方法确保一次有且只有一个线程对数据结构进行操作,或者当另一个线程在对临界区标记时,就禁止其他访问。锁提供这种机制。

如上请求队列,可使用一个单独的锁进行保护。每当有一个新请求要加入队列,线程会首先站住锁,然后可以安全地将请求加入到队列中,结束操作后再释放该锁;同样当一个线程从请求队列中删除一个请求时,也需要先占住锁,然后才能从队列中读取和删除请求,操作完成必须释放锁。要访问队列的任何其他线程,必须获得锁后才能进行操作。因为一个时刻只能有一个线程持有锁,所以在一个时刻只有一个线程可以操作队列。如果一个线程正在更新队列时,出现另一个线程,那么第二个线程必须等待第一个线程释放锁,它才能继续进行。由此可见,锁机制可防止并发执行,且保护队列不受竞争条件影响。

任何要访问队列的代码首先都需要获得相应的锁,这样该锁就能阻止别的执行线程的并发访问:

备注:锁的使用是自愿的、非强制的,它属于一种编程者自选的编程手段。没有什么可以强制编程者在操作虚构的队列时必须使用锁。如果不这么做,会造成竞争条件而破坏队列。

锁的形式多种多样,加锁的粒度范围各不相同——Linux自身实现了几种不同的锁机制。各种锁机制之间的区别主要在于:当锁被其它线程持有,不可用时的行为表现——一些锁被争用时会简单地执行忙等待,而另外一些锁会使当前任务睡眠直到锁变得可用为止。

锁是把临界区缩小到加锁和解锁之间的代码,但仍然有潜在的竞争!锁是采用原子操作实现的,而原子操作不存在竞争。

1、造成并发执行的原因

用户空间同步,是因为用户程序会被调度程序抢占和重新调度。用户进程可能在任何时刻被抢占,而调度程序完全可能选择另一个高优先级的进程到处理器上执行,所以会使得一个程序正处在临界区时,被非自愿抢占了。如果新调度的进程随后也进入同一个临界区,前后两个进程之间会产生竞争。另外,因为信号处理是异步发生的,所以,即使是单线程的多个进程共享文件,或者在一个程序内部处理信号,也可能产生竞争条件。这种类型的并发操作——其实两者并不真是同时发生,但它们相互交叉进行,也可称作伪并发执行。

如果有支持SMP的机器,那么两个进程可以真正地在临界区中同时执行了,这是真并发。虽然真并发和伪并发的原因和含有不同,但都会造成竞争条件,而且也需要同样的保护。

内核中有类似可能造成并发执行的原因:

中断——中断几乎可以在任何时刻异步发生,也就可能随时打断当前正在执行的代码。

软中断和tasklet——内核能在任何时刻唤醒或调度软中断和tasklet,打断当前正在执行的代码。

内核抢占——因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占。

睡眠及与用户空间的同步——在内核中执行的进程可能会睡眠,会唤醒调度程序,导致调度一个新的用户进程执行。

SMP——两个或多个处理器可以同时执行代码。

内核开发者必须理解并发执行的原因,并且事先做足准备工作。如果在一段内核代码操作某资源时系统产生一个中断,而且该中断的处理程序还要访问这一资源,这是一个BUG;如果一段内核代码在访问一个共享资源期间可被抢占,这也是一个BUG。注意:两个处理器绝对不能同时访问同一共享数据。当清楚什么样的数据需要被保护时,提供锁来保护系统稳定也就不难做到。真正的困难是发现上述的潜在并发执行的可能,并有意识地采取某些措施来防止并发执行。

在编写代码的开始阶段就要设计恰当的锁。

在中断处理程序中能避免并发访问的安全代码称作中断安全代码,在SMP的机器中能避免并发访问的安全代码称为SMP安全代码,在内核抢占时能避免并发访问的安全代码称为抢占安全代码。

2、了解要保护些什么

找出哪些数据需要保护是关键。什么数据需要加锁?如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁;如果任何其他什么东西都能看到它,那么就要锁住它。注意:要给数据而不是代码加锁。

编写内核代码时,要问自己如下这些问题:

这个数据是不是全局的?除了当前线程外,其他线程能不能访问它?

这个数据会不会在进程上下文和中断上下文中共享?是不是要在两个不同的中断处理程序中共享?

进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?

当前进程是不是会睡眠在某些资源上,如果是,它会让共享数据处于何种状态?

怎样防止数据失控?

如果这个函数又在另一个处理器上被调度将会发生什么?

如何确保代码远离并发威胁?

总结:几乎访问所有的内核全局变量和共享数据都需要某种形式的同步方法。

9.3 死锁

产生死锁需要一定条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。所有线程都在相互等待,但它们永远不会释放已经占有的资源。于是任何线程都无法继续,这就意味着产生死锁。

最简单的死锁例子是自死锁:如果一个执行线程试图去获得一个自己已经持有的锁,它将不得不等待锁被释放,但因为它正在忙着等待这个锁,所以自己永远不会有机会释放锁,最终结果就是死锁。

同样道理,有n个线程和n个锁,如果每个线程都持有一把其他进程需要得到的锁,那么所有的线程都将阻塞地等待它们希望得到的锁重新可用。例如有两个线程和两把锁,通常被叫做ABBA死锁。

每个线程都在等待其他线程持有的锁,但是没有一个线程会释放一开始持有的锁,所以没有任何锁会在释放后被其他线程使用。

预防死锁的发生非常重要,虽很难证明代码不会发生死锁,但可以写出避免死锁的代码,一些简单的规则对避免死锁有帮助:

按顺序加锁。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。

防止发生饥饿。

不要重复请求同一个锁。

设计应力求简单——越复杂的加锁方案越有可能造成死锁。

如果有两个或多个锁曾在同一时间里被请求,那么以后其他函数请求它们也必须按照前次的加锁顺序进行。

备注:尽管释放锁的顺序和死锁无关,但最好还是以获得锁的相反顺序来释放锁。

猜你喜欢

转载自blog.csdn.net/xiezhi123456/article/details/82781877