Linux多线程【线程互斥与同步】

简介: Linux多线程【线程互斥与同步】

🌇前言


初学者在使用 多线程 并发执行任务时一定会遇到 并发访问的问题,最直观的感受就是每次运行得出的结果值大概率不一致,这种执行结果不一致的现象是非常致命,因为它具有随机性,即结果可能是对的,也可能是错的,无法可靠的完成任务,类似物理学神兽 薛定谔的猫


🏙️正文

1、资源共享问题

1.1、多线程并发访问

比如存在全局变量 g_val 以及两个线程 thread_Athread_B,两个线程同时不断对 g_val减减 -- 操作

注意:用户的代码无法直接对内存中的 g_val 做修改,需要借助 CPU

如果想要对 g_val 进行修改,至少要分为三步:

  1. 先将 g_val 的值拷贝至寄存器中
  2. CPU 内部通过运算寄存器完成计算
  3. 将寄存器中的值拷贝回内存

假设 g_val 初始值为 100,如果 thread_A 想要进行 g_val--,就必须这样做

也就是说,简单的一句 g_val-- 语句实际上至少会被分成 三步

单线程场景下步骤分得再细也没事,因为没有其他线程干扰它,但我们现在是在 多线程 场景中,存在 线程调度问题,假设此时 thread_A 在执行完第2步后被强行切走了,换成 thread_B 运行

thread_A 的第3步还没有完成,内存中 g_val 的值还没有被修改,但 thread_A认为自己已经修改了(完成了第2步),在线程调度时,thread_A上下文及相关数据会被保存thread_A 被切走后,thread_B 会被即刻调度入场,不断执行 g_val-- 操作

thread_B 的运气比较好,进行很多次 g_val-- 操作后都没有被切走

thread_Bg_val 中的值修改为 10 后,就被操作系统切走了,此时轮到 thread_A 登场,thread_A 带着自己的之前的上下文数据,继续进行它的未尽事业(完成第3步操作),当然 thread_B 的上下文数据也会被保存

此时尴尬的事情发生了:thread_Ag_val 的值改成了 99,这对于 thread_B 来说很不公平,倘若下次再从内存中读取 g_val 时,结果为 99,自己又得重新进行计算,但站在两个线程的角度来说,两者都没有错

  • thread_A: 将自己的上下文恢复后继续执行操作,合情合理
  • thread_B: 按照要求不断对 g_val 进行操作,也是合情合理

错就错在 thread_A 在错误的时机被切走了,保存了老旧的 g_val 值(对于 thread_B 来说),直接影响就是 g_val 的值飘忽不定

倘若再出现一个线程 thread_C 不断打印 g_val 的值,那么将会看到 g_val 值减为 10 后又突然变为 99 的 “灵异现象”

产出结论:多线程场景中对全局变量并发访问不是 100% 可靠的

1.2、临界区与临界资源

在多线程场景中,对于诸如 g_val 这种可以被多线程看到的同一份资源称为 临界资源,涉及对 临界资源 进行操作的上下文代码区域称为 临界区

临界资源 本质上就是 多线程共享资源,而 临界区 则是 涉及共享资源操作的代码区间

1.3、“锁” 概念引入

临界资源 要想被安全的访问,就得确保 临界资源使用时的安全性

举个例子:公共厕所是共享的,但卫生间只能供一人使用,为了确保如厕时的安全性,就需要给每个卫生间都加上一道门,并且加上一把锁

对于 临界资源 访问时的安全问题,也可以通过 加锁 来保证,实现多线程间的 互斥访问互斥锁 就是解决多线程并发访问问题的手段之一

我们可以 在进入临界区之前加锁,出临界区之后解锁, 这样可以确保并发访问 临界资源 时的绝对串行化,比如之前的 thread_Athread_B 在并发访问 g_val 时,如果进行了 加锁,在 thread_A 被切走后,thread_B 无法对 g_val 进行操作,因为此时 thread_A 持有,thread_B 只能 阻塞式等待锁,直到 thread_A 解锁(意味着 thread_A 的整个操作都完成了)

因此,对于 thread_A 来说,在 加锁 环境中,只要接手了访问临界资源 g_val 的任务,要么完成、要么不完成,不会出现中间状态,像这种不会出现中间状态、结果可预期的特性称为 原子性

说白了 加锁 的本质就是为了实现 原子性

注意:

  • 加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度
  • 加锁后的代码是串行化执行的,势必会影响多线程场景中的运行速度
  • 所以为了尽可能的降低影响,加锁粒度要尽可能的细

2、多线程抢票

实践出真知,接下来通过代码演示多线程并发访问问题

现在是 924 号,中秋+国庆 假期即将到来,所以各位都抢到票了吗?

2.1、并发抢票

思路很简单:存在 1000 张票和 5 个线程,5 个线程同时抢票,直到票数为 0,程序结束后,可以看看每个线程分别抢到了几张票,以及最终的票数是否为 0

共识:购票需要时间,抢票成功后也需要时间,这里通过 usleep 函数模拟耗费时间

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 有 1000 张票
void* threadRoutine(void* args)
{
    int sum = 0;
    const char* name = static_cast<const char*>(args); 
    while(true)
    {
        // 如果票数 > 0 才能抢
        if(tickets > 0)
        {
            usleep(2000); // 耗时 2ms
            sum++;
            --tickets;
        }
        else
            break; // 没有票了
        usleep(2000); //抢到票后也需要时间处理
    }
    cout << "线程 " << name << " 抢票完毕,最终抢到的票数 " << sum << endl;
    delete name;
    return nullptr;
}
int main()
{
    pthread_t pt[5];
    for(int i = 0; i < 5; i++)
    {
        char* name = new char(16);
        snprintf(name, 16, "thread-%d", i);
        pthread_create(pt + i, nullptr, threadRoutine, name);
    }
    for(int i = 0; i < 5; i++)
        pthread_join(pt[i], nullptr);
    cout << "所有线程均已退出,剩余票数: " << tickets << endl;
    return 0;
}


理想状态下,最终票数为 05 个线程抢到的票数之和为 1000,但实际并非如此

最终剩余票数 -3,难道 12306 还欠了 3 张票?这显然是不可能的,5 个线程抢到的票数之和为 1020,这就更奇怪了,总共 1000 张票还多出来 20 张?

显然多线程并发访问是绝对存在问题的

2.2、引发问题

这其实就是 thread_Athread_B 并发访问 g_val 时遇到的问题,举个例子:假设 tickets = 500thread-0 在抢票,准备完成第3步,将数据拷贝回内存时被切走了,thread-1 抢票后,tickets = 499;轮到 thread-0 回来时,它也是把 tickets 修改成了 499,这就意味着 thread-0thread-1 之间有一个人白嫖了一张票(按理来说 tickets = 498 才对)

对于 这种 临界资源,可以通过 加锁 进行保护,即实现 线程间的互斥访问,确保多线程购票时的 原子性

  • 3 条汇编指令要么不执行,要么全部一起执行完

--tickets 本质上是 3 条汇编指令,在任意一条执行过程中切走线程都会引发并发访问问题


3、线程互斥

互斥 -> 互斥排斥:事件 A 与事件 B 不会同时发生

比如 多线程并发抢票场景中可以通过添加 互斥锁 的方式,来确保同一张票不会被多个线程同时抢到

3.1、互斥锁相关操作

3.1.1、互斥锁创建与销毁

互斥锁 同样出自 原生线程库,类型为 pthread_mutex_t互斥锁 在创建后需要进行 初始化

#include <pthread.h>
pthread_mutex_t mtx; // 定义一把互斥锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
             const pthread_mutexattr_t *restrict attr);


其中,参数1 pthread_mutex_t*表示想要初始化的锁,这里传的是地址,因为需要在初始化函数中对 互斥锁 进行初始化

参数2 const pthread_mutexattr_t*表示初始化时 互斥锁 的相关属性设置,传递 nullptr 使用默认属性

返回值:初始化成功返回 0,失败返回 error number

互斥锁 是一种向系统申请的资源,在 使用完毕后需要销毁

#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);


其中只有一个参数 pthread_mutex_t*表示想要销毁的 互斥锁

返回值:销毁成功返回 0,失败返回 error number

以下是创建并销毁一把 互斥锁 的示例代码

#include <iostream>
#include <pthread.h>
using namespace std;
int main()
{
    pthread_mutex_t mtx; //定义互斥锁
    pthread_mutex_init(&mtx, nullptr); // 初始化互斥锁
    // ...
    pthread_mutex_destroy(&mtx); // 销毁互斥锁
    return 0;
}


注意:

  • 互斥锁是一种资源,一种线程依赖的资源,因此 [初始化互斥锁] 操作应该在线程创建之前完成,[销毁互斥锁] 操作应该在线程运行结束后执行;总结就是 使用前先创建,使用后需销毁
  • 对于多线程来说,应该让他们看到同一把锁,否则就没有意义
  • 不能重复销毁互斥锁
  • 已经销毁的互斥锁不能再使用

使用 pthread_mutex_init 初始化 互斥锁 的方式称为 动态分配,需要手动初始化和销毁,除此之外还存在 静态分配,即在定义 互斥锁 时初始化为 PTHREAD_MUTEX_INITIALIZER

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
• 1

静态分配 的优点在于 无需手动初始化和手动销毁,锁的生命周期伴随程序,缺点就是定义的 互斥锁 必须为 全局互斥锁

分配方式 操作 适用场景
动态分配 手动初始化/销毁 局部锁/全局锁
静态分配 自动初始化/销毁 全局锁

注意:使用静态分配时,互斥锁必须定义为全局锁

3.1.2、加锁操作

互斥锁 最重要的功能就是 加锁与解锁 操作,主要使用 pthread_mutex_lock 进行 加锁

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);


参数 pthread_mutex_t*表示想要使用哪把互斥锁进行加锁操作

返回值:成功返回 0,失败返回 error number

使用 pthread_mutex_lock 加锁时可能遇到的情况:

  1. 当前互斥锁没有被别人持有,正常加锁,函数返回 0
  2. 当前互斥锁被别人持有,加锁失败,当前线程被阻塞(执行流被挂起),无法向后运行,直到获得 [锁资源]
3.1.3、解锁操作

使用 pthread_mutex_unlock 进行 解锁

#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);


参数 pthread_mutex_t*表示想要对哪把互斥锁进行解锁

返回值:解锁成功返回 0,失败返回 error number

加锁 成功并完成对 临界资源 的访问后,就应该进行 解锁,将 [锁资源] 让出,供其他线程(执行流)进行 加锁

注意:如果不进行解锁操作,会导致后续线程无法申请到 [锁资源] 而永久等待,引发 死锁 问题

3.2、解决抢票问题

为了方便所有线程看到同一把 ,可以给线程信息创建一个类 TData,其中包括 namepmtx

pmtx 表示指向 互斥锁 的指针

// 需要定义在 threadRoutine 之前
class TData
{
public:
    TData(const string &name, pthread_mutex_t* pmtx)
        :_name(name), _pmtx(pmtx)
    {}
public:
    string _name;
    pthread_mutex_t* _pmtx;
};


接下来就可以使用 互斥锁 解决 多线程并发抢票 问题了

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 有 1000 张票
// 需要定义在 threadRoutine 之前
class TData
{
public:
    TData(const string &name, pthread_mutex_t* pmtx)
        :_name(name), _pmtx(pmtx)
    {}
public:
    string _name;
    pthread_mutex_t* _pmtx;
};
void* threadRoutine(void* args)
{
    int sum = 0;
    TData* td = static_cast<TData*>(args); 
    while(true)
    {
        // 进入临界区,加锁
        pthread_mutex_lock(td->_pmtx);
        // 如果票数 > 0 才能抢
        if(tickets > 0)
        {
            usleep(2000); // 耗时 2ms
            sum++;
            tickets--;
            // 出临界区了,解锁
            pthread_mutex_unlock(td->_pmtx);
        }
        else
        {
            // 如果判断没有票了,也应该解锁
            pthread_mutex_unlock(td->_pmtx);
            break; // 没有票了
        }
        // 抢到票后还有后续动作
        usleep(2000); //抢到票后也需要时间处理
    }
    // 屏幕也是共享资源,加锁可以有效防止打印结果错行
    pthread_mutex_lock(td->_pmtx);
    cout << "线程 " << td->_name << " 抢票完毕,最终抢到的票数 " << sum << endl;
    pthread_mutex_unlock(td->_pmtx);
    delete td;
    return nullptr;
}
int main()
{
    // 创建一把锁
    pthread_mutex_t mtx;
    // 在线程创建前,初始化互斥锁
    pthread_mutex_init(&mtx, nullptr);
    pthread_t pt[5];
    for(int i = 0; i < 5; i++)
    {
        char* name = new char(16);
        snprintf(name, 16, "thread-%d", i);
        TData *td = new TData(name, &mtx);
        pthread_create(pt + i, nullptr, threadRoutine, td);
    }
    for(int i = 0; i < 5; i++)
        pthread_join(pt[i], nullptr);
    cout << "所有线程均已退出,剩余票数: " << tickets << endl;
    // 线程退出后,销毁互斥锁
    pthread_mutex_destroy(&mtx);
    return 0;
}


此时无论运行多少次程序,结果都没有问题:最终的剩余票数都是 0,并且所有线程抢到的票数之和为 1000

假设某个线程在解锁后,没有后续动作,那么它会再次加锁,继续干自己的事,如此重复形成竞争锁,该线程独享一段时间的资源

  • 解决方法:解锁后让当前线程执行其他动作,也可以选择休眠一段时间,确保 [锁资源] 能尽可能均匀的分发给其他线程
3.2.1、互斥锁细节

多线程加锁互斥中的细节处理才是重头戏

细节1:凡是访问同一个临界资源的线程,都要进行加锁保护,而且必须加同一把锁,这是游戏规则,必须遵守

比如在上面的代码中,5 个并发线程看到的是同一把 互斥锁,只有看到同一把 互斥锁 才能确保线程间 互斥


细节2:每一个线程访问临界区资源之前都要加锁,本质上是给临界区加锁

并且建议加锁时,粒度要尽可能的细,因为加锁后区域的代码是串行化执行的,代码量少一些可以提高多线程并发时的效率


细节3:线程在访问临界区前,需要先加锁 -> 所有线程都要看到同一把锁 -> 锁本身也是临界资源 -> 锁如何保证自己的安全?

加锁 是为了保护 临界资源 的安全,但 本身也是 临界资源,这就像是一个 先有鸡还是先有蛋的问题 的设计者也考虑到了这个问题,于是对于 这种 临界资源 进行了特殊化处理:加锁 和 解锁 操作都是原子的,不存在中间状态,也就不需要保护了


细节4:临界区本身是一行代码,或者一批代码

  1. 线程在执行临界区内的代码时可以被调度吗?
  2. 调度切换后,对于锁及临界资源有影响吗?

首先,线程在执行临界区内的代码时,是允许被调度的,比如线程 1 在持有 [锁资源] 后结束运行,是完全可行的(证明可以被调度);其次,线程在持有锁的情况下被调度是没有影响的,不会扰乱原有的加锁次序

简单举例说明

假设你的学校里有一个 顶级 VIP 自习室,一次只允许一个人使用。作为学校里的公共资源,这个 顶级 VIP 自习室 开放给所有学生使用

使用规则:

  • 一次只允许一个人使用
  • 自习室的门上装有一把锁,优先到达自习室的可以获取钥匙并进入自习室
  • 自习室内无限制,允许一直自习,直到自愿退出,退出后需要把钥匙交给下一个想要自习的同学

假设某天早上 6:00 张三就到达了 顶级 VIP 自习室,并成功获取钥匙,解锁后进入了自习室自习;之后陆陆续续有同学来到了 顶级 VIP 自习室 门口,因为他们都没有钥匙,只能默默等待张三或上一个进入自习室的人交接钥匙

此时的张三不就是持有 [锁资源],并且在进行 临界资源 访问的 线程(执行流) 吗?其他线程(执行流)无法进入 临界区,只有等待张三 解锁(交出 [锁资源] / 钥匙)

假如张三此时想上厕所,并且不想失去钥匙,那么此时他就会带着钥匙去上厕所,即便自习室空无一人,但其他同学也无法进入自习室!

张三上厕所的行为可以看作线程在持有 [锁资源] 的情况下被调度了,显然此时对于整体程序是没有影响的,因为 锁还是处于 lock 状态,其他线程无法进入临界区

假若张三自习够了,潇洒出门,把钥匙往门上一放,正好被李四同学抢到了,那么此时 顶级 VIP 自习室 就是属于李四的

交接钥匙的本质是让出 自习室 的访问权,这不就是 线程解锁后离开临界区,其他线程加锁并进入临界区吗

综上可以借助 张三与顶级 VIP 自习室 的故事理解 线程持有锁时的各种状态


细节5:互斥会给其他线程带来影响

当某个线程持有 [锁资源] 时,对于其他线程的有意义的状态:

  1. 锁被我申请了(其他线程无法获取)
  2. 锁被我释放了(其他线程可以获取锁)

在这两种状态的划分下,确保了多线程并发访问时的 原子性


细节6:加锁与解锁配套出现,并且这两个对于锁的操作本身就是原子的

至于如何确保 加锁和解锁 时的原子性,可以接着往下看

3.3、互斥锁的原理

在如今,大多数 CPU 的体系结构(比如 ARMX86AMD 等)都提供了 swap 或者 exchange 指令,这种指令可以把 寄存器内存单元 的数据 直接交换,由于这种指令只有一条语句,可以保证指令执行时的 原子性

即便是在多处理器环境下(总线只有一套),访问内存的周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期,即 swapexchange 指令在多处理器环境下也是原子的

首先看一段伪汇编代码(加锁相关的)

本质上就是 pthread_mutex_lock() 函数

lock:
  movb $0, %al
  xchgb %al, mutex
  if(al寄存器里的内容 > 0){
    return 0;
  } else
    挂起等待;
  goto lock;


其中 movb 表示赋值,al 为一个寄存器,xchgb 就是支持原子操作的 exchange 交换语句

共识:计算机中的硬件,如 CPU 中的寄存器只有一份,被所有线程共享,但其中的内容随线程,不同线程的内容可能不同,也就是我们常说的上下文数据

  • 寄存器 != 寄存器中的内容(执行流的上下文)

当线程 thread_A 首次加锁时,整体流程如下:

0 赋值给 al 寄存器,这里假设 mutex 默认值为 1(其他不为 0 的整数也行)

movb $0, %al


al 寄存器中的值与 mutex 的值交换(原子操作

xchgb %al, mutex


判断当前 al 寄存器中的值是否 > 0

if(al寄存器里的内容 > 0){
    return 0;
  } else
    挂起等待;


此时线程 thread_A 就可以快快乐乐的访问 临界区 代码了,如果此时线程 thread_A 被切走了(并没有出临界区,[锁资源] 也没有释放),OS 会保存 thread_A 的上下文数据,并让线程 thread_B 入场

thread_B 也是执行 pthread_mutex_lock() 的代码,试图进入 临界区

首先将 al 寄存器中的值赋为 0

movb $0, %al


其次将 al 寄存器中的值与 mutex 的值交换(原子操作

mutex 作为内存中的值,被所有线程共享,因此 thread_B 看到的 mutex 是被 thread_A 修改后的值

显然此时交换了个寂寞

最后判断 al 寄存器中的值是否 > 0

if(al寄存器里的内容 > 0){
  return 0;
} else
  挂起等待;


此时的 thread_B 因为没有 [锁资源] 而被拒绝进入 临界区,不止是 thread_B, 后续再多线程(除了 thread_A) 都无法进入 临界区

不难看出,此时 thread_A 的上下文数据中,al = 1 正是解开 临界区钥匙,其他线程是无法获取的,因为 钥匙 只能有一份

而汇编代码中 xchgb %al, mutex 的本质就是 加锁,当 mutex 不为 0 时,表示 钥匙 可用,可以进行 加锁;并且因为 xchgb %al, mutex 只有一条汇编指令,足以确保 加锁 过程是 原子性


现在再来看看 解锁 操作吧,本质上就是执行 pthread_mutex_unlock() 函数

unlock:
  movb $1, mutex
  唤醒等待 [锁资源] 的线程;
  return


thread_A 登场,并进行 解锁

mutex 中的值赋为 1

movb $1, mutex


既然 thread_A 都走到了 解锁 这一步,证明它已经不需要再访问 临界资源 了,可以让其他线程去访问,也就是 唤醒其他等待 [锁资源] 的线程,然后 return 0 走出 临界区

唤醒等待 [锁资源] 的线程;
return 0;


现在 [锁资源] 跑到 thread_B 手里了,并没有新增或丢失,如此重复,就是 加锁 / 解锁 的原理

至于各种被线程执行某条汇编指令时被切出的情况,都可以不会影响整体 加锁 情况

注意:

  • 加锁是一个让不让你通过的策略
  • 交换指令 swapexchange 是原子的,确保 锁 这个临界资源不会出现问题
  • 未获取到 [锁资源] 的线程会被阻塞至 pthread_mutex_lock()

3.4、多线程封装

现在 互斥 相关内容已经学习的差不多了,可以着手编写一个小组件:Demo版线程库

目标:对 原生线程库 提供的接口进行封装,进一步提高对线程相关接口的熟练程度

既然是封装,那必然离不开类,这里的类成员包括:

  • 线程 ID
  • 线程名 name
  • 线程状态 status
  • 线程回调函数 fun_t
  • 传递给回调函数的参数 args

创建头文件,并编写代码

大体框架如下:

#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
enum class Status
{
    NEW = 0, // 新建
    RUNNING, // 运行中
    EXIT // 已退出
};
// 参数、返回值为 void 的函数类型
typedef void (*func_t)(void*);
class Thread
{
private:
    pthread_t _tid; // 线程 ID
    std::string _name; // 线程名
    Status _status; // 线程状态
    func_t _func; // 线程回调函数
    void* args; // 传递给回调函数的参数
};


首先完成 构造函数,初始化时只需要传递 编号、函数、参数 就行了

Thread(int num = 0, func_t func = nullptr, void* args = nullptr)
    :_tid(0), _status(Status::NEW), _func(func), _args(args)
{
    // 根据编号写入名字
    char name[128];
    snprintf(name, sizeof name, "thread-%d", num);
    _name = name;
}


其次完成各种获取具体信息的接口

// 获取 ID
pthread_t getTID() const
{
  return _tid;
}
// 获取线程名
std::string getName() const
{
  return _name;
}
// 获取状态
Status getStatus() const
{
    return _status;
}


接下来就是处理 线程启动

// 启动线程
void run()
{
    int ret = pthread_create(&_tid, nullptr, runHelper, nullptr /*需要考虑*/);
    if(ret != 0)
    {
        std::cerr << "create thread fail!" << std::endl;
        exit(1); // 创建线程失败,直接退出
    }
    _status =  Status::RUNNING; // 更改状态为 运行中
}


线程执行的方法依赖于回调函数 runHelper

// 回调方法
void* runHelper(void* args)
{
    // 很简单,回调用户传进来的 func 函数即可
    _func(_args);
}


此时这里出现问题了,pthread_create 无法使用 runHelper 进行回调

参数类型不匹配

原因在于:类中的函数(方法)默认有一个隐藏的 this 指针,指向当前对象,显然此时 tunHelper 中的参数列表无法匹配

解决方法:有几种解决方法,这里选一个比较简单粗暴的,直接把 runHelper 函数定义为 static 静态函数,这样他就会失去隐藏的 this 指针

不过此时又出现了一个新问题:失去 this 指针后就无法访问类内成员了,也就无法进行回调了!

有点尴尬,不过换个思路,既然他想要 this 指针,那我们直接利用 pthread_create 的参数4 进行传递就好了,实现曲线救国

// 回调方法
static void* runHelper(void* args)
{
    Thread* myThis = static_cast<Thread*>(args);
    // 很简单,回调用户传进来的 func 函数即可
    myThis->_func(myThis->_args);
    return nullptr;
}
// 启动线程
void run()
{
    int ret = pthread_create(&_tid, nullptr, runHelper, this);
    if(ret != 0)
    {
        std::cerr << "create thread fail!" << std::endl;
        exit(1); // 创建线程失败,直接退出
    }
    _status =  Status::RUNNING; // 更改状态为 运行中
}


最后完成 线程等待

// 线程等待
void join()
{
    int ret = pthread_join(_tid, nullptr);
    if(ret != 0)
    {
        std::cerr << "thread join fail!" << std::endl;
        exit(1); // 等待失败,直接退出
    }
    _status = Status::EXIT; // 更改状态为 退出
}
• 1
• 2
• 3
• 4
• 5
• 6
• 7
• 8
• 9
• 10
• 11


现在使用自己封装的 Demo版线程库,简单编写多线程程序

注意:需要包含头文件,我这里是 Thread.hpp

#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
using namespace std;
void threadRoutine(void* args)
{}
int main()
{
    Thread t1(1, threadRoutine, nullptr);
    cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;
    t1.run();
    cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;
    t1.join();
    cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;
    return 0;
}


运行结果如下,可以看出线程的状态从 02,即 创建 -> 运行 -> 退出

足以证明我们自己封装的 Demo版线程库 没啥大问题

3.5、互斥锁的封装

原生线程库 提供的 互斥锁 相关代码比较简单,也比较好用,但有一个很麻烦的地方:就是每次都得手动加锁、解锁,如果忘记解锁,还会导致其他线程陷入无限阻塞的状态

因此我们对锁进行封装,实现一个简单易用的 小组件

封装思路:利用创建对象时调用构造函数,对象生命周期结束时调用析构函数的特点,融入 加锁、解锁 操作即可

非常简单,直接创建一个 LockGuard

创建头文件,编写代码

#pragma once
#include <pthread.h>
class LockGuard
{
public:
    LockGuard(pthread_mutex_t*pmtx)
        :_pmtx(pmtx)
    {
        // 加锁
        pthread_mutex_lock(_pmtx);
    }
    ~LockGuard()
    {
        // 解锁
        pthread_mutex_unlock(_pmtx);
    }
private:
    pthread_mutex_t* _pmtx;
};


现在把 Demo版线程库Demo版互斥锁 融入 多线程抢票 程序中,可以看到此时代码变得十分优雅

#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
#include "LockGuard.hpp"
using namespace std;
// 创建一把全局锁
pthread_mutex_t mtx;
int tickets = 1000; // 有 1000 张票
// 自己封装的线程库返回值为 void
void threadRoutine(void *args)
{
    int sum = 0;
    const char* name = static_cast<const char*>(args);
    while (true)
    {
        // 进入临界区,加锁
        {
            // 自动加锁、解锁
            LockGuard guard(&mtx);
            // 如果票数 > 0 才能抢
            if (tickets > 0)
            {
                usleep(2000); // 耗时 2ms
                sum++;
                tickets--;
            }
            else
                break; // 没有票了
        }
        // 抢到票后还有后续动作
        usleep(2000); // 抢到票后也需要时间处理
    }
    // 屏幕也是共享资源,加锁可以有效防止打印结果错行
    {
        LockGuard guard(&mtx);
        cout << "线程 " << name << " 抢票完毕,最终抢到的票数 " << sum << endl;
    }
}
int main()
{
    // 在线程创建前,初始化互斥锁
    pthread_mutex_init(&mtx, nullptr);
    // 创建一批线程
    Thread t1(1, threadRoutine, (void*)"thread-1");
    Thread t2(2, threadRoutine, (void*)"thread-2");
    Thread t3(3, threadRoutine, (void*)"thread-3");
    // 启动
    t1.run();
    t2.run();
    t3.run();
    // 等待
    t1.join();
    t2.join();
    t3.join();
    // 线程退出后,销毁互斥锁
    pthread_mutex_destroy(&mtx);
    cout << "剩余票数: " << tickets << endl;
    return 0;
}


3.5.1、RAII 风格

像这种 获取资源即初始化 的风格称为 RAII 风格,由 C++ 之父 本贾尼·斯特劳斯特卢普 提出,非常巧妙的运用了 类和对象 的特性,实现半自动化操作

这里是首次遇到,后面学习 智能指针 时还会遇到


4、线程安全 VS 重入

概念

  • 线程安全:多线程并发访问同一段代码时,不会出现不同的结果,此时就是线程安全的;但如果在没有加锁保护的情况下访问全局变量或静态变量,导致出现不同的结果,此时线程就是不安全的
  • 重入:同一个函数被多个线程(执行流)调用,当前一个执行流还没有执行完函数时,其他执行流可以进入该函数,这种行为称之为 重入;在发生重入时,函数运行结果不会出现问题,称该函数为 可重入函数,否则称为 不可重入函数

常见线程不安全的情况

  • 不保护共享变量,比如全局变量和静态变量
  • 函数的状态随着被调用,而导致状态发生变化
  • 返回指向静态变量指针的函数
  • 调用 线程不安全函数 的函数

常见线程安全的情况

  • 每个线程对全局变量或静态变量只有读取权限,而没有写入权限,一般来说都是线程安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致执行结果存在二义性

常见不可重入的情况

  • 调用了 malloc / free 函数,因为这些都是 C语言 提供的接口,通过全局链表进行管理
  • 调用了标准 I/O 库函数,其中很多实现都是以不可重入的方式来使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用 mallocnew 开辟空间
  • 不调用不可重入函数
  • 不返回全局或静态数据,所有的数据都由函数调用者提供
  • 使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据

重入与线程安全的联系

  • 如果函数是可重入的,那么函数就是线程安全的;不可重入的函数有可能引发线程安全问题
  • 如果一个函数中使用了全局数据,那么这个函数既不是线程安全的,也不是可重入的

重入与线程安全的区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,反过来可重入函数一定是线程安全的
  • 如果对于临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数中没有被释放会引发死锁,因此是不可被重入的

一句话总结:是否可重入只是函数的一种特征,没有好坏之分,但线程不安全是需要规避的


5、常见锁概念

5.1、死锁问题

死锁:指在一组进程中的各个线程均占有不会释放的资源,但因相互申请被其他线程所占用不会释放的资源处于一种永久等待状态

概念比较绕,简单举个例子

两个小朋各持 五毛钱 去商店买东西,俩人同时看中了一包 辣条,但这包 辣条 售价 一块钱,两个小朋友都想买了自己吃,但彼此的钱都不够,双方互不谦让,此时局面就会僵持不下

两个小朋友:两个不同的线程

辣条:临界资源

售价:访问临界资源需要的锁资源数量,这里需要两把锁

两个小朋友各自手里的钱:一把锁资源

僵持不下的场面:形成死锁,导致程序无法继续运行

所以死锁就是 多个线程都因锁资源的等待而被同时挂起,导致程序陷入 死循环

只有一把锁会造成死锁吗?

答案是 会的,如果线程 thread_A 申请锁资源,访问完临界资源后没有释放,会导致 线程 thread_B 无法申请到锁资源,同时线程 thread_A 自己也申请不到锁资源了,不就是 死锁

死锁 产生的四个必要条件

  1. 互斥:一个资源每次只能被一个执行流使用
  2. 请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放
  3. 环路等待:若干执行流之间形成一种首尾相接的循环等待资源关系
  4. 不剥夺条件:不能强行剥夺其他线程的资源

只有四个条件都满足了,才会引发 死锁 问题

如何避免 死锁 问题?

核心思想:破坏四个必要条件的其中一个或多个

方法1:不加锁

不加锁的本质是不保证 互斥,即破坏条件1


方法2:尝试主动释放锁

比如进入 临界区 访问 临界资源,需要两把锁,thread_Athread_B 各自持有一把锁,并且都在尝试申请第二把锁,但如果此时 thread_A 放弃申请,主动把锁释放,这样就能打破 死锁 的局面,主打的就是一个牺牲自己

可以借助 pthread_mutex_trylock 函数实现这种方案

#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);


这个函数就是尝试申请锁,如果长时间申请不到锁,就会把自己当前持有的锁释放,然后放弃加锁,给其他想要加锁的线程一个机会


方法3:按照顺序申请锁

按照顺序申请锁 -> 按照顺序释放锁 -> 就不会出现环路等待的情况


方法4:控制线程统一释放锁

首先要明白:锁不一定要由申请锁的线程释放,其他线程也可以释放锁

这是由释放锁的机制决定的,直接向 mutex 赋值而非交换,意味着其他线程也能解锁

比如在下面这个程序中,主线程就释放了次线程申请的锁,打破了 死锁 的局面

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 全局互斥锁,无需手动初始化和销毁
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* threadRoutine(void* args)
{
    cout << "我是次线程,我开始运行了" << endl;
    // 申请锁
    pthread_mutex_lock(&mtx);
    cout << "我是次线程,我申请到了一把锁" << endl;
    // 在不释放锁的情况下,再次申请锁,陷入 死锁 状态
    pthread_mutex_lock(&mtx);
    cout << "我是次线程,我又再次申请到了一把锁" << endl;
    pthread_mutex_unlock(&mtx);
    return nullptr;
}
int main()
{
    pthread_t t;
    pthread_create(&t, nullptr, threadRoutine, nullptr);
    // 等待次线程先跑
    sleep(3);
    // 主线程帮忙释放锁
    pthread_mutex_unlock(&mtx);
    cout << "我是主线程,我已经帮次线程释放了一把锁" << endl;
    // 等待次线程后续动作
    sleep(3);
    pthread_join(t, nullptr);
    cout << "线程等待成功" << endl;
    return 0;
}


最终程序运行后,可以看到 主线程成功帮次线程释放了锁资源

因此,我们可以设计一个 控制线程,专门掌管所有的锁资源,如果识别到发生了 死锁 问题,就释放所有的锁,让线程重新竞争

注意:规定只有申请锁的人才能释放锁,规定可以不遵守,但最好遵守

死锁 一般比较少见,因为这是因代码编写失误而引发的问题

常见的避免 死锁 问题的算法:死锁检测算法、银行家算法


6、线程同步

6.1、同步的相关概念

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免 饥饿问题

至于该如何正确理解 饥饿问题,需要再次请张三出场

话说张三在早上 6:00 抢到了自习室的钥匙,并开开心心的进入了自习室自习

此时自习室外人声鼎沸,显然有很多人都在等待张三交出钥匙,但张三不急,慢悠悠的自习到了中午 12:00,此时张三有些饿了,想出去吃个饭,吃饭就意味着张三需要把钥匙归还(这是规定)

张三刚把钥匙放到门上,扭头就发现了大批的同学正在等待钥匙,张三心想:要是我就这样把钥匙归还了,那等我吃完饭回来岂不是也需要等待

于是法外狂徒张三决定放弃吃饭,强忍着饥饿再次拿起钥匙进入了自习室自习;刚进入自习室没几分钟,肚子就饿的咕咕叫,于是张三就又想出去吃饭,刚出门归还了钥匙,扭头看见大批同学就感觉很亏,一咬牙就又拿起钥匙进入了自习室,就这样张三反复横跳,直到下午 6:00 都还没吃上午饭,不仅自己没吃上午饭、没好好自习,还导致其他同学无法自习!

张三错了吗?张三没错,十分符合自习室的规定,只是 不合理

因为张三这种不合理的行为,导致 自习室 资源被浪费了,在外等待的同学也失去了自习,陷入 饥饿状态,活生生被张三 “饿惨了”

为此校方更新了 自习室 的规则:

  • 所有自习完的同学在归还钥匙之后,不能立即再次申请
  • 在外面等待钥匙的同学必须排队,遵守规则

规则更新之后,就不会出现这种 饥饿问题 了,所以解决 饥饿问题 的关键是:在安全的规则下,使多线程访问资源具有一定的顺序性

即通过 线程同步 解决 饥饿问题


原生线程库 中提供了 条件变量 这种方式来实现 线程同步

逻辑链:通过条件变量 -> 实现线程同步 -> 解决饥饿问题

条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,什么也做不了

比如当一个线程访问队列时,发现队列为空,它只能等待,直到其他线程往队列中添加数据,此时就可以考虑使用 条件变量

条件变量的本质就是 衡量访问资源的状态

竞态条件:因为时序问题而导致程序出现异常

可以把 条件变量 看作一个结构体,其中包含一个 队列 结构,用来存储正在排队等候的线程信息,当条件满足时,就会取 队头 线程进行操作,操作完成后重新进入 队尾

队列是保证顺序性的重要工具

6.2、同步相关操作

6.2.1、条件变量创建与销毁

作为出自 原生线程库条件变量,使用接口与 互斥锁 风格差不多,比如 条件变量 的类型为 pthread_cond_t,同样在创建后需要初始化

#include <pthread.h>
pthread_cond_t cond; // 定义一个条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,
      const pthread_condattr_t *restrict attr);


参数1 pthread_cond_t*表示想要初始化的条件变量

参数2 const pthread_condattr_t*表示初始化时的相关属性,设置为 nullptr 表示使用默认属性

返回值:成功返回 0,失败返回 error number

条件变量 在使用结束后需要销毁

#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);


pthread_cond_t*表示想要销毁的条件变量

返回值:成功返回 0,失败返回 error number

注:同互斥锁一样,条件变量支持静态分配,即在创建全局条件变量时,定义为 PTHREAD_COND_INITIALIZER,表示自动初始化、自动销毁

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;


注意:这种定义方式只支持全局条件变量

6.2.2、条件等待

原生线程库 中提供了 pthread_cond_wait 函数用于等待

#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
       pthread_mutex_t *restrict mutex);


参数1 pthread_cond_t*想要加入等待的条件变量

参数2 pthread_mutex_t*互斥锁,用于辅助条件变量

返回值:成功返回 0,失败返回 error number

参数2值得详细说一说,首先要明白 条件变量是需要配合互斥锁使用的,需要在获取 [锁资源] 之后,在通过条件变量判断条件是否满足

传递互斥锁的理由:

  1. 条件变量也是临界资源,需要保护
  2. 当条件不满足时(没有被唤醒),当前持有锁的线程就会被挂起,其他线程还在等待锁资源呢,为了避免死锁问题,条件变量需要具备自动释放锁的能力

当某个线程被唤醒时,条件变量释放锁,该线程会获取锁资源,并进入 条件等待 状态

6.2.3、唤醒线程

条件变量 中的线程是需要被唤醒的,否则它也不知道何时对 队头线程 进行判断,可以使用 pthread_cond_signal 函数进行唤醒

#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);


pthread_cond_t表示想要从哪个条件变量中唤醒线程

返回值:成功返回 0,失败返回 error number

注意:使用 pthread_cond_signal 一次只会唤醒一个线程,即队头线程

如果想唤醒全部线程,可以使用 pthread_cond_broadcast

#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);


参数和返回值含义与前者一致,broadcast 就是广播的意思,也就是挨个通知该 条件变量 中的所有线程访问 临界资源


6.3、简单同步 Demo

接下来简单使用一下 线程同步 相关接口

目标:创建 5 个次线程,等待条件满足,主线程负责唤醒

这里演示 单个唤醒广播 两种方式,先来看看 单个唤醒 相关代码

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 互斥锁和条件变量都定义为自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
const int num = 5; // 创建五个线程
void* Active(void* args)
{
    const char* name = static_cast<const char*>(args);
    while(true)
    {
        // 加锁
        pthread_mutex_lock(&mtx);
        // 等待条件满足
        pthread_cond_wait(&cond, &mtx);
        cout << "\t线程 " << name << " 正在运行" << endl;
        // 解锁
        pthread_mutex_unlock(&mtx);
    }
  delete[] name;
    return nullptr;
}
int main()
{
    pthread_t pt[num];
    for(int i = 0; i < num; i++)
    {
        char* name = new char[32];
        snprintf(name, 32, "thread-%d", i);
        pthread_create(pt + i, nullptr, Active, name);
    }
    // 等待所有次线程就位
    sleep(3);
    // 主线程唤醒次线程
    while(true)
    {
        cout << "Main thread wake up Other thread!" << endl;
        pthread_cond_signal(&cond); // 单个唤醒
        sleep(1);
    }
    for(int i = 0; i < num; i++)
        pthread_join(pt[i], nullptr);
    return 0;
}


可以看到,在 单个唤醒 模式下,一次只会有一个线程苏醒,并且得益于 条件变量,线程苏醒的顺序都是一样的


可以将唤醒方式换成 广播

// ......
pthread_cond_broadcast(&cond); // 广播
// ......


现在就变成一次唤醒一批线程了,并且顺序仍然不会改变

互斥锁+条件变量 可以实现 生产者消费者模型,关于 生产者消费者的实现条件变量 的更多细节将会在下一篇文章中揭晓


🌆总结

以上就是关于 Linux多线程【线程互斥与同步】的全部内容了,在本文中,我们首先认识到了多线程并发访问而导致的数据不一致问题,并通过多线程抢票这一个实例验证了现象;然后着重学习了互斥锁相关知识,包括互斥锁的概念、操作、原理,以及多线程与互斥锁的封装;最后简单学习了线程同步相关内容,重点在于对条件变量的理解及使用。至于互斥锁+条件变量的实战:生产者消费者模型将会在下一篇文章中完成


相关文章推荐

Linux多线程 =====:>

【初始多线程】【线程控制】

Linux进程信号 ===== :>

【信号产生】【信号保存】【信号处理】

Linux进程间通信 ===== :>

【消息队列、信号量】【共享内存】【命名管道】【匿名管道】

Linux基础IO ===== :>

【软硬链接与动静态库】【深入理解文件系统】【模拟实现C语言文件流】【重定向及缓冲区理解】【文件理解与操作】

Linux进程控制 ===== :>

【简易版bash】【进程程序替换】【创建、终止、等待】

Linux进程学习 ===== :>

【进程地址】【环境变量】【进程状态】【基本认知】

Linux基础 ===== :>

【gdb】【git】【gcc/g++】【vim】Linux 权限理解和学习听说Linux基础指令很多?这里都帮你总结好了

目录
相关文章
|
17天前
|
Java 调度
Java 线程同步的四种方式,最全详解,建议收藏!
本文详细解析了Java线程同步的四种方式:synchronized关键字、ReentrantLock、原子变量和ThreadLocal,通过实例代码和对比分析,帮助你深入理解线程同步机制。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
Java 线程同步的四种方式,最全详解,建议收藏!
|
22天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
16 3
|
22天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
15 2
|
22天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
28 2
|
22天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
28 1
|
22天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
33 1
|
22天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
24 1
|
3天前
|
Linux
在 Linux 系统中,“cd”命令用于切换当前工作目录
在 Linux 系统中,“cd”命令用于切换当前工作目录。本文详细介绍了“cd”命令的基本用法和常见技巧,包括使用“.”、“..”、“~”、绝对路径和相对路径,以及快速切换到上一次工作目录等。此外,还探讨了高级技巧,如使用通配符、结合其他命令、在脚本中使用,以及实际应用案例,帮助读者提高工作效率。
18 3
|
3天前
|
监控 安全 Linux
在 Linux 系统中,网络管理是重要任务。本文介绍了常用的网络命令及其适用场景
在 Linux 系统中,网络管理是重要任务。本文介绍了常用的网络命令及其适用场景,包括 ping(测试连通性)、traceroute(跟踪路由路径)、netstat(显示网络连接信息)、nmap(网络扫描)、ifconfig 和 ip(网络接口配置)。掌握这些命令有助于高效诊断和解决网络问题,保障网络稳定运行。
15 2
|
11天前
|
缓存 监控 Linux