关于c:面试官让你讲讲Linux内核的竞争与并发你该如何回答

44次阅读

共计 10147 个字符,预计需要花费 26 分钟才能阅读完成。

@[TOC]

内核中的并发和竞争简介

  在晚期的 Linux 内核中,并发的起源绝对较少。晚期内核不反对对称多解决(symmetric multi processing,SMP),因而,导致并发执行的惟一起因是对硬件中断的服务。这种状况解决起来较为简单,但并不适用于为取得更好的性能而应用更多处理器且强调疾速响应事件的零碎。

  为了响应古代硬件和应用程序的需要,Linux 内核曾经倒退到同时解决更多事件的时代。Linux 零碎是个多任务操作系统,会存在多个工作同时拜访同一片内存区域的状况,这些工作可能会互相笼罩这段内存中的数据,造成内存数据凌乱。针对这个问题必须要做解决,重大的话可能会导致系统解体。当初的 Linux 零碎并发产生的起因很简单,总结一下有上面几个次要起因:

  1. 多线程并发拜访,Linux 是多任务(线程)的零碎,所以多线程拜访是最根本的起因。
  2. 抢占式并发拜访,内核代码是可抢占的,因而,咱们的驱动程序代码可能在任何时候失落对处理器的独占
  3. 中断程序并发拜访,设施中断是异步事件,也会导致代码的并发执行。
  4. SMP(多核)核间并发拜访,当初 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发拜访。正在运行的多个用户空间过程可能以一种令人诧异的组合形式拜访咱们的代码,SMP 零碎甚至可在不同的处理器上同时执行咱们的代码。

  只有咱们的程序在运行当中,就有可能产生并发和竞争。比方,当两个执行线程须要拜访 雷同的数据结构 (或硬件资源)时,混合的可能性就永远存在。因而在设计本人的驱动程序时,就应该防止资源的共享。 如果没有并发的拜访,也就不会有竞态的产生 。因而,认真编写的内核代码应该具备 起码的共享 。这种思维的最显著利用就是 防止应用全局变量。如果咱们将资源放在多个执行线程都会找到的中央(临界区),则必须有足够的理由。

  如何避免咱们的数据被并发拜访呢?这个时候就要建设一种爱护机制,上面介绍几种内核提供的几种并发和竞争的解决办法。

原子操作

原子操作简介

  原子,在早接触到是在化学概念中。原子指化学反应不可再分的根本微粒。同样的,在内核中所说的原子操作示意这一个拜访是一个步骤,必须一次性执行完,不能被打断,不能再进行拆分。
  例如,在多线程拜访中,咱们的线程一对 a 进行赋值操作,a=1,线程二也对 a 进行赋值操作a=2,咱们现实的执行程序是线程一先执行,线程二再执行。然而很有可能在线程一执行的时候被其余操作打断,使得线程一最初的执行后果变为a=2。要解决这个问题,必须保障咱们的线程一在对数据拜访的过程中不能被其余的操作打断,一次性执行实现。

整型原子操作函数

函数 形容
ATOMIC_INIT(int i) 定义原子变量的时候对其初始化。
int atomic_read(atomic_t*v) 读取 v 的值,并且返回
void atomic_set(atomic_t *v, int i) 向 v 写入 i 值。
void atomic_add(int i, atomic_t *v) 给 v 加上 i 值。
void atomic_sub(int i, atomic_t *v) 从 v 减去 i 值。
void atomic_inc(atomic_t *v) 给 v 加 1,也就是自增。
void atomic_dec(atomic_t *v) 从 v 减 1,也就是自减。
int atomic_dec_return(atomic_t *v) 从 v 减 1,并且返回 v 的值。
int atomic_inc_return(atomic_t *v) 给 v 加 1,并且返回 v 的值。
int atomic_sub_and_test(int i, atomic_t *v) 从 v 减 i,如果后果为 0 就返回真,否则就返回假
int atomic_dec_and_test(atomic_t *v) 从 v 减 1,如果后果为 0 就返回真,否则就返回假
int atomic_inc_and_test(atomic_t *v) 给 v 加 1,如果后果为 0 就返回真,否则就返回假
int atomic_add_negative(int i, atomic_t *v) 给 v 加 i,如果后果为负就返回真,否则返回假

注:64 位的整型原子操作只是将“atomic_”前缀换成“atomic64_”,将 int 换成 long long。

位原子操作函数

函数 形容
void set_bit(int nr, void *p) 将 p 地址的 nr 地位 1
void clear_bit(int nr,void *p) 将 p 地址的 nr 位清零
void change_bit(int nr, void *p) 将 p 地址的 nr 位反转
int test_bit(int nr, void *p) 获取 p 地址的 nr 位的值
int test_and_set_bit(int nr, void *p) 将 p 地址的 nr 地位 1,并且返回 nr 位原来的值
int test_and_clear_bit(int nr, void *p) 将 p 地址的 nr 位清 0,并且返回 nr 位原来的值
int test_and_change_bit(int nr, void *p) 将 p 地址的 nr 位翻转,并且返回 nr 位原来的值

原子操作例程

/* 定义原子变量,初值为 1 */
static atomic_t xxx_available = ATOMIC_INIT(1); 
static int xxx_open(struct inode *inode, struct file *filp)
{
 ...
 /* 通过判断原子变量的值来查看 LED 有没有被别的利用应用 */
 if (!atomic_dec_and_test(&xxx_available)) {
 /* 小于 0 的话就加 1, 使其原子变量等于 0 */
 atomic_inc(&xxx_available);
 /* LED 被应用,返回忙 */
 return - EBUSY; 
 }
...
/* 胜利 */
 return 0;
static int xxx_release(struct inode *inode, struct file *filp)
{
 /* 敞开驱动文件的时候开释原子变量 */
 atomic_inc(&xxx_available); 
 return 0;
}

自旋锁

  下面咱们介绍了原子变量,从它的操作函数能够看出,原子操作只能针对 整型变量或者位。如果咱们有一个构造体变量须要被线程 A 所拜访,在线程 A 访问期间不能被其余线程拜访,这怎么办呢?自旋锁就能够实现对构造体变量的爱护。

自旋锁简介

  自旋锁,顾名思义,咱们能够把他了解成厕所门上的一把锁。这个厕所门只有一把钥匙,当咱们进去时,把钥匙取下来,进去后反锁。那么当第二个人想进来,必须等咱们进来后才能够。当第二个人在里面期待时,可能会始终期待在门口转圈。

  咱们的自旋锁也是这样,自旋锁只有锁定和解锁两个状态。当咱们进入拿上钥匙进入厕所,这就相当于自旋锁锁定的状态,期间谁也不能够进来。当第二个人想要进来,这相当于线程 B 想要拜访这个共享资源,然而目前不能拜访,所以线程 B 就始终在原地期待,始终查问是否能够拜访这个共享资源。当咱们从厕所进去后,这个时候就“解锁”了,只有再这个时候线程 B 能力拜访。

  如果,在厕所的人待的工夫太长怎么办?里面的人始终期待吗?如果换做是咱们,必定不会这样,几乎浪费时间,可能咱们会寻找其余办法解决问题。自旋锁也是这样的,如果线程 A 持有 自旋锁工夫过长,显然会节约处理器的工夫,升高了零碎性能 。咱们晓得 CPU 最平凡的创造就在于多线程操作,这个时候让线程 B 在这里傻傻的不晓得还要期待多久,显然是不合理的。因而,如果 自旋锁只适宜短期持有,如果遇到须要长时间持有的状况,咱们就要换一种形式了(下文的互斥体)。

自旋锁操作函数

函数 形容
DEFINE_SPINLOCK(spinlock_t lock) 定义并初始化一个自旋变量
int spin_lock_init(spinlock_t *lock) 初始化自旋锁
void spin_lock(spinlock_t *lock) 获取指定的自旋锁,也叫加锁
void spin_unlock(spinlock_t *lock) 开释指定的自旋锁。
int spin_trylock(spinlock_t *lock) 尝试获取指定的锁,如果没有获取到,返回 0
int spin_is_locked(spinlock_t *lock) 查看指定的自旋锁是否被获取,如果没有被获取返回非 0,否则返回 0.

  自旋锁是次要为了 多处理器零碎 设计的。对于 单处理器且内核不反对抢占的零碎 ,一旦进入了自旋状态,则会永远自旋上来。因为,没有任何线程能够获取 CPU 来开释这个锁。因而,在单处理器且内核不反对抢占的零碎中, 自旋锁会被设置为空操作

  以上列表中的函数 实用于 SMP 或反对抢占的单 CPU下线程之间的并发拜访,也就是用于线程与线程之间,被自旋锁爱护的临界区 肯定不能调用任何可能引起睡眠和阻塞(其实实质依然是睡眠)的 API 函数 ,否则的话会可能会导致死锁景象的产生。自旋锁会 主动禁止抢占 ,也就说当线程 A 失去锁当前会 临时禁止内核抢占 。如果线程 A 在持有锁期间进入了 休眠状态 ,那么线程 A 会 主动放弃 CPU 使用权 。线程 B 开始运行,线程 B 也想要获取锁,然而此时锁被 A 线程持有,而且 内核抢占还被禁止了 !线程 B 无奈被调度岀去,那么线程 A 就无奈运行,锁也就无奈开释 死锁 产生了!

  当线程之间产生并发拜访时,如果此时中断也要插一脚,中断也想访问共享资源,那该怎么办呢?首先能够必定的是,中断外面应用自旋锁,然而在中断外面应用自旋锁的时候,在获取锁之前肯定要 先禁止本地中断 (也就是 本 CPU 中断,对于多核 SOC 来说会有多个 CPU 核),否则可能导致锁死景象的产生。看下上面一个例子:

// 线程 A
spin_lock(&lock);
.......
functionA();
.......
spin_unlock(&lock);

// 中断产生,运行线程 B
spin_lock(&lock);
.......
functionA();
.......
spin_unlock(&lock);

  线程 A 先运行,并且获取到了 lock 这个锁,当线程 A 运行 functionA 函数的时候中断产生了,中断抢走了 CPU 使用权。下边的中断服务函数也要获取 lock 这个锁,然而这个锁被线程 A 占有着,中断就会始终自旋,期待锁无效。然而在中断服务函数执行完之前,线程 A 是不可能执行的,线程 A 说“你先撒手”,中断说“你先撒手”,局面就这么僵持着死锁产生!

  应用了自旋锁之后能够保障临界区不受别的 CPU 和本 CPU 内的抢占过程的打搅,然而失去锁的代码在执行临界区的时候,还可能受到中断和底半部的影响,为了避免这种影响,倡议应用以下列表中的函数:

函数 形容
void spin_lock_irq(spinlock_t *lock) 禁止本地中断,并获取自旋锁
void spin_unlock_irq(spinlock_t *lock) 激活本地中断,并开释自旋锁
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags) 保留中断状态,禁止本地中断,并获取自旋锁
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)       将中断状态复原到以前的状态,并且激活本地中断,开释自旋锁

  在多核编程的时候,如果过程和中断可能拜访同一片临界资源,咱们个别须要在过程上下文中调用 spin_ lock irqsave()spin_unlock_irqrestore(),在中断上下文中调用 spin_lock()spin _unlock()。这样,在 CPU 上,无论是过程上下文,还是中断上下文取得了自旋锁,尔后,如果 CPU1 无论是过程上下文,还是中断上下文,想取得同一自旋锁,都必须忙期待,这防止所有核间并发的可能性。同时,因为每个核的过程上下文持有锁的时候用的是 spin_lock_irgsave(),所以该核上的中断是不可能进入的,这防止了核内并发的可能性。

DEFINE_SPINLOCK(lock) /* 定义并初始化一个锁 */ 
/* 线程 A */
void functionA (){ 
unsigned long flags; /* 中断状态 */
 spin_lock_irqsave(&lock, flags) /* 获取锁 */ 
  /* 临界区 */ 
spin_unlock_irqrestore(&lock, flags) /* 开释锁 */ 
} 
 /* 中断服务函数 */
 void irq() {spin_lock(&lock) /* 获取锁 */ 
   /* 临界区 */ 
 spin_unlock(&lock) /* 开释锁 */ 
}

自旋锁例程

static int xxx_open(struct inode *inode, struct file *filp)
{
...
    spinlock(&xxx_lock);
    if (xxx_count) {/* 曾经关上 */
    spin_unlock(&xxx_lock);
    return -EBUSY;
 }
     xxx_count++;/* 减少应用计数 */
     spin_unlock(&xxx_lock);
 ...
     return 0;/* 胜利 */
}

static int xxx_release(struct inode *inode, struct file *filp)
{
     ...
     spinlock(&xxx_lock);
     xxx_count--;/* 缩小应用计数 */
     spin_unlock(&xxx_lock);
     return 0;
}

读写自旋锁

  当临界区的一个文件能够被 同时读取 ,然而并 不能被同时读和写 。如果一个线程在读,另一个线程在写,那么很可能会读取到谬误的 不残缺的数据 。读写自旋锁是能够 容许对临界区的共享资源进行并发读操作的。然而并不容许多个线程并发读写操作。如果想要并发读写,就要用到了程序锁。
  读写自旋锁的读操作函数如下所示:

函数 形容
DEFINE_RWLOCK(rwlock_t lock) 定义并初始化读写锁
void rwlock_init(rwlock_t *lock) 初始化读写锁
void read_lock(rwlock_t *lock) 获取读锁
void read_unlock(rwlock_t *lock 开释读锁
void read_unlock_irq(rwlock_t *lock) 关上本地中断,并且开释读锁
void read_lock_irqsave(rwlock_t *lock,unsigned long flags) 保留中断状态,禁止本地中断,并获取读锁
void read_unlock_irqrestore(rwlock_t *lock,unsigned long flags) 将中断状态复原到以前的状态,并且激活本地中断,开释读锁
void read_lock_bh(rwlock_t *lock) 敞开下半部,并获取读锁
void read_unlock_bh(rwlock_t *lock) 关上下半部,并开释读锁

  读写自旋锁的写操作函数如下所示:

函数 形容
void write_lock(rwlock_t *lock) 获取写锁
void write_unlock(rwlock_t *lock) 开释写锁
void write_lock_irq(rwlock_t *lock) 禁止本地中断,并且获取写锁。
void write_unlock_irq(rwlock_t *lock) 关上本地中断,并且开释写锁
void write_lock_irqsave(rwlock_t *lock,unsigned long flags) 保留中断状态,禁止本地中断,并获取写锁
void write_unlock_irqrestore(rwlock_t *lock,unsigned long flags) 将中断状态复原到以前的状态,并且激活本地中断,开释写锁
void write_lock_bh(rwlock_t *lock) 敞开下半部,并获取写锁
void write_unlock_bh(rwlock_t *lock) 关上下半部,并开释写锁

读写锁例程

rwlock_t lock; /* 定义 rwlock */
rwlock_init(&lock); /* 初始化 rwlock */
/* 读时获取锁 */
read_lock(&lock);
... /* 临界资源 */
read_unlock(&lock);
/* 写时获取锁 */
write_lock_irqsave(&lock, flags);
... /* 临界资源 */
write_unlock_irqrestore(&lock, flags);

程序锁

  程序锁是读写锁的优化版本,读写锁不容许同时读写,而应用 程序锁能够实现同时进行读和写的操作 但并不容许同时的写。尽管程序锁能够同时进行读写操作,但并不倡议这样,读取的过程并不能保证数据的完整性。

程序锁操作函数

  程序锁的读操作函数如下所示:

函数 形容
DEFINE_SEQLOCK(seqlock_t sl) 定义并初始化程序锁
void seqlock_ini seqlock_t *sl) 初始化程序锁
void write_seqlock(seqlock_t *sl) 程序锁写操作
void write_sequnlock(seqlock_t *sl) 获取写程序锁
void write_seqlock_irq(seqlock_t *sl) 禁止本地中断,并且获取写程序锁
void write_sequnlock_irq(seqlock_t *sl) 关上本地中断,并且开释写程序锁
void write_seqlock_irqsave(seqlock_t *sl,unsigned long flags) 保留中断状态,禁止本地中断,并获取写程序
void write_sequnlock_irqrestore(seqlock_t *sl,unsigned long flags) 将中断状态复原到以前的状态,并且激活本地中断,开释写程序锁
void write_seqlock_bh(seqlock_t *sl) 敞开下半部,并获取写读锁
void write_sequnlock_bh(seqlock_t *sl) 关上下半部,并开释写读锁

  程序锁的写操作函数如下所示:

函数 形容
DEFINE_RWLOCK(rwlock_t lock) 读单元访问共享资源的时候调用此函数,此函数会返回程序锁的顺序号
unsigned read_seqretry(const seqlock_t *sl,unsigned start) 读完结当前调用此函数查看在读的过程中有没有对资源进行写操作,如果有的话就要重读

自旋锁应用注意事项

  1. 因为在期待自旋锁的时候处于“自旋”状态,因而锁的持有工夫不能太长,肯定要短,否则的话会升高零碎性能。如果临界区比拟大,运行工夫比拟长的话要抉择其余的并发解决形式,比方稍后要讲的信号量和互斥体。
  2. 自旋锁爱护的临界区内不能调用任何可能导致线程休眠的 API 函数,比方 copy_from_user()、copy_to_user()、kmalloc()和 msleep()等函数,否则的话可能导致死锁。
  3. 不能递归申请自旋锁,因为一旦通过递归的形式申请一个你正在持有的锁,那么你就必须“自旋”,期待锁被开释,然而你正处于“自旋”状态,基本没法开释锁。后果就是本人把本人锁死了
  4. 在编写驱动程序的时候咱们必须思考到驱动的可移植性,因而不论你用的是单核的还是多核的 SOC,都将其当做多核 SOC 来编写驱动程序。

copy_from_user 的应用是联合过程上下文的,因为他们要拜访“user”的内存空间,这个“user”必须是某个特定的过程。如果在驱动中应用这两个函数,必须是在实现零碎调用的函数中应用,不可在实现中断解决的函数中应用。如果在中断上下文中应用了,那代码就很可能操作了基本不相干的过程地址空间。其次因为操作的页面可能被换出,这两个函数可能会休眠,所以同样不可在中断上下文中应用。

信号量

信号量简介

  信号量和自旋锁有些类似,不同的是信号量会收回一个信号通知你还须要等多久。因而,不会呈现傻傻期待的状况。比方,有 100 个停车位的停车场,门口电子显示屏上实时更新的停车数量就是一个信号量。这个停车的数量就是一个信号量,他通知咱们是否能够停车进去。当有车开进去,信号量加一,当有车开进去,信号量减一。
  比方,厕所一次只能让一个人进去,当 A 在外面的时候,B 想进去,如果是自旋锁,那么 B 就会始终在门口傻傻期待。如果是信号量,A 就会给 B 一个信号,你先回去吧,我进去了叫你。这就是一个信号量的例子,B 听到 A 收回的信号后,能够先回去睡觉,期待 A 进去。
  因而,信号量显然能够进步零碎的执行效率,防止了许多无用功。信号量具备以下特点:

  1. 因为信号量能够使期待资源线程进入休眠状态,因而实用于那些 占用资源比拟久 的场合。
  2. 因而信号量不能用于中断中,因为信号量会引起休眠,中断不能休眠
  3. 如果共享资源的持有工夫比拟短,那就不适宜应用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的那点劣势

信号量操作函数

函数 形容
DEFINE_SEAMPHORE(name) 定义一个信号量,并且设置信号量的值为 1
void sema_init(struct semaphore *sem, int val) 初始化信号量 sem,设置信号量值为 val
void down(struct semaphore *sem) 获取信号量,因为会导致休眠,因而不能在中断中应用
int down_trylock(struct semaphore *sem); 尝试获取信号量,如果能获取到信号量就获取,并且返回 0. 如果不能就返回非 0,并且不会进入休眠
int down_interruptible(struct semaphore                       获取信号量,和 down 相似,只是应用 dow 进入休眠状态的线程不能被信号打断。而应用此函数进入休眠当前是能够被信号打断的
void up(struct semaphore *sem) 开释信号量

信号量例程

struct semaphore sem; /* 定义信号量 */ 
sema_init(&sem, 1);/* 初始化信号量 示意只能由一个线程同时拜访这块资源 */
 down(&sem); /* 申请信号量 */
  /* 临界区 */ 
 up(&sem); /* 开释信号量 */

互斥体

互斥体简介

  互斥体示意一次只有一个线程访问共享资源,不能够递归申请互斥体
  信号量也能够用于互斥体,当信号量用于互斥时(即防止多个过程同时在一个临界区中运行),信号量的值应初始化为 1. 这种信号量在任何给定时刻只能由单个过程或线程领有。在这种应用模式下,一个信号量有时也称为一个“互斥体(mutex)”,它是互斥(mutual exclusion)的简称。Linux 内核中 几平所有的信号量均用于互斥

互斥体操作函数

函数 形容
DEFINE_MUTEX(name) 定义并初始化一个 mutex 变量
void mutex_init(mutex *lock) 初始化 mutex
void mutex_lock(struct mutex *lock) 获取 mutex,也就是给 mutex 上锁。如果获取不到就进休眠
void mutex_unlock(struct mutex *lock) 开释 mutex,也就给 mutex 解锁
int mutex_trylock(struct mutex *lock)                       判断 mutex 是否被获取,如果是的话就返回,否则返回 0
int mutex_lock_interruptible(struct mutex *lock) 应用此函数获取信号量失败进入休眠当前能够被信号打断

互斥体例程

struct mutex lock; /* 定义一个互斥体 */ 
mutex_init(&lock); /* 初始化互斥体 */ 
mutex_lock(&lock); /* 上锁 */ 
/* 临界区 */
mutex_unlock(&lock); /* 解锁 */

互斥体与自旋锁

  互斥体和自旋锁都是解决互斥问题的一种伎俩。互斥体是过程级别的,互斥体在应用的时候会产生过程间的切换,因而,应用互斥体资源开销比拟大。自旋锁能够节俭上下文切换的工夫,如果持有锁的工夫不长,应用自旋锁是比拟好的抉择,如果持有锁工夫比拟长,互斥体显然是更好的抉择。

互斥体应用注意事项

  1. 当锁不能被获取到时,应用互斥体的开销是 过程上下文切换工夫 ,应用自旋锁的开销是期待 获取自旋锁(由临界区执行工夫决定)。若临界区比拟小,宜应用自旋锁,若临界区很大,应应用互斥体。
  2. 互斥体所爱护的临界区可蕴含可能引起阻塞的代码,而自旋锁则相对要防止用来爱护蕴含这样代码的临界区。因为 阻塞意味着要进行过程的切换,如果过程被切换岀去后,另一个过程希图获取本自旋锁,死锁就会产生。
  3. 互斥体存在于过程上下文。因而,如果被爱护的共享资源须要在中断或软中断状况下应用,则在互斥体和自旋锁之间只能抉择自旋锁。当然,如果肯定要应用互斥体,则只能通过 mutex trylock()形式进行,不能获取就立刻返回以防止阻塞。

  大家的激励是我持续创作的能源,如果感觉写的不错,欢送关注,点赞,珍藏,转发,谢谢!

如遇到排版错乱的问题,能够通过以下链接拜访我的 CSDN。

**CSDN:[CSDN 搜寻“嵌入式与 Linux 那些事”]

正文完
 0