一、Race condition的定义
竞态条件(race condition)最早出现在《英汉计算机技术大辞典》中。这一术语用于描述在并发程序设计中的一个常见问题,即多个进程的相对执行速度导致的结果不确定性,依赖于事件间相对时间的排列顺序。竞态条件的产生往往与多个进程的相对执行速度有关,是并发程序设计中的一个重要概念。在计算机科学和软件工程领域,竞态条件是一个关键问题,因为它可能导致程序的行为不可预测,从而引发错误或异常。此外,竞态条件不仅在计算机软件中常见,也在电子系统,尤其是逻辑电路和多线程环境中出现,导致结果的不确定性。
Race condition是计算机科学中一个重要且基础的概念,它描述了在多线程或多进程环境中,当两个或多个线程/进程访问共享资源时,由于执行顺序的不确定性,可能导致程序行为出现不可预测的情况。这种条件通常出现在并发编程中,尤其是在没有适当同步措施的情况下。
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待(例如:bounded waiting 等待法),有一些同步的机制必须在临界区段的进入点与离开点实现,以确保这些共用资源是被互斥获得使用。对临界区没有处理就容易导致竞态条件发生。
二、Race condition的情形
竞态条件发生是由于共享资源被同时访问,共享资源包括共享文件,共享内存,共享变量,共享打印,共享屏幕等。
例如共享变量,在单核或者多核系统中,由于任务调度具有随机性及抢占性,共享变量可能在操作过程中被打断,当变量被其他任务或者中断更改后,后续的操作会出现异常,这样就导致了竞态条件。
2.1 操作时序问题
任务1在a==1时,action1和action2顺序执行;当如果在程序运行在line1行,中断1发生,中断对a作加3操作,后a=1+2+3=6.这样就会导致中断执行完成后a=6,本来要执行的action2无法正常执行。这样软件的执行动作action1->action2无法按照预定设计的顺序执行。
//task1
if(a==1)
{
a = a+2;//line1
action1();
}
if(a == 3)
{
action2();
}
//interrupt1
if(a==1)
{
a = a+3;
action3();
}
2.2 数据一致性问题
当数据量比较大,一个操作指令无法完成所有的读写操作时,当读数据的操作被其他任务或者中断打断并写入数据时,会导致读取数据的不一致问题。
下例中,如果task2执行到line1被task1打断,并执行结构体a的新操作,此时,a.1和和a.2被更改,当task2再往下执行时,a.2的值已发生变化,b.3的数据就不是最新的数据求和。这样会导致结构体内数据之间的一致性问题。
task1()
{
a.1=a.1 + 2;
a.2=a.2 + 3;
a.3=a.1+a.2;
}
task2()
{
struct b;
b.1=a.1;
b.2=a.2;//line1
b.3=b.1+b.2;
}
2.3 访问冲突问题
在多核或者多主系统中,如果CPU,DMA等主机同时操作改写某个地址的数据,就有可能发生同时访问同一地址的总线冲突。这时就需要CPU和DMA之间进行同步处理。例如使用信号量,IOC技术。
三、Race condition的解决方案
竞态条件的主要解决思路就是避免同时操作共享变量,也就是采用同步机制:信号量、互斥量、条件变量、自旋锁和状态机等操作。
3.1 信号量
信号量(semaphore)是操作系统用来解决并发中的互斥和同步问题的一种方法。信号量其实就是一个变量(可以是整型,也可以是更为复杂的记录型,一般如果未提到具体类型,那么就是记录型),可以用一个信号量来表示系统中某些资源的数量。
互斥信号量的初值实际上是表示某种资源的数量,而我们这里临界区同一时间段内只允许一个进程来访问,所以可以把临界区理解成一种特殊的资源,该资源只有一个,只能被分配给一个进程使用,只有这个进程释放了,才能被其他进程使用。如果一个进程需要使用临界区这种特殊的资源的时候,那么使用之前就应该对其所对应的信号量进行P操作,使用之后进行V操作。
PV操作:通过对信号量S进行两个标准的原子操作(不可中断的操作)wait(S)和signa(S),可以实现进程的同步和互斥。这两个操作又常被称为P、V操作,其定义如下:
P(S):①将信号量S的值减1,即S.value=S.value-1;
②如果S.value≥0,则该进程继续执行;否则该进程置为等待状态,排入等待队列。
V(S):①将信号量S的值加1,即S.value=S.value+1;
②如果S.value>0,则该进程继续执行;否则释放S.L中第一个的等待进程。
一次P操作意味着请求分配一个单位资源,因此S.value减1,当S.value<0时,表示已经没有可用资源,请求者必须等待别的进程释放该类资源,它才能运行下去。
而执行一次V操作意味着释放一个单位资源,因此S.value加1;若S≤0,表示有某些进程正在等待该资源,因此要唤醒一个等待状态的进程,使之运行下去。
信号量获取不成功,对应的线程将会进入等待状态,直到信号量在别的线程中释放。
3.2 互斥量mutex
互斥量又称互斥信号量(本质是信号量),是一种特殊的二值信号量,它和信号量不同的是,它支持互斥量所有权、递归访问以及防止优先级翻转的特性,用于实现对临界资 源的独占式处理。任意时刻互斥量的状态只有两种,开锁或闭锁。当互斥量被任务持有时, 该互斥量处于闭锁状态,这个任务获得互斥量的所有权。当该任务释放这个互斥量时,该 互斥量处于开锁状态,任务失去该互斥量的所有权。
互斥量的具体实现方式为:每个线程在对共享资源操作前都尝试先加锁,成功加锁后才可以对共享资源进行读写操作,操作结束后解锁。
3.3 条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制。它主要用于实现“等待->唤醒”逻辑,即一个线程等待某个条件成立,而另一个线程在条件成立时通知等待的线程。
条件变量的作用是用于多线程之间关于共享数据状态变化的通信。当一个动作需要另外一个动作完成时才能进行,即:当一个线程的行为依赖于另外一个线程对共享数据状态的改变时,这时候就可以使用条件变量。
在多线程编程中,当多个线程之间需要进行某些同步机制时,如某个线程的执行需要另一个线程完成后才能进行,可以使用条件变量。例如,在生产者-消费者模型中,生产者线程在生产数据后通知消费者线程,消费者线程在数据准备好后继续执行。
例如一个队列问题:
条件变量是条件相关的数据结构。它允许线程在某些条件变为真之前被阻塞。例如,thread_push
可能希望检查队列是否已满,如果是这样,就在队列未满之前阻塞。所以我们感兴趣的“条件”就是“队列未满”。
void queue_push(Queue *queue, int item) {
mutex_lock(queue->mutex);
while (queue_full(queue)) {
cond_wait(queue->nonfull, queue->mutex); //-- new
}
queue->array[queue->next_in] = item;
queue->next_in = queue_incr(queue, queue->next_in);
mutex_unlock(queue->mutex);
cond_signal(queue->nonempty); //-- new
}
在queue_pop
中,如果我们发现队列为空,我们不要退出,而是使用条件变量来阻塞:
int queue_pop(Queue *queue) {
mutex_lock(queue->mutex);
while (queue_empty(queue)) {
cond_wait(queue->nonempty, queue->mutex); //-- new
}
int item = queue->array[queue->next_out];
queue->next_out = queue_incr(queue, queue->next_out);
mutex_unlock(queue->mutex);
cond_signal(queue->nonfull); //-- new
return item;
}
cond_wait的
第一个参数是条件变量。这里我们需要等待的条件是“队列非空”。第二个变量是保护队列的互斥体。在你调用cond_wait
之前,你需要先锁住互斥体,否则它不会生效。
当锁住互斥体的线程调用cond_wait
时,它首先解锁互斥体,之后阻塞。这非常重要。如果cond_wait
不在阻塞之前解锁互斥体,其它线程就不能访问队列,不能添加任何物品,队列会永远为空。
3.4 自旋锁spinlock
自旋锁是专为防止处理器并发而引入的一种锁,它在内核中大量应于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)。
自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁已经被另外一个线程占有了,那么此线程就无法获取这把锁,该线程会等待,间隔一段时间后再次尝试获取。这种采用循环加锁,等待锁释放的机制就称为自旋锁(spinlock)。
由于在多处理器的环境中某些资源的有限性,有时需要互斥访问,这时候就需要引入锁的概念,只有获取到锁的线程才能对临界资源进行访问,由于多线程的核心是CPU的时分片,所以同一时刻只能又一个线程获取到锁。但是那些没有获取到锁的线程该怎么办呢?
通常有两种做法:一种是没有获取到锁的线程就一直等待判断该资源是否已经释放了锁,这种锁叫做自旋锁,它不会引起线程阻塞(本质上是一种忙等待机制,避免线程切换带来的系统开销)。还有一种是,没有获取到锁的线程把自己阻塞起来,重新等待CPU的调度,这种锁称为互斥锁
自旋锁实现的原理比较简单,当那些不能立马获取到锁资源的线程,它们不会像互斥锁那样直接将自己挂起进入阻塞状态,而是等一会(自旋)不判断的去判断锁资源是否被释放了,如果释放了那么就去获取锁资源。这样做避免了那些锁竞争不激烈的情况下,从核心态到用户态的切换,避免了系统上下文切换的开销。
在线程竞争不激烈的时候,自旋锁是需要等待前面一个获的锁的线程释放锁后就可以在很短的时间内获取到锁从而继续执行,避免了直接将线程阻塞再去重新等待CPU调度所浪费的两次上下文切换的系统开销,这可以极大的提升系统的性能
在线程竞争很激烈的时候,自旋锁就显得有一点笨拙了。因为自旋锁在获取到锁资源之前CPU是一直在做无用功,同时大量的线程去竞争一个锁资源,会导致获取锁的时间很长。这种情况下,就白白的浪费了许多CPU资源。
3.5 状态机
为了避免多个线程同时访问共享资源,可以使用状态机来进行协调各个线程的访问时序,这种方式是一种主动避免竞争条件的方式。比如有三个线程访问需要输出数据到共享变量,可以使用以下状态机来实现:
可以将访问共享资源的功能函数放在一个核中执行,这样就更容易避免多核对共享变量的访问操作。
switch process_n
{
case : process1
setvalue(1);
case : process2
setvalue(2);
case : process3
setvalue(3);
}
3.6 使用握手标志
在没有操作系统的软件工程中,工程师可以自己对共享变量的操作加以保护。比如在两个核要对共享变量进行读和写时,采用一对变量来对两个核进行握手操作,如下图,a3是a1和a2的和,为了避免在读写过程中发生数据一致性问题,在读写这三个变量时引用flag_r_en和flag_w_en两个变量来协调核之间的数据操作,在CPU对数据操作时,总要询问另外一个CPU是否允许这样的操作,如果不满足条件,软件直接跳过此步骤。
//core1
if(flag_r_en == 1)
{
flag_r_en = 0;
a_1=a1;
a_2=a2;
a_3=a1+a2;
flag_w_en = 1;
}
//core2
if(flag_w_en == 1)
{
flag_w_en = 0;
a1=a1-1;
a2=a2+2;
a3=a1+a2;
flag_w_en = 1;
}
3.7 使用关中断或者关总线操作
在单核系统中,如果线程中访问的变量在中断里也会操作,那么在临界区代码的前后使用开关中断指令是解决竞态条件的非常简单且有效的方式。
在多核系统中,采用关闭总线的方式,也可以有效防止多核对共享变量的读写操作。如下图所示:
disableinterrupt();
//start of criticall section
a_1=a1;
a_2=a2;
a_3=a1+a2;
//end of criticall section
enableinterrupt();