Linux设备驱动中的并发控制
Linux设备驱动中的并发控制
幸福阶梯 发表于5个月前
Linux设备驱动中的并发控制
  • 发表于 5个月前
  • 阅读 9
  • 收藏 0
  • 点赞 0
  • 评论 0

腾讯云 技术升级10大核心产品年终让利>>>   

在Linux设备驱动中必须解决的一个问题是多个进程对共享资源的并发问题,并发的访问会导致竞态,即使是经验丰富的驱动工程师也会常常设计出包含并发问题bug的驱动程序。

7.1并发与竞态

并发(concurrency)指的是多个执行单元同时、并发被执行,而并发的执行单元对共享资源的访问则很容易导致竞态。例如对于globalmem设备,假如一个个执行单元A对其写入3000个字符“a”,而另一个执行单元B对其写入4000个“b”,第三个执行单元C读取globalmem的所有字符。如果执行单元A、B的写操作按图7.1那样顺序发生,执行单元C的读操作当然不会有什么问题。但是,如果执行单元A、B按图7.2那样执行,而执行单元C又“不合时宜”地读,则会读出3000个“b”。

 

 

比图7.2更复杂、更混乱的并发大量存在于设备驱动中,只要并发的多个执行单元存在对共享资源的访问,竞态就可能发生。在Linux内核中,主要的竞态发生于如下几种情况。

1、对称多处理器(SMP)的多个CPU

SMP是一种紧耦合、共享存储的系统模型,其体系结构如图7.3所示,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。

 

在SMP的情况下,两个核(CPU0和CPU1)的竞态可能发生于CPU0的进程和CPU1的进程之间,CPU0的进程与CPU1的中断之间以及CPU0的中断与CPU1的中断之间,图7.4中任何一条线连接的两个实体都有核间并发可能性。

 

2、单CPU内进程与抢占它的进程

Linux2.6以后的内核支持内核抢占调度,一个进程在内核执行的时候可能耗完了自己的时间片,也可能被另一个高优先级进程打断,进程与抢占它的进程访问共享资源的情况类似于SMP的多个CPU。

3、中断与进程之间

中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则竞态也会发生。

此外,中断也有可能被新的高优先级的中断打断,因此,多个中断之间本身也可能引起并发而导致竞态。但是Linux2.6.35之后,就取消了中断的嵌套。老版本的内核可以在申请中断时,设置标记IRQF_DISABLED以避免中断嵌套,由于新内核直接就默认不嵌套中断,这个标记反而变得无用了。

上述并发的发生除了SMP是真正的并行以外,其他的都是单核上的“宏观并行,围观穿行”,但其引发的实质问题和SMP相似。图7.5再现了SMP情况下总的竞争状态可能性,即包含某一个核内的,也包括两个核间的竞态。

 

解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。

访问共享资源的代码区域被称为临界区,临界区需要被以某种互斥机制加以保护。中断屏蔽、原子操作、自旋锁、信号量、互斥体等是Linux设备驱动中可采用的互斥途径。

7.3中断屏蔽

在单CPU范围内避免竞态的一种简单而有效的方法是在进入临界区之前屏蔽系统的中断,但是在驱动编程中不值得推荐,驱动通常需要考虑跨平台特点而不假定自己在单核上运行。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免了。

中断屏蔽的使用方法为:

void local_irq_disable(void);    //屏蔽中断
......
critical section                //临界区
......
void local_irq_enable(void);     //开中断

其底层的实现原理是让CPU本身不响应中断,比如,对于ARM处理器而言,其底层的实现是屏蔽ARM CPSR的I位:

static inline void arch_local_irq_disable(void)
{
   asm volatile(

     " cpsid i @ arch_local_irq_disable"

     : : :

     "memory", "cc");
}

由于Linux的异步I/O、进程调度等很多重要操作都依赖于中断,中断对于内核的运行非常重要,在屏蔽中断期间所有的中断都无法得到处理,因此长时间屏蔽中断是很危险的,这有可能造成数据丢失乃至系统崩溃等后果。这就要求在屏蔽了中断之后,当前的内核执行路径应当尽快地执行完临界区的代码。

local_irq_disable()和local_irq_enable()都只能禁止和使能本CPU内的中断,因此,并不能解决SMP多CPU引发的竞态。因此,单独使用中断屏蔽通常不是一种值得推荐的避免竞态的方法(换句话说,驱动中使用local_irq_disable/enable()通常意味着一个bug),它适合与下文将要介绍的自旋锁联合使用。

与local_irq_disable()不同的是,local_irq_save(flags)除了进行禁止中断的操作以外,还保存目前CPU的中断位信息,local_irq_restore(flags)进行的是与local_irq_save(flags)相反的操作,对于ARM处理器而言,其实就是保存和恢复CPSR。

如果只是想禁止中断的底半部,应使用local_bh_disable(),使能被local_bh_disable()禁止的底半部应该调用local_bh_enable()。

7.4原子操作

原子操作可以保证对一个整形数据的修改是排他性的。Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。位和整型变量的原子操作都依赖于底层CPU的原子操作,因此所有这些函数都与CPU架构密切相关。对于ARM处理器而言,底层使用LDREX和STREX指令,比如atomic_inc()底层的实现会调用到atomic_add(),其代码如下:

static inline void atomic_add(int i, atomic_t *v)
{
   unsigned long tmp;
   int result;

   prefetchw(&v->counter);

   __asm__ __volatile__("@ atomic_add\n"

       "1: ldrex %0, [%3]\n" 
       " add %0, %0, %4\n"
       " strex %1, %0, [%3]\n"
       " teq %1, #0\n"
       " bne 1b"
             : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)
             : "r" (&v->counter), "Ir" (i)
             : "cc");
}

ldrex指令跟stex配对使用,可以让总线监控ldrex到strex之间有无其他的实体存取该地址,如果有并发的访问,执行strex指令时,第一个寄存器的值被设置为1并且存储的行为也不成功;如果没有并发的存取,strex在第一个寄存器设置0并且存储的行为也成功的。本例中,如果两个并发实体同时调用ldrex+strex,如图7.6所示,在T3时间点上,CPU0的strex会执行失败,在T4时间点上CPU1的stex会执行成功。所以CPU0和CPU1之间只有CPU1执行成功了,执行strex失败的CPU0的“teq %1, #0”判断语句不会成立,于是失败的CPU0通过“bne lb”再次进入ldrex。ldrex和strex的之一过程不仅适用于多核之间的并发,也适用于同一个核内部并发的情况。

 

7.4.1整型原子操作

1、设置原子变量的值

void atomic_set(atomic_t *v, int i);   //设置原子变量的值为i

atomic_t v = ATOMIC_INIT(0);     //定义原子变量V并初始化为0

2、获取原子变量的值

int atomic_read(const atomic_t *v);  //返回原子变量的值

3、原子变量加/减

void atomic_add(int i, atomic_t *v);  //原子变量增加i

void atomic_sub(int i, atomic_t *v);  //原子变量减少i

4、原子变量自增/自减

void atomic_inc(atomic_t *v);  //原子变量增加1

void atomic_dec(atomic_t *v);  //原子变量减少1

5、操作并测试

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

上述操作对原子变量执行自增、自减和减操作后,测试其是否为0,为0返回true,否则返回false。

6、操作并返回

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

上述操作对原子变量进行加/减和自增/自减操作,并返回新的值。

7.4.2位原子操作

1、设置位

void set_bit(int nr, void * addr);

上述操作设置addr地址的第nr位,所谓设置位即是将位写1。

2、清除位

void clear_bit(int nr, void * addr);

上述操作清除addr地址的第nr位,所谓清除即是将位写0。

3、改变位

void change_bit(int nr, void * addr);

上述操作对addr地址的第nr位进行置反。

4、测试位

int test_bit(int nr, void *addr);

上述操作返回addr地址的第nr位。

5、测试并操作位

int test_and_set_bit(unsigned long nr, void *addr);

int test_and_clear_bit(unsigned long nr, void *addr);

int test_and_change_bit(unsigned long nr, void *addr);

上述test_and_xxx_bit(nr, void *addr)操作等同于执行test_bit(nr, void *addr)后再执行xxx_bit(nr, void *addr)。

代码清单7.2给出了原子变量的使用例子,它使得设备最多只能被一个进程打开。

static atomic_t xxx_available = ATOMIC_INIT(1); /* 定义原子变量 */
static int xxx_open(struct inode *inode, struct file *filp)
{
    ...
    if (!atomic_dec_and_test(&xxx_available)) {
         atomic_inc(&xxx_available);
          return - EBUSY; /* 已经打开 */
     }
    ...
    return 0; /* 成功 */
}

static int xxx_release(struct inode *inode, struct file *filp)
{
    atomic_inc(&xxx_available); /* 释放设备 */
    return 0;
}

7.5自旋锁

7.5.1自旋锁的使用

自旋锁(Spin Lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设置某个内存变量。由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行;如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置”操作,即进行所谓的“自旋”,通俗地说就是“在原地打转”,如图7.7所示。当自旋锁的持有者通过重置该变量释放这个自旋锁后,某个等待的“测试并设置”操作向其调用者报告锁已释放。

理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区标记为“我当前在运行,请稍等一会”或标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁;当B单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。

 

在ARM体系结构下,自旋锁的实现借用了ldrex指令、strex指令、ARM处理器内存屏障指令dmb和dsb、wfe指令和sev指令,这类似于代码清单7.1的逻辑。可以说即要保证排他性,也要处理好内存屏障。

Linux中与自旋锁相关的操作主要有以下4种。

1、定义自旋锁

spinlock lock;

2、初始化自旋锁

spin_lock_init(lock);

该宏用于动态初始化自旋锁lock。

3、获得自旋锁

spin_lock(lock);

该宏用于获得自旋锁lock,如果能够立即获得锁,它就马上返回,否则,它将在哪里自旋,直到该自旋锁的保持着释放。

spin_trylock(lock);

该宏尝试获得自旋锁lock,如果能立即获得锁,它获得锁并返回true,否则立即返回false,实际上不再“在原地打转”。

4、释放自旋锁

spin_unlock(lock);

该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。

自旋锁一般这样被使用:

/* 定义一个自旋锁 */

spinlock_t lock;

spin_lock_init(&lock);

spin_lock (&lock) ; /* 获取自旋锁, 保护临界区 */

. . ./* 临界区 */

spin_unlock (&lock) ; /* 解锁 */

自旋锁主要针对SMP或单CPU但内核可抢占的情况,对于单CPU和内核不支持抢占的系统,自旋锁退化为空操作。在单CPU和内核可抢占的系统中,自旋锁持有期间中内核的抢占将被禁止。由于内核可抢占的单CPU系统的行为实际上很类似于SMP系统,因此,在这样的单CPU系统中使用自旋锁扔十分必要。另外,在多核SMP的情况下,任何一个核拿到自旋锁,该核上的抢占调度也暂时禁止了,但是没有禁止另外一个核的抢占调度。

尽管用了自旋锁可以保护临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和低半部的影响。为了防止这种影响,就需要用到自旋锁的衍生。spin_lock()/spin_unclock()是自旋锁机制的基础,它们和关中断local_irq_disable()/开中断local_irq_enable()、关低半部local_bh_disable()/开低半部local_bh_enable()、关中断并保存状态字local_irq_save()/开中断并恢复状态字local_irq_restore()结合就形成了整套自旋锁机制,关系如下:

spin_lock_irq() = spin_lock() + local_irq_disable()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = spin_lock() + local_irq_save()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bh() = spin_lock() + local_bh_disable()
spin_unlock_bh() = spin_unlock() + local_bh_enable()

spin_lock_irq()、spin_lock_irqsave()、spin_lock_bh()类似函数会为自旋锁的使用系好安quan带以避免突如其来的中断驶入对系统造成的伤害。

在多核编程的时候,如果进程和中断可能访问同一片临街资源,我们需要在进程上下文中调用spin_lock_irqsave()/spin_lock_irqrestore(),在中断上下文中调用spin_lock()/spin_unlock(),如图7.8所示。这样,在CPU0上,无论是进程上下文,还是中断上下文获得了自旋锁,此后,如果CPU1无论是进程上下文,还是中断上下文,想获得同一自旋锁,都必须忙等待,这避免一起核间并发的可能性。同时,由于每个核的进程上下文持有锁的时候用的时spin_lock_irqsave(),所以该核上的中断是不可能进入的,这避免了内核并发的可能性。

驱动工程师应该谨慎使用自旋锁,而且在使用中还要特别注意如下几个问题。

1、自旋锁实际上是忙等待,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。

2、自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。

3、在自旋锁锁定期间不能调用可能引起进程调度的函数,如果进程获得自旋锁之后再阻塞,如调用copy_from_user()、copy_to_user()、kmalloc()和msleep()等函数,则可能导致内核崩溃。

在单核情况下编程的时候,也应该认为自己的CPU时多核的,驱动特别强调跨平台的概念。比如,在单CPU的情况下,若中断和进程可能访问同一临界区,进程里调用spin_lock_irqsave()是安quan的,在中断里其实不调用spin_lock()也没有问题,因为spin_lock_irqsave()可以保证这个CPU的中断服务程序不可能执行。但是,若CPU变成多核,spin_lock_irqsave()不能屏蔽另一个核的中断,所以另外一个可能造成并发问题。因此,无论如何,我们在中断服务程序里也应该调用spin_lock()。
代码清单7.3给出了自旋锁的使用例子,它被用于实现使得设备只能被最多1个进程打开,功能和代码清单与7.2类似。

int xxx_count = 0;/* 定义文件打开次数计数 */
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;
}

7.6信号量

信号量(semaphore)是操作系统中最典型的用于同步和互斥的手段,信号量的值可以是0、1或者n。信号量与操作系统中的经典概念PV操作对应。

P(S):将信号量S的值减1,即S = S-1;

     如果S大于等于0,则该进程继续执行;否则该进程设置为等待状态,  排入等待队列。

V(S):将信号量S的值加1,即S = S+1;

      如果S大于0,唤醒队列中等待信号量的进程。

Linux中与信号量相关的操作主要有下面几种。

1、定义信号量

下列代码定义名称为sem的信号量:

struct semaphore sem;

2、初始化信号量

void sema_init (struct semaphore *sem, int val);

该函数初始化信号量,并设置信号量sem的值为val。

3、获得信号量

void down (struct semaphore * sem);

该函数用于获得信号量sem,它会导致睡眠,因此不能再中断上下文中使用。

int down_interruptible (struct semaphore * sem);

该函数功能与down类似,不同之处为,因为down()进入睡眠状态的进程不能被信号打断,但因为down_interruptible()进入睡眠状态的进程能被信号打断,信号也会导致该函数返回,这时候函数的返回值非0。

int down_trylock (struct semaphore *sem);

该函数尝试获得信号量sem,如果能够立即获得,它就获得该信号量并返回0,否则,返回非0值。它不会导致调用者睡眠,可以在中断上下文中使用。

使用down_interruptible()获得信号量时,对返回值一般会进行检查,如果非0,通常立即返回-ERESTARTSYS,如:

if (down_interruptible(&sem))

return -ERESTARTSYS;

4、释放信号量

void up(struct semaphore *sem);

该函数释放信号量sem,唤醒等待者。

作为一种可能的互斥手段,信号量可以保护临界区,它的使用方式和自旋锁类似。与自旋锁相同,只有得到信号量的进程才能执行临界区代码。但是,与自旋锁不同的是,当获取不到信号量时,进程不会原地打转而是进入休眠等待状态。用作互斥时,信号量一般这样被使用:

 

 

由于新的Linux内核倾向于直接使用mutex作为互斥手段,信号量用作互斥不再被推荐使用。

信号量也可以用于同步,一个进程A执行down()等待信号量,另外一个进程B执行up()释放信号量,这样进程A就同步地等待了进程B。其过程类似:

 

此外,对于关系具体数值的生产者/消费者问题,使用信号量则较为合适。因为生产者/消费者问题也是一种同步问题。

7.7互斥体

尽管信号量已经可以实现互斥的功能,但是“正宗”的mutex在Linux内核中还是真实地存在着。

下面代码定义了名为my_mutex的互斥体并初始化它:

struct mutex my_mutex;

mutex_init(&my_mutex);

下面的两个函数用于获取互斥体:

void mutex_lock(struct mutex *lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_trylock(struct mutex *lock);

mutex_lock()与mutex_lock_interruptible()的区别和down()与down_trylock()的区别完quan一致,前者引起的睡眠不能被信号打断,而后者可以。mutex_trylock()用于尝试获得mutex,获取不到mutex时不会引起进程睡眠。

下列函数用于释放互斥体:

void mutex_unlock(struct mutex *lock);

mutex的使用方法和信号量用于互斥的场合完quan一样:

struct mutex my_mutex; /* 定义 mutex */

mutex_init(&my_mutex); /* 初始化 mutex */

mutex_lock(&my_mutex); /* 获取 mutex */

... /* 临界资源 */

mutex_unlock(&my_mutex); /* 释放 mutex */

自旋锁和互斥体都是解决互斥问题的基本手段,面对特定的情况,应该如何取舍这两种手段呢?选择的依据是临界区的性质和系统的特点。

从严格意义上说,互斥体和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者。在互斥体本身的实现上,为了保证互斥体结构存取的原子性,需要自旋锁来互斥。所以自旋锁属于更底层的手段。

互斥体是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其他进程。鉴于进程上下文切换的开销也很大,因此,只有当进程占用资源时间较长时,用互斥体才是较好的选择。

当所要包含的临界区访问时间比较短时,用自旋锁时非常方便的,因为它可节省上下文切换的时间。但是CPU得不到自旋锁会在哪里空转直到其他执行单元解锁为止,所以要求锁不能再临界区里长时间停留,否则会降低系统的效率。

由此,可以总结出自旋锁和互斥体选用的3项原则。

1、当锁不能被获取到时,使用互斥体的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁。如临界区比较小,宜使用自旋锁,若临界区很大,应使用互斥体。

2、互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个企图获取本自旋锁,死锁就会发生。

3、互斥体在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁。当然,如果一定使用互斥体,则只能通过mutex_trylock()方式进行,不能获取就立即返回以避免阻塞。

总结

并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁和互斥体都是解决并发问题的机制。中断屏蔽很少单独使用,原子操作只能针对整数进行,因此自旋锁和互斥体应用最为广泛。

自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。互斥体允许临界区阻塞,可以适用于临界区大的情况。

共有 人打赏支持
粉丝 0
博文 61
码字总数 114148
×
幸福阶梯
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: