并发执行的原因:
中断:中断几乎可以在任何时刻异步发生,也就随时可能打断当前正在执行的代码。
软中断和tasklet:内核能在任何时刻唤醒或者调度软中断和tasklet,打断当前正在执行的代码。
内核抢占:内核具有抢占性,所以内核任务可能被另一任务抢占。
睡眠及用户空间的同步:在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而调度一个新的用户进程执行。
对称多处理:两个或多个处理器可以同时执行代码。
内核代码操作某资源的时候,系统产生了一个中断,而且该中断的处理程序要访问这一资源,这就是一个bug;如果一段内核代码在访问一个共性资源期间可以被抢占,这也是一个bug;如果内核在临界区里睡眠,这也是一个bug;两个处理器绝对不能在同一时间访问同一资源。
编写内核代码需要考虑的问题:
- 这个数据是不是全局的?除了当前线程外,其他线程能不能访问它?
- 这个数据会不会在进程上下文和中断上下文共享?它是不是在两个不同的中断处理程序中共享?
- 进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?
- 当前集成是不是会睡眠(阻塞)在某些资源上,如果是,它会让共享数据处于何种状态?
- 怎样防止数据失控?
- 如果这个函数又在另一个处理器上被调度将会发生什么?
- 如何确保代码远离并发威胁
避免死锁的一些原则:
- 按顺序加锁。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其他人也能按照此顺序使用。
- 防止发生饥饿。试问,这个代码的执行是否一定会结束?如果“张”不发生?“王”一定要等待下去吗?
- 不要重复请求一个锁。
- 设计应力求简单。越复杂的加锁方案越有可能造成死锁。
信号量和互斥体
一个信号量本质上是一个整数值,它和一对函数联合使用,这对函数通常称为P和V。希望进入临界区的进程将在相关信号量上调用P;如果信号量的值大于零,则该值减一,进程可以继续。如果信号量的值为零(或者更小),则进程必须等待直到其他人释放信号量。对信号量的解锁通过调用V完成;该函数增加信号量的值,并在必要时唤醒等待的进程。当信号量用于互斥时(避免多个进程同时在一个临界区中运行),信号量的值应该初始化为1 。这种信号量在任何给定时刻只能由单个进程或线程拥有。在这种使用模式下,一个信号量有时也称为一个“互斥体”。如果信号量大于1,可允许多个线程进入临界区。Linux内核中几乎所有的信号量均用于互斥。和自旋锁不同,当进程获取不到信号量时并不是原地打转而是睡眠等待,中断服务程序不能进行睡眠,因此信号量不能用于中断,如果中断函数一定要用信号量,可以使用常识上锁进行操作,不能获取锁就立即返回,以避免阻塞。
Linux信号量的实现
#include <linux/semaphore.h> //信号量相关函数的头文件
struct semaphore sem; //定义一个信号量
void sema_init(struct semaphore *sem, int val );//初始化信号量,参数1:信号量变量,参数2:信号量初始值。
在Linux世界中,P函数被称为down,指的是该函数减小了信号量的值,它也行会将调用者置于休眠状态,然后等待信号量变得可用,之后授予调用者对被保护资源的访问。下面是down的三个版本:
void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_trylock(struct semaphore *sem);
down减小信号量的值,并在必要时一直等待。down_interruptible完成相同的操作,但是可以被一个信号中断,并返回EINTR;当没有被中断,信号量变为可用,返回0 。可参照内核该函数的注释部分。down_trylock,永远不会休眠,如果信号量在调用时不可获得,会立即返回一个非零值。当一个线程成功调用上述down的某个版本后,就称该线程获得了该信号量,该线程就被赋予访问由该信号量保护的临界区的权利。当互斥操作完成之后,必须返回该信号量。Linux等价于V的函数是up:
void up(struct semaphore *sem);调用up之后,调用者不再拥有该信号量。
任何拿到信号量的线程都必须通过一次(只有一次)对up的调用而释放该信号量。如果拥有一个信号量时发生错误,必须在将错误状态返回给调用者之前释放该信号量。
读取者/写入者信号量
信号量对所有的调用者执行互斥,不管每个线程想做什么。但是,许多任务可以划分为两种不同的工作类型:一些任务只需要读取受保护的数据结构,而其他的则必须做出修改。允许多个并发的读取者是可能的,这样可以大大提高性能。Linux内核提供了一种特殊的信号量类型:rwsem。
使用rwsem代码必须包含<linux/rwsem.h>。相关的数据类型是:struct rw_semaphore;
初始化函数:void init_rwsem(struct rw_semaphore *sem);新初始化的rwsem可用于其后出现的任务(读取者或写入者)。对只读访问,可用的接口如下:
void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);
void up_read(struct rw_semaphore *sem);
对down_read的调用提供了对受保护资源的只读访问,可和其他读取者并发访问。注意down_read可能会将调用者置于不可中断的休眠。down_read_trylock不会在读取访问不可获得时等待,它在授予访问时返回非零,其他情况下返回零。由down_read获得的rwsem对象最终必须通过up_read被释放。
对于写入者的接口:
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);//该函数用于把写者降级为读者,这有时是必要的。因为写者是排他性的,因此在写者保持读写信号量期间,任何读者或写者都将无法访问该读写信号量保护的共享资源,对于那些当前条件写不需要写访问的写者,降级为读者,使得等待访问的读者能够立即访问,从而增加了并发性。一个rwsem可允许一个写入者或无限多个读者拥有该信号量。写入者具有更高优先级;当某个给定写入者试图进入临界区时,在所有写入者完成其工作之前,不会允许读取者获得访问。如果有大量的写入者竞争该信号量,则这种实现会导致读取者“饿死”。
completion
completion是一种轻量级机制,它允许一个线程告诉另一个线程某个工作已经完成。必须包含<linux/completion.h>。可以利用DECLARE_COMPLETION(my_completion);或者struct completion my_completion; init_completion(&my_completion);要等待completion,可进行如下调用:void wait_for_completion(struct completion *c);该函数执行一个非中断的等待。如果代码调用了wait_for_completion且没有人会完成该任务,则将产生一个不可杀的进程。另一方面,实际的completion事件可通过调用下面的函数触发:
void complete(struct completion *c);//只会唤醒一个等待线程
void complete_all(struct completion *c);//允许唤醒所有等待线程
一个completion通常是一个单次设备;也就是说,它只会被使用一次然后丢弃。但是仔细处理,也可以被重复利用。如果没有使用completion_all,则可以重复使用completion结构,只要那个将要触发的事件是明确而不含糊的。但是如果使用了completion_all,则必须在重复使用该结构之前,重新初始化,下面的宏可以快速执行重新初始化:
INIT_COMPLETION(struct completion c);
自旋锁
和信号量不同,自旋锁可在不能休眠的代码中使用,比如中断处理例程。在正确使用的情况下,自旋锁通常可以提供比信号量更高的性能。Linux自旋锁同一时刻只能被一个可执行线程持有,当一个线程试图获取一个已经被持有的自旋锁时,就会一直忙着循环-选择-等待锁重新可用。忙等待免去了线程挂起再被唤醒的转换,省去了两次上下文切换的时间。因而自旋锁适合下面的情景:SMP多核系统中,持有自旋锁的时间小于完成两次上下文切换的时间,这种场景使用自旋锁效率会比较高。如果是单核CPU或者禁止内核抢占时,编译的时候自旋锁会被完全剔除内核。自旋锁不可递归,可用于中断处理程序中(中断处理程序中不能使用信号量,因为会导致睡眠)。在中断处理程序中处理自旋锁时,一定要在获取锁之前,关闭当前核的中断,防止在中断中又去试图获得锁而造成死锁。
自旋锁API介绍
自旋锁包含的文件是<linux/spinlock.h>。实际的锁具有spinlock_t类型。自旋锁的初始化:
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);
自旋锁和原子上下文
锁定一个自旋锁的函数有如下四个:
void spin_lock(spinlock_t *lock);
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_lock_irq(spinklock_t *lock);
void spin_lock_bh(spinlock_t *lock);
spin_lock_irqsave会在获得自旋锁之前禁止中断(只在本地处理器上),而先前的中断状态保存在flags中。如果我们能够确保没有任何其他代码禁止本地处理器的中断(或者换句话说,我们能够确保在释放自旋锁时应该启用中断)则可以使用spin_lock_irq,而无需跟踪标志。spin_lock_bh在获得锁之前禁止软件中断,但是会让硬件中断保持打开。
如果有一个自旋锁,它可以被运行在(硬件或软件)中断上下文的代码获得,则必须使用某个禁止中断的spin_lock形式。如果我们不会在硬件中断处理例程中访问自旋锁,但可能在软件中断(例如tasklet形式运行代码)中访问,则应该使用spin_lock_bh,以便在安全地避免死锁的同时还能服务硬件中断。严格对应于获取自旋锁的函数,释放自旋锁的四种函数:
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_unlock的变种都会撤销对应的spin_lock函数所做的工作。传递到spin_unlock_irqrestore的flags参数必须是传递给spin_lock_irqsave的同一个变量。还必须在同一个函数中调用spin_lock_irqsave和spin_unlock_irqrestore,否则代码可能在某些架构上出现问题。
还有如下非阻塞的自旋锁操作:
int spin_trylock(spinlock_t *lock);
int spin_trylock_bh(spinlock_t *lock);
这两个函数在成功(即获得自旋锁)时返回非零值,否则返回零。
读取者/写入者自旋锁
允许任意数量的读取者同时进入临界区,但写入者必须互斥访问。
锁陷阱
不论是信号量还是自旋锁,都不允许锁的拥有者第二次获得这个锁。
原子变量
完整的锁机制对一个简单的整数来讲,显得有些浪费,针对这种情况,内核提供了一种原子的整数类型:atomic_t,定义在<asm/atomic.h>中。
void atomic_set(atomic_t *v, int i);
atomic_t v = ATOMIC_INIT(0);
将原子变量v的值设置为整数值i。也可以在编译时,利用ATOMIC_INIT宏来初始化原子变量的值。
int atomic_read(atomic_t *v);
返回v的当前值。
void atomic_add(int i, atomic_t *v);
将i累加到v指向的原子变量。
void atomic_sub(int i, atomic_t *v);
从*v中减去i。
void atomic_inc(atomic_t *v);
void atomic_dec(atomic_t *v);
增加或缩减一个原子变量。
本文探讨了并发编程中的核心问题,包括中断、抢占、睡眠等导致的并发执行原因,以及如何使用信号量、自旋锁和原子变量等机制解决并发问题。

1042

被折叠的 条评论
为什么被折叠?



