Linux中的并发控制学习

1、并发与竞态

        Linux设备驱动中必须解决的一个问题是多个进程对共享资源的并发访问,并发访问会导致竞态并发指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竟态。简单理解就是常说的读写冲突的问题。除此之外,更复杂、更混乱的并发大量存在于linux设备驱动中,只要并发的多个执行单元存在对共享资源的访问,竟态就有可能发生。在linux中竟态主要发生于如下几种情况。

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

单CPU内进程与抢占它的进程。 Linux内核支持抢占调度,一个进程在内核执行的时候可能被另外一个高优先级进程打断,进程与抢占它的进程访问共享资源的情况类似于SMP的多个CPU。

中断(硬中断、软中断、Tasklet、底半部)与进程之间。 中断可以打断正在执行的进程,如果中断处理程序访问进程正在访问的资源,则竟态也会发生。此外,中断也有可能被新的更高优先级的中断打断,因此,多个中断之间本身也可能引起并发而导致竟态。

        上述并发的发生情况除了SMP是真正的并行以外,其他都是“宏观并行,微观串行”的,但其引发的实质问题和SMP相似。解决竟态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问这个共享资源。访问共享资源的代码区称为临界区,临界区需要以某种互斥机制加以保护。中断屏蔽、原子操作、自旋锁、信号量等是Linux设备驱动中可采用的互斥途径

2、中断屏蔽

        在单CPU范围内避免竟态的一种简单方法是在进入临界区之前屏蔽系统的中断。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竟态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也就得以避免了。中断的屏蔽方法为:

local_irq_disable();
/* 临界区代码 */
local_irq_enable();

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

        local_irq_disable()local_irq_enable()都只能禁止和使能本CPU内的中断,因此,并不能解决SMP多CPU引发的竟态。因此,单独使用中断屏蔽通常不是一种值得推荐的避免竟态的方法。它适宜与自旋锁联合使用。

        与local_irq_diable()不同的是,local_irq_save(flags)除了进行禁止中断的操作以外,还保存目前CPU的中断位信息,local_irq_restore(flags)进行的是与loacl_irq_save(flags)相反的操作。如果只是想禁止中断的底半部,应使用loacl_bh_disable(),使能被local_bh_disable()禁止的底半部应该调用local_bh_enable()

3、原子操作

        原子操作是指在执行过程中不会被代码路径所中断的操作。Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。它们的共同点是在任何情况下操作都是原子的,即不可分割的。内核代码可以安全地调用它们而不被打断。位和整型变量原子操作都依赖底层CPU地原子操作来实现,因此所有这些函数都与CPU架构密切相关。

        感觉就是可以通过原子操作申请一个信号量,由于是原子操作,所以可以保证这个信号量同时只会被一个进程申请到,也就可以避免对同一资源访问下的竟态的产生。

整型原子操作

1、设置原子变量的值

void atomic_set(atomic_t *v int i);//设置原子变量的值为i
atomic_t v = ATOMIC_INIT(0); //定义原子变量v并初始化为0

2、获取原子变量的值
 

atomic_read(atomic_t v);//返回原子变量i的值

3、原子变量加/减

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

4、原子变量自增/自减

atomic_inc(atomic_t v);//原子变量增加1
atomic_dec(atomic_t v);//原子变量减少1

5、操作并测试

atomic_inc_and_test(atomic_t v);
atomic_dec_and_test(atomic_t v);
atomic_sub_and_test(atomic_t v);

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

6、操作并返回

atomic_add_return(int i,atomic_t *v);
atomic_sub_return(int i,atomic_t *v);
atomic_inc_return(atomic_t *v);
atomic_dec_return(atomic_t *v);

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

 位原子操作

1、设置位

void set_bit(nr,void *addr);

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

2、清除位

void set_bit(nr,void *addr);

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

3、改变位

void change_bit(nr void *addr);

上述操作对addr地址的第nr位取反

4、测试位

test_bit(nr,void *addr);

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

5、测试并操作位

test_and_set_bit(nr,void *addr);
test_and_clear_bit(nr,void *addr);
test_and_change_bit(nr,void *addr);

上述test_and_xxx_bit(nr,void *addr)操作等同于执行test_bit(nr,void *addr)后再执行xxx_bit(nr,void *addr)。修改对应位的值并返回修改前的值。

4、自旋锁 

        自旋锁是一种对临界资源进行互斥访问的典型手段。其名称来源于它的工作方式,为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设置(test-and-set)某个内存变量。由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问到这个变量。

        如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行,如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置”操作,即进行所谓的“自旋”,通俗的说就是“在原地打转”。当自旋锁的持有者通过重置该变量释放这个自旋锁后,某个等待的“测试并设置”操作向其调用者报告锁已经释放

Linux系统中与自旋锁相关的操作主要有如下4种:

1、定义自旋锁:

spinlock_t lock;

2、初始化自旋锁

spin_lock_init(spinlock_t lock);//动态初始化自旋锁lock

3、获得自旋锁

spin_lock(spinlock_t lock);//获得自旋锁lock,如果不能获得,则自旋,直到锁被释放
spin_trylock(spinlock_t lock);//尝试获得锁lock,如果不能获得,立即返回假,不会自旋

4、释放自旋锁

spin_unlock(spinlock_t lock);//释放自旋锁

        尽管使用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候还可能受到中断和底半部的影响。为了防止这种影响,就需要用到自旋锁的衍生。spin_lock/spin_unlock()是自旋锁机制的基础,它们和关中断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_lock() + local_irq_restore();
spin_lock_bh() = spin_lock() + local_bh_disable();
spin_unlock_bh() = spin_unlock() + local_bh_enable();

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

  • 自旋锁实际上是忙等待,当锁不可用时,CPU一直循环执行”测试并设置“该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
  • 自旋锁可能导致系统死锁引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。此外,如果进程获得自旋锁之后再阻塞,也有可能导致死锁的发生。copy_from_user().copy_to_user()和kmalloc()等函数都有可能引起阻塞,因此在自旋锁的占用期间不能调用这些函数。

        读写锁。 上述自旋锁不关心锁定的临界区究竟进行什么操作,所以无论是读操作还是写操作,同时都只允许一个进程进行。这就大大降低了系统效率,因为在很多情况下,多个读操作是可以同时进行的。因此自旋锁衍生的读写自旋锁就允许读的并发。

        读写自旋锁在写操作方面,只能最多有一个写进程。在读操作方面,同时可以有多个读执行单元,当然读和写操作不可以同时进行。

读写自旋锁涉及的操作如下:

1、定义和初始化读写自旋锁

rwlock_t my_rwlock = __RW_LOCK_UNLOCKED;//静态初始化
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);//动态初始化

2、读锁定

read_lock(rwlock_t *lock);
read_lock_irqsave(rwlock_t lock,unsigned long flags);
read_lock_irq(rwlock_t lock);
read_lock_bh(rwlock_t lock);

3、读解锁

read_unlock(rwlock_t *lock);
read_unlock_irqrestore(rwlock_t lock,unsigned long flags);
read_unlock_irq(rwlock_t lock);
read_unlock_bh(rwlock_t lock);

4、写锁定

write_lock(rwlock_t *lock);
write_lock_irqsave(rwlock_t lock,unsigned long flags);
write_lock_irq(rwlock_t lock);
write_lock_bh(rwlock_t lock);
write_trylock(rwlock_t lock);

5、写解锁

write_unlock(rwlock_t *lock);
write_unlock_irqrestore(rwlock_t lock,unsigned long flags);
write_unlock_irq(rwlock_t lock);
write_unlock_bh(rwlock_t lock);

        顺序锁是对读写锁的一种优化。若使用顺序锁,读执行单元绝不会被写执行单元阻塞,也就是说,读执行单元可以在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待读执行单元完成读操作才去进行写操作。

        但是,写执行单元与写执行单元之间仍然是互斥的,如果有写执行单元正在进行写操作,其他写执行单元必须自旋,直到写执行单元释放了顺序锁。

        如果读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新读取数据,以便确保得到的数据是完整的。这种锁在读写同时进行的概率比较小时,性能是非常好的,而且它允许读写同时进行,因而更大的提高了并发性。

        顺序锁有一个限制,它必须要求被保护的共享资源不含有指针,因为写执行单元可能使得指针失效,但读执行单元如果正要访问该指着,会导致Oops。

在linux内核中,写执行单元涉及如下顺序锁操作。

1、获得顺序锁

void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
write_seqlock_irqsave(lock,flags);
write_seqlock_irq(lock);
write_seqlock_bh(lock);

2、释放顺序锁

write_sequnlock(seqlock_t *sl);
write_sequnlock_irqrestore(lock,flags);
write_sequnlock_irq(lock);
write_sequnlock_bh(lock);

读执行单元涉及如下操作:

1、读开始

unsigned read_seqbegin(const seqlock_t *sl);

2、重读:读执行单元在访问完被顺序锁s1保护的共享资源后需要调用该函数来检查,在读访问期间是否有写操作。如果有写操作,读执行单元就需要重新进行读操作。

int read_seqretry(const seqlock *sl,unsigned iv);

do{
    seqnum = read_begin(&seqlock_a);
}while(read_seqretry(&seqlock_a,seqnum));

读-拷贝-更新。RCU(Read-Copy Update),对于被RCU保护的共享数据结构,读执行单元不需要获得任何锁就可以访问它。使用RCU的写执行单元在访问它前需要首先复制一个副本,然后对副本进行修改,最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,这个时机就是所有引用该数据的CPU都退出对共享数据的操作的时候。读执行单元没有任何同步开销,而写执行单元的同步开销则取决于使用的写执行单元间的同步机制。

        当写操作比较多时,对读执行单元的性能提高不能弥补写执行单元导致的损失。因为使用RCU时,写执行单元之间的同步开销会比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行其他写执行单元的修改操作。

5、信号量

        信号量是用于保护临界区的一种常用方法,它的使用方式和自旋锁类似。与自旋锁相同,只有得到信号量的进程才能执行临界区代码。但与自旋锁不同的是,当获取不到信号量时,进程不会原地打转而是会进入休眠状态

linux系统中与信号量相关的操作有:

1、定义信号量

struct semaphore sem;

2、初始化信号量

sema_init(struct semaphore *sem,int val);

3、获得信号量

down(struct semaphore *sem);//会导致睡眠,不能在中断上下文使用
down_interruptible(struct semaphore *sem);//与down类似,不同之处是会被信号唤醒
down_trylock(struct semaphore *sem);//获得不了立即返回非0值,不会导致睡眠,可以在中断上下文使用

4、释放信号量

up(struct semaphore *sem);

 自旋锁VS信号量。自旋锁和信号量都是解决互斥问题的基本手段,面对特定的情况,应该如何进行选择呢?

  • 当锁不能被获取时,使用信号量的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁的时间(由临界区执行时间决定)。谁消耗的时间小用谁。
  • 信号量所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
  • 信号量存在于进程的上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则只能选择自旋锁。

读写信号量。 读写信号量与信号量的关系与读写自旋锁和自旋锁的关系类似。读写信号量可能引起进程阻塞,但它可允许N个读执行单元同时访问共享资源,而最多只能有一个写执行单元。因此,读写信号量是一种相对放宽条件的粒度稍大于信号量的互斥机制。

读写自旋锁涉及的操作包括如下5种:

1、定义和初始化读写信号量

struct rw_semaphore my_rws;//定义读写信号量
void init_rwsem(struct rw_semaphore *sem);//初始化读写信号量

2、读信号量获取

void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);

3、读信号量释放

up_read(struct rw_semaphore *sem);

4、写信号量获取

void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);

5、写信号量释放

void up_semaphore(struct rw_semaphore *sem);

6、互斥体

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

struct mutex my_mutex;
mutex_init(&my_murex);

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

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

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

void mutex_unlock(struct mutex *lock);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值