线程安全介绍

本文主要介绍线程安全(thread-safe)的相关知识。

1 概述

1.1 what

介绍“线程安全”,那么就必须要提到“线程不安全”,甚至可以说,是先出现了“线程不安全”这个问题,为了解决这个问题,才有了“线程安全”的概念。

“线程安全”及“线程不安全”的概念,都是在涉及多线程编程时才会用到,在单线程的场景下是无需考虑的。至于为何要用到多线程编程,请参考此文

在操作系统中,线程是由进程创建的,线程本身几乎不占有系统资源,线程用到的系统资源是属于进程的。一个进程可以创建多个线程,这些线程共享着进程中的资源。所以,当这些线程并发运行时,如果同时对一个数据(该数据属于进程,被线程共享使用)进行修改,那么就可能会造成该数据的表现出不符合我们预期的变化,此所谓线程不安全

与线程不安全对应,线程安全是多线程编程场景下的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过(自身实现的)同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

从代码角度来说,假设进程中有多个线程在同时运行,而这些线程可能会同时运行一段代码,如果这段代码在多线程并发情况下的运行结果与单线程运行的结果是一样的,而且其他的变量的值也和预期一样,那么这段代码就是线程安全的。

从接口的角度来说,如果一个类(或者程序)提供的接口,对于(调用该接口的)线程来说是原子的、或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用额外考虑调用该接口时进程同步的问题,那么这个接口就是线程安全的。

线程安全问题都是由全局变量静态变量引起的。如果每个线程中对全局变量或静态变量只有读操作,而无写操作,一般来说,这个全局变量或静态变量是线程安全的;如果有多个线程同时对全局变量或静态变量执行写操作,则一般都需要考虑线程同步,否则就可能影响线程安全。

1.2 类的线程安全

线程安全的类,首先必须在单线程环境中有正确行为:如果一个类的实现正确(即符合规格说明),那么对这个类的对象的任何操作序列(读或写公共字段以及调用公共方法),都不会让该对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。

此外,一个类要成为线程安全的,在被多个线程同时访问时,不管这些线程是怎样的时序安排或者交错,该类必须仍然具备上述的正确行为,并且在调用代码中不需要进行任何额外的同步。其效果是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。

1.3 线程不安全的示例

1.3.1 示例1

比如一个 ArrayList 类,在添加一个元素的时候,包括两个步骤:

1. 在 Items[Size] 的位置存放此元素;

2. 增大 Size 的值。

在单线程运行的情况下,如果 Size = 0,添加一个元素后,此元素在位置 0,而 Size=1。该类功能正常,数组变化与我们的预期一致。

在多线程情况下,比如有两个线程(A 和 B),线程 A 先将元素存放在位置 0。但是此时经 CPU 调度,线程 A 暂停(挂起),线程 B 开始运行,线程 B 也向 ArrayList 类的 Items 中添加元素,由于此时线程 B 获取的 Size 仍然为 0(因为前面线程 A 仅仅完成了存放操作,尚未进行增大 Size 值操作),所以线程 B 也将元素存放在位置 0,之后线程 A 和线程 B 继续运行,(无论CPU如何调度)线程 A 和线程 B 都会增加 Size 的值,导致 Size 值变为 2。流程结束。

此时,Items 中的元素实际上只有一个,存放在位置 0,而 Size 却等于2了,这就出现了“线程不安全”现象了。

1.3.2 示例2

有变量 counter = 0,counter 在两线程 A 和 B 之间共享。假设线程 A、线程 B 同时对 counter 进行递增计算(counter++),那么正常情况下,计算后的结果应该是 2,但是在“线程不安全”的情况下,真实的结果却是 1。

具体流程与示例1类似,递增计算一定要分为多步(本例为三步)进行,步骤如下:

1. 先获取 counter 的值:

2. 对 counter 进行递增计算;

3. 将计算后的结果重新赋值给 counter。

从 CPU 调度角度来看,线程 A 执行上述第1、2步之后,CPU 转而去执行线程 B,当线程 B 也执行了上述第1、2步之后,无论后续 CPU 如何调度,最终计算出来的 counter 都是1(线程A的计算结果覆盖线程B,或者相反),这样就出现了“线程不安全”的问题。

1.4 how

通过前面的讲述,我们已经知道了线程安全的原理,那么应该如何解决多线程并发情况下的线程安全问题呢?

通常可以通过使用,来保证线程安全。

有些文章在多线程编程中的引入了“原子性”、“可见性”、“顺序性”的概念,然后以保证这三者正常实现的方式来保证线程安全,不过最终的方案归纳起来,就包括可以使用来保证这三者的正常实现,即,使用来保证线程安全性。

锁能使其保护的代码路径以串行形式被访问,因此可以通过锁就可以实现:(对变量操作的)原子性、(完整的变量操作之后才释放锁,保证其他线程对于该变量操作后结果的)可见性、(锁之内的代码全部执行完成之前,其他线程不能进入该代码逻辑,进行变量修改操作)顺序性。

2 代码示例

我们在这里编写一个程序,该程序会创建两个线程,这两个线程执行同一段代码,该段代码会对全局变量count进行修改(加5000次)并打印count的值。正常情况下,最终的count值应该为10000。

我们现在分别在线程不安全和线程安全场景下,编写上述程序。

2.1 线程不安全代码

线程不安全代码(thread_unsafe_test1.cpp)示例如下:

#include <iostream>
#include <pthread.h>

using namespace std;

// 定义全局变量count
int count = 1;

void* run(void * arg1)
{
    int i = 0;

    while (1)
    {
        // 定义在此处时,是线程不安全的
        int val = count;
        // 在取值和赋值之间,增加打印操作
        cout << "count is: " << count << endl;
        // 定义在此处时,是线程安全的
        //int val = count;
        count = val + 1;

        i++;

        if (5000 == i)
        {
            break;
        }
    }
}

int main()
{
    pthread_t tid1, tid2;
    // 创建线程tid1和tid2,线程的运行函数为run
    pthread_create(&tid1, NULL, run, NULL);
    pthread_create(&tid2, NULL, run, NULL);

    // 等待线程结束,回收线程资源
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    
    return 0;
}

编译并执行上述代码,(部分)结果可能如下:


关于上述代码,有以下几点说明:

  1. 由于 CPU 调度的不确定性,上述程序每次的执行结果都可能会不同,即最终的count值为不确定值;
  2. 在上述代码中,在读取count的值(int val = count;)和把新值赋给count(count = val + 1;)之间,插入一个打印操作(cout << "count is: " << count << endl;),该操作会调用 write 系统调用,此时(程序)会从用户态进入内核态,为内核调度别的线程(执行)提供了一个很好的时机,所以就可能会导致线程的并发运行(CPU 在线程间来回切换),进而出现线程不安全的问题;
  3. 通过程序的打印结果,我们正面证实了上述第1点说明结论。另外,为了从另一角度证实第1点说明结论,我们将打印操作从“读取与赋值语句之间”移动到“赋值语句之后”,再次编译执行程度,就没有出现线程不安全的问题,即最终的count值为10000;

通过上述几点说明,我们可以得出一种小的编程方法(或者说编程习惯):我们的代码需要尽量内聚,这里不仅仅是针对代码模块,更多的是针对代码行与行之间逻辑。就拿本例来说,我们需要把“打印操作”放到“取值与赋值操作”之前或之后,这样就保证了“取值与赋值操作”的内聚性(虽然仅仅是两行代码),而这样的操作同时也(误打误撞地)实现了线程安全线,尽管我们的初衷并不是为了解决线程不安全的问题。可以说,提高代码内聚性,确实好处多多!

2.2 线程安全代码

还是针对本例的问题,我们使用锁机制,来实现线程安全。线程安全(thread_safe_test1.cpp)的代码如下:

#include <iostream>
#include <pthread.h>

using namespace std;

// 定义全局变量count
int count = 1;
// 定义线程互斥锁
pthread_mutex_t mutex;

void* run(void * arg1)
{
    int i = 0;
    // 加锁
    pthread_mutex_lock(&mutex);
    
    while (1)
    {
        int val = count;
        // 在取值与赋值之间插入打印操作
        cout << "count is: " << count << endl;
        count = val + 1;

        i++;

        if (5000 == i)
        {
            break;
        }
    }

    // 解锁
    pthread_mutex_unlock(&mutex);
}

int main()
{
    pthread_t tid1, tid2;
    
    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);
    
    // 创建线程tid1和tid2,线程的运行函数为run
    pthread_create(&tid1, NULL, run, NULL);
    pthread_create(&tid2, NULL, run, NULL);

    // 等待线程结束,回收线程资源
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
    
    return 0;
}

编译并执行上述代码,(多次执行的)结果如下:


通过上述执行结果可知,通过引入互斥锁(Mutex),保证了加锁的代码块会被一个线程“独占”,直到该线程执行完成这段代码,并进行解锁操作,其他线程才能进入该代码块,以此达到了线程安全的目的。

猜你喜欢

转载自blog.csdn.net/liitdar/article/details/81030176