Table of Contents
Concurrency and Its Management
The Linux Semaphore Implementation
Introduction to the Spinlock API
Fine- Versus Coarse-Grained Locking
在最开始,kernel是不支持并发的,同一时刻只能有一个正在运行的code。后来随着硬件的升级,以及对performance的追求,kernel需要充分利用硬件的特性,才引入了并发。因为同一时刻可能有多个进程在执行,因此device driver需要考虑并发冲突的问题。
Concurrency and Its Management
并发和竞争条件,这个是计算机操作系统里经典话题。并发是一个很复杂的场景,复杂到没有人能预测当前的code会是怎么样的环境下执行,看似简答的device driver code,也许因为时间片用完,CPU被切走了;可能中断发生,CPU处理中断去了;可能多个进程同时执行,多CPU同时在执行同一段code,访问同一个全局资源;另外,还要考虑资源是否可用,是否会休眠,以及device的hotplug,而且,kernel中还提供了delay的机制,比如workqueue,timer,tasklet等,可以让code在将来的某个时刻还行,这些都会引入并发,有并发就可能存在问题,这在coding的时候其实是很难考虑全面的。
kernel提供了很多用于防止竞争条件产生的方式,主要是加锁,比如mutex/spinlock等。竞争条件的产生,最直接的原因就是多个进程/线程访问了共享的资源,如果能够少共享资源,就能减少竞争条件的发生,因此driver里要尽量少的使用全局变量,这是第一个原则。但是在实际的device driver中,全局共享变量不要太多,而且device resource本身肯定是share的。在kernel中,共享很难避免,既然不能不用共享资源,那就对共享资源进行加锁以保证互斥访问,这是第二个原则。能记住这两个原则,就能解决竞争条件的问题:尽可能少的使用全局共享变量,如果必须共享,就加锁保护。
如果要加锁,那么 kernel中的共享资源或者共享变量必须能够一直存在并正常工作,直到没有任何的reference。因为是共享的,所以不止一个人在使用,如果在有人使用的情况下共享资源或者变量除了问题,那么所有使用它的人都会碰到问题。从这一点又可以引申出来几个注意事项:
1, device driver必须能够正常处理所有的request,直到没有被open的device。
2,在kernel driver真正ready之前,不要暴露接口给kernel;
3,所有的object/resource要能够track,比如通过reference counter。
下面,就是操作系统里保证资源互斥访问的几种方式。
Semaphores and Mutexes
在保护资源之前,要先确定critical section,也就是一次只能运行一个线程执行的代码段,只有确定了critical section,才可以使用系统的保护机制对资源进行保护。critical section的界定由developer自己决定,因为只有developer才知道哪些资源要互斥访问,之后就可以使用kernel的锁机制进行保护。根据要保护的资源的不同,kernel提供了不同的机制实现。
这里这本书描述了sleep的概念,所谓的sleep就是为了等待IO或者其他资源进入了睡眠,此时会失去CPU执行权,直到IO完成或者等待的资源可用被唤醒。这里提供给了一个思路,如何选择合适的保护机制?可以从是否允许sleep开始,比如你只是一个user process,通过系统调用call进来,要分配memory,那么考虑到user process的性质,在保护memory的时候就可以使用允许sleep的机制,比如semaphore;如果是下irq context,或者别的atomic context,不允许休眠,那么就使用spinlock。
semaphore,信号量,就是操作系统提供的保护机制之一。semaphore的值可以是大于1的数,每次进critical section就减掉1,每次出critical section就增加1。如果某个人访问在进critical section的时候发现semaphore的值是0,那么就只能等待,直到semaphore变为大于等于1的值。如果semaphore的最大值就是1,就变成mutex(mutual exclusion),一次就只允许一个进程/线程访问critical section。在kernel中,绝大部分的semaphore都是1,也就说都是被保护的 资源只能同时被一个人进程或者线程使用。需要注意的是,semaphore是允许休眠的,如果当前锁拿不到,线程就会休眠,直到锁可用而被唤醒。
The Linux Semaphore Implementation
原型:
#include <asm/semaphore.h>
//自己创建semaphore并通过下面的函数初始化,val是初始值。
void sema_init(struct semaphore *sem, int val);
//通常情况下semaphore都用来做mutex,静态初始化,kernel提供了helper:
DECLARE_MUTEX(name); //semaphore初始值为1
DECLARE_MUTEX_LOCKED(name); //semaphore初始值为0
//也可以在运行时初始化
void init_MUTEX(struct semaphore *sem);
void init_MUTEX_LOCKED(struct semaphore *sem);
//进入critical section之前要做down操作,有三种接口:
void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_trylock(struct semaphore *sem);
最后面的三种down操作是用来获取锁的:第一个在拿不到锁时陷入睡眠,除非锁可用,否则不会被唤醒,如果进程在这里休眠,kill命令是无法杀死进程的;第二种down_interruptible,如果锁不可用也会休眠,但是是允许中断的休眠,可以被中断等唤醒,进程可以被杀死,因为可以进程可以接收signal,所以如果使用down_interruptible,一定要检查返回值,有可能函数是因为收到了signal,而不是成功拿到了锁;第三种down_trylock,不会休眠,是检测当前锁是否可用,如果可用就获取锁,不可用直接返回非零值。
一旦down操作成功,进程或线程就成功的拿到了锁,就可以访问critical section。如果leave critical section,需要释放锁,就调用对应的up操作,一旦up操作返回,进程或线程就不再持有这个semaphore。
void up(struct semaphore *sem);
需要注意的是,down操作和up操作要严格一一对应,一次down对应一次up,否则就会出现问题。尤其在程序运行出错,需要返回的时候,要把已经获得的锁释放掉才能返回。
Reader/Writer Semaphores
读者/写者信号量。有些进程或者线程访问critical section只是读取,并不会修改,这种情况下,应该允许所有的读者同时访问;如果进程或者线程是要修改共享资源,那么就要获取锁,以实现互斥的访问,这种情况下普通的信号量就做不到了,所以kernel还有一种信号量是读者/写者信号量。虽然device driver中用的很少,但是也有。
涉及到的接口如下:
#include <linux/rwsem.h>
//注意:读写信号量只能在运行时初始化,不能静态初始化
void init_rwsem(struct rw_semaphore *sem);
//对于读者,可以使用下面的方式使用读者锁
void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);
void up_read(struct rw_semaphore *sem);
//对于写者,可以使用下面的方式使用写者锁
void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);
void up_write(struct rw_semaphore *sem);
void downgrade_write(struct rw_semaphore *sem);
读写锁允许一个写者,或者无限多个读者持有,而且写者的优先级更高,如果有很多的写者需要获取锁,那么所有的读者要等这些写者完成访问之后才被允许进入critical section。这样会有一个问题,如果写者很多,或者执行的时间太长,那么读者有可能会饥饿。所以读写锁适合写者很少访问,并且访问时间很短的情况。
Completions
这里介绍了一个如何用semaphore实现两个task的等待问题。
很简单,创建一个sempahore,初始化为锁定状态,在需要wait 另外一个task的地方down,这里肯定sleep在这里,直到另外的task up了这个semaphore,这样就实现了两个task的等待。但是这种实现方式,performance比较差,因为第一个task是几乎肯定sleep的,这就导致poor performance。
kernel针对这种需求有单独的实现,这就是completion。初始化一个completion:
//静态初始化一个completion
DECLARE_COMPLETION(my_completion); //method 1
//动态创建并初始化一个completion
struct completion my_completion;
/* ... */
init_completion(&my_completion);
wait这个completion:
//这个wait是uninterruptible的wait,无法被中断
void wait_for_completion(struct completion *c);
在需要唤醒waiter的地方complete这个completion:
void complete(struct completion *c); //如果有多个waiter,只唤醒第一个
void complete_all(struct completion *c); //如果有多个waiter,唤醒所有
completion默认情况只会用一次,但是其实可以重复使用,如果唤醒的时候只是调用了complete这个函数,而不是complete_all,那么completion可以继续使用,可以继续被wait,继续被complete。如果唤醒的时候使用了complete_all,那么completion被使用前必须再次被初始化:
INIT_COMPLETION(struct completion c);
另外,completion有一个很重要的作用,用来等待thread结束。在device driver中,有可能会创建多个work thread,这个thread中通过while(1)这种方式工作。在driver module被rmmod,做cleanup的时候,就需要等待driver的work thread结束,这个时候在thread中就用complete_exit:
void complete_and_exit(struct completion *c, long retval);
Spinlock
自旋锁,和semaphore不同,spinlock不会休眠,如果当前拿不到锁,CPU就会一致loop查询,直到锁可用。因为这种性质,spinlock可以用在中断处理例程里。因为中断处理程序执行前,会关闭中断,在当前中断处理完以后打开中断,如果中间发生休眠,那就没有人打开中断,也没人可以产生中断,中断处理程序再也不会被唤醒,系统over。所以有很多场景,进程是不能休眠,但又需要锁保护某些资源,就用spinlock。自旋锁一般使用int中的一个bit来实现,进程在进入critical section之前检查这个bit,如果可用,就把bit设上,然后进入critical section,如果不可用,就loop检查这个bit,直到可用为止。
在介绍spinlock之前,这里又讲了一些spinlock的注意事项。比如spinlock的实现必须是atomic(原子)的,因为检查并且设置bit如果不是原子,有可能多个thread同时检测到spinlock可用,就会产生冲突,原子操作在各个架构上也许有不同的实现。spinlock的诞生,就是为了在多核机器上运行,因为spinlock会关闭中断和抢占,如果在单核机器运行,一旦spinlock不可用,就会一直loop下去,不会结束,因此如果单核机器上关闭了抢占,spinlock的实现其实啥也不干。
Introduction to the Spinlock API
接口有如下几种:
#include <linux/spinlock.h>
spinlock_t my_lock = SPIN_LOCK_UNLOCKED; //编译时初始化
void spin_lock_init(spinlock_t *lock); //运行时初始化
void spin_lock(spinlock_t *lock); //获取锁,一旦执行,就只能等到spinlock可用为止,不可中断
void spin_unlock(spinlock_t *lock);//释放锁
Spinlocks and Atomic Context
关于spinlock的几点注意事项:
1, spinlock保护的code里,不能发生sleep,整个critical section必须是atomic的,要做就一直做完,不能放弃CPU。因为在kernel中,一旦发生了sleep,CPU就会切到别的进程执行,别的进程可能也需要这把锁,这就存在死锁的可能;
2, spinlock只要拿到,就会禁止当前的CPU被抢占,某些情况下也会关闭当前CPU的中断。想象一下,在kernel中,如果已经拿到了spinlock,又发生了抢占,CPU就会切到别的进程执行,同样会导致死锁。如果允许中断,也会发生和抢占类似的事情,所以要关当前CPU的中断。
3, hold spinlock的时间越短越好,因为你持有自旋锁的时间越长,别人等待你的时间就越长,performance就越差。
The Spinlock Functions
关于获取spinlock的几个函数:
//普通的spinlock
void spin_lock(spinlock_t *lock);
//关闭local CPU的中断,并获取自旋锁,保存中断状态到flags
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
//关闭local CPU的中断,并获取自旋锁,不保存中断状态,这样释放锁的时候无法恢复之前的状态,所以要确定中断只有你一个人在操作它。
void spin_lock_irq(spinlock_t *lock);
//获取自旋锁,关闭软中断,不关闭硬件中断
void spin_lock_bh(spinlock_t *lock);
如果软中断或者硬中断处理例程里可能会获取spinlock,那么所有获取锁的时候都要关闭当前CPU的中断,以防止spinlock被中断处理例程获取,引起死锁。如果你的程序不会运行在硬中断处理例程里,只会运行在软中断里,那么可以使用spin_lock_bh,只关闭软中断即可。
和上面对应的四种释放锁的方式:
void spin_unlock(spinlock_t *lock);
//flags就是irqsave拿到的,restore再传进去。注意:save和restore一定要在同一个函数里配对调用。
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);
void spin_unlock_irq(spinlock_t *lock);
void spin_unlock_bh(spinlock_t *lock);
注意,spin_lock_irqsave和spin_lock_irqrestore必须在同一个函数中调用,否则在某些架构下会产生问题。另外有try的版本:
int spin_trylock(spinlock_t *lock);
int spin_trylock_bh(spinlock_t *lock);
Reader/Writer Spinlocks
kernel中还提供reader/writer版本的自旋锁,如果都是读者,那么都可以进入critical section,如果是写者,就要独自占用自旋锁。读者/写者自旋锁的初始化方式也是两种:
#include <linux/spinlock.h>
rwlock_t my_rwlock = RW_LOCK_UNLOCKED; /* Static way */
rwlock_t my_rwlock;
rwlock_init(&my_rwlock); /* Dynamic way */
读者获取锁和释放锁的方式如下:
//获取锁
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
//释放锁
void read_unlock(rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);
获取锁和释放锁的函数要对应调用。写者获取锁和释放锁的方式如下:
//获取锁
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
//释放锁
void write_unlock(rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);
读者/写者模型都有一个类似的问题,即写者有可能造成读者饿死。因为写者获取锁是独占的,如果长时间占用CPU,就会导致读者一直获取不到CPU而饥饿。
Locking Traps
解决并发是一个很tricky的事情,所以往往有很多陷阱。
Ambiguous Rules
关于是否获取锁的规则:
陷阱一:已经获取了锁的function,在执行过程中又要试图获取这个锁,死锁发生了。因为function的相互调用关系,很难确定一个函数会调用什么函数,中间是否会发生重复获取同一个锁的情况。
陷阱二:在某个函数调用前,是否需要调用者自己获取锁。这个也是个头疼的问题,调用者和被调用者需要提前约定好,一般而言,函数都做成自包含的,同一个函数里完成获取锁和释放锁,不依赖于外部获取锁。如果需要调用者持有锁,一定要在comment里明确的写出来,否则以后再看code会很难发现调用这个函数到底是否需要获取锁。
Lock Ordering Rules
获取锁的顺序有一些讲究:
1. 在复杂一些系统中,往往会同时持有不止一个锁。如果同时持有两个锁,就容易导致死锁。解决办法就是如果需要获取多个锁,那就保持同样的获取锁的顺序。比如都先获取lock1,然后lock2,这样如果一个线程先拿到了lock1,别的线程试图获取lock1就会等待,不会在我们之前拿到lock2。
2. 如果两个锁不同,比如一个kernel的锁,和自己module driver里的锁,那就先获取自己module driver的锁,再获取kernel的锁,这样持有kernel锁的时间短一些。也就是先获取小锁,再获取大锁。
3. 如果既要获取semaphore,又要获取spinlock,那就先获取semaphore,因为spinlock持有的情况下不允许休眠。
Fine- Versus Coarse-Grained Locking
kernel最开始的版本只有一个大锁(BKL,big kernel lock),这个锁锁住了整个kernel,这就导致即便你有多个CPU,同时也只有一个能运行kernel,虽然解决互斥访问的问题,但也导致扩展性和性能都很差。这种就是粗粒度锁,后来kernel逐渐细化,针对每个不同但resource都有相应的锁,可以很大程度上提高扩展性和性能。
然后细粒度的锁也有自己的问题,粒度细,锁的数量必然就会多,访问某一些资源,获取锁就会比较困难,更容易出现问题,而且一旦出问题,都会比较难debug。所以,锁的粒度是一个tradeoff,需要慎重思考和决策。
Alternatives to Locking
锁是为了解决资源的互斥访问,除了锁以外,是否有其他保护资源的方式呢?
锁可以解决资源互斥访问的问题,但是不是唯一的解决办法,还有别的方式可以实现,比如原子操作和无锁算法。
Lock-Free Algorithms
无锁算法,其实就是特殊设计的数据结构,允许数据在不加锁的情况下不会同时被访问。比如circular buffer,针对读者和写者的模型,就可以做到不加锁的实现同时访问。具体方式如下,写者维护一个write index,并且只有写者有权限修改write index,读者维护一个read index,并且只有读者有权限修改read index。当需要写入数据时,写者把数据放在buffer的end,并更新write index,读者从head读取数据,并更新read index,这样二者访问数据时不会重叠,自然不会破坏数据,也就不用加锁了。
Atomic Variables
有时候需要被多个线程同时访问的只是一个int变量,这种情况下使用锁保护有点大材小用,所以有了atomic_t这种数据结构。这种数据结构在所有的架构上都支持,不同点在于这个变量不一定是int,有可能是24bit的一个变量,取决于具体的硬件架构。对atomic的操作都能在一个机器指令中完成,所以速度很快。支持的操作有:
#include <asm/atomic.h>
void atomic_set(atomic_t *v, int i); //动态初始化
atomic_t v = ATOMIC_INIT(0); //静态初始化
int atomic_read(atomic_t *v); //读取atomic的值
void atomic_add(int i, atomic_t *v); //加上i
void atomic_sub(int i, atomic_t *v); //减掉i
void atomic_inc(atomic_t *v); //自增
void atomic_dec(atomic_t *v); //自减
//操作完test,如果是0返回true
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);
//加个数,如果负数,返回true
int atomic_add_negative(int i, atomic_t *v);
//带返回值的版本
int atomic_add_return(int i, atomic_t *v);
int atomic_sub_return(int i, atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);
Bit Operations
kernel支持针对bit操作的atomic,也是在一条机器指令中完成。操作有:
#include <asm/ bitops.h>
//Sets bit number nr in the data item pointed to by addr.
void set_bit(nr, void *addr);
//Clears the specified bit in the unsigned long datum that lives at addr. Its seman- tics are otherwise the same as set_bit.
void clear_bit(nr, void *addr);
//Toggles the bit.
void change_bit(nr, void *addr);
//This function is the only bit operation that doesn’t need to be atomic; it simply returns the current value of the bit.
test_bit(nr, void *addr);
//Behave atomically like those listed previously, except that they also return the previous value of the bit.
int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);
使用bit operation来保证资源互斥的一个例子:
/* try to set lock */
while (test_and_set_bit(nr, addr) != 0)
wait_for_a_while( ); /* do your work */
/* release lock, and check... */
if (test_and_clear_bit(nr, addr) == 0)
something_went_wrong(); /* already released: error */
seqlock
这种锁比较特殊,应用场景也比较特殊。它针对大部分是reader,极少数是writer的情况,并且被保护的资源simple,small并且frequently accessed。它允许所有的读者直接访问data,但是要check是否和writer发生冲突,如果有,就重新读取data。这种方式不适合资源中有指针的情况。
初始化:
#include <linux/seqlock.h>
seqlock_t lock1 = SEQLOCK_UNLOCKED; //静态初始化
seqlock_t lock2;
seqlock_init(&lock2); //动态初始化
工作原理是这样的,在进入critical section之前,先读取seqlock的值,在出critical section的时候再次读取,如果两次读出来的不一致,说明writer修改过critical section,那么reader就要重新执行critical section。sample code:
unsigned int seq;
do {
seq = read_seqbegin(&the_lock);
/* Do what you need to do */
} while read_seqretry(&the_lock, seq);
critical section中适合做一些简单的工作,并且能够重复运算。(貌似很少有这样的需求?)
如果在ISR中使用seqlock,需要使用:
unsigned int read_seqbegin_irqsave(seqlock_t *lock,unsigned long flags);
int read_seqretry_irqrestore(seqlock_t *lock, unsigned int seq,unsigned long flags);
对于writer而言,在进入critical section之前,需要写seqlock:
void write_seqlock(seqlock_t *lock);
writer使用了自旋锁来实现,所以函数和自旋锁类似。比如释放锁:
void write_sequnlock(seqlock_t *lock);
以及其他的一些操作:
void write_seqlock_irqsave(seqlock_t *lock, unsigned long flags);
void write_seqlock_irq(seqlock_t *lock);
void write_seqlock_bh(seqlock_t *lock);
void write_sequnlock_irqrestore(seqlock_t *lock, unsigned long flags);
void write_sequnlock_irq(seqlock_t *lock);
void write_sequnlock_bh(seqlock_t *lock);
Read-Copy-Update
用来实现资源的互斥访问,在某些特定的场景下使用,可以获得很高的performance。但是限制条件比较严格,1, 读多写少;2, 被保护的资源是指针形式来访问;3, 对资源(指针)的引用在atomic的code中完成。当需要更新数据时,writer thread 先分配新的memory,然后copy resource,然后修改,然后把新的地址更新,这样后来的reader看到的就是新的指针。这也是RCU名字的由来,先read,再copy,然后update。
对读者而言,进入critical section之前和之后也要获取和释放锁,获取使用rcu_read_lock,释放使用rcu_read_unlock,互斥的资源(指针)在中间使用。sample code:
struct my_stuff *stuff;
rcu_read_lock( );
stuff = find_the_stuff(args...);
do_something_with(stuff);
rcu_read_unlock( );
其中,rcu_read_lock中只是关闭了抢占,并不等待任何东西。在锁被拿到以后,释放之前,中间的code要求是atomic的(这个不明白,需要研究下啥意思),在unlock之后就不允许引用资源了。
对于写者而言,需要几个步骤:首先分配新的memory,把旧的数据copy进来,修改这份copy,然后把新的memory指针更新即可。writer更新了新的地址以后,后续读者都能看到新的地址。但是老的资源什么时候释放呢?因为read是atomic的,一旦执行必定会完成对资源的引用,所以只需要等待所有的CPU被调度了一次,那么读者一定被执行完了,所以一定没有人再引用旧的数据了,此时可以释放旧的资源。写者设置了一个callback,一旦所有的CPU都被调度了一次,就可以做cleanup的工作了。在写者端,修改资源,要自己分配一个struct rcu_head,不需要初始化,在资源修改完成以后,调用:
void call_rcu(struct rcu_head *head, void (*func)(void *arg), void *arg);
参数里有一个func,这个func用到的参数和call_rcu一样,并且里面唯一需要做的事情就是free旧的资源。