内核同步问题

前言:随着2.6版内核出现,LINUX内核已经发展成抢占式内核,这意味着调度程序可以在不加保护的情况下抢占正在运行的内核代码,重新调度其他的进程执行,而它们必须被妥善的保管起来。

一、造成并发的原因

  • 中断————中断几乎可以在任何时刻异步发生,也就可能随时打断当前正在执行的代码
  • 软中断和tasklet————内核能在任何时刻唤醒或调度软中断和tasklet,打断当前正在执行的代码
  • 内核抢占————因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占
  • 睡眠及用户空间的同步————在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而导致调度一个新的用户进程
  • 对称多处理————两个或多个处理器可以同时执行代码

临界区:访问和操作共享数据的代码段

死锁:死锁的产生需要一定的条件:要有一个或多个执行线程和一个或多个资源,每个线程都在其中的一个资源,但所有的资源都已经被占用了,所有线程都在互相等待,但它们永远不会释放已经占有的资源。于是任何线程都无法继续执行,这便意味着死锁的发生。

防止死锁的办法:

  • 按顺序加锁。使用嵌套的锁的时候必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。
  • 防止发生饥饿(当线程访问它所需要的资源时却被永久拒绝,以至于不能在继续进行,这要就发生了饥饿)。
  • 不要重复等待请求同一个锁
  • 设计应力求简单

其他活跃度的危险:

  • 弱响应性:不良的锁管理也可能引起弱响应性。如果一个线程长时间占有一个锁(可能正在对一个大容器进行迭代,并对每一个元素进行耗时的工作),其他想要访问该容器的线程就必须等待很长时间。
  • 活锁:活锁(livelock)是线程中活跃失败的另一种形式,尽管没有被阻塞,线程却仍然不能继续,因为它不断重试相同的操作。却总是失败。

二、内核同步的方法

1.原子操作:

内核提供了两组原子操作接口———一组对整数(atomic_t)进行操作,另一组针对单独的位进行操作。

/********************整数操作***********************/
typedef struct{
        volatile int counter;
}atomic_t;
void atomic_set(atomic_t *v,int i);    //设置原子变量v的值为i
atomic_t v = ATOMIC_INIT(0);     //定义原子变量v,并初始化为0;

atomic_read(atomic_t* v);     //返回原子变量v的值(int型);
void atomic_add(int i, atomic_t* v);     //原子变量v增加i;
void atomic_sub(int i, atomic_t* v);    
void atomic_inc(atomic_t* v);     //原子变量增加1;
void atomic_dec(atomic_t* v);     

int atomic_inc_and_test(atomic_t* v);        //先自增1,然后测试其值是否为0,若为0,则返回true,否则返回false;
int atomic_dec_and_test(atomic_t* v);        
int atomic_sub_and_test(int i, atomic_t* v);     //先减i,然后测试其值是否为0,若为0,则返回true,否则返回false;
/***********************原子位操作************************/
//分为 设置,清除,改变,测试
void set_bit(int nr, volatile void* addr);        //设置地址addr的第nr位,所谓设置位,就是把位写为1;
void clear_bit(int nr, volatile void* addr);      //清除地址addr的第nr位,所谓清除位,就是把位写为0;

void change_bit(int nr, volatile void* addr);     //把地址addr的第nr位反转;

int test_bit(int nr, volatile void* addr);    //返回地址addr的第nr位;

int test_and_set_bit(int nr, volatile void* addr);    //测试并设置位;若addr的第nr位非0,则返回true; 若addr的第nr位为0,则返回false;
int test_and_clear_bit(int nr, volatile void* addr);    //测试并清除位;
int test_and_change_bit(int nr, volatile void* addr);    //测试并反转位;
上述操作等同于先执行test_bit(nr,voidaddr)然后在执行xxx_bit(nr,voidaddr)

2.自旋锁

自旋锁最多只能被一个可执行线程持有,如果试图获取自旋锁的线程发现锁已经被占有,那么该线程一直处于忙等待状态,直到获取到该锁,所以自旋锁不应该被长时间占有。自旋锁是不可以递归的,如果你试图获得一个你正持有的锁,你必须自旋等待自己释放这个锁,但是自己处于自旋忙等待中,永远没机会释放该锁。
自旋锁的使用:

DEFINE_SPINLOCK(mr_lock);
spin_lock(&mr_lock);
/*****************
    临界区
*****************/
spin_unlock(&mr_lock);
//在中断处理程序中使用自旋锁的时候,一定要在获取锁以前,首先禁止本地中断,否则,
//中断处理程序就会打断正持有锁的内核代码,有可能会试图争取这个已被持有的自旋锁。这样可能会发生死锁。
DEFINE_SPINLOCK(mr_lock);
unsigned long flags;
spin_lock_irqsave(&mr_lcok,flags);
/*******************
    临界区
********************/
spin_unlock_irqrestore(&mr_lock,flags);
//如果确定在获取锁之前本地中断是开启的,那么就不需要保存中断状态,解锁的时候直接将本地中断启用就可以,这样可以用如下的接口.
DEFINE_SPINLOCK(mr_lock);
spin_lock_irq(&mr_lock,);
/*******************
    临界区
********************/
spin_unlock_irqr(&mr_lock,);

3.读写自旋锁

当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞;当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是以写模式对它进行枷锁的线程将阻塞;读写锁的实现原理:维护一个很大的内存计数器读写锁的原理

DEFINE_RWLOCK(mr_rwlock);
read_lock(&mr_rwlock);
/****************
       临界区
*****************/
read_unlock(&mr_rwlock);
write_lock(&mr_rwlock);
write_unlock(&mr_rwlock);
//不能把一个读锁升级为写锁,因为写锁会不断自旋,等待所有读者释放锁
//write_lock(&mr_rwlock);
//read_lock(&mr_rwlock);

4.信号量

linux中的信号量是一种睡眠锁如果一个线程试图获取一个已经被抢占的信号量时,信号量会将其推进一个等待队列,然后让其睡眠,这个时候处理器可以去执行其他代码,当信号量可用的时候,处于等待队列的线程会被唤醒,并获取该信号量。由于等待信号量的线程会睡眠,所以适用于锁会被长时间占有的情况,由于信号量允许睡眠,所以信号量比自旋锁开销更大。在占用信号量的同时不能占用自旋锁,因为你等到的信号量可能会睡眠,而在持有自旋锁的时候是不能睡眠的。

自旋锁在一个时刻只能运行一个数量的持有者,而信号量在一个时刻可以允许任意数量的持有者,该数量是在信号量初始化的时候指定,也就是同一时刻可能有多个进程同时访问临界区。(但一般都使用互斥信号量)

由睡眠特性得出的结论:

  1. 只能在进程上下文中才能获取信号量,因为在中断上下文中是不能进行调度的。
  2. 可以在持有信号量的时候睡眠,因为当其他进程试图获取同一信号量的时候不会因此死锁(因为该进程也只是去睡眠,而最终会执行的)
  3. 当占用信号量的时候不能同时占用自旋锁,因为持有自旋锁时不允许睡眠

down()的操作通过对信号量计数减1来请求一个信号量,如果结果是0或者大于0就会获得信号量,任务就可以进入临界区,如果结果为负数,任务将被放入等待队列,up()和down()的目前实现还允许这两个函数在同一个信号量上并发,因此可能存在错误.

//静态初始化信号量
static DECLARE_MUTEX(mr_sem);
//动态初始化信号量
sema_init(&mr_sem,count);

if (down_interruptible(&mr_sem))  
/*试图获取指定的信号量,如果信号量不可用,它将会把调用进程设置为TASK_INTERRUPTIBLE状态并进入睡眠,睡眠的进程可以被唤醒,而down()会让进程状态设置为TASK_UNINTERRUPTIBLE进入睡眠*/
/*临界区*/
up(&mr_sem);

5,互斥锁

互斥锁和信号量为1的信号量含义类似,可以允许睡眠.

使用互斥锁的要求:

  • 在任何时刻中,只有一个任务可以持有mutex,mutex使用计数永远是1
  • 给mutex上锁者必须负责给其再解锁————在同一个上下文中上锁和解锁
  • 不能递归的持有一个锁,同样也不能再去解锁一个已经被解开的mutex
  • 当持有一个mutex时,进程不可以退出
  • mutex不能再中断或者下半部中使用,即使使用mutex_trylock()也不行
struct mutex mutex;
mutex_init(&mutex);
mutex_lock(&mutex);
mutex_unlock(&mutex);
低开销加锁 优先使用自旋锁
短期锁定 优先使用自旋锁
长期加锁 优先使用互斥体
中断上下文中加锁 使用自旋锁
持有锁需要睡眠 使用互斥体

互斥锁与自旋锁的区别:

自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。虽然它的效率比互斥锁高,但是它也有些不足之处:
    1、自旋锁一直占用CPU,他在未获得锁的情况下,一直运行--自旋,所以占用着CPU,如果不能在很短的时 间内获得锁,这无疑会使CPU效率降低。
    2、在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。

互斥锁:线程会从sleep(加锁)——>running(解锁),过程中有上下文的切换,cpu的抢占,信号的发送等开销。

自旋锁:线程一直是running(加锁——>解锁),死循环检测锁的标志位,机制不复杂。

互斥锁的起始原始开销要高于自旋锁,但是基本是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁、,但是随着持锁时间,加锁的开销是线性增长。

6、大内核锁、顺序锁、完成变量、屏障

猜你喜欢

转载自blog.csdn.net/m0_37760347/article/details/81387731