Concurrency and Race Conditions [LDD3 05]

Concurrency and Its Management

并发和竞争条件,这个计算机操作系统里经典话题,只看文字体会不出什么,如果真正做过devcie driver的人都会深有体会。并发是一个很复杂的场景,复杂到没有人能预测当前的code会是怎么样的环境下执行,看似简答的device driver code,也许正在执行的时候因为时间片用完,CPU被切走了;可能中断发生,CPU处理中断去了;可能多个进程同时执行,多CPU同时在执行同一份code,访问同一个资源;另外,还要考虑资源是否可用,是否会休眠等等等等,在coding的时候其实是很难考虑全面的。

kernel提供了很多用于防止竞争条件的方式,比如mutex/spinlock等。竞争条件,说白了就是多个进程/线程访问了share的资源,尽可能保证少共享资源,就能有效减少竞争条件的发生,因此driver里要尽量少的使用全局变量,这是第一个原则。但是在实际的device driver中,全局共享变量不要太多啊。。既然不能不用共享,那就对共享资源进行加锁互斥访问。

这里又有几个重要的原则:

1, kernel中的object必须能够存在并正常工作,直到没有任何的reference。

2, device driver必须能够正常处理所有的request,直到没有被open的device。

a,在kernel driver真正ready之前,不要暴露接口给kernel;

b,所有的object/resource要能够track。

下面,就是操作系统里保证资源互斥访问的几种方式。

Semaphores and Mutexes

在保护资源之前,要先确定critical section,也就是一次只能运行一个线程执行的代码段。只有确定了critical section,才好使用系统的保护机制对资源进行保护。

semaphore,信号量,就是操作系统里的生产者消费者模型,可以允许是多个。当只有一个时,就变成里mutex,用来做资源保护,一次只允许一个线程访问critical section。在kernel中,绝大部分的semaphore都是1,也就说都是用来保证资源互斥访问用的。需要注意的是,semaphore是允许休眠的,如果当前锁拿不到,线程就会休眠,直到锁可用。

void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_trylock(struct semaphore *sem);

这三种down操作是用来获取锁的,第一个是不可中断的获取锁,一旦休眠,无法唤醒,除非锁可用被唤醒;down_interruptible是允许中断的休眠,可以被中断等唤醒;down_trylock是检测当前锁是否可用,如果可用就获取锁,不可用直接返回。如果需要释放锁:

void up(struct semaphore *sem);

调用完up以后锁就被释放了。down和up要严格一一对应,否则就会有问题。

Completions

如何用semaphore实现两个task的等待问题。很简单,创建一个sempahore,初始化为锁定状态,在某个时间点down,这里肯定sleep在这里,直到别的task up了这个semaphore,这样就实现了两个task的等待。但是这种实现方式,performance比较差,因为第一个task是几乎肯定sleep的,这就导致poor performance。因此,kernel针对这种需求有单独的实现,completion。

创建一个completion:

DECLARE_COMPLETION(my_completion);  //method 1

也可以用动态memory:

struct completion my_completion;
/* ... */
init_completion(&my_completion);

wait这个completion:

void wait_for_completion(struct completion *c);

别的task signal这个completion:

void complete(struct completion *c); //如果有多个waiter,只唤醒第一个
void complete_all(struct completion *c);  //如果有多个waiter,唤醒所有

另外,completion有一个很重要的作用,用来正确的等待thread结束。在device driver中,有可能会创建多个work thread,在module被rmmod,做cleanup的时候,就需要等待work thread结束,这个时候就用complete_exit:

void complete_and_exit(struct completion *c, long retval);

这个要研究下怎么用,work thread一般是while 1 loop,什么情况下调用这个complete_and_exit,因为这个function一旦调用,就会退出。

Spinlock

自旋锁,和semaphore不同,spinlock不会休眠,如果当前拿不到锁,CPU就会一致loop查询,直到锁可用。因为这种性质,spinlock可以用在中断处理例程里。因为中断处理程序执行前,会关闭中断,在当前中断处理完以后打开中断,如果中间发生休眠,那就没有人打开中断,也没人可以产生中断,中断处理程序再也不会被唤醒,系统over。所以有很多场景,是不能休眠,有需要锁保护某些资源,就用spinlock。

初始化也是两种方式,编译时和运行时:

spinlock_t my_lock = SPIN_LOCK_UNLOCKED;  //编译时初始化
void spin_lock_init(spinlock_t *lock); //运行时初始化

获取锁:

void spin_lock(spinlock_t *lock);

释放锁:

void spin_unlock(spinlock_t *lock);

关于spinlock的几点注意事项:

1, spinlock保护的code里,不能发生sleep。因为在kernel中,一旦发生了sleep,CPU就会切到别的进程执行,别的进程可能也需要这把锁,这就存在死锁的可能性;

2, spinlock会禁止相关的CPU被抢占,也会关闭local CPU的中断。在kernel中,如果已经拿到了spinlock,又发生了抢占,CPU也会切到别的进程执行,同样会导致死锁。为什么要关闭中断?因为中断处理程序允许用spinlock,假如driver code刚刚拿到了spinlock,中断发生了,中断处理程序运行在当前的CPU上,并且也需要拿同样的spinlock,这个CPU就发生了死锁,所以spinlock要关当前CPU的中断,别的CPU不影响,如果中断处理程序执行在别的CPU上,最多ISR自旋一会儿拿不到锁,但不会导致死锁。

3, hold spinlock的时间越短越好,因为你持有自旋锁的时间越长,别人等待你的时间就越长,performance就越差。

其他几个关于spinlock的函数:

void spin_lock(spinlock_t *lock);  //普通的spinlock
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);  //关闭local CPU的中断,并获取自旋锁,保存中断状态到flags
void spin_lock_irq(spinlock_t *lock); //关闭local CPU的中断,并获取自旋锁,不保存中断状态。前提是你确定中断没其他人关,只有你一个人在操作它。
void spin_lock_bh(spinlock_t *lock)  //获取自旋锁,关闭软中断,不关闭硬件中断

注意,如果你的code运行在软件或者硬件中断程序里,那么必须使用关中断(关闭当前CPU的中断)的方式获取自旋锁,否则会导致死锁。如果你的程序不会运行在硬件ISR里,只会运行在软件中断里,那么可以使用spin_lock_bh来获取自旋锁。如果拿到了自旋锁,这个时候硬件中断产生了,占用了CPU,只要硬件ISR不试图获取自旋锁,就不会发生死锁。

和上面对应的四种释放锁的方式: 

 void spin_unlock(spinlock_t *lock);
 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);

另外,kernel中还提供reader/writer版本的自旋锁,如果都是读者,那么都可以进入critical section,如果是写者,就要独自占用自旋锁。读者/写者自旋锁的初始化方式也是两种:

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而饥饿。

Traps

解决并发是一个很tricky的事情,所以往往有很多陷阱。

关于是否获取锁的规则:

陷阱一:已经获取了锁的function,在执行过程中又要试图获取这个锁,死锁发生了。因为function的相互调用关系,很难确定一个函数会调用什么函数,中间是否会发生重复获取同一个锁的情况。实际上,在驱动开发中,我确实碰到过这样的问题。

陷阱二:在某个函数调用前,是否需要调用者自己获取锁。这个也是个头疼的问题,调用者和被调用者需要提前约定好,一般而言,函数都做成自包含的,同一个函数里完成获取锁和释放锁,不依赖于外部获取锁,如果有这种需求,就在comment里明确的写出来,否则以后再看code会很难发现调用这个函数到底是否需要获取锁。

关于获取锁的顺序:

在复杂一些系统中,往往会同时持有不止一个锁。如果同时试图持有两个锁,就有可能导致死锁。解决办法就是如果需要获取多个锁,那就保持同样的获取锁的顺序。比如都先获取lock1,然后lock2。。等,这样如果一个线程先拿到了lock1,别的线程试图获取lock1就会等待,从而起到互斥的作用。

如果两个锁不同,比如一个kernel的锁,和自己module driver里的锁,那就先获取自己module driver的锁,再获取kernel的锁,这样持有kernel锁的时间短一些;如果既要获取semaphore,又要获取spinlock,那就先获取semaphore,因为spinlock持有的情况下不允许休眠。

细粒度和粗粒度锁:

kernel最开始的版本只有一个大锁,这个锁锁住了整个kernel,这就导致即便你有多个CPU,同时也只有一个能运行kernel,虽然解决互斥访问的问题,但也导致扩展性和性能都很差。这种就是粗粒度锁,后来kernel逐渐细化,针对每个不同但resource都有相应的锁,可以很大程度上提高扩展性和性能。

然后细粒度的锁也有自己的问题,粒度细,锁的数量必然就会多,访问某一些资源,获取锁就不会比较困难,更容易出现问题,而且一旦出问题,都会比较难debug。

所以,锁的粒度是一个tradeoff,需要慎重思考和决策。

除了锁以外的其他方式:

锁可以解决资源互斥访问的问题,但是不是唯一的解决办法,还有别的方式可以实现,比如原子操作和无锁算法。

无锁算法:

其实就是特殊设计的数据结构,可以允许数据在不加锁的情况下,保证不会同时被访问。比如circular buffer,针对读者和写者的模型,就可以做到不加锁的实现同时访问。具体方式如下,写者维护一个write index,并且只有写者有权限修改write index,读者维护一个read index,并且只有读者有权限修改read index。当需要写入数据时,写者把数据放在buffer的end,并更新write index,读者从head读取数据,并更新read index,这样二者不会重叠,就不用加锁了。

原子操作:

有时候需要被多个线程同时访问的只是一个int变量,这种情况下使用锁保护有点大材小用,所以有了atomic_t这种数据结构。这种数据结构在所有的架构上都支持,不同点在于这个变量不一定是int,有可能是24bit的一个变量,取决于具体的硬件架构。对atomic的操作都能在一个机器指令中完成,所以速度很快。支持的操作有:

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);
void atomic_sub(int i, atomic_t *v);

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操作

kernel支持针对bit操作的atomic,也是在一条机器指令中完成,所以SMP和中断都不会影响。操作有:

void set_bit(nr, void *addr);  //Sets bit number nr in the data item pointed to by addr.

void clear_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 change_bit(nr, void *addr); //Toggles the bit.
test_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.

//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); 
 

seqlock

这种锁比较特殊,应用场景也比较特殊。它针对大部分是reader,极少数是writer的情况。并且被保护的资源simple,small并且frequently accessed。它允许所有的读者直接访问data,但是要check是否和writer发生冲突,如果有,就重新读取data。这种方式不适合资源中有指针的情况。

初始化:

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中,需要使用:

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 (RCU)

用来实现资源的互斥访问。但是限制条件比较严格,1, 读多写少;2, 被保护的资源是指针形式访问;3, 对资源的引用在atomic的code中完成。当需要更新数据时,writer thread 先分配新的memory,然后copy resource,然后修改,然后把新的地址更新,这样后来的reader看到的就是新的指针。对读者而言,进入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,不允许引用资源了。

writer thread更新了新的地址以后,后续读者都能看到新的地址。但是老的资源什么时候释放呢?因为read是atomic的,所以只需要等待所有的CPU被调度了一次,那么一定没有人再引用旧的数据了,此时可以释放旧的资源。

发布了32 篇原创文章 · 获赞 6 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/scutth/article/details/105258401