首页 > 编程语言 > 详细

线程安全浅说

时间:2021-07-02 00:25:11      阅读:34      评论:0      收藏:0      [点我收藏+]

线程安全浅说

首先,我们回顾一下线程的特点:

  • 每个线程有自己独立的栈;
  • 同时多个线程共享进程空间中的数据。

竞争

如果每个线程对共享部分数据都是只读的,那么大概不会出现什么问题。但是,如果同时有多个线程尝试对同一份数据进行写入操作,那么最终的结果可能会是不可预期的。考虑这一经典的例子:

共享数据 int i = 0;
线程 1 试图执行 ++i;
线程 2 试图执行 --i。

首先考虑 ++i 背后的意义(--i 类似)。在大多数体系结构上,++i 在编译出的汇编代码中,会被翻译为

X <- i # 将 i 的值读入某个寄存器,比如 X 或者 Y
X++    # 增加寄存器中的值
i <- X # 将寄存器中的值写入变量 i

由于这一句代码会被翻译成多条指令,那么必然存在这样的情况:线程 1 在执行三条指令的过程中被中断,系统调度线程 2 继续执行。这样,在两边线程执行完毕之后,变量 i 的值可能是 0, 1, -1;而具体取值多少是不可预期的。这种因为多个线程竞争对同一变量进行操作导致不可预期后果的过程,称为线程不安全。

原子性

回顾刚才的分析,线程不安全的根本原因,是线程中多条指令连续执行的过程可能会被系统调度中断,而现场恢复之后共享变量的值可能已经被修改。因此,如果我们能保证指令的执行不被打断,那么自然就能保证线程安全了。这种特性被称作原子性。

显然,单条指令是不可打断的。那么对应单条指令的代码,都是具有原子性的。例如 i386 架构中,有一个 inc 指令,可以直接增加内存某个区域的值。这样一来,自增操作就是原子的了。

由单条指令提供的原子性,显然有非常大的局限性——这是因为单条指令能够达成的效果总是有限的。在实际生产中,我们会需要保证连续多条指令的原子性。这就需要引入同步和锁的概念。

同步与锁

在这里,同步是一种规则,而锁则是实现这种规则的具体方法。

所谓同步,指的是多线程程序里,多个线程不得同时对某一共享变量进行访问。锁是实现同步的一种具体方案——准确地说,这是一种非常强的方案。锁有多种形式,最符合直觉的锁是所谓的互斥量(Mutex)。具体来说,线程在访问某个共享变量的时候,必须先获取锁;如果获取不到锁,那么就必须等待(或者进行其他操作,总之不许访问这个变量);在结束对这个变量的访问之后,持有锁的线程应当释放。

值得一提的是,所作为一种同步手段,是非常强的。但是,这种强,仅限于逻辑层面。在实际情况中,编译器优化、CPU 动态调度,都有可能打破锁对于同步的保护。这时候,这些优化就变成了过度优化。

过度优化对线程安全的破坏

这一小节我们会举 2 个例子,说明在某些情况下锁也是不靠谱的。

编译器优化

int x = 0;
Thread 1    Thread 2
lock();     lock();
++x;        ++x;
unlock();   unlock();

对于共享的变量 x,我们在线程 1 和线程 2 中并发地尝试访问它。为了保证线程安全,我们在对它的访问前后加上了锁。在逻辑上,这已经做到了线程安全,于是在执行完毕之后,x 的值应当必然是 2。但是,编译器优化可能会破坏逻辑上的线程安全:如果线程 1 在这之后会多次使用变量 x,那么编译器可能会将 x 自增后的值存放在寄存器中,暂不写回。于是,在线程 2 中尝试自增 x 的时候,获取到的 x 的值,可能是尚未从线程 1 的寄存器中更新值的 x。整个流程如下:

线程 1:获取锁
线程 1:从 x 中读取数据,写入寄存器 X
线程 1:X++
线程 1:释放锁
线程 2:获取锁
线程 2:从 x 中读取数据,写入寄存器 Y
线程 2:Y++
线程 2:从寄存器 Y 中读取数据,写入 x
线程 2:释放锁
线程 1:(很久之后)从寄存器 X 中读取数据,写入 x

显而易见,最终 x 的值,取决于寄存器中 X 的值;而在这个例子中,它是 1。

对于这种情况,我们可以用 C 语言关键字 volatile。这个关键字能在两种情况下阻止编译器优化:

  • 为了提高速度,将一个变量缓存到寄存器而不写回;
  • 调整操作该变量的指令的顺序。

因此,在这个例子里,我们只需要使用 volatile int x = 0,就能保证 x 变量总是能得到即时的更新了。

CPU 动态调度

程序在执行的过程中,出于效率的考量,两个(在当前线程中)没有依赖的指令可能会调换顺序执行。对于 CPU 来说,这已经是几十年的老技术了。我们来看这段 C++ 代码

volatile T* pInst = nullptr;
T* GetInstance() {
    if (nullptr == pInst) {
        lock();
        if (nullptr == pInst) {
            pInst = new T;
        }
        unlock();
    }
    return pInst;
}

在单例模式中,这是一段典型的 double-check 的代码。双层的 if 各有作用:

  • 外层 if 确保仅在 pInst 是空指针的情况下才去获取锁并尝试构造对象;
  • 内侧 if 则是为了防止这样一种可能,避免重复操作和内存泄露:在外层 if 检测是,pInst 尚为空,但是,待 lock() 执行完毕后,别的线程已经为 pInst 赋值。

这段代码,乍一看是没有问题的;但仍需小心揣摩。我们看 pInst = new T; 这一行代码,它基本完成了三件事情

  • 为 T 类型的对象分配内存;
  • 在这片内存上执行 T 的构造函数;
  • 将这片内存的起始地址赋值给 pInst。

由于构造函数的执行和指针的赋值是互不依赖的,所以 CPU 可能会交换这两个步骤的顺序。因此,在线程执行的过程中,可能存在这样一种情况:nullptr != pInst,但是它指向的对象尚未构造成功。于是,如果在这一时刻,当前线程被中断,并且其它线程调用 GetInstance 函数,那么函数在外层 if 执行之后,会直接返回 pInst 的值。而此时 pInst 实际上指向的是一片尚未初始化的内存。如果线程代码对 pInst 进行访问,那么程序很有可能就会崩溃。

为了解决这类 CPU 动态调度导致的问题,我们需要有在某些情况下阻止指令换序执行的能力。然而遗憾的是,由于动态调度是 CPU 的功能,所以在高级语言的层次,我们没有通用的解决办法——只能依赖具体的 CPU 架构,对代码进行调整。对于 i386 架构的 CPU 来说,它提供了一条指令 mfence(memory fence 的缩写),可以阻止这种换序执行。

volatile T* pInst = nullptr;
T* GetInstance() {
    if (nullptr == pInst) {
        lock();
        if (nullptr == pInst) {
            T* temp = new T;
            barrier();
            pInst   = temp;
        }
        unlock();
    }
    return pInst;
}

在这里,我们用 barrier() 保证了在 pInst 被赋值之前,相关内存区域已经正确地初始化了。

可见,线程安全是个烫手山芋。为了写出线程安全的程序,程序员们都需要好好学习一个。

线程安全浅说

原文:https://www.cnblogs.com/maxing1997/p/14960669.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!