并发专题(三) 并发带来的问题——共享数据

并发带来的问题

并发固然可以提高程序的运行效率。但是同样也带来了许多沉重的代价,例如:

  1. 共享数据问题。
  2. 并发同步问题。
  3. BUG不易复现问题。

共享数据问题

#include <stdio.h>
#include <pthread.h>

static int counter = 0; /* 全局变量 */

/* 对变量counter进行递增操作 */
void *decrement(void *args) {
    printf("In thread %s\n", (char*)args);
    int i;
    for (i=0; i<100000; ++i)
        counter--;
    return NULL;
}

/* 对变量进行递减操作 */
void *increment(void *args) {
    printf("In thread %s\n", (char*)args);
    int i;
    for (i=0; i<100000; ++i) 
        counter++;
    return NULL;
}

int main() {

    pthread_t p1,p2;
    int rc;
    rc = pthread_create(&p1, NULL, decrement, "DECREMENT");
    if (rc != 0) {
        printf("thread [DECREMENT] create error!\n");
        exit(-1);
    }
    rc = pthread_create(&p2, NULL, increment, "INCREMENT");
    if (rc != 0) {
         printf("thread [INCREMENT] create error!\n");
        exit(-1);
    }
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);
    printf("counter = %d\n", counter);
    return 0;
}

上述C语言代码逻辑很简单,一个线程对变量counter进行循环递增操作,另一个线程对变量进行循环递减操作,因为循环的次数是一样的,所以我们预期的结果是,最终counter的值不会改变。但是实际运行结果并不是这样。在这里插入图片描述

线程上下文和原子性

之所以会产生这样的结果,根本原因在于线程在运行时处于不可控状态。也就是说,你无法确定某一时刻某个线程是否在运行。当我们创建好线程之后,线程的执行与调度将交由操作系统,我们无法管理我们的线程。

线程的调度,一般采用时间片轮转算法进行调度,即给一个线程分配一定的执行实行例如2ms,2ms之后操作系统会将这个线程当前运行的状态保存到TCB(Thread Control Block,主要用于调度中恢复线程的执行现场), 这个TCB也称为线程上下文。

正如我们所说,一个线程什么时候被执行,什么时候被挂起完全取决于操作系统,那么当线程用完CPU时间片时,线程函数中代码停止的位置也具有一定的随机性。但是这种随机性是导致出现共享数据问题的原因吗?

答案是:不全是。导致共享数据问题的原因不仅仅在于线程的调度,还取决于指令的原子性。我们写的高级语言代码最终要编译为二进制数据保存于内存中,那么我们在高级语言中可以通过一行(一句)代码完成的事情,真正交给CPU去做的时候,可能需要好几个步骤。

例如上述代码中的counter++counter--。这两句代码看起来好像是一步就可以完成,但是CPU真正去执行的时候并不是。我们可以通过gcc -S [source file]的方式,去查看编译后的汇编代码。

movl	counter(%rip), %eax
subl	$1, %eax
movl	%eax, counter(%rip)

通过汇编代码我们可以看到,counter--需要三个步骤才可以完成。同时也要注意,我们说代码停止运行的位置具有随机性,这个位置是对于最终的机器指令来说的。而不是针对于源代码来说。

我们看到CPU在执行的时候,首先它要讲counter从内存中转移至寄存器中。然后对寄存器中的值加上立即数1,然后再将加1之后的寄存器中的值转移至内存中。

我们可以将上述三个步骤分别用LOAD,CALC,STORE来代替。问题出现的关键点便在于,我们对数据进行CALC之后是否能及时的STORE至内存中,也就是,现在内存中的值,是否是一个最新的值(合理的值)。如果现在CALC之后,未来得及进行STORE操作就移交了CPU 的使用权,那么其他线程读取到的值,就不是一个合理的值。

那么什么是原子性,原子性就是我们期望事件不可再分。例如一条指令,我们期望他不会被分解为其他若干条指令。而是一次性,作为一个基本单元的去执行,并且在执行过程中不可能被中断。

上述代码的问题就在于,我们把counter++counter--误以原子指令的形式去运行。

值得注意的是,有时候一条汇编指令并不一定代表一条原子指令。即汇编指令也不能保障原子性。原子性的保障还需依靠硬件系统的微指令来保障。

竞态条件与临界区

在多线程并发的环境下,多个线程在竞争着对同一资源对象进行操作,那么这两个线程将处于竞态条件(Race Condition),竞态条件下执行的代码结果依赖于并发执行或者事件的顺序,这种结果往往具有不确定性和不可重现性。

临界区(Critical section) 是指进程中一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会执行的代码区域。简单说,临界区就是访问共享变量的代码段,这个代码段一定不能被多个线程同时执行。
临界区的特点

  • 互斥性:同一时间,临界区最多只有一个线程进行访问。
  • Progress:如果一个线程想要进入临界区,那么它最终会成功。
  • 有限等待:如果 线 i 线程_i 出入临界区入口,那么 线 i 线程_i 的请求被接受之前,其他线程进入临界区时间是有限制的。
  • 无忙等待:如果一个线程在等待进入临界区,那么在此之前它可选择无忙等待。(Optional)

临界区是一种逻辑概念。那么针对于临界区的性质,有三种实现策略

  • 基于硬件中断的实现。
  • 基于软件
  • 更深层次的抽象在这里插入图片描述

基于中断的临界区实现

在分时操作系统中,没有时钟中断,就没有上下文切换,就没有并发。操作系统的调度器的实现就是依赖于时钟中断。那么我们在实现临界区的时候,可以在一个线程进入临界区代码后主动禁用掉CPU对中断的响应,在线程离开临界区代码后,再开启CPU对中断的响应。这种实现可以实现良好的互斥性和其他临界区的特性。

但是这种实现并不是最好的实现,因为禁用CPU中断带来的开销非常大。一旦CPU中断响应被禁止,那么不仅仅是其他线程无法被调度,甚至一些基本的设备请求,网络请求等都会受到影响。而且一旦我们临界区代码的开销也同样巨大,那么这种实现的效果就会很差。换言之,这种实现的粒度太大了。

同时这种实现只能作用于单核CPU,对于多核CPU,就不能保障临界区的特性了。
基于软件的实现

基于软件的实现,就是利用一下数据结构+算法,来实现临界区的功能。

例如Bakery算法:

do{
    flag[i] = TRUE
    turn = j
    while (flag[i] && turn == j);
    	进入临界区
    flag[i] = FALSE
    	离开临界区
}while(TRUE)

相对比基于中断的实现方式,基于软件的实现能够达到一种细粒度的控制。但是基于软件实现的方式会很复杂。

更深层次的抽象

锁和信号量。它们是操作系统提供的更高级的编程抽象用来解决临界区问题。锁和信号量不仅能够解决共享数据问题,同时他也可以解决线程间同步的问题,同时可以将我们代码的稳定性提高,降低出现BUG的风险。这两个概念十分重要,它们是解决并发问题的关键,在下面的章节中会详细的介绍。

这种更高层次的抽象,并不是上述两种实现方法的Next Generation。而是借鉴了上述两种实现方式之后的一个更为通用和抽象的解决方案。

猜你喜欢

转载自blog.csdn.net/hbn13343302533/article/details/106862396