前言
- 在多线程中操作全局变量一般都会引起线程冲突
1 线程冲突
#include <stdio.h>
#include <stdlib.h>
#include <process.h>
#include <Windows.h>
int g_count = 0;
void count(void *p)
{
Sleep(100); //do some work
//每个线程把g_count加1共10次
for (int i = 0; i < 10; i++)
{
g_count++;
}
Sleep(100); //do some work
}
int main(void)
{
printf("******多线程访问全局变量演示***by David***\n");
//共创建10个线程
HANDLE handles[10];
for (int i = 0; i < 10; i++)
{
for (int j = 0; j < 10; j++)
{
handles[j] = _beginthread(count, 0, NULL);
}
WaitForMultipleObjects(10, handles, 1, INFINITE);
printf("%d time g_count = %d\n", i, g_count);
//重置
g_count = 0;
}
getchar();
return 0;
}
运行结果:
理论上,g_count的最后结果应该是100,可事实却并非如此,不但结果不一定是100,而且每次的结果还很可能不一样。
原因是,多个线程对同一个全局变量进行访问时,特别是在进行修改操作,会引起冲突。
设置断点,查看反汇编
g_count++;被分为三步操作:
- 把g_count的内容从内存中移动到寄存器eax
- 把寄存器eax加1
- 把寄存器eax中的内容移动到内存g_count的地址
三步以后,g_count的值被顺利加1。
分析:
- 一条最简单加减法语句都有可能会被翻译成几条指令执行;为了避免语句在CPU这一层级上的指令交叉带来的行为不可知,在多线程程序设计时我们必须通过一些方式来进行规范
- CPU执行的时间片内包含多条指令,每条指令执行期间不会被打断,
- 但如果 一个操作包含多条指令,则 *很有可能该操作会被打断 *。g_count++ 就是这样的操作。
g_count被顺利加到100的前提:每次加1,都建立在上一次加1顺利完成的基础上。也就是说,如果上一次加1被打断,这一次的加1就得不到上一次加1的累积效果。自然,最后的结果,多半会小于100。
2. 原子操作
-
为了解决线程冲突,引入原子操作。
-
所谓原子操作,是指 不会被线程调度机制打断的操作,操作一旦开始,就得执行到结束为止。
-
原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者切割掉只执行部分。
-
原子操作一般靠 底层汇编 实现。
-
在头文件
winnt.h
中提供了很多的原子操作函数,它们使用 自加锁 的方式,保证操作的原子性,如 自增操作InterlockedIncrement
解决冲突的最常见做法是:
- 使用互斥锁
- 其大概的模型就是篮球模式:几个人一起抢球,谁抢到了谁玩,玩完了再把球丢出来重新抢;
- 但 互斥锁是操作系统这一层级 的,最终 映射到CPU上也是一堆指令,是指令就必然会带来额外的开销.
既然CPU指令是多线程不可再分的最小单元,那我们如果有办法将代码语句和指令对应起来,不就不需要引入互斥锁从而提高性能了吗? 而这个对应关系就是所谓的原子操作.
函数原型
LONG CDECL_NON_WVMPURE InterlockedIncrement( _Inout_ _Interlocked_operand_ LONG volatile *Addend);
其它相关操作,请自行查看头文件。
使用该函数,我们修改线程函数count。
修改很简单,只需把 g_count++
改为 InterlockedIncrement((LONG)&g_count)
即可
运行如下:
显然,在原子操作下,我们肯定是可以得到正确结果的。
C++11中的原子操作
#include <atomic>
std::atomic_int myInt;
在C++11的atomic中有两种做法:
- 模拟, 比如说对于一个atomic类型,我们可以给他附带一个mutex,操作时lock/unlock一下,这种在多线程下进行访问,必然会导致线程阻塞;
- 有相应的CPU层级的对应,这就是一个标准的lock-free类型. 可以通过is_lock_free函数,判断一个atomic是否是lock-free类型.
自旋锁
使用原子操作模拟互斥锁的行为就是自旋锁
互斥锁状态是由操作系统控制的,自旋锁的状态是程序员自己控制的
- 线程反复检查锁变量是否可用。
- 由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。