向下之旅(十三):内核同步方法

  原子操作

  原子操作可以保证指令以原子的方式执行——执行过程不会被打断。

  内核提供了两组原子操作的接口——一组针对整数进行操作,另一组针对单独的位进行操作。在Linux支持的所有体系结构中都实现了这两组接口。

  原子整数操作

  针对整数的原子操作只能对atomic_t类型的数据进行处理。在这里之所以引入了一个特殊数据类型,而没有直接使用C语言的int类型,主要是出于两个原因:首先,确保原子操作只与这种特殊类型数据一起使用,同时,也保证了该类型的数据不会被传递给其他任何非原子函数。此外,使用atomic_t类型确保编译器不对相应的值进行访问优化——使得原子操作最终接收到正确的内存地址,而不是一个别名。使用如下:

  atomic_t v ;   /* 定义 */

  atomic_t u = ATOMIC_INIT(0) ;   /* 定义 u 并把它初始化为0 */

  操作如下:

  atomic_set (&v,4) ; /* v = 4 (原子的) */

  atomic_add (2,&v) ; /* v = v+2 = 6 (原子的)*/

  atomic_inc (&v) ; /* v = v+1 = 7 (原子的) */

  如果需要将atomic_t转换成int型,可以使用atomic_read()来完成:

  printk ("%d\n",atomic_read(&v)) ; /* 将打印"7" */

  

  原子位操作

  位操作函数是对普通的内存地址进行操作的,它的参数是一个指针和一个位号,第0位是给定地址的最低有效位,在32位机上,31是最高有效位,32是下一个字的最低有效位。

  此外,内核还提供了组与上述操作对应的非原子位函数,但是不保证原子性,且其名字前缀多两个下划线。例如,与test_bit()对应的非原子形式是__test_bit()。

  自旋锁

  Linux内核中最常见的锁时自旋锁。自旋锁最多只能被一个可执行线程持有。如果一个执行线程视图获得一个被争用(已经被持有)的自旋锁,那么该线程就会一直进行忙循环—旋转—等待锁重新可用。在任何时间,自旋锁都可以防止多于一个的执行线程同时进入临界区。

  当一个执行线程获得锁,另外的执行线程只能等待其执行完释放所,一直处于自旋状态(特别耗费处理器时间),所以应该尽可能的减少持有锁的时间。在单处理器上,编译的时候不会加入自旋锁,它仅仅被当作一个设置内核抢占知己是否被启用的开关。如果禁止内核抢占,那么在编译的时候自旋锁会被完全的剔除内核。

  自旋锁是不可递归的,因此不要当持有该自旋锁的时候再次请求该自旋锁,这样会打造成死锁。自旋锁操作基本如下:

  

  由于下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文的共享数据进行保护,所以需要加锁的同时还要禁止下半部执行。同样,由于中断处理程序可以抢占下半部,所以如果中断处理程序和下半部共享数据,那么就必须在获取恰当的锁的同时还要禁止中断。

  读—写自旋锁

  有时,锁的用户可以明确的分为读取和写入,当进行读取操作是,同时多个执行读取操作也是安全的,但是当执行写入操作时,必须只有一个任务在进行。所以读—写自旋锁允许读取操作有多个获得锁的权利,而写入的锁只能由一个写任务持有。这就是读—写自旋锁。

  读者—写者自旋锁方法列表:

  信号量

  Linux中的信号量是一种睡眠锁。但一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时处理器能重获自由,去执行其他的代码,当持有信号量的进程将信号量释放后,处于等待队列中的那个任务将被唤醒,并获得该信号量。

  1.由于争用信号量的进程在等待锁重新变为可用时会睡眠,所以信号量适用于锁会被长时间持有的情况。

  2.相反,锁被短时间持有时,使用信号量太不适宜了。因为睡眠,维护等待队列以及唤醒所花费的开销可能比锁被占用的全部时间还要长。

  3.由于执行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获取信号量锁,因为在中断上下文中是不能进行调度的。

  4.你可以在持有信号量时去睡眠(当然也可能不需睡眠),因为当其它进程视图获得同一信号量时不会因此而死锁(因为该进程只是去睡眠而已,最终会继续执行)。

  5.在你占用信号量的同时不能占用自旋锁,因为你在等待信号量的时候可能会进入睡眠,而持有自旋锁时是不能睡眠的。

  此外信号量允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务执行。一般的,都是使用互斥信号量(计数等于1的信号量)

  与自旋锁一样,信号量也有读—写信号量。使用自旋锁和信号量的情况:

  

  完成变量

  如果在内核中一个任务需要发出信号通知另一任务发生了某个特定的条件,利用完成变量是使两个任务得以同步的简单方法。如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。例如,当子进程执行或退出的时候,vfork()系统调用使用完成变量唤醒父进程。

  

  禁止抢占

  实际中,某些情况并不需要自旋锁,但是仍然需要关闭内核抢占,出现的最频繁的情况就是每个处理器上的数据。如果数据对每个处理器是唯一的,那么,这样的数据可能就不需要使用锁来保护,因为数据只能被一个处理器访问。如果自旋锁没有被持有,内核又是抢占式的,那么一个新调度的任务就可能访问同一个变量。

  

  这样,即使这是一个单处理器计算机,变量foo也会被多个进程以伪并发的方式访问,通常,这个变量会请求得到一个自旋锁(防止多处理器上的真并发),但是如果这是每个处理器上独立的变量,可能就不需要锁。此时可以通过preempt_disable()禁止内核抢占。使用preempt_enable()被调用后,内核抢占才会重新启用。

  顺序和屏障

  当处理多处理器之间或硬件设备之间的同步问题时,有时需要在程序代码中制定的顺序发出读内存(读入)和写内存(存储)指令。所有可能重新排序和写处理器提供了机器指令来确保顺序要求。同样也可以指示编译器不要对给定点周围的指令序列进行重新排序,这些确保顺序的指令称为屏障。

  

 

  参考自:《Linux Kernel Development》.

posted on 2016-03-22 17:37  画家丶  阅读(196)  评论(0编辑  收藏  举报