线程的互斥

目录

线程互斥

线程互斥的背景知识

多线程抢票

多线程抢票加锁

锁的原理

可重入函数与线程安全

常见的线程安全的情况

常见的不可重入情况

常见的可重入情况

总结


线程互斥

线程互斥的背景知识

临界资源:临界资源就是多个执行流共享的资源就叫做临界资源。

临界区:访问临界资源的代码就叫做临界区。

互斥:就是在任何时刻,只能有一个执行流进入进入临界区然后访问临界资源,可以对临界资源起到保护作用。

原子性:不会被任何调度打断,且只有两态,要么完成,要么未完成。

上一次我们说了关于线程的控制,而我们就是通过多线程来执行一些代码,提高效率(但是不是线程越多越好,而且有些场景不适合多线程)。

而且我们也说了,多线程是比较容易出错的,所以我们通过写代码来发现多线程容易出错的问题,然后来慢慢调整。

如果由多线程访问临界资源然后导致出现问题,那么我们就可以选择对临区进行加锁,来保证多线程执行的正确性。

多线程抢票

下面呢,我们要写一个关于多线程抢票的代码:

我们创建一批线程,然后让这批线程区执行一个抢票(对一个变量进行减减操作):

#define THREAD_NUM 5
​
int tickets = 10000;
​
void *threadRun1(void *args)
{
    while (true)
    {
        if (tickets > 0)
        {
            // 模拟抢票前需要做的事
            usleep(rand() % 3000 + 500);
            
            printf("%d : 抢了第 %d 张票\n", (long long)args, tickets);
            --tickets;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
​
// 多线程抢票逻辑
void test1()
{
    // 创建多线程
    vector thread_num(THREAD_NUM);
    for (int i = 0; i < THREAD_NUM; ++i)
    {
        pthread_create(&thread_num[i], nullptr, threadRun1, (void *)(i + 1));
    }
​
    // 循环等待每个线程
    for (int i = 0; i < THREAD_NUM; ++i)
    {
        pthread_join(thread_num[i], nullptr);
    }
}

如果在我们平时的情况下,这个代码抢到 1 就会停下,那么现在我们看一下。

结果:

4 : 抢了第 6 张票
4 : 抢了第 5 张票
3 : 抢了第 4 张票
5 : 抢了第 3 张票
2 : 抢了第 2 张票
4 : 抢了第 1 张票
1 : 抢了第 0 张票
2 : 抢了第 -1 张票
5 : 抢了第 -2 张票
3 : 抢了第 -3 张票
[lxy@hecs-348468 mutex]$ 

这里看到,我们前面的抢票还抢到了负数,那么这是什么情况?

下面我们为大家解释:

我们前面说了,线程事CPU调度的基本单位,由于操作系统中有很多线程,所以为了公平,系统京可能让所有的线程都执行相同的时间,而线程也随时都可能被切换。

虽然说线程被切换,但是其实并没有问题,因为在线程被切换的时候,线程会把CPU中的上下文数据带走,当这个线程回来的时候,会进行上下文恢复,然后继续执行代码。

所以我们知道线程事随时都可能被切换走的。

线程的互斥_第1张图片

看这幅图,首先,我们在 if 条件判断那里,判断也是计算的一种,所以需要加载到CPU里面,然后进行判断,如果判断发现大于0,那么就可以进入到 if 里面了,然后执行ticket--操作。

由于这个判断编译成汇编之后,并不是一条指令,所以这个判断也是分为几步的。

由于我们知道,进程随时都可能被切换走,所以如果刚好判断发现大于0,后然后被切换走了,线程此时已经静茹到 if 条件里面了,此时被切换走后,该线程会保护自己的上下文数据,然后也会记录自己执行到哪里了,等下一次回来后接着执行。

然后此时其他的线程又进来了,同样发现大于0,也对 tickets-- 操作,然后经过多次减减操作后,最终减为0,但是此时被切换走的哪个线程又被系统调度了,此时它回复上下文后,就准备执行后续的代码,也就是对 tickets--,但是此时的 tickets 已经被减为0了,不能才减了,也就是票已经售空了,但是被调度的进程在前面已经判断过了,进入了 if 语句,所以此时还是对 tickets 进程 减减操作,此时就出现问题了,线程不安全。

所以就需要对临界区进程加锁。

多线程抢票加锁

这里我们先直接看如何进行加锁,以及加锁的函数以及代码如何编写,后面我们在说一下加锁的原理。

pthread_mutex_init

NAME
       pthread_mutex_destroy, pthread_mutex_init - destroy and initialize a mutex
​
SYNOPSIS
       #include 
​
       int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);
  • 这个函数是加锁,可以让临界区只能由一个执行流进入。

  • 第一个参数是一个 pthread_mutex_t 的指针,也就是我们说的锁,我们需要对锁进行初始化。

  • 第二个参数是一个关于设置锁的属性的变量,我们设置为 nullptr 即可。

  • 但是我们也可以不对锁锁进行这样的初始化,如果是全局的锁,那么我们可以直接使用一个宏来初始化。

  • 全局锁的初始化,可以使用——PTHREAD_MUTEX_INITIALIZER

下面再介绍三个关于锁的常用操作:

NAME
       pthread_mutex_lock, pthread_mutex_trylock, pthread_mutex_unlock - lock and unlock a mutex
​
SYNOPSIS
       #include 
​
       int pthread_mutex_lock(pthread_mutex_t *mutex);
       int pthread_mutex_trylock(pthread_mutex_t *mutex);
       int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 首先第一个函数就是加锁,我们看函数名也可以看出来,该函数如果没有竞争到锁的话,那么就会阻塞住。

  • 第二个函数也是加锁的,但是这个函数如果没有竞争到锁的话,就会返回。

  • 第三个函数就是解锁。

  • 而这三个函数的参数分别是锁的指针。

下面我们写一个代码,对临界区进行加锁:

下面为了简单一点,我们这一次使用全局的锁。

​
// 为了简单一点,这里先使用全局的锁
// 全局的锁可以使用系统中的一个宏来初始化,如果不是全局的,那么就需要使用 pthread_mutex_init 来初始化

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

​
// 加锁条件下测试多线程抢票是否会异常
void *threadRun2(void *args)
{
    while (true)
    {
        pthread_mutex_lock(&mtx);
        if (tickets > 0) // 这里虽然是判断,但是也是计算的一种,所以加锁需要对判断也进行加锁
        {
            printf("%ld : 抢了第 %d 张票\n", (long long)args, tickets);
            int tmp = tickets--;
            --tickets;
            pthread_mutex_unlock(&mtx);
        }
        else
        {
            pthread_mutex_unlock(&mtx);
            break;
        }
        // 这里为了模拟抢票后还需要做的事情,这里就用sleep代替
        usleep(rand() % 1500 + 500);
    }
​
    return nullptr;
}
​

这里我们再 if 之前就需要加锁,为什么?

因为我们前面也说了,判断也是计算,因为后面可能会修改 tickets 所以需要对 if 判断也需要加锁。

那么什么时候解锁呢?

我们可不可以再else 后面解锁?为什么?

我不可以再else后面解锁,因为如果没有走 if 里面呢?而是直接走了else 逻辑,那么此时该进程持有锁,但是它却没有释放,然后直接退出了,这样会导致其他线程竞争锁,然后没有锁就会导致死锁的问题。

如果我们要写到 else 后面,那么我们还需要到 else 的逻辑里面也写一条解锁的代码,这样即使持有锁,那么再走了 else 的逻辑后,还会释放锁。

那么我们为什么不把解锁写到 else 后面呢?

实际上,我们知道,如果加锁了的话,那么此时这段代码就是串行的,那么就是只有一个执行流可以再同一时间执行,所以会导致效率低下,所以加锁的粒度是越细越好。

下面我们看一下试验结果:

1 : 抢了第 7 张票
5 : 抢了第 6 张票
4 : 抢了第 5 张票
1 : 抢了第 4 张票
2 : 抢了第 3 张票
3 : 抢了第 2 张票
5 : 抢了第 1 张票
[lxy@hecs-348468 mutex]$ 
​

这里我们看到结果就正确了。

但是下面我们由几个疑问:

  1. 加锁了,那么加锁的那一段是串行的吗?

  2. 加锁之后,就不会到临界区被切换吗?

  3. 如果被切换,那么是否安全呢?

下面我们就来回答这三个问题。

我们先回答第二个,加锁之后会不会到临界区被切换?

回答:会的! 我们前面也说过,线程随时都可能被切换,那么如果当这个线程执行到临界区,然后时间片到了,那么操作系统一定会把它从CPU上剥下来,然后换另一个线程上去执行,而且不光是时间片到了,如果是抢占式,那么还可能被优先级高的线程给抢占。

回答第三个问题:那么既然可以被抢占,那么切换后,临界资源是否安全呢?

回答:安全! 为什么?我们在进入临界区的时候,我们是先加锁的,那么也就是说,如果进入临界区说明该线程一定是尺有锁的,既然是持有锁,那么说明其他线程是没有锁的,既然如此,那么即使该线程被切换下去,系统将其他线程调度,其他线程因为没有锁而处于阻塞状态,所以也就不会调度其他的线程,等待该线程再一次被调度,首先是恢复上下文,该线程还是持有锁的,所以在此期间,其他的线程都不会访问临界资源,只有持有锁的线程可以访问,所以临界资源是安全的。

回答最后一个(第一个)问题:那么加锁后是串行执行吗?

回答:是的!其实我们回答了上面的两个问题后,我们也就理所当然的知道加锁后是串行执行的,但是串行执行的之哟临界区的代码,其他的代码还是可能是并行的,所以加锁后,临界区的代码执行是串行的。

上一个代码我们使用了全局的锁,这一次我们使用局部的锁,还可以在理解一下加锁:

// 用于将线程的名字和锁都传过去,如果还有其他数据需要传的话,那么就可以写到该结构体中
struct threadDate
{
    threadDate(int _thread_name, pthread_mutex_t* _mutex)
        :thread_name(_thread_name)
        ,mutex(_mutex)
    {}
​
    int thread_name = 0;
    pthread_mutex_t* mutex = nullptr;
};
​
​
// 下面是使用了局部的锁,所以需要加锁的话,那么就需要将锁传过来,可以通过 void* 进程传参
void *threadRun3(void *args)
{
    threadDate* date = reinterpret_cast(args);// 相似类型转换
    while(true)
    {
        pthread_mutex_lock(date->mutex);
        if(tickets > 0)
        {
            int tmp = tickets--;
            pthread_mutex_unlock(date->mutex);
            printf("%ld 号线程: 抢到了 %d 张票\n", date->thread_name, tmp);
        }
        else 
        {
            pthread_mutex_unlock(date->mutex);
            break;
        }
        usleep(rand() % 1500 + 500);
    }
    delete date;
}
​
// 上面为了方便,使用了全局的锁,下面我们使用局部的锁
void test2()
{
    // 先创建一个局部的锁
    pthread_mutex_t mutex;
    // 需要对锁进程初始化
    pthread_mutex_init(&mutex, nullptr);
    cout << "锁初始化成功" << endl;
​
    // 创建多线程
    vector thread_num(THREAD_NUM);
    for (int i = 0; i < THREAD_NUM; ++i)
    {   
        // 为了将锁和线程的其他数据传入到回调函数中,我们需要构造一个对象,用来存放每个线程的数据
        threadDate* date = new threadDate(i, &mutex);
        pthread_create(&thread_num[i], nullptr, threadRun3, (void *)date);
    }
​
    // 循环等待每个线程
    for (int i = 0; i < THREAD_NUM; ++i)
    {
        pthread_join(thread_num[i], nullptr);
        // cout << "等待线程 " << i << " 号线程成功" << endl;
    }
​
    // 使用完后需要对锁进行释放
    pthread_mutex_destroy(&mutex);
}

如果我们使用局部的锁,那么如果我们需要在线程中加锁的话,我们就需要把这个锁传入到线程中,那么怎么传入呢?我们在线程创建的时候,最后一个参数是一个 void 的指针,然后我们可以通过这个指针将锁传进去。

但是我们要是还想传入其他的数据呢?

我们可以用一个类/结构体来传入,我们可以将我们想要传入的数据放到一个对象中,然后将该对象的地址传进去。但是如果是局部的锁初始化后,那么我们就需要对这个锁进行释放,所以我们在使用完后还需要进行释放。

这个试验结果我们就不看了,因为这个和上面的结果一样。

锁的原理

上面我们以及使用过锁了,下面我们说一下锁的原理:

在C/C++中,对一个变量进行++/-- 操作的时候,我们看似是一条语句,但是实际上却不是一条语句。

当我们进行++的时候:

  1. 先将变量加载到寄存器中

  2. 然后对寄存器中的数据进程++

  3. 最后将该数据拷贝回内存。

而我们前面说了线程是什么时候都可能被切换的,那么我们可能对一个变量进行加加的时候,可能刚执行完第二步,然后就被切换走了。

而我们前面还说了一个原子性,原子性就是要么完成,要么未完成,而前面的这个加加/减减显然就不是原子的,因为有三条语句,而我们认为如果编译未汇编后只有一条语句,那么该语句就是原子的。

结论:如果是一条汇编,那么就是是原子性。

上面的一个结论以及知道了,下面我们在看一个。

前面我们访问的 tickets 是全局的数据,但是我们访问全局的数据的时候需要进行访问保护,而我们访问保护又需要锁,但是我们加锁又是多线程使用同一把锁,那么这个锁是不是临界资源呢?是的,那么当访问一个临界资源的时候需要加锁,但是锁也是临界资源,那么怎么办呢?

下面我们就谈一下锁的原理:

在谈这个之前,我们先介绍一个背景知识:

我们系统中可能又一条汇编可以让寄存器中的数,直接和内存中的数据交换:

swap / exchange #可以使用一条汇编,就可以将寄存器中的数据和内存中的数据交换

不同的操作系统可能不同,但是一定会有这条汇编,可能是 swap 也可能是 exchange。

那么锁我们应该怎么理解呢?下面我们可以把锁理解为内存中的一个值,我们认为锁就是 1,没有锁就是 0.

如果时 lock 的话,那么时怎么样加锁呢?

lock

lock:
    movb $0,%al
    swap %al,mutex
    if(%al > 0)
    {
        return;
    }
    else
    {
        阻塞...
        goto lock
    }

unlock

unlock:
    movb $1,mutex
    唤醒等待 mutex 的线程
    return

上面我们说了,汇编中有一条语句,可以帮助我们将寄存器中的数据和内存中的数据用一条汇编就可以完成。

我们认为,一条汇编就是原子的,也就是要么完成了,要么未完成。

既然如此那么先看依稀 lock 的代码:

  1. 首先将 0 move 到一个 %al 的寄存器中

  2. 使用一条汇编 swap 将 %al 中的数据和内存中 mutex 中的数据进行交换

  3. 判断 %al 中的数据是否是 1,如果是 1 的话,那么就说明竞争到锁了,那么如果是 0 的话,说明还是没有锁。

  4. 如果没有锁的话,就需要去 else 的逻辑中去阻塞。

  5. 如果有锁了,那么就直接返回,可以执行加锁后的代码。

  6. 既然锁以及被该进程拿走了,此时内存中 mutex 中的值就是 0,此时其他的线程来了,也经过上面的操作,发现内存中的数据是 0,所以就会进入到 else 的逻辑,阻塞起来。

下面看一下 unlock 的代码:

其实 unlock 就比较简单,此时能执行 unlock 一定说明你是加锁了的(在逻辑正确的情况下),那么既然内存中的数据是 0 ,那么就可以将 1 直接放到内存中的 mutex 中,所以这就完成了解锁,解锁之后还需要对阻塞的线程进行唤醒。

可重入函数与线程安全

下面再说一下比较容易混淆的两个概念:可重入与线程安全

线程安全:多线程情况下,同时并发访问梯段代码,不会出现不同的结果,就是说明是线程安全的,一般情况下,如果对全局数据或者静态变量访问了,那么就容易是线程不安全的,当然还可能使 malloc 了,或者其他的操作。

重入:同一个函数被不同的执行流调用,再一个执行流还没有结束的时候,就有其他的执行流执行这段代码,那么就称之为重入,如果重入后,不会对结果产生影响,那么说明该函数时可重入的,如果重入后对结果产生了影响,那么说明该函数时不可重入的。

常见的线程安全的情况
  • 多线程场景下,对全局或者静态的变量具有只读权限,那么一般情况下,这样就是线程安全的。

  • 多线程锁调用的函数都是有原子性的,那么说明时线程安全的。

  • 多线程之间切换,不会导致接口调用出现二义性。

常见的不可重入情况
  • 调用了 malloc/free 等函数...

  • 调用了 IO 类的接口,一般的 IO 都是不可重入的

  • 可重入函数内使用了静态的数据结构

常见的可重入情况
  • 不使用 malloc/free 函数

  • 不使用 IO 类接口

  • 没有使用静态变量,全局变量

  • 没有调用不可重入函数

  • 不返回静态或者全局的数据,使用本地的变量

总结
  • 一般情况下,如果函数是可重入的,那么线程就是安全的。

  • 如果函数是不可重入的,那么说明多线程访问会有问题,所以就不能多线程访问。

  • 线程安全的不一定是可重入函数,而可重入函数一定是线程安全的。

  • 例如:如果对临界资源访问加上锁,那么说明这个是线程安全的,但是如果这个函数是可重入的,那么当锁还未释放的时候就重入了,此时会竞争锁,此时救护产生死锁,所以是不可重入的。

你可能感兴趣的:(linux)