Linux驱动开发-------- 内核的同步与互斥

面试:请说一下,线程间同步方式有哪些?

同一进程内的多个线程共享同一地址空间,为了避免多个线程同时访问数据造成的混乱,需要考虑线程之间的同步问题,所谓同步,即协同步调,按预定的先后次序访问共享资源,以免造成混乱。

线程同步的实现方式主要有6种:互斥锁、自旋锁、读写锁、条件变量、屏障、信号量。

1、互斥锁。互斥锁在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量进行解锁。对互斥量加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞,直至当前线程释放该互斥量。

2、自旋锁。自旋锁与互斥量类似,但它不使线程进入阻塞态,而是在获取锁之前一直占用CPU,处于忙等自旋状态。自旋锁适用于锁被持有的时间短且线程不希望在重新调度上花费太多成本的情况。

3、读写锁。读写锁有三种状态:读模式加锁、写模式加锁和不加锁,一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。读写锁非常适合对数据结构读的次数远大于写的情况。

4、条件变量。条件变量允许线程睡眠,直到满足某种条件,当满足条件时,可以向该线程发送信号,通知并唤醒该线程。条件变量通常与互斥量配合一起使用。条件变量由互斥量保护,线程在改变条件状态之前必须首先锁住互斥量,其他线程在获得互斥量之前不会察觉到条件的改变,因为必须在锁住互斥量之后它才可以计算条件是否发生变化。

5、屏障。屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。

6、信号量。信号量本质上是一个计数器,用于为多个进程提供共享数据对象的访问。编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1。

内核中的互斥

一、内核的竞态与并发

【1】内核中的并发

  • 当内核有多条执行路径同时访问同一个共享资源时,就会造成竞态
  • 常见的共享资源有全局变量、静态变量、硬件的寄存器和共同使用的动态分配的同一段内存等。
  • 造成竞态的根本原因就是内核中的代码对共享资源产生了并发(同时)的访问

【2】内核中有哪些并发的情况

  • 硬件中断:当处理器允许中断时,一个内核执行路线可能在任意时间都会被一个外部中断打断
  • 软中断和tasklet:内核可以在任意硬中断快要返回之前执行软中断以及tasklet,也可能唤醒软中断处理线程,并执行tasklet
  • 普通的多进程环境:当一个进程因为等待的资源暂时不可用时,就会主动放弃CPU,内核会调度另外一个进程来执行。
  • 抢占内核的多进程环境:如果一个进程在执行时发生系统调用,进入到内核,由内核代替该进程完成相应的操作,此时如有一个更高优先级的进程准备就绪,内核判断在可抢占的条件成立的情况下可以抢占当前进程,然后去执行更高优先级的进程。
  • 多处理器或多核CPU:同一时刻,可在多个处理器上并发执行多个程序,这是真正意义上的并发

内核提供的互斥的手段

并发对共享资源访问就会引起竞态

如何解决竟态?:互斥。即在一条内核执行路径上访间共享资源时,不允许其他内核执行路径来访问共享资源。

共享资源有时又叫作临界资源,而访问共享资源的这段代码又叫作临界代码段或临界区

一些概念:临界段、互斥锁、死锁

临界段:有可能出问题、出现并发的代码段,在这种代码段的前后加上互斥从而保护它。

互斥锁:直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

死锁:死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。


1.中断屏蔽

【1】中断屏蔽的要点

  • 如果在访问共享资源之前先将中断屏蔽,然后再访问共享资源,等共享资源访问完成后再重新使能中断,就能避免竟态的产生。
  • 需要另外说明的是,如果明确知道是哪一个中断会带来竞态,我们通常应该只屏蔽相应的中断,而不是屏蔽本地CPU的全局中断,这样可以使其他中断照常执行。
  • 如果非要屏蔽本地CPU的中断,那么应该尽量使用local_irq_save和 local_irg_restore这对宏,因为如果中断在屏蔽之前本身就是屏蔽的,那么 local_irq_enable会将本来就屏蔽的中断错误地使能,从而造成中断使能状态的前后不一致。
  • 中断屏蔽到中断重新使能之间的这段代码不宜过长,否则中断屏蔽的时间过长,将会影响系统的性能。

【2】比如可以在计数i++之前屏蔽中断,之后重新使能中断,代码的形式如下:

unsigned long flags;

local_irq_save(flags);
i++;
local_irg_restore(flags);

【3】使用中断屏蔽来做互斥时的总结

  • 对解决中断引起的并发而带来的竟态简单高效
  • 应该尽量使用local_irq_save和local_irg_restore来屏蔽和使能中断
  • 中断屏蔽时间不宜过长
  • 只能屏蔽本地CPU的中断,对于多CPU系统,中断也可能在其他CPU上产生

2.原子变量(操作开始无法被打断直至执行完。)

【1】原子变量的定义
如果一个变量的操作是原子性的,即不能再被分割,类似于在汇编级代码上也只要一条汇编指令就能完成,那么对这样变量的访问就根本不需要考虑并发带来的影响。因此,内核专门提供了一种数据类型 atomic_t,用它来定义的变量为原子变量,其类型定义如下

/*include/linux/types. h */
 typedef struct
{
	int counter;
} atomic_t;

【2】操作原子变量的API接口
由上可知,原子变量其实是一个整型变量。对于整型变量,有的处理器专门提供一些指令来实现原子操作(比如ARM处理器中的swp指令),内核就会使用这些指令来对原子变量进行访问,但是有些处理器不具备这样的指令,内核将会使用其他的手段来保证对它访问的原子性,比如中断屏蔽。

  • 内核提供了哪些接口来操作原子变量。现将主要的API罗列如下:
atomic_read(v);                          				 	  //读取原子变量V的值
atomic_set(v, i);                           				 //设置原子变量的值为i

int atomic_add_return(int i,atomic_t *v);					//将原子变量加上i,_return 表示还要返回修改后的值
int atomic_sub_return(int i, atomic_t *v);					//将原子变量减去i,_return 表示还要返回修改后的值
int atomic_add_negative(int i, atomic_t *v);				//将原子变量加上i,_negative 表示当结果为负返回真

void atomic_add(int i, atomic_t *v);						//将原子变量加上i
void atomic_sub(int i, atomic_t *v);						//将原子变量减去i
void atomic_inc(atomic_t *v);								//将原子变量自加1
void atomic_dec(atomic_t *v);								//将原子变量自减1

atomic_dec_return(v);										//_return 表示还要返回修改后的值
atomic_inc_return(v);	
								
atomic_sub_and_test(i, v);									//_test 表示当结果为0返回真							
atomic_dec_and_test(v);
atomic_inc_and_test(v);
atomic_xchg(ptr, v);                                       //交换V和prt指针指向的数据
atomic_cmpxchg(v, old, new);							   //如果V的值和old想等则将V的值设为NEW并返回原来的值

void atomic_clear_mask(unsigned long mask, atomic_t *v);     //将V中的mask为1的对应位清零
void atomic_set_mask(unsigned int mask, atomic_t *v); 		 //将V中的mask为1的对应位置1
void set_bit(int nr, volatile unsigned long *addr);          //nr位置1
void clear_bit(int nr, volatile unsigned long *addr);        //nr位置清零
void change_bit(int nr, volatile unsigned long *addr); 		 //nr位置反转

int test_and_set_bit(int nr, volatile unsigned long adar);    //test_表示要返回原来的值
int test_and_clear_bit(int nr, volatile unsigned long * addr);
int test_and_change_bit(int nr, volatile unsigned long addr);

【3】使用下面代码保证对i++的访问原子性

atomic_t i = ATOMIC_INIT(5);
atomic_inc(&i);

【4】对于非整形变量(如整个结构就不能使用这一套方法)。原子变量相比于锁机制开销较小。


3.自旋锁(不会交出CPU)

【1】自旋锁的解释

比如A、B两人使用公共厕所,A先到达厕所也就是拿到了自旋锁,但是使用共享资源——厕所时,有一个电话打给了A,短时间内A会一直拿着锁,不会让出来,等到条件成熟了才会继续去使用共享资源。就让B一直干等着。

【2】内核中自旋锁的类型是 spinlock_t,相关的API如下:

spin_lock_init(_lock);                                       //初始化自旋锁,在使用自旋锁之前必须初始化
void spin_lock(spinlock_t *lock); 							//获取自旋锁,如果不能获取自旋锁,则进行忙等待
void spin_lock_irq(spinlock_t *lock);						//获取自旋锁并竞争中断
spin_lock_irqsave(lock, flags);								//获取自旋锁并竞争中断,保存中断状态到flags中
void spin_lock_bh(spinlock_t *lock);						//获取自旋锁并竞争下半部
int spin_trylock(spinlock_t *lock);							//尝试获取自旋锁,即使不能获取,也立即返回,返回值为0表示成功获得自旋锁,否则表没有获得自旋锁
int spin_trylock_bh(spinlock_t * lock);						//其他的变体和 spin_lock变体的意义相同。
int spin_trylock_irq(spinlock t *lock);


void spin_unlock(spinlock t *lock);							//释放自旋锁,其他的 unlock版本可以依据前面的解释判断其作用
void spin_unlock_irq(spinlock_t *lock);
void spin_unlock_irgrestore(spinlock t *lock, unsigned long flags);
void spin_unlock_bh(spinlock_t *lock);

【3】在i++的例子中,我们可以使用下面的代码来使自旋锁对i的操作进行互斥

int i=5;
/*定义自旋锁*/
spinlock_t lock;
/*用于保存中断屏蔽状态的变量*/
unsigned long flags;
/*使用自旋锁之前必须初始化自旋锁*/
spin_lock init(&lock);
/*访问共享资源之前获得自旋锁,禁止中断,并将之前的中断屏蔽状态保存在f1ags变量中*/
spin_lock_irqsave(&lock, flags);
/*为访问共享资源*/
i++;
/*共享资源访问完成后释放自旋锁,用f1ags的值恢复中断屏蔽的状态*/
spin_unlock_irgrestore(&lock, flags);

【4】关于自旋锁的一些重要特性和使用注意事项总结如下

  • 获得自旋锁的临界代码段执行时间不宜过长,因为是忙等锁,如果临界代码段执行时间过长,就意味着其他想要获得锁的内核执行路径会进行长时间的忙等待,这会影响系统的工作效率。
  • 自旋锁的核心要求是:拥有自旋锁的代码必须不能睡眠,要一直持有CPU直到释放自旋锁
  • 在获得锁的期间,不能够调用可能会引起进程切换的函数,因为这会增加持锁的时间,导致其他要获取锁的代码进行更长时间的等待,更糟糕的情况是,如果新调度的进程也要获取同样的自旋锁,那么会导致死锁。
  • 自旋锁是不可递归的,即获得锁之后不能再获得自旋锁,否则会因为等待一个不能获得的锁而将自己锁死。
  • 自旋锁可以用在中断上下文(信号量不可以,因为可能睡眠),但是在中断上下文中获取自旋锁之前要先禁用本地中断。(拥有自旋锁的代码必须不能睡眠,所以不能被其他中断打断)

信号量:(当信号量不能获取时,当前的进程就应该休眠了)

struct semaphore
{
	raw_spinlock_t   lock;
	unsigned int    count;
	struct list_head wait listi;
};
  • 可以看到,它有一个 count成员,这是用来记录信号量资源的情况的,当 count的值不为0时是可以获得信号量的,当 count的值为0时信号量就不能被获取,这也说明信号量可以同时被多个进程所持有。
  • 我们还看到了一个 wait_list成员,不难猜想,当信号量不能获取时,当前的进程就应该休眠了。最后,lock成员在提示我们,信号量在底层其实使用了自旋锁的机制。

信号量最常用的API接口如下

void sema_init(struct semaphore *sem, int val);    //用于初始化信号量,val是赋给 count成员的初值,这样就可以有val个进程同时获得信号量。
void down(struct semaphore "sem");                 //获取信号量(信号量的值减1),当信号量的值不为0时,可以立即获取信号量,否则进程休眠。
int down_interruptible(struct semaphore *sem);   //同down,但是能够被信号唤醒
int down_trylock(struct semaphore *sem);         //只是尝试获取信号量,如果不能获取立即返回,返回0表示成功获取,返回1表示获取失败。
int down_timeout (struct semaphore *sem,sem, long jiffies);  //同down,但是在jies个时钟周期后如果还没有获取信号量,则超时返回,返回0表示成功获取信号量,返回负值表示超时
void up(struct semaphore *sem);            //释放信号量(信号量的值加1),如果有进程等待信号量,则唤醒这些进程

【4】当给信号量赋初值1时,则表示在同一时刻只能有一个进程获得信号量,这种信号量叫二值信号量,利用这种信号量可以实现互斥,典型的应用代码如下。

/*定义信号量*/
struct semaphore sem;

/*初始化信号量,赋初值为1,用于互斥*/
sema_init(&sem, 1);

/*获取信号量,如果是被信号唤醒,则返回一 ERESTARTSYS*/
if (down_interruptible(&sem))
return -ERESTARTSYS;

/*对共享资源进行访问,执行一些耗时的操作或可能会引起进程调度的操作*/
xxxxxxxx

/*共享资源访问完成后,释放信号量*/
up(&sem);

【5】对于信号量的特点及其他的一些使用注意事项总结如下:

  • 信号量可以被多个进程同时持有,当给信号量赋初值1时,信号量成为二值信号量,也称为互斥信号量,可以用来互斥。
  • 如果不能获得信号量,则进程休眠,调度其他的进程执行,不会进行忙等待。
  • 因为获取信号量可能会引起进程切换,所以不能用在中断上下文中,如果必须要用,只能使用 down_trylock。不过在中断上下文中可以使用up释放信号量,从而唤醒其他进程。
  • 持有信号量期间可以调用调度器,但需要特别注意是否会产生死锁。
  • 信号量的开销比较大,在不违背自旋锁的使用规则的情况下,应该优先使用自旋锁。

互斥锁:

【1】互斥量的引入

  • 信号量除了不能用于中断上下文,还有一个缺点就是不是很智能。在获取信号量的代码中,只要信号量的值为0,进程马上就休眠了。

  • 更一般的情况是,在不会等待太长的时间后,信号量就可以马上获得,那么信号量的操作就要经历使进程先休眠再被唤醒的一个漫长过程。

  • 在信号量不能获取的时候,稍微耐心地等待一小段时间,如果在这段时间能够获取信号量,那么获取信号量的操作就可以立即返回,否则再将进程休眠也不迟。

  • 为了实现这种比较智能化的信号量,内核提供了另外一种专门用于互斥的高效率信号量,也就是互斥量,也叫互斥体,类型为 struct_mutex.

【2】互斥量的相关API

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

【3】有了前面互斥操作的基础,使用互斥量来做互斥也就很容易实现了,示例代码如下

int i = 5

/*定义互斥量*/
struct mutex lock;

/*使用之前初始化互斥量*/
mutex_init(&lock);

/*访问共享资源之前获得互斥量*/
mutex_lock(&lock);
i++;

/*访问完共享资源后释放互斥量*/
mutex_unlock(&lock);

【4】互斥量的使用比较简单,不过它还有一些更多的限制和特性,现将关键点总结如下

  • 要在同一上下文对互斥量上锁和解锁,比如不能在读进程中上锁,然后在写进程中解锁。
  • 和自旋锁一样,互斥量的上锁是不能递归的。
  • 当持有互斥量时,不能退出进程
  • 不能用于中断上下文,即使 mutex_trylock也不行。
  • 持有互斥量期间,可以调用可能会引起进程切换的函数
  • 在不违背自旋锁的使用规则时,应该优先使用自旋锁。
  • 在不能使用自旋锁但不违背互斥量的使用规则时,应该优先使用互斥量,而不是信号量。

内核中的同步


1.完成量


【1】讨论完内核中的互斥后,接下来我们来看看内核中的同步

  • 同步是指内核中的执行路径需要按照一定的顺序来进行,例如执行路径A要继续往下执行则必须要保证执行路径B执行到某一个点才行。
  • 以一个ADC设备来说,假设一个驱动中的一个执行路径是将ADC采集到的数据做某些转换操作(比如将若干次采样结果做平均),而另一个执行路径专门负责ADC采样,那么做转换操作的执行路径要等待做采样的执行路径。
  • 同步可以用信号量来实现,就以上面的ADC驱动来说,可以先初始化一个值为0的信号量,做转换操作的执行路径先用down来获取这个信号量,如果在这之前没有采集到数据,那么做转换操作的路径将会休眠等待。
  • 当做采样的路径完成采样后,调用释放信号量,那么做转换操作的执行路径将会被唤醒。这就保证了采样发生在转换之前,也就完成了采样和转换之间的同步。

【2】不过内核专门提供了一个完成量来实现该操作,完成量的结构类型定义如下

struct completion
{
	unsigned int done;      //done是否完成的状态,是一个计数值,为0表示未完成
	wait_queue_head_t wait;  //wait是一个等待队列头
};
  • 回想前面阻塞操作的知识,不难想到完成量的工作原理。
  • 当done为0时进程阻塞,当内核的其他执行路径使done的值大于0时,负责唤醒被阻塞在这个完成量上的进程。

【3】完成量的主要AP如下

void init_completion(struct completion *x);
wait_for_completion(struct completion *);
wait_for_completion_interruptible(struct completion *x);
unsigned long wait_for_completion_timeout(struct completion *x, unsigned longtimeout);
long wait_for_completion_interruptible_timeout(struct completion *x, unsignedlong timeout);
bool try_wait_for_completion(struct completion *x);
void complete(struct completion *);
void complete_all(struct completion *);
  • 有了前面知识的积累,上面函数的作用及参数的意义都能见名知意,在这里就不再细述。
  • 只是需要说明一点, complete唤醒一个进程,而 complete_al唤醒所有体眠的进程。

【4】完成量的使用例子如下

/定义完成量*/
struct completion comp;

/*使用之前初始化完成量*/
init_completion(&comp);

/*等待其他任务完成某个操作*/
wait_for_completion(&comp);

/*某个操作完成后,唤醒等待的任务*/
complete(&comp);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值