1. 前言
操作系统是第一个并发程序,是在拥有多线程的进程中应该研究的问题。每个线程都是一个进程的执行流,代表着程序做事,但是当线程访问内存的时候,对于他们来说有些内存节点是与其他线程共享的,如果不协调线程之间的内存访问,程序将无法按着预期工作。如何解决呢?操作系统使用锁或者条件变量这样的原语,来支持多线程应用程序,如果不非常小心的访问自己的内存,将会发生许多奇怪而可怕的事情,接下来就看看会发生什么。
2. 共享变量加一发生什么事了
有如下代码,使用pthread_create函数创建16个线程,它们都对全局变量sum进行加一操作,每个线程操作1000次。
1 |
|
按照我们的期望,如果程序“正常”执行的话,很容易可以得到结果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 |
|
运行结果与预期相同了:
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