Linux Kernel Development 笔记(九)内核同步方法

Linux内核提供了一些同步方法,可以使开发者开发出高效并且是无race condition的代码。下面就讨论这些方法:

原子操作(Atomic Operations)

因为大部分的其他同步方法都基于此原子操作的方法创建的,故此下面首先讨论原子操作的同步方法。原子操作提供了不被打断的,原子的指令。就如原子被看成是一个不可被分割的物体一样,原子操作也是不可分割的指令。内核提供了两套原子操作,一个是作用于整数,一个是作用于位。这些接口都是在Linux支持的平台上实现的。大部分平台都包含提供原子操作版本的算术操作。

原子整数方法作用于特殊的数据类型 atomic_t。这种类型的使用就如C类型的int操作是对照起来的。这里有如下原因,第一,让原子操作只接受atomic_t类型可以保证原子操作只被用于这种类型,因而保证此类型的数据不会被传给别的非原子函数。接着,atomic_t的采用,可以让编译器不会优化访问其数据,因为原子操作接收正确的变量内存地址而不是变量别名是很重要的。最后,atomic_t可以隐藏平台的差异性。atomic_t在 Linux/types.h定义

typedef struct

{

volatile int counter;

}atomic_t;

尽管是定义为整数,且在多数平台上是32位的,但他们的值大小曾经被假设为不超过24位。(因为SPARC 机器,会嵌入一个8位的锁到数据中,所以数据剩下24位可用)。要使用原子操作,还必须声明 asm/atomic.h头文件。虽然一些平台提供了额外的原子操作,但所有的平台都会提供一套最小原子操作集,在整个内核中使用。

定义一个atomic_t是用通常的方式,当然可以赋予初始化值:

atomic_t v;

atomic u = ATOMIC_INIT(9);

操作是很简单的

atomic_set(&v, 4);  /*v=4*/

atomic_add(2, &v); /*v = 2 + v*/

atomic_inc(&v); /*v = v+1*/

如果你需要把atomic_t转为整数,则采用atomic_read(&v),会返回int类型。

一种普遍的用法是作为计数器的实现。通过锁来保护一个计数器,会杀鸡用牛刀。所以开发者用atomic_inc 和 atomic_dec这种轻量级的原子操作。另外一种用法是原子的执行一个操作并检测它,如

int atomic_dec_and_test(atomic_t *v);

这个函数减去一,如果结果为0,会返回true,否则返回false。详细的操作可以参考<asm/atomic.h>。原子操作典型的都是作为inline函数实现的。在函数里内嵌原子操作的使用地方,这个给定的函数通常就相当于一个宏(因为inline类型)。在你的代码里,尽量的选择原子操作而不是锁机制。大部分的平台,一到两个原子操作引起的负荷比复杂的同步方法要小的多。随着64位系统的流行,Linux内核也提供了针对64位的原子操作。但atomic_t即使在64位系统里,也是指32位的,64位的有专门的atomic64_t.

原子位操作(atomic bitwise operation)

除了原子整数操作外,内核还提供了一套基于位的原子操作。这些操作都是平台相关的,且定义在asm/bitops.h里。让人惊奇的是,位操作是作用于一般的内存地址上的。操作的参数是地址以及位序号。位0代表内存的最低位(least significant bit).在32位系统中,位31代表最高一位(most significant bit),32位代表是下一个word的最低一位(least significant bit). 虽然大部分位操作的位序号都是0到31(32位系统)或0到63(64位系统),但这个位序号是不做限制的。因为操作是作用于一般的内存地址,故此跟原子整数操作有atomic_t变量不一样,他们没有对应的特殊类型,任意数据的指针都行。

unsigned long word = 0;

set_bit(0, &word);

set_bit(1, &word);

clear_bit(1, &word); 清掉位1

change_bit(0, &word) 反操作位0

test_and_set_bit(0, &word) 判断没改动前的位,并把位0设1.

内核也提供了一组函数来查找第一个设置的或未设置的位。

int find_first_bit(unsigned long *addr, unsigned int size);

int find_first_zero_bit(unsigned long *addr, unsigned int size);

第一个参数是一个指针,第二个参数是要查找的内存的总位数。返回的是第一个设置或第一个未设置的位序号。如果你要搜索的是word类型,可以调用__ffs()或ffz(),会更优化有点,只带一个参数。


Spin Locks(自旋锁)

如果每一个关键区都由不会比增加变量复杂的代码组成,那会是很好的。但现实是残酷的。在实际中,关键区域可能是横跨多个函数的。例如:数据常常会被从一个结构上移除,重组或解析,和加到别的结构去。这整个操作必须是原子性的。其他代码必须不能在更新完成前去读取或更改整个结构体。因为简单的原子操作明显不足以提供保护这样复杂场景的需求,一种更一般的同步方法是必须的,那就是锁。在Linux内核中,最普遍的锁就是spin lock。自旋锁是一种至多被一个线程执行体所持有。如果一个线程执行体尝试去获取被持有的自旋锁(竞争),那么线程会进入忙循环(自旋,就是while 1之类的),直到锁可以再次被持有。如果锁没有被别的持有,则线程可以立即获得锁并继续。这种自旋阻止了超过一个线程在任何时刻进入关键区域。该锁可以用在多个地方,因此所有对该数据结构的访问都会得到保护和同步。竞争自旋锁会导致线程自旋(忙循环),这是一个很突出的问题。这种行为是自旋锁的特点。因此长时间持有自旋锁是不明智的。自旋锁本质就是一种轻量级的单持有的锁,仅仅只能短时间持有。当线程竞争锁的时候,另外一种行为是让线程休眠,直到锁可以获取的时候唤醒。但这里也会引起别的开销,就是进程上下文的切换,等待线程切出去以及切回来。因此,一种明智的做法是持有自旋锁不超过2此上下文切换的时间,否则就采用别的机制的锁。

自旋锁的方法:

自旋锁是平台相关的,而且是以汇编实现的,存于代码 linux/spinlock.h,基本用法如下:

DEFINE_SPINLOCK(mr_lock);

spin_lock(&mr_lock);

.....................................

spin_unlock(&mr_lock);

因为该锁是仅有一个线程执行体可以持有,故此仅有一个线程在某个时刻可以存在于关键区域。这给多处理器的机器并发处理提供了保护。在单一处理器机器上,编译器会把自旋锁去掉,换成一种标识,表明此时禁止或允许内核抢占。如果内核是禁止抢占的,则此自旋锁完全就不会被编译进去。自旋锁可以用在中断函数里。如果自旋锁用在中断函数里,则必须在获得锁前禁止当前处理器的中断(本地中断)。否则,当一个中断函数持有锁后,如果此时另外一个中断抢占允许,同时也去争取锁,但锁被持有,故此只能自旋等待,但持有锁的中断函数又必须等待当前中断函数的返回才会继续处理从而释放锁。这就会造成死锁。要注意的是,获取锁前,禁止的仅仅是本地中断,另外的处理器中断不需要禁止,因为另外处理器的中断函数不会阻止本地中断函数的继续运行。内核提供了一套接口很便利的禁止本地中断以及获取锁。

DEFINE_SPINLOCK(mr_lock);

unsigned long flags;

spin_lock_irqsave(&mr_lock, flags);

.................................................

spin_unlock_irqrestore(&mr_lock, flags);

以上操作是先禁止本地中断,保存中断状态,再去获取锁,处理完毕后,就释放锁,然后恢复原来的中断状态。这样做可以保证,在中断系统初始是禁止的时候,你的代码不会错误的激活它,而是使其保持原有的禁止。flags看上去似乎是直接传值,这是没有问题的,因为这些调用是宏定义来的。在单处理器系统上,还是按照lock的流程走,只是编译器会把lock机制去掉。上锁以及去锁同时也会分别的禁止以及激活内核抢占。有一点很重要的是,锁是与其被锁的东西是紧密关联的。更重要的是,锁是对数据的保护而不是对代码的保护。前面描述的对关键区域的保护,其实是保护里面的数据而非代码。记得给你的共享数据指定一个锁。在访问您的共享数据的时候,记得要获取相应的锁,当处理完数据后,要释放相应的锁。

如果你提前知道中断系统是激活的,则你可以在上锁前直接禁掉,然后解锁时候激活。通过这方式的调用来获得小小的优化:

DEFINE_SPINLOCK(mr_lock);

spin_lock_irq(&mr_lock);

.......................................

spin_unlock_irq(&mr_lock);

但是,随着内核在规模以及复杂度的增加,越来越难确认中断是否在某些内核路径内是激活的,因此使用以上过程是不提倡的。使用前,必须保证中断是激活的。

你可以使用spin_lock_init来初始化动态创建的spin锁。spin_trylock函数尝试去获取锁。如果锁被占用,则直接返回0,而不是一直等待。如果可以获得锁,则返回非0.相似的,spin_is_locked是判断锁是否被占用,但它不会获得锁。

在于bottom halves一起工作时候,某种特殊的锁措施要注意采用。spin_lock_bh() 获取指定的锁并禁止bottom halves,函数spin_unlock_bh则是释放锁并激活bottom halves。因为bottom halves会抢占进程上下文,所以在两者中如果存在共享数据,则在进程上下文中要对数据进行加锁并禁止bottom halves。相同的,因为中断函数也会抢占bottom halves,故此它们两者间的数据共享,也需要在bottom halves中上锁并禁止中断。回忆一下,同类型的tasklet是不能并发的,故此同类型的tasklet之间共享数据不需要做保护。但不同类型的tasklet之间,则要采用一般的spin锁机制,但并不需要禁止bottom halves,因为tasklet不会抢占另一个在同一个处理器上运行的tasklet。softirq不管类型一不一样,如果共享数据,都要注意加锁,因为即使同类型softirq都可以跑在多个处理器之上。但同一处理器之间的softirq,因为不会出现抢占,故此不需要禁止bottom halves。

读写spin锁(reader-writer spin locks)

某些时候,锁的用法会清晰的分成针对读操作以及写操作。举个例子,一个列表可以更新也可以搜索访问。当列表被更新(Write to)时候,它是不允许别的执行体去访问的(写或读)。相反,列表被搜索(read from)的时候,它仅仅要求不能同时存在别的执行体进行写操作,但却允许多个并发的执行体进行读操作。进程的task 列表是一个很好的例子,而且毫无疑问的,这个列表就是用 读写锁 保护的。

当一个数据结构可以很清晰的分为读/写或消费/生产的使用模式时,使用这种读写锁是很有意义的。为了满足这种需求,Linux内核提供了一种读写spin锁。读写spin锁提供了读以及写两种不同的锁。一个以上的并发reader可以并发的拥有reader锁,但是,至多只有一个writer锁能被同时持有。读写锁有时候被称为 共享/互斥或并发/互斥锁,因为这个锁可以以共享(reader)以及互斥(writer)的形式存在。它的使用方法与spin锁类似,如下:

DEFINE_RWLOCK(mr_rwlock); 初始化

然后是

read_lock(&mr_rwlock);

/* 关键区域,仅仅是读取*/

read_unlock(&mr_rwlock);


write_lock(&mr_rwlock);

/*关键区域,可读写*/

write_unlock(&mr_rwlock);

一般情况下,reader和writer都是存在于不同的代码路径里。请注意,不要“升级”reader锁为write锁,如下片段:

read_lock(&mr_rwlock);

write_lock(&mr_rwlock);

因为这样会导致死锁。如果你希望要写数据,则就应该一开始用write锁。如果在reader以及writer之间的边界比较模糊,则这意味着也许不应该用这种锁机制,这情况下,请使用普通的spin锁。多个reader采用同样的锁是安全的。事实上,同一个线程递归的获取一样的锁是安全的。如果你在中断函数中只有一个reader,而没有writer,那么你可以混用“禁止中断”的锁。如,你可以用read_lock来替代read_lock_irqsave来保护read。但你还是需要在写数据的时候,调用write_lock_irqsave来保护写,否则中断函数如果再去获得read锁,就有可能出现死锁。

Linux的reader-writer的spin锁,会偏重于reader的用户。如果read锁被持有,writer需要等待,但reader尝试获取read锁可以得到成功并继续运行。writer直到所有的reader释放read锁后才能获得write锁。因此,足够多的read锁会让writer处于饥饿状态(一直等待)。这种情况,在设计之初要考虑清楚。有些时候,这种锁是有好处的,但有时候这种锁也会带来灾难。

spin锁提供了一种快速以及简单的锁。spin锁对于简短的持有锁且不休眠的代码是最佳的。在一些等待时间久以及会有休眠可能的情况下,semaphore(信号量)是解决办法。


Semaphores(信号量)

信号量是Linux一种休眠的锁。当一个任务试图获取一个已被占用完的信号量的时候,信号量会让该任务置于等待队列,并让任务处于休眠。然后处理器就可以接着处理别的事情。当信号量可以获取的时候,等待队列中的一个任务就会被唤醒以至于其可以获得信号量。回到门以及钥匙这个模拟场景。跟spin锁不一样的是,如果门后有人,则新来的人不是在门口等着,而是把自己的名字放在一个列表里,取一个号。当房内的人离开时候,会检查一下列表,然后走到最前一个名字的人那里,叫醒他,并把钥匙给他让他可以进房。这种方式依然保持着钥匙(信号量)只能让一个人(执行体)呆在房子里。这个提供了比spin锁更好的处理器使用率,因为不会让处理器白白花时间等待。但信号量会比spin锁需要更多的系统开销。生活总是如此平衡的。你可以从这种信号量可以休眠的行为得出如下结论:

1. 因为争夺信号量的任务会休眠直到可以获取信号量为止,这样信号量比较适合那些需要持有锁较长时间的情况

2. 相反,信号量不适合那些短期持有锁的情况,因为其休眠,维护等待队列以及唤醒一系列行为造成系统开销很大。

3. 因为线程执行体会在争夺锁中休眠,因此信号量必须在进程上下文中获取,因为中断上下文是不能调度的。

4. 你可以在获得锁之后休眠,这不会在另一个进程尝试获取相同锁的时候死锁

5. 你不能在获取spin锁后又去获取信号量,因为你有可能会因为获取信号量而休眠,但在获取spin锁后,是不能休眠的。

这些结论都突出了信号量使用对于spin锁的使用特性。在信号量的大部分使用中,就采用何种锁而言是几乎没有选择的。如果你的代码需要休眠,信号量是唯一选择。如果不是必需的,则使用信号量是比较简单的方法,因为它允许你可以灵活的休眠。如果你要做出spin锁以及信号量之间的选择,则应该要基于锁持有时间来选择。理想的,所有你的锁都尽量可能的短期持有。只是用信号量,稍长的锁持有时间是可接受的。而且,不像spin锁,信号量并不禁止内核抢占,因此持有信号量的代码也是能被抢占的。这也表明,信号量的使用并不会对系统调度造成不良影响。

计数器以及二元信号量

信号量最有用的地方是,其允许同时间任意数量的信号量持有者。而spin锁只允许同一时间至多一个任务持有锁。可允许同时持有信号量的持有者数量可以在信号量声明的时候定义。这个数值称为使用计数或简单的称为计数。最普遍的形式就是如spin锁一样,只允许一个时间内只有一个持有者。此时信号量成为二元信号量(要么被持有,要么不被持有)。计数的值被初始化为非零的,大于1的值,则此信号量称为计数信号量,它允许同一时间内至多初始化值一样多的持有者。计数信号量并不互斥持有者,相反,它只是限制一定数量的持有者。在内核中,这种信号量用的不多。如果你用信号量,则你一定会用到互斥(mutex,仅限制1个持有者的信号量)。

Linux的信号量是调用down以及up方法。down是通过减去计数1来获取一个信号量,如果计数是大于等于0,则任务可以获取信号量并进入关键区域。如果计数为负,则任务就会被放到等待队列里并休眠,让出处理器来运行别的事情。up方法被用来释放信号量,该方法增加计数1.如果信号量的等待队列不为空,则等待中的一个任务会被唤醒并允许其获得信号量。

信号量的实现是平台相关的,定义在 asm/semaphore.h。结构体 struct semaphore 代表信号量。静态创建如下,name代表变量名字,count代表计数初始值。

struct semaphore name;

sema_init(&name, count);

一种快速的创建二元信号量(互斥)的方法

static DECLEAR_MUTEX(name);

大部分情况,信号量都是动态创建的,一般都是作为大数据结构的一部分。可以调用sema_init初始化。Mutex也可以动态创建并初始化 init_MUTEX(sem);

函数down_interruptible尝试去获得信号量,如果信号量目前被占用完,则会让调用进程已TASK_INTERRUPTIBLE的方式休眠。这种状态意味着进程可以被信号signal唤醒。如果进程是因为信号而唤醒,则down_interruptible返回-EINTR。相反,down则会让进程以TASK_UNINTERRUPTILE的方式休眠。你或许并不希望进程在因为信号量休眠时候不能被信号唤醒。因此down_interruptible更经常使用。你也可以用down_trylock函数来尝试获取信号量,如果信号量不能被获取,则进程不需休眠,函数会返回非零。成功则返回0.要释放信号量则调用up。

static DECLEAR_MUTEX(mr_sem);

if(down_interruptible(&mr_sem))

{

//do something

}

up(&mr_sem);

读写信号量(reader-writer semaphores)

信号量如spin锁一样,Linux也提供了reader-writer的形式。采用此种方式而不是普通模式的情况,就跟spin的情况是一样的。reader-writer 信号量是由 struct rw_semaphore类型代表,定义在 Linux/rwsem.h里。下面是静态创建读写信号量的方式

static DECLEAR_RWSEM(name);

动态创建的读写信号量,则由 init_rwsem来初始化。所有的读写信号量都是互斥的,就是说计数值都是1,虽然这个互斥仅仅针对writer,而不是reader。只有没有writer持有信号量,任意数量的reader可以同时获取信号量,相反只有一个writer能获得writer信号量。所有的读写锁都是采用uninterruptible休眠方式。例子:

static DECLEAR_RWSEM(mr_rwsem);

down_read(&mr_rwsem);

/*do something read*/

up_read(&mr_rwsem);


down_write(&mr_rwsem);

/*do something write*/

up_writer(&mr_rwsem);

读写信号量有一个唯一的函数,但读写spin是没有此类型的函数的。就是downgrade_write。这个函数原子的转换一个获得的write锁为一个read锁。如读写spin一样,只有你的代码有比较明晰的读以及写路径,否则不建议用。

互斥(Mutexes)

直到最近,内核中能休眠的锁就只有信号量。大部分信号量的使用者都会初始计数值为1来当初互斥锁来使用,是一种可休眠版本的spin锁。不幸的是,信号量使用的相当普遍并没有对使用强加过多的限制。这使得他们在处理模糊的情况比较有用,如在内核以及用户空间之间不断跳转的情况。但这意味着简单的锁比较难适用,且缺乏强制的规则会使自动调试或约束的执行变得不可能。Linux内核引进了一种简单的可休眠的锁,就是互斥mutex。这个名字很容易混淆,互斥这个术语是一般指任何强制执行互斥的锁的名字,如计数为1的信号量。最近,互斥这个词才正式用语Linux内核的一种特别的实现互斥的休眠的锁 mutex。

Mutex是由struct mutex类型表示的。其行为跟计数值为1的信号量是相似的,但拥有更简单的接口,更高的效率以及对使用有附加的限制。静态定义Mutex如下:

DEFINE_MUTEX(name); 动态创建的Mutex可调用 mutex_init来初始化。

mutex_lock(&mutex);

/*critical region*/

mutex_unlokc(&mutex);

mutex的简单性和高效性是得益于额外强加在其使用上的限制,而这些正是信号量需要做的。与信号量不一样的是,其使用是很严格的,范围很窄的:

1. 在同一时间里仅有一个task可以拥有mutex

2. 持有mutex的必须解锁,不能在一个上下文情况下上锁,但在另外一个上下文中解锁。这意味着mutex不适用于在内核与用户空间中复杂的同步。多数的使用情况,都是在一个上下文中上锁以及解锁

3. 递归的上锁和递归的解锁是不允许的,即不能连续的请求mutex,也不能连续的释放mutex

4. 进程持有mutex时候,不能退出

5. 中断函数或bottom half都不能去请求mutex,即使mutex_trylock也不行

6. Mutex仅能通过官方的API操作,不能拷贝,手工初始化以及重复初始化

新的struct mutex最有用之处也许就是通过特殊的调试模式,内核可以编程来检测对以上条件的违规甚至提出警告。这个可以通过配置 CONFIG_DEBUG_MUTEXES选项来使能此项功能。

Mutex与信号量是很相似的,都存在于内核中是让人疑惑的。还好,有一个公式可以判断使用那个:如果不存在以上条件中的一条困扰你,那你就使用mutex。当写代码时,仅仅是特别指定,经常是比较底层的使用会用到信号量。开始使用Mutex,当且仅当以上条件有不满足时候才转向信号量。清楚何时使用spin lock或使用mutex是很重要的。在很多情况下,选择是很有限的。只有spin lock可用在中断上下文中,只有mutex可以被会休眠的task持有,下面列表列出具体的使用情况以及选择。



结束变量(completion veriables)

当任务发送信号通知另外一个任务事件发生时候,使用结束变量是这两个内核任务间同步的最简单方法。其中一个task会等待结束变量,而另外一个task则执行一些工作。当另外一个task完成工作后,它会使用结束变量来唤醒任何等待的task。这个很像信号量。事实上,结束变量仅仅提供了需要用到信号量问题的另一种简单的方法。举个例子,vfork系统调用会在子进程完成后或退出时,使用结束变量来唤醒父进程。结束变量是由struct completion来表示,定义在linux/completion.h。静态的创建方式:

DECLARE_COMPLETION(mr_comp);

动态创建的可以由 init_completin来初始化。给定结束变量,task可以通过wait_for_completion来等待。通过complete()来通知所有等待中的任务唤醒。例子可以参考kernel/sched.c 和 kernel/fork.c。


BKL:The Big Kernel Lock

BKL是一种全局的spin锁,创建的作用是为了缓和从Linux的原始实现到细粒度锁的过渡。BKL有一些有趣的特性:

1. 你可以在持有BKL的时候休眠。当Task不在被调度时候,锁会自动释放,同时当task再被调度时,又会重新请求。当然这不意味着持有BKL时候休眠是安全的,仅仅是你可以并且不会死锁。

2. BKL是可以递归申请的锁,也就是一个进程可以请求这个锁多次而不会死锁,但spin lock会

3. 你仅仅只能在进程上下文中使用BKL,不能用在中断上下文那里

4. 目前越来越少的驱动以及子系统会依赖于BKL了

目前是不鼓励使用BLK的,但它在内核的一些部分还是用的不错的。因此,了解BKL是重要的。其工作方式跟spin lock很像。lock_kernel是请求锁,unlock_kernel是释放锁。一个执行体可以递归的调用lock_kernel,但必须记得调用同样次数的unlock_kernel来释放锁,只有最后一次的unlock_kernel才真正的释放锁。kernel_locked在锁被占用的时候会返回非零,否则返回零。BKL在被持有时候,也同样禁止了内核抢占。经常的,BKL看上去像是对代码上锁而不是数据。因此用spin lock来替换BKL总是很难的,因为不容易判断其保护的对象。

顺序锁(Sequential Locks)

这个锁是linux 2.6版本引进的。它提供了一种可以读写共享数据的简单机制。它通过维护一个顺序计数器的方式来工作。无论什么时刻,只要数据要被写,则锁会被获取,并且顺序数自增。读数据前以及后,都会去读顺序数。如果值不变,说明写操作没有发生在读的过程中。更进一步,如果值是偶数,说明写操作没执行。(获得锁,顺序数变成奇数,释放后变成偶数。因为顺序数是从0开始)

定义顺序锁如下:

seqlock_t mr_seq_lock = DEFINE_SEQLOCK(mr_seq_lock);

写路径上的使用:

write_seqlock(&mr_seq_lock);

/*写操作*/

write_sequnlock(&mr_seq_lock);

这个看上去很像spin lock。顺序数变奇数是发生在读路径中


unsigned long seq;

do {

seq = read_seqbegin(&mr_seq_lock);

/*读操作*/

}while(read_seqretry(&mr_seq_lock)); 

Seq锁是一种好用的轻量级可扩展的锁,可用在大量读操作以及少数写操作的情况下。然而,Seq锁偏重于写操作多于读操作。只要锁没有被别的写操作获取,请求写操作的Seq锁总是会成功的。读操作不会影响到写操作锁,如读写spin锁以及读写信号量一样情况。如上例子,writer获取锁后,读操作必须不断的重复读数据,直到没有任何写操作锁被持有,才成功读取到值。

Seq锁对于以下大部分或所有情况下是很有用的:

1. 你的数据有大量的读者

2. 你的数据只有少量的写者

3. 虽然写者少,但你更偏重与写者,不允许读者让写者饥饿。

4. 你的数据是简单的,如简单的结构体或简单的整数,但却不是原子的。

使用顺序锁最突出的是 jiffies,一个存储Linux机器uptime的变量。Jiffies持有一个64位宽的时钟ticks计数器,从机器开机初开始计数。因为机器不能原子的读取整个64位宽的数据,get_jifies_64的函数是如下实现的:

u64 get_jiffies_64(void)

{

unsigned long seq;

u64 ret;

do{

seq = read_seqbegin(&xtime_lock);

ret = jiffies_64;

}while(read_seqretry(&xtime_lock, seq));

return ret;

}

更新JIffies是在时钟中断时刻,此时会去获取write锁

write_seqlock(&xtime_lock);

jiffies_64 += 1;

write_sequnlock(&xtime_lock);


禁止抢占(preemption disabling)

因为内核是可抢占的,因此内核中的进程会在任意时刻停止运行,来使更高优先级的进程运行。这意味着,一个任务可以在同样的被打断任务的关键区域里开始运行。为了阻止此情况,内核使用spin锁作为不可被抢占区域的标志。如果spin被持有,则内核是禁止被抢占的。某些情况下,我们或许并不需要锁,但需要内核抢占被禁止。最频繁的情况是处理器私有数据。如果处理器私有数据对处理器来说是唯一的,那么因为只有该处理器能访问,因此也许就不需要锁了。如果不用spin锁,内核又能被抢占,则有可能一个新调度运行的任务也访问同样的数据。如下:

任务A访问一个处理器私有数据,但没有上锁

任务A被任务B抢占

任务B处理同样的变量

任务B完成,任务A被调度回来

任务A接着执行未完成的动作

因此,就算是单一处理器,变量也可能因为伪并行被多个进程访问。一般情况下,该变量需要spin锁来保护(保护多处理器的真并发访问)。要解决这个问题,内核抢占可以通过preempt_disable被禁止。这个调用可以是嵌套的,可以调用多次。但每一次调用,都必须要有相应的preempt_enable来恢复。最后的一次的preempt_enbale会让内核恢复抢占。

preempt_disable();

/*抢占被禁止*/

preempt_enable();

可抢占计数器会存储preempt_disable以及持有锁的数目。如果这个数目为0,则内核可被抢占的。该功能是非常有用的,尤其是用在原子性以及休眠调试。函数preempt_count返回这个值。



排序和栅栏(Ordering and Barriers)

当处理在多个处理器之间或硬件设备之间的同步中,有些时候会要求,内存的读操作以及内存的写操作在你代码中按照一定顺序执行的。让这个事情更复杂的是,处理器以及编译器也能为了性能原因而重排读操作以及写操作的顺序。还好,所有处理器都提供支持重排读或写顺序的机器指令。同样,编译器也提供相应的指令来限制在某点上重排序。这些指令称为栅栏(Barriers). 

a = 1;

b = 2;

本质上来说,在某些处理器在处理以上代码时,会允许处理器在a存储前存储b。处理器以及编译器都看不到a跟b之间的关联。编译器会在编译期间执行则个重排顺序的动作。编译器的重排序是静态的,也就是编译好的机器码是先设置b在设置a。但处理器是在执行期间按照它认为最好的方式来提取分派不相关的指令来动态重排序的。大部分时刻,该重排序是有意的,因为a跟b之间没有明显关联。但某些时刻,程序员却有自己认为最好的方式。虽然上面的例子会有可能重排序,但下面的代码是不会比重排序的,因为他们之间是有关联的。

a = 1;

b = a;

但编译器或处理器,都不清楚在别的上下文中的代码情况。让别的代码或外部的世界知道你代码中写操作的顺序,有时候是很重要的。这在硬件设备中是很常见的,在多处理器的机器上也是很普遍的现象。rmb函数提供了读内存的栅栏,保证没重排序的内存加载操作会越过rmb的调用。这就是说,在函数调用前的内存加载操作不会排在调用之后,调用之后的内存加载也不不会被重排序到调用前。wmb提供了写操作的栅栏。工作方式与rmb是一样的,但此只针对写内存的操作而不是读内存操作。mb则提供了读栅栏以及写栅栏那的功能。rmb的一种变化,就是 read_barrier_depends(),也提供了读栅栏。但它仅仅只是对于后续加载所依赖的加载。所有在栅栏前的读操作,都会在栅栏后面的任何读操作之前完成,而这些读操作又依赖于栅栏前的读操作。基本来说,就是强制了一个读栅栏,类似rmb一样,但仅仅是针对那些互相依赖的读操作,而不是全部。看看下面的例子:

a初始值为1,b初始值为2


当不使用内存栅栏的时候,在一些处理器里,很有可能c会收到最新b,而d会收到最初的a。就是c是4,d是1.而这不是你所期待的。使用mb可以保证a跟b是以想要的顺序来赋值。而rmb则保证c跟d以想要的顺序来读取。这类型的重排序的发生,是因为现代的处理器,为了优化使用他们的多管道,会在分派以及执行指令时候,是无序的。因此上面例子,有可能是a跟b的读取指令是非顺序执行的。rmb跟wmb对应的指令会告诉处理器在继续执行前去立即执行等待中的读取以及写入指令。在看一个例子,但是使用了read_barrier_depends。初始化时,a是b,p是&b


在没有任何内存栅栏的时候,很有可能是b在pp设置为p之前设置为pp。read_barrier_depends提供了足够的栅栏,因为*pp是依赖于p的。使用rmb也是能满足的,但因为读取的数据是依赖的,故此用read_barrier_depends会更快速一点。在两种情况下,mb都要使用来保证左边的读取和写入顺序是按照设定的。








评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值