线程安全

39 阅读4分钟

什么是线程安全?

先从一个简单的例子引入:有两个线程,他们想要共享数据,可以用全局变量。假如两个程序都要修改这个全局变量,会发生什么事情呢?

“修改”这个词让人觉得他是一瞬间完成的,但其实,我们慢放这个过程,它分为三个步骤:

读取->计算->写入

具体到代码,variable = variable + 3,先读取variable原来的值,计算出variable + 3的值,写入variable

如果一个操作,就像这里的“修改”一样,其实由多个小步骤组成,我们就称它“不是原子操作”,也可以说它不是线程安全的。

那么,来详细分析一下,它哪里不安全了呢?

其实很好理解:一个进程先读取了这个全局变量,它还没写入呢,这时候第二个线程是不是还可以读取这个变量呀?这就出现了问题--他读取的是还没有修改的值!那不管第一个进程写了什么,都会被第二个进程覆盖,第一个进程就白修改了。

所有涉及到全局变量的操作,都应该考虑是否线程安全。比如说:cout的调用,生产者消费者模型中对缓冲区的修改,或者是上面的简简单单的 variable = variable + 3。

怎么解决线程安全?

知道了什么是线程安全,那解决方案是简明的:既然它是因为操作非原子而导致的,那就让它变成原子操作呗。

换言之,只要让“读取->计算->写入”这一整个过程,中间不能插入任何对这个全局变量的读取与修改,那就能够解决线程安全了

基于这个思路,我们有常见的两种方案。

一、互斥锁

下面给出一个示例,再来分析理解。

这是一个修改variable变量的线程函数,他不是线程安全的:

void test_thread()
{
    variable = variable + 3;
}

我们可以这样修改:

void test_thread()
{
    //加锁
    variable = variable + 3;
    //解锁
}

这样,当一个线程调用这段代码,会给这一段代码上锁。其他线程想要调用这段代码时,就会被堵在“加锁”这个环节。很好理解,卫生间里有人,从外面是打不开的。

所以,要理解,锁的不是全局变量本身,锁的是这一段操作全局变量的代码

二、原子变量

虽然说两者的核心都是要将修改全局变量的操作封装为原子操作,如果说互斥锁的重点在给“代码块”上锁,那原子变量的重点就在全局变量本身了。

可以这么来理解:全局变量本质上是一个模板类,里面封装了“operator=”这个赋值运算符,或者还有说相关修改它的操作。怎么封装呢?其实还是加锁。只不过这是一个 “更为高级的锁”,他是cpu指令那个级别的锁,由cpu负责将相关的“读改写”这个操作封装为原子操作。所以呢,可以放心地对他进行写入了。

对比与理解

互斥锁和原子变量,我是这样理解的:

在一个十字路口,各方来车可能会相撞,那么我们有几种处理方法呢?

第一种,就是红绿灯,你应该明白了,这就是互斥锁。他是强行让其他方向的车停下来,让某一个方向通过(当然,是特殊的红绿灯,需要各个方向来抢夺绿灯的控制权)。这样的处理确实有效,但也可以直观地看出来,效率很低

另一个方法就是修高架桥,也就是原子变量。程序员们花费了大量的功夫在cpu指令这个级别搭建了高架桥,他就从实现了多个方向的车都能够畅通无阻。当然,比起常规的变量,毕竟有高架桥嘛,还是要绕绕远路。但比红绿灯是要更好的。

所以,面对线程安全问题,我们的决策路径变得清晰:

  • 红绿灯(互斥锁) :当你需要保护的是一个复杂的“路口” (即一大段临界区代码,涉及多个变量或复杂逻辑)时,它是你可靠且唯一的选择。

  • 高架桥(原子变量) :当你仅仅需要保证一个简单的  “通过”动作(即对一个变量的简单读写、递增递减)是安全的,那么它就是性能更优的解决方案。