并发

1. 前言

操作系统是第一个并发程序,是在拥有多线程的进程中应该研究的问题。每个线程都是一个进程的执行流,代表着程序做事,但是当线程访问内存的时候,对于他们来说有些内存节点是与其他线程共享的,如果不协调线程之间的内存访问,程序将无法按着预期工作。如何解决呢?操作系统使用锁或者条件变量这样的原语,来支持多线程应用程序,如果不非常小心的访问自己的内存,将会发生许多奇怪而可怕的事情,接下来就看看会发生什么。

2. 共享变量加一发生什么事了

有如下代码,使用pthread_create函数创建16个线程,它们都对全局变量sum进行加一操作,每个线程操作1000次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <errno.h>
#include <unistd.h>

//定义线程数量
#define PTHREAD_NUM 16
//定义全局变量
unsigned long sum = 0;

//子线程执行的函数,对全局变量sum做加一操作10000次
void *thread(void *arg)
{
for(int i=0; i < 10000; i++)
sum += 1;
}

int main(void)
{
printf("before ...sum = %lu\n", sum);

//定义线程标识符,线程id,与线程交互。
pthread_t pthread[PTHREAD_NUM];
int ret;
void *retval[PTHREAD_NUM];

for (int i = 0; i < PTHREAD_NUM; ++i)
{
/*
创建新线程,可能会立即执行,也可能处于就绪状态,等待执行。
第一个参数:pthread_t结构类型指针,传进去并初始化。
第二个参数:pthread_attr_t 结构类型指针,用于指定该线程的属性,例如栈大小、调度优先级。
第三个参数:函数指针,线程应该在哪个函数中运行。
第四个参数:传递给线程开始执行的函数的参数。
*/
ret = pthread_create(&pthread[i],NULL,thread,NULL);
if (ret != 0)
{
perror("cause:");
printf("creat pthread %d failed.\n", i+1);
}
}
for (int i = 0; i < PTHREAD_NUM; ++i)
{
/*
主程序等待某个线程完成。
第一个参数:pthread_t类型,指定要等待的线程。
第二个参数:希望得到的返回值。
本程序的thread函数没有返回值。
*/
pthread_join(pthread[i],&retval[i]);
}
printf("after.....sum = %lu\n", sum );

return 0;
}

按照我们的期望,如果程序“正常”执行的话,很容易可以得到结果sum应该是160000。
编译程序:

gcc thread.c -g -o thread -lpthread

szp@szp-pc:~/code/sync$ gcc thread.c -g -o thread -lpthread
szp@szp-pc:~/code/sync$ ./thread
before ...sum = 0
after.....sum = 137320
szp@szp-pc:~/code/sync$ ./thread
before ...sum = 0
after.....sum = 117388
szp@szp-pc:~/code/sync$ ./thread
before ...sum = 0
after.....sum = 151319
szp@szp-pc:~/code/sync$ ./thread
before ...sum = 0
after.....sum = 94043
szp@szp-pc:~/code/sync$ 

运行程序,并没有得到预期的结果,而且每次结果都在变化,情况逐渐变得失控,程序产生了不确定的结果。要想理解这个问题,必须了解更新全局变量sum的汇编代码。

对可执行文件thread进行反汇编:

objdump -d thread

找到thread函数的汇编代码。

00000000000011c9 <thread>:
    11c9:    f3 0f 1e fa              endbr64 
    11cd:    55                       push   %rbp
    11ce:    48 89 e5                 mov    %rsp,%rbp
    11d1:    48 89 7d e8              mov    %rdi,-0x18(%rbp)
    11d5:    c7 45 fc 00 00 00 00     movl   $0x0,-0x4(%rbp)
    11dc:    eb 16                    jmp    11f4 <thread+0x2b>
    11de:    48 8b 05 33 2e 00 00     mov    0x2e33(%rip),%rax        # 4018 <sum>
    11e5:    48 83 c0 01              add    $0x1,%rax
    11e9:    48 89 05 28 2e 00 00     mov    %rax,0x2e28(%rip)        # 4018 <sum>
    11f0:    83 45 fc 01              addl   $0x1,-0x4(%rbp)
    11f4:    81 7d fc 0f 27 00 00     cmpl   $0x270f,-0x4(%rbp)
    11fb:    7e e1                    jle    11de <thread+0x15>
    11fd:    90                       nop
    11fe:    5d                       pop    %rbp
    11ff:    c3                       retq   

我们来看这一行代码sum += 1;在被编译之后成了什么样子,从中截取代码如下。

    11de:    48 8b 05 33 2e 00 00     mov    0x2e33(%rip),%rax        # 4018 <sum>
    11e5:    48 83 c0 01              add    $0x1,%rax
    11e9:    48 89 05 28 2e 00 00     mov    %rax,0x2e28(%rip)        # 4018 <sum>
    11f0:    83 45 fc 01              addl   $0x1,-0x4(%rbp)

全局变量sum的地址在注释中给出了就是0x4018,需要主要的是全局的变量的地址计算,是通过rip(愿死者安息)寄存器中存储的偏移量+下一条指令的地址计算出来的。例如上面取出sum的值存入%rax寄存器,sum地址=0x11e5+0x2e33=0x4018,add命令对%rax寄存器的值加1(0x1)。最后%rax的值被放回内存中相同的地址,计算方式是0x11f0+0x2e28=0x4018。

2.1 多个线程并发执行,共享变量的加1操作到底会带来什么问题?

试想一个线程进入这个代码区域,要对sum增加一个值,它将sum的值(假设此时是100)从内存中读入它的%rax寄存器,然后执行下一条指令add,对%rax寄存器的值加1,这是该线程%rax寄存器存储的sum值为51。当它要继续执行下面的写回sum值的指令时,发生了时钟中断,操作系统将当前线程的状态(%rax寄存器,PC)保存到线程的TCB中。然后接下来另一个线程被选中,同样也进入了这一块代码,取sum值放入自己的%rax,现在值是50,对其值加1,现在%rax值为51,最后写回内存,一气呵成,完成了操作。目前全局变量sum的值为51了。现在又发生了线程切换,上面的线程一又获得了执行权,它将从TCB中恢复刚才执行到的状态,它的%rax中的值为51,接下来执行写回命令,sum的值被重新赋值为51。好了,现在两个线程对sum执行了加1操作,sum的值应当是52才对,但是在刚才的情况下,sum的值是51。

这只是其中一种情况,事实上程序产生不确定结果的原因也真是源于此。这种情况被称为竞态条件,运气不好就会产生错误的结果。可能产生竞态条件的这段代码就叫做临界区。临界区就是访问共享资源的代码片段。我们想要实现的理想的代码运行顺序就是所谓的互斥,保证如果一个线程正处于临界区,其他线程将被阻止进入临界区。互斥最终实现的结果就是线程的同步执行,即让程序按照某种既定的顺序执行。

3. 什么是原子操作?Linux下如何进行原子操作?

上面的问题就是不能一步就完成要做的事情,从而产生了不合时宜的中断。指令集不能提供一个指令完成对内存中的一个数值增加1,当中断发生的时候,要么没有执行,要么执行完成了,不存在中间状态。原子的意思是“作为一个单元”,全部执行或者不执行,有时将多个行为组合为单个原子动作称为事务,常见于数据库中。事实上,硬件提供了一些有用的指令,在这些指令上构建通用的集合,同步原语。通过这些原语,加上操作系统的帮助就能够同步的访问临界区。

gcc从4.1.2开始提供了sync_*系列的build-in函数,用于提供加减和逻辑运算的原子操作。
__sync_fetch_and_add系列一共有十二个函数,有加/减/与/或/异或/等函数的原子性操作函数,
sync_fetch_and_add,顾名思义,先fetch,然后自加,返回的是自加以前的值。以count = 4为例,调用__sync_fetch_and_add(&count,1)之后,返回值是4,然后,count变成了5。

4. 什么是临界区和临界资源?

临界资源:系统中一次只允许一个进程(线程)访问的资源称为临界资源。一旦分配给进程,不能强制剥夺,通常是一个变量或者数据结构。
临界区:并发执行的进程中, 访问(读取或修改)临界资源必须互斥执行的程序段叫临界区。

5. Linux C中如何保护临界区

POSIX通过锁来提供互斥进入临界区的函数,最基本的一对函数是:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

利用锁来保护临界区,使用方法大致如下:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
x = x + 1; // 临界区
pthread_mutex_unlock(&lock);

如果在调用pthread_mutex_lock的时候,没有其他进程持有该锁,线程将获取该锁,并进入临界区;如果另一个进程已经获取了该锁,那么尝试获取该锁的线程不会从该函数调用返回,而是会进入睡眠,在获取锁的函数内等待,直到获得该锁。

另外在使用锁(锁简单来讲就是一个变量)之前,应该对其初始化,常见两种方式对初始化:

  • 使用PTHREAD_MUTEX_INITIALIZER宏定义
  • 使用pthread_mutex_init(&lock, NULL);函数

接下来就可以对上面的程序进行修改,对临界区sum=sum+1加锁保护。
修改thread函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

unsigned long sum = 0;
void *thread(void *arg)
{

for(int i=0; i < 10000; i++){

int rc = pthread_mutex_lock(&lock);
assert(rc==0);

sum += 1;

pthread_mutex_unlock(&lock);

}

}

运行结果与预期相同了:

szp@szp-pc:~/code/sync$ gcc thread.c -o thread -lpthread
szp@szp-pc:~/code/sync$ ./thread
before ...sum = 0
after.....sum = 160000
szp@szp-pc:~/code/sync$ ./thread
before ...sum = 0
after.....sum = 160000

PS:前往公众号“Linux工坊”,可以查看最新内容!关注即可免费领取面试&Linux技术书籍!

------ 本文结束------
  • 文章标题: 并发
  • 本文作者: 你是我的阳光
  • 发布时间: 2020年12月25日 - 20:03:48
  • 最后更新: 2022年11月07日 - 16:45:00
  • 本文链接: https://szp2016.github.io/os/并发/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
0%