简介: 入门级别讲解,分析多线程竞争同一共享资源,运行结果不符预期的原因;故书一简例,讲解何谓 非线程安全 ?以及使用 atomic 和 mutex 来解决此缺陷场景,最终 线程安全 获得预期结果。

[TOC]


本文初发于 “偕臧的小站“,同步转载于此。


受众

  有基本的多线程理论知识,但未曾深入研究 “运行不符预期结果” 的萌新?亦或对多线程未曾了解,道听途说、知识点模糊,实际不甚理解原理的读者。都是本文的最佳读者。若已早熟练和精通于多线程项目,可直接关闭本文,珍惜时间,可以用来干点其它的什么,摸个🐟,喝杯肥宅快乐水(加冰)。


场景

  在 win10 21H1 📎 Visual Studio 2019 的环境中,写了一个最简单的多线程例子,下面来分析一波:

#include <thread>
#include <iostream>
using namespace std;

int g_num = 0;

void addition()
{
    for (int i = 0; i < 5000; ++i) {
        g_num++;
    }
}

int main()
{
    thread th1(addition);
    thread th2(addition);
    th1.join();
    th2.join();

    cout << "g_num:" << g_num << endl;

    return 0;
}

  同时起两个线程 th1,th2;不做任何限制;对同一全局变量 g_num 进行写增加操作;预期 运行结果一定为 10000。理由为 即使是两个线程经过 CPU 时间片轮转、互相争夺此 g_num 变量的写增加;但是总的是次数来看,Line 7-12 即使互相咬合交替执行(线程竞争),但最终此函数 addition()(此段代码片)都一定被运行了 5000 + 5000 = 10000 次;并且此处只有一行自增代码,是不应该会有问题的。


实际运行结果 为:有时候能数据为 10000, 有时候低于 10000,两者概率都很大。和预期的不符合,被打脸了😰;

  故疯狂吐槽 CPU 不讲武德,明明代码片会运行 10000 次,但是最终结果经常不符合预期(偶尔会符合预期),完全不可控。此被称为 线程不安全,出现了多线程竞争情况,导致结果未能符合预期。


造成差异的 根因分析

  即使是仅一行 g_num++; 代码,也属于高级语言,被编译器翻译为汇编(低级语言)后,是被解释为多行汇编语言执行的; 而 CPU 每次执行的最小单位可看作是一条精简的指令(此处姑且理解为一行汇编代码)。你以为只执行一行代码 C++ 代码,实际则是执行三行汇编代码。此属于非原子操作、或非最小执行单位的机器指令。此时多线程切换出去,某种巧合情况下会得出错误结果(下文有详细分析)。



分析

  以上也仅仅是揣测,下面一块看实锤。在 VS 2019 中 Line 9: g_num++; 处行打上 F9 断点,Debug 模式下 F5 调试卡住此行,右键菜单选中 “转到反汇编(T) ”,可看到如下代码片,进行 C++ 的对应的汇编分析。

        g_num++;
00C310EF  mov         ecx,dword ptr [g_num (0C353F8h)]          // 将 g_num 的内容写入到 ecx
00C310F5  add         ecx,1                                     // 计数器 ecx 数值加 1
00C310F8  mov         dword ptr [g_num (0C353F8h)],ecx          // 将 ecx 的内容写入到 g_num 中

此三行汇编分析至此,只能说真的已到了最底层,无法再往下给你刨更底层了( 01010101 二进制可不算)


汇编语言储备:

  • mov 指令:据传送指令,用于将一个数据从源地址传送到目标地址(寄存器间的数据传送本质上也是一样的)。其特点是不破坏源地址单元的内容。
  • add 指令: 加法指令, 两数相加。
  • ecx:计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。

  结合代码分析多线程运行:

对于出现多线程竞争情况,实际运行结果为 5000 多,化抽象为一具体理解。每次执行 g_num++; 三行汇编代码时,线程 th1 、th2 每次都按照如下循序交替运行;

  • 【1步骤】th1: A
  • 【2步骤】th2: ①
  • 【3步骤】th1: B
  • 【4步骤】th2: ②

  按照此顺序来模拟执行一遍,每一步骤结果如下:

// 第一次进来 g_num = 0 ecx的值 ptr[g_num (0C353F8h)]的数值
执行【1步骤】th1: A 0 0
1 0
执行【2步骤】th2: ① 0(非希望的结果,线程竞争错误根因) 0( 此时未被改变)
执行【3步骤】th1: B 0 0
执行【4步骤】th2: ② 1 0
1 1(虽共运行两次加1,可最终结果却为1)

  可发现,线程 th1 和 th2 各自交替 的执行一遍完整的 addition() 函数后,共执行了两遍对 g_num++ 的操作,但是其结果只会增加一遍。每次按照此顺序线程交替歌执行 5000 次后,最终的结果就是 5000。但实际总有那么几次是被 th1 执行完成了后,才切换到 th2 执行,故而实际运行结果为 5000出点头左右;也算是印证了无限制的多线程竞争读写同一资源,会出现不符合预期的情况。



解决

  问题产生了,那么我们应该如何修正、或避免此情况呢?实际的解决的方式就有很多种了,这里提两种方案,最终代码如下

#include <atomic>
#include <mutex>
#include <thread>
#include <iostream>
using namespace std;

// 方案一:定义为原子变量 atomic
//atomic<int> g_num = 0;

// 方案二:加锁 mutex
int g_num = 0;
mutex g_mutex;

void addition()
{
	for (int i = 0; i < 5000; ++i) {
        g_mutex.lock();
        g_num++;
        g_mutex.unlock();
    }
}

int main()
{
    thread th1(addition);
    thread th2(addition);
    th1.join();
    th2.join();

    cout << "g_num:" << g_num << endl;

    return 0;
}

方案一(atomic)

  如果每次能够将 g_num++; 的三行汇编捆绑打包,执行完这一坨之后, CPU 才会切到其它线程,那么此问题便迎刃而解。巧了;前辈们早提供工具实现此愿望 – 原子操作 atomic;

只需要修改一行代码即可,即可保证此自增代码一定是原子操作的。

// atomic
atomic<int> g_num = 0;   // 替换 int g_num = 0;  

  再看一眼汇编也发生了变化,从而确保了此行对 g_num 的自增一定是原子操作级别的(姑且理解为:此原子行为不可再分割多个步骤;或 CPU 一定是运行此捆绑的几行后,才会切给其它线程);

无论运行多少次,此结果一定是符合预期的 10000;此也被称作为 线程安全 。和使用单线程运行和结果是一致的。


方案二(mutex)

  在 g_num++; 的上面使用同一把互斥锁 mutex g_mutex; 对共享资源进行加锁和解锁,确保共享全局变量的写操作,每次 只能有一个线程对其写操作,使用期间其它线程不能对此写操作,若其它线程代码执行到加锁处,则变更为等待状态;只有被当前线程使用完毕解锁后,其它线程才可以去加锁,进行数值修改,修改完成后也会对应的解锁。如此循环往复。最终也是得到符合预期的结果,加锁后也属于 线程安全 的。加锁的原理 对应现实生活,就是多人上厕所,先进去的把厕所门关了,后来的人只能门外等着,上一个人用完后开门后,其他人才能进去;循环往复(就像历史总是循环交替一样)。

// 加锁 mutex
int g_num = 0;
mutex g_mutex;

void addition()
{
	for (int i = 0; i < 5000; ++i) {
        g_mutex.lock();
        g_num++;
        g_mutex.unlock();
    }
}

额外探索

  使用加锁的目的和预期我们已经达到了,但是可以再更进一步探索;故意写一行 cout << 打印语句,再执行加锁;

//  mutex
int g_num = 0;
mutex g_mutex;

void addition()
{
	for (int i = 0; i < 5000; ++i) {
        cout << "threadId:" << this_thread::get_id() << "  g_num:" << g_num << endl;
        g_mutex.lock();  // 尝试和上一行的 cout 行互换位置
        g_num++;
        g_mutex.unlock();
    }
}

  发现 g_num 的最终结果 是符合预期的,但是 cout << 输出语句出现了错乱!!! 是不是有一种时曾相识的感觉,甚是眼熟;你看像不像“这一行被翻译为了多行汇编指令,但是这一推汇编指令还没执行完毕,就被 CPU 通过时间片轮转,切换到其他线程了。” ???

  cout<< 的底层是会调用系统级的输出函数的,此时会由用户态切换为内核态;且打印语句一般也耗时(相对),结合两者情况,是很容易出现下图的此情况的,输出语句被交替执行。

而将 g_mutex.lock();cout << xxx 行互换时,运行的结果每一行都是符合预期的。运行结果全是这种,整整齐齐的:

threadId:21496  g_num:9994
threadId:21496  g_num:9995
threadId:21496  g_num:9996
threadId:21496  g_num:9997
threadId:21496  g_num:9998
threadId:21496  g_num:9999
g_num:10000

源码

https://github.com/xmuli/QtExamples【ExThreadSafety】



提示

  若是自己写例子的话,推荐在 for 循环中 的次数写大一点;加写一句 printf() 或 cout<< 输出,都是很容易得到线程不安全的运行结果。另外若是写在 main() 中,则用 th1.join(); 函数,用来阻塞等待线程退出,获取线程退出的状态;而在类或者函数中,则使用 .detach() 来设置设置线程分离的属性。关于差异差异可参考 此文