linux内核同步问题

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了linux内核同步问题相关的知识,希望对你有一定的参考价值。

参考技术A

Linux内核设计与实现 十、内核同步方法

手把手教Linux驱动5-自旋锁、信号量、互斥体概述

== 基础概念: ==

并发 :多个执行单元同时进行或多个执行单元微观串行执行,宏观并行执行

竞态 :并发的执行单元对共享资源(硬件资源和软件上的全局变量)的访问而导致的竟态状态。

临界资源 :多个进程访问的资源

临界区 :多个进程访问的代码段

== 并发场合: ==

1、单CPU之间进程间的并发 :时间片轮转,调度进程。 A进程访问打印机,时间片用完,OS调度B进程访问打印机。

2、单cpu上进程和中断之间并发 :CPU必须停止当前进程的执行中断;

3、多cpu之间

4、单CPU上中断之间的并发

== 使用偏向: ==

==信号量用于进程之间的同步,进程在信号量保护的临界区代码里面是可以睡眠的(需要进行进程调度),这是与自旋锁最大的区别。==

信号量又称为信号灯,它是用来协调不同进程间的数据对象的,而最主要的应用是共享内存方式的进程间通信。本质上,信号量是一个计数器,它用来记录对某个资源(如共享内存)的存取状况。它负责协调各个进程,以保证他们能够正确、合理的使用公共资源。它和spin lock最大的不同之处就是:无法获取信号量的进程可以睡眠,因此会导致系统调度。

1、==用于进程与进程之间的同步==

2、==允许多个进程进入临界区代码执行,临界区代码允许睡眠;==

3、信号量本质是==基于调度器的==,在UP和SMP下没有区别;进程获取不到信号量将陷入休眠,并让出CPU;

4、不支持进程和中断之间的同步

5、==进程调度也是会消耗系统资源的,如果一个int型共享变量就需要使用信号量,将极大的浪费系统资源==

6、信号量可以用于多个线程,用于资源的计数(有多种状态)

==信号量加锁以及解锁过程:==

sema_init(&sp->dead_sem, 0); / 初始化 /

down(&sema);

临界区代码

up(&sema);

==信号量定义:==

==信号量初始化:==

==dowm函数实现:==

==up函数实现:==

信号量一般可以用来标记可用资源的个数。

举2个生活中的例子:

==dowm函数实现原理解析:==

(1)down

判断sem->count是否 > 0,大于0则说明系统资源够用,分配一个给该进程,否则进入__down(sem);

(2)__down

调用__down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);其中TASK_UNINTERRUPTIBLE=2代表进入睡眠,且不可以打断;MAX_SCHEDULE_TIMEOUT休眠最长LONG_MAX时间;

(3)list_add_tail(&waiter.list, &sem->wait_list);

把当前进程加入到sem->wait_list中;

(3)先解锁后加锁;

进入__down_common前已经加锁了,先把解锁,调用schedule_timeout(timeout),当waiter.up=1后跳出for循环;退出函数之前再加锁;

Linux内核ARM构架中原子变量的底层实现研究

rk3288 原子操作和原子位操作

原子变量适用于只共享一个int型变量;

1、原子操作是指不被打断的操作,即它是最小的执行单位。

2、最简单的原子操作就是一条条的汇编指令(不包括一些伪指令,伪指令会被汇编器解释成多条汇编指令)

==常见函数:==

==以atomic_inc为例介绍实现过程==

在Linux内核文件archarmincludeasmatomic.h中。 执行atomic_read、atomic_set这些操作都只需要一条汇编指令,所以它们本身就是不可打断的。 需要特别研究的是atomic_inc、atomic_dec这类读出、修改、写回的函数。

所以atomic_add的原型是下面这个宏:

atomic_add等效于:

result(%0) tmp(%1) (v->counter)(%2) (&v->counter)(%3) i(%4)

注意:根据内联汇编的语法,result、tmp、&v->counter对应的数据都放在了寄存器中操作。如果出现上下文切换,切换机制会做寄存器上下文保护。

(1)ldrex %0, [%3]

意思是将&v->counter指向的数据放入result中,并且(分别在Local monitor和Global monitor中)设置独占标志。

(2)add %0, %0, %4

result = result + i

(3)strex %1, %0, [%3]

意思是将result保存到&v->counter指向的内存中, 此时 Exclusive monitors会发挥作用,将保存是否成功的标志放入tmp中。

(4) teq %1, #0

测试strex是否成功(tmp == 0 ??)

(5)bne 1b

如果发现strex失败,从(1)再次执行。

Spinlock 是内核中提供的一种比较常见的锁机制,==自旋锁是“原地等待”的方式解决资源冲突的==,即,一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地“打转”(忙等待)。由于自旋锁的这个忙等待的特性,注定了它使用场景上的限制 —— 自旋锁不应该被长时间的持有(消耗 CPU 资源),一般应用在==中断上下文==。

1、spinlock是一种死等机制

2、信号量可以允许多个执行单元进入,spinlock不行,一次只能允许一个执行单元获取锁,并且进入临界区,其他执行单元都是在门口不断的死等

3、由于不休眠,因此spinlock可以应用在中断上下文中;

4、由于spinlock死等的特性,因此临界区执行代码尽可能的短;

==spinlock加锁以及解锁过程:==

spin_lock(&devices_lock);

临界区代码

spin_unlock(&devices_lock);

==spinlock初始化==

==进程和进程之间同步==

==本地软中断之间同步==

==本地硬中断之间同步==

==本地硬中断之间同步并且保存本地中断状态==

==尝试获取锁==

== arch_spinlock_t结构体定义如下: ==

== arch_spin_lock的实现如下: ==

lockval(%0) newval(%1) tmp(%2) &lock->slock(%3) 1 << TICKET_SHIFT(%4)

(1)ldrex %0, [%3]

把lock->slock的值赋值给lockval;并且(分别在Local monitor和Global monitor中)设置独占标志。

(2)add %1, %0, %4

newval =lockval +(1<<16); 相当于next+1;

(3)strex %2, %1, [%3]

newval =lockval +(1<<16); 相当于next+1;

意思是将newval保存到 &lock->slock指向的内存中, 此时 Exclusive monitors会发挥作用,将保存是否成功的标志放入tmp中。

(4) teq %2, #0

测试strex是否成功

(5)bne 1b

如果发现strex失败,从(1)再次执行。

通过上面的分析,可知关键在于strex的操作是否成功的判断上。而这个就归功于ARM的Exclusive monitors和ldrex/strex指令的机制。

(6)while (lockval.tickets.next != lockval.tickets.owner)

如何lockval.tickets的next和owner是否相等。相同则跳出while循环,否则在循环内等待判断;

* (7)wfe()和smp_mb() 最终调用#define barrier() asm volatile ("": : :"memory") *

阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。

== arch_spin_unlock的实现如下: ==

退出锁时:tickets.owner++

== 出现死锁的情况: ==

1、拥有自旋锁的进程A在内核态阻塞了,内核调度B进程,碰巧B进程也要获得自旋锁,此时B只能自旋转。 而此时抢占已经关闭,(单核)不会调度A进程了,B永远自旋,产生死锁。

2、进程A拥有自旋锁,中断到来,CPU执行中断函数,中断处理函数,中断处理函数需要获得自旋锁,访问共享资源,此时无法获得锁,只能自旋,产生死锁。

== 如何避免死锁: ==

1、如果中断处理函数中也要获得自旋锁,那么驱动程序需要在拥有自旋锁时禁止中断;

2、自旋锁必须在可能的最短时间内拥有

3、避免某个获得锁的函数调用其他同样试图获取这个锁的函数,否则代码就会死锁;不论是信号量还是自旋锁,都不允许锁拥有者第二次获得这个锁,如果试图这么做,系统将挂起;

4、锁的顺序规则(a) 按同样的顺序获得锁;b) 如果必须获得一个局部锁和一个属于内核更中心位置的锁,则应该首先获取自己的局部锁 ;c) 如果我们拥有信号量和自旋锁的组合,则必须首先获得信号量;在拥有自旋锁时调用down(可导致休眠)是个严重的错误的;)

== rw(read/write)spinlock: ==

加锁逻辑:

1、假设临界区内没有任何的thread,这个时候任何的读线程和写线程都可以键入

2、假设临界区内有一个读线程,这时候信赖的read线程可以任意进入,但是写线程不能进入;

3、假设临界区有一个写线程,这时候任何的读、写线程都不可以进入;

4、假设临界区内有一个或者多个读线程,写线程不可以进入临界区,但是写线程也无法阻止后续的读线程继续进去,要等到临界区所有的读线程都结束了,才可以进入,可见:==rw(read/write)spinlock更加有利于读线程;==

== seqlock(顺序锁): ==

加锁逻辑:

1、假设临界区内没有任何的thread,这个时候任何的读线程和写线程都可以键入

2、假设临界区内没有写线程的情况下,read线程可以任意进入;

3、假设临界区有一个写线程,这时候任何的读、写线程都不可以进入;

4、假设临界区内只有read线程的情况下,写线程可以理解执行,不会等待,可见:==seqlock(顺序锁)更加有利于写线程;==

读写速度 CPU > 一级缓存 > 二级缓存 > 内存 ,因此某一个CPU0的lock修改了,其他的CPU的lock就会失效;那么其他CPU就会依次去L1 L2和主存中读取lock值,一旦其他CPU去读取了主存,就存在系统性能降低的风险;

mutex用于互斥操作。

互斥体只能用于一个线程,资源只有两种状态(占用或者空闲)

1、mutex的语义相对于信号量要简单轻便一些,在锁争用激烈的测试场景下,mutex比信号量执行速度更快,可扩展

性更好,

2、另外mutex数据结构的定义比信号量小;、

3、同一时刻只有一个线程可以持有mutex

4、不允许递归地加锁和解锁

5、当进程持有mutex时,进程不可以退出。

• mutex必须使用官方API来初始化。

• mutex可以睡眠,所以不允许在中断处理程序或者中断下半部中使用,例如tasklet、定时器等

==常见操作:==

struct mutex mutex_1;

mutex_init(&mutex_1);

mutex_lock(&mutex_1)

临界区代码;

mutex_unlock(&mutex_1)

==常见函数:==

=

Linux内核设计与实现——内核同步

内核同步


同步介绍


同步的概念

临界区:也称为临界段,就是訪问和操作共享数据的代码段。

竞争条件: 2个或2个以上线程在临界区里同一时候运行的时候,就构成了竞争条件。

所谓同步。事实上防止在临界区中形成竞争条件。

假设临界区里是原子操作(即整个操作完毕前不会被打断),那么自然就不会出竞争条件。但在实际应用中。临界区中的代码往往不会那么简单,所以为了保持同步,引入了锁机制。但又会产生一些关于锁的问题。

死锁产生的条件:要有一个或多个运行线程和一个或多个资源,每一个线程都在等待当中的一个资源。但全部资源都已被占用。

所以线程相互等待。但它们永远不会释放已经占有的资源。于是不论什么线程都无法继续,死锁发生。

自死锁:假设一个运行线程试图去获得一个自己已经持有的锁。它不得不等待锁被释放。但由于它正在忙着等待这个锁。所以自己永远也不会有机会释放锁,死锁产生。

饥饿(starvation) 是一个线程长时间得不到须要的资源而不能运行的现象。

 

造成并发的原因

中断——中断差点儿能够在不论什么时刻异步发生。也就是可能随时打断当前正在执行的代码。

软中断和tasklet ——内核能在不论什么时刻唤醒或调度中断和tasklet。打断当前正在运行的代码。

内核抢占——由于内核具有抢占性。所以内核中的任务可能会被还有一任务抢占。

睡眠及用户空间的同步——在内核运行的进程可能会睡眠,这就会唤醒调度程序从而导致调度一个新的用户进程运行。

对称多处理——两个或多个处理器能够同一时候运行代码。

避免死锁的简单规则

加锁的顺序是关键。

使用嵌套的锁时必须保证以同样的顺序获取锁,这样能够阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其它人能照此顺序使用。

防止发生饥饿。推断这个代码的运行是否会结束。假设A不发生,B要一直等待下去吗?

不要反复请求同一个锁。

越复杂的加锁方案越可能造成死锁。---设计应力求简单。

锁的粒度

加锁的粒度用来描写叙述加锁保护的数据规模。一个过粗的锁保护大块数据,比方一个子系统的全部数据结构。一个过细的锁保护小块数据。比方一个大数据结构中的一个元素。

在加锁的时候,不仅要避免死锁,还须要考虑加锁的粒度。

锁的粒度对系统的可扩展性有非常大影响,在加锁的时候,要考虑一下这个锁是否会被多个线程频繁的争用。

假设锁有可能会被频繁争用。就须要将锁的粒度细化。

细化后的锁在多处理器的情况下。性能会有所提升。


同步方法


原子操作

原子操作指的是在运行过程中不会被别的代码路径所中断的操作。内核代码能够安全的调用它们而不被打断。

原子操作分为整型原子操作和位原子操作。

spinlock自旋锁

自旋锁的特点就是当一个线程获取了锁之后,其它试图获取这个锁的线程一直在循环等待获取这个锁。直至锁又一次可用。

因为线程实在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能非常快处理完的临界区。

自旋锁使用时有2点须要注意:  

1.自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。

2.线程获取自旋锁之前。要禁止当前处理器上的中断。(防止获取锁的线程和中断形成竞争条件)比方:当前线程获取自旋锁后。在临界区中被中断处理程序打断,中断处理程序正好也要获取这个锁,于是中断处理程序会等待当前线程释放锁,而当前线程也在等待中断运行完后再运行临界区和释放锁的代码。

中断处理下半部的操作中使用自旋锁尤其须要小心:

1.   下半部处理和进程上下文共享数据时,因为下半部的处理能够抢占进程上下文的代码,所以进程上下文在对共享数据加锁前要禁止下半部的运行,解锁时再同意下半部的运行。

2.   中断处理程序(上半部)和下半部处理共享数据时,因为中断处理(上半部)能够抢占下半部的运行。所下面半部在对共享数据加锁前要禁止中断处理(上半部),解锁时再同意中断的运行。

3.   同一种tasklet不能同一时候执行。所以同类tasklet中的共享数据不须要保护。

4.   不同类tasklet中共享数据时,当中一个tasklet获得锁后。不用禁止其它tasklet的运行,由于同一个处理器上不会有tasklet相互抢占的情况

5.   同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,由于同一个处理器上不会有软中断互相抢占的情况

读-写自旋锁

假设临界区保护的数据是可读可写的,那么仅仅要没有写操作,对于读是能够支持并发操作的。

对于这样的仅仅要求写操作是相互排斥的需求,假设还是使用自旋锁显然是无法满足这个要求(对于读操作实在是太浪费了)。为此内核提供了还有一种锁-读写自旋锁,读自旋锁也叫共享自旋锁,写自旋锁也叫排他自旋锁。

读写自旋锁是一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,可是在写操作方面。仅仅能最多有一个写进程。在读操作方面,同一时候能够有多个读运行单元,当然,读和写也不能同一时候进行。

自旋锁提供了一种高速简单的所得实现方法。假设加锁时间不长而且代码不会睡眠,利用自旋锁是最佳选择。假设加锁时间可能非常长或者代码在持有锁时有可能睡眠,那么最好使用信号量来完毕加锁功能。

信号量

Linux中的信号量是一种睡眠锁。假设有一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠,这时处理器能重获自由,从而去运行其他代码,当持有信号量的进程将信号量释放后。处于等待队列中的哪个任务被唤醒。并获得该信号量。

 1)由于争用信号量的过程在等待锁又一次变为可用时会睡眠。所以信号量适用于锁会被长时间持有的情况;相反,锁被短时间持有时,使用信号量就不太适宜了。

由于睡眠、维护等待队列以及唤醒所花费的开销可能比锁被占用的所有时间还要长。

 2)由于运行线程在锁被争用时会睡眠。所以仅仅能在进程上下文中才干获取信号量锁,由于中断上下文中是不能进行调度的。


3)你能够在持有信号量时去睡眠。由于当其它进程试图获得同一信号量时不会因此而死锁(由于该进程也仅仅是去睡眠而已,终于会继续运行的)。

4)在你占用信号量的同一时候不能占用自旋锁。由于在你等待信号量时可能会睡眠,而在持有自旋锁时是不同意睡眠的。

5)信号量同一时候同意随意数量的锁持有者,而自旋锁在一个时刻最多同意一个任务持有它。

原因是信号量有个计数值,比方计数值为5,表示同一时候能够有5个线程訪问临界区。

假设信号量的初始值始1,这信号量就是相互排斥信号量(MUTEX)。对于大于1的非0值信号量,也可称为计数信号量(counting semaphore)。

对于一般的驱动程序使用的信号量都是相互排斥信号量。

信号量支持两个原子操作:P/V原语操作(也有叫做down操作和up操作的):

P:假设信号量值大于0,则递减信号量的值,程序继续运行。否则。睡眠等待信号量大于0。

V:递增信号量的值,假设递增的信号量的值大于0,则唤醒等待的进程。

down操作有两个版本号,分别对于睡眠可中断和睡眠不可中断。

读-写信号量

读写信号量和信号量之间的关系 与 读写自旋锁和普通自旋锁之间的关系 差点儿相同。

读写信号量都是二值信号量,即计数值最大为1,添加读者时。计数器不变,添加写者,计数器才减一。也就是说读写信号量保护的临界区,最多仅仅有一个写者,但能够有多个读者。

全部读-写锁的睡眠都不会被信号打断,所以它仅仅有一个版本号的down操作。

了解何时使用自旋锁和信号量对编写优良代码非常重要,可是多数情况下,并不须要太多考虑。由于在中断上下文仅仅能使用自旋锁,而在任务睡眠时仅仅能使用信号量。

    

完毕变量

建议的加锁方法

低开销加锁

优先使用自旋锁

短期加锁

优先使用自旋锁

长期加锁

优先使用信号量

中断上下文加锁

使用自旋锁

持有锁须要睡眠

使用信号量

完毕变量

假设在内核中一个任务须要发出信号通知还有一任务发生了某个特定事件,利用完毕变量(completion variable)是使两个任务得以同步的简单方法。假设一个任务要运行一些工作时,还有一个任务就会在完毕变量上等待。

当这个任务完毕工作后,会使用完毕变量去唤醒在等待的任务。比如。当子进程运行或者退出时,vfork()系统调用使用完毕变量唤醒父进程。

Seq锁(顺序锁)

这样的锁提供了一种非常easy的机制,用于读写共享数据。

实现这样的锁主要依靠一个序列计数器。当有疑义的数据被写入时,会得到一个锁。而且序列值会添加。

在读取数据之前和之后,序列号都被读取。假设读取的序列号值同样,说明在读操作进行的过程中没有被写操作打断过。

此外,假设读取的值是偶数。那么就表明写操作没有发生(要明确由于锁的初值是0。所以写锁会使值成奇数,释放的时候变成偶数)。

 

在多个读者和少数写者共享一把锁的时候,seq锁有助于提供一种很轻量级和具有可扩展性的外观。可是 seq 锁对写者更有利,仅仅要没有其它写者,写锁总是可以被成功获得。挂起的写者会不断地使得读操作循环(前一个样例),直到不再有不论什么写者持有锁为止。

禁止抢占

因为内核是抢占性的,内核中的进程在不论什么时刻都可能停下来以便还有一个具有更高优先权的进程执行。这意味着一个任务与被抢占的任务可能会在同一个临界区内执行。

为了避免这样的情况,内核抢占代码使用自旋锁作(能够防止多处理器机器上的真并发和内核抢占)为非抢占区域的标记。假设一个自旋锁被持有,内核便不能进行抢占。

实际中,某些情况(不须要仿真多处理器机器上的真并发。但须要防止内核抢占)并不须要自旋锁,可是仍然须要关闭内核抢占。

为了解决问题。能够通过 preempt_disable 禁止内核抢占。这是一个能够嵌套调用的函数。能够调用随意次。每次调用都必须有一个对应的 preempt_enable 调用。当最后一次 preempt_enable 被调用后,内核抢占才又一次占用。

顺序和屏障

对于一段代码。编译器或者处理器在编译和运行时可能会对运行顺序进行一些优化。从而使得代码的运行顺序和我们写的代码有些差别。

普通情况下,这没有什么问题。可是在并发条件下,可能会出现取得的值与预期不一致的情况,比方以下的代码:

/* 
 * 线程A和线程B共享的变量 a和b
 * 初始值 a=1, b=2
 */
int a = 1, b = 2;

/*
 * 如果线程A 中对 a和b的操作
 */
void Thread_A()
{
    a = 5;
    b = 4;
}

/*
 * 如果线程B 中对 a和b的操作
 */
void Thread_B()
{
    if (b == 4)
        printf("a = %d\\n", a);
}

因为编译器或者处理器的优化。线程A中的赋值顺序可能是b先赋值后,a才被赋值。

所以假设线程A中 b=4; 运行完,a=5; 还没有运行的时候,线程B開始运行,那么线程B打印的是a的初始值1。

这就与我们预期的不一致了,我们预期的是a在b之前赋值,所以线程B要么不打印内容,假设打印的话,a的值应该是5。

 

在某些并发情况下,为了保证代码的运行顺序。引入了一系列屏障方法来阻止编译器和处理器的优化。

方法

描写叙述

rmb()

阻止跨越屏障的加载动作发生重排序

read_barrier_depends()

阻止跨越屏障的具有数据依赖关系的加载动作重排序

wmb()

阻止跨越屏障的存储动作发生重排序

mb()

阻止跨越屏障的加载和存储动作又一次排序

smp_rmb()

在SMP上提供rmb()功能,在UP上提供barrier()功能

smp_read_barrier_depends()

在SMP上提供read_barrier_depends()功能。在UP上提供barrier()功能

smp_wmb()

在SMP上提供wmb()功能,在UP上提供barrier()功能

smp_mb()

在SMP上提供mb()功能,在UP上提供barrier()功能

barrier()

阻止编译器跨越屏障对加载或存储操作进行优化

为了使得上面的小样例能正确运行,用上表中的函数改动线程A的函数就可以:

/*
 * 如果线程A 中对 a和b的操作
 */
void Thread_A()
{
    a = 5;
    mb(); 
    /* 
     * mb()保证在对b进行加载和存储值(值就是4)的操作之前
     * mb()代码之前的所有加载和存储值的操作所有完毕(即 a = 5;已经完毕)
     * 仅仅要保证a的赋值在b的赋值之前进行,那么线程B的运行结果就和预期一样
     */
    b = 4;
}

总结:

下图来自http://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html

 

 

 

 參考:

http://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html

http://www.cnblogs.com/pennant/archive/2012/12/28/2833383.html

Linux内核设计与实现

以上是关于linux内核同步问题的主要内容,如果未能解决你的问题,请参考以下文章

Linux内核设计与实现——内核同步

[内核同步]Linux内核同步机制之completion

一文搞懂 , Linux内核—— 同步管理(下)

《Linux内核设计与实现》读书笔记- 内核同步介绍

Linux内核同步机制之completion

Linux 内核同步之自旋锁与信号量的异同