<Linux> 线程的同步与互斥

目录

前言:

一、资源共享问题

(一)多线程并发访问

(二)临界资源与临界区

(三)“锁” 是什么

二、多线程抢票场景

(一)并发抢票

(二)并发访问问题原因

三、线程互斥

(一)互斥锁相关操作接口

1. 互斥锁的创建及释放

2. 加锁解锁操作

(二) 解决抢票问题

1. 互斥锁细节

(三)互斥锁的原理

1. 加锁原理

2. 解锁原理

(四)多线程封装

(五)互斥锁的封装

1. RAII 风格

(六)总结

四、线程安全 VS 重入

五、死锁

(一)概念

(二)死锁产生条件及如何避免

六、线程同步

(一)同步概念

(二)同步相关操作

1. 条件变量的创建与销毁

2. 条件等待

3. 条件唤醒

(三)简单同步demo

(四)总结


前言:

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

并发访问就是同时有多个请求请求同一服务。比如我和你现在都同时在请求****的服务器提供搜索博客服务。大量的并发访问如果超出了服务器的承受能力的话,轻则导致服务器抛弃一部分请求,重则导致服务器资源耗尽,宕机。
有一种攻击叫分布式拒绝服务攻击(DDOS),就是利用这个。使得大量的垃圾请求阻塞服务器,使得服务器无法处理正常的请求从而耗尽资源。

一、资源共享问题

(一)多线程并发访问

比如存在全局变量 g_val 以及两个线程 thread_A 和 thread_B,两个线程同时不断对 g_val 做 减减 (--) 操作:

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

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

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

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

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

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

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

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

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

此时尴尬的事情发生了:A 把 g_val 的值改成了 99,这对于 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% 可靠的。

(二)临界资源与临界区

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

int g_val = 100; // 临界资源

void *action(void *args)
{
    // ==== 临界区 ====
    while(g_val)
    {
        cout << "g_val: " << g_val << endl;
        g_val--;
    }
    // ==== 临界区 ====
    
    pthread_exit((void*)0);
}

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

(三)“锁” 是什么

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

在我们现实生活中,对于人类共享的资源都是有锁进行安全使用的。例如:公共厕所门、共享单车、共享电宝等等。对于公共厕所来说,卫生间只能供一人使用,为了确保如厕时的安全性,就需要给每个卫生间都加上一道门,并且加上一把锁。

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

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

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

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

注意:

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

二、多线程抢票场景

(一)并发抢票

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

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

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

using namespace std;

int tickets = 1000; // 临界资源

void *threadRoutine(void *args)
{
    int sum = 0;
    const char *name = static_cast<const char*>(args);
    while(true)
    {
        // 有票才能抢
        if(tickets > 0)
        {
            usleep(2000);
            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[64];
        snprintf(name, 64, "thread|%d", i);
        pthread_create(pt+i, nullptr, threadRoutine, (void*)name);
    }
    for(int i = 0; i < 5; i++)
        pthread_join(pt[i], nullptr);

    cout << "所有线程已退出,无抢票操作,剩余票数:" << tickets << endl;

    return 0;
}

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

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

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

(二)并发访问问题原因

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

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

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

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

三、线程互斥

互斥通常指的是在多进程或多线程环境下,当一个进程或线程在访问共享资源或执行某段关键代码时,其他进程或线程不能同时访问该资源或执行该代码。互斥 -> 互斥排斥事件 A 与事件 B 不会同时发生。

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

(一)互斥锁相关操作接口

1. 互斥锁的创建及释放

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

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

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

int pthread_mutex_destroy(pthread_mutex_t *mutex);

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

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

注意:

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

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

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

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

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

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

2. 加锁解锁操作

互斥锁 最重要的功能就是 加锁与解锁 操作

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

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

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

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

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

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

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

(二) 解决抢票问题

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

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

using namespace std;

int tickets = 1000; // 临界资源

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);

        // 有票才能抢
        if(tickets > 0)
        {
            usleep(2000);
            sum++;
            --tickets;

            // 出了临界区,解锁
            pthread_mutex_unlock(td->_pmtx);
        }
        else
        {
            // 没有票也是得解锁
            pthread_mutex_unlock(td->_pmtx);
            break;
        }

        // 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[64];
        snprintf(name, 64, "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 

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

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

1. 互斥锁细节

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

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


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

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


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

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


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

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

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

简单举例说明:

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

使用规则:

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

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

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

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

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

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


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

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

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

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


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

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

(三)互斥锁的原理

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

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

首先看一段伪汇编代码(加锁相关的),本质上就是 pthread_mutex_lock() 函数

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

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

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

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

1. 加锁原理

当线程 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 入场:

⑤首先将 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 只有一条汇编指令,足以确保 加锁 过程是 原子性

2. 解锁原理

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

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

让 thread_A 登场,并进行 解锁

将 mutex 中的值赋为 1

movb $1, mutex

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

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

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

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

注意:

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

(四)多线程封装

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

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

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

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

大体框架如下:

class Thread
{
public:
    // 状态表
    typedef enum
    {
        NEW = 0,
        RUNNING,
        EXITED
    }ThreadStatus;
    typedef void (*func_t)(void*);
private:
    pthread_t _tid; // 线程ID
    string name; // 线程名
    func_t _func; // 线程回调函数
    ThreadStatus _status; // 线程状态
    void *_args; // 回调函数的参数,可以设置成模板
};

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

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

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

    // 获取线程ID
    pthread_t getID() { return _tid; }

    // 获取线程名
    string getName() { return _name; }

    // 获取线程状态
    int getStatus() { return _status; }

接下来就是处理 线程启动,及回调函数

    // 启动线程
    void run()
    {
        int n = pthread_create(&_tid, nullptr, runHelper, nullptr/*需考虑*/);
        if(n != 0)
        {
            cerr << "create thread fail" << endl;
            exit(1);
        }
        _status = RUNNING;// 线程跑起来状态为运行中
    }

    // 回调函数
    static void *runHelper(void *args)
    {
        _func(_args);
    }

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

参数类型不匹配

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

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

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

既然他想要 this 指针,那我们直接利用 pthread_create 参数4 进行传递就好了:

    // 启动线程
    void run()
    {
        int n = pthread_create(&_tid, nullptr, runHelper, this/*需考虑*/);
        if(n != 0)
        {
            cerr << "create thread fail" << endl;
            exit(1);
        }
        _status = RUNNING;// 线程跑起来状态为运行中
    }

    // 回调函数
    static void *runHelper(void *args)
    {
        Thread *ts = static_cast<Thread*>(args);// 强转对象指针

        ts->_func(ts->_args);// 回调用户传进来的 func 即可
        return nullptr;
    }

最后完成 线程等待

    // 线程等待
    void join()
    {
        int n = pthread_join(_tid, nullptr);
        if(n != 0)
        {
            cerr << "join thread fail" << endl;
            exit(1);
        }
        _status = EXITED;// 线程等待成功后状态为退出
    }

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

#include "Thread.hpp"

void threadRoutine(void *args)
{
    string message = static_cast<const char*>(args);
    int cnt = 3;
    while(cnt--)
    {
        cout << cnt << " | thread created, " << message << endl;
        sleep(1);
    }
}
int main()
{
    Thread t1(1, threadRoutine, (void*)"good morning");
    cout << "thread ID: " << t1.getID() << " | thread name: " 
    << t1.getName() << " | thread status: " << t1.getStatus() << endl;
    t1.run();
    cout << "thread ID: " << t1.getID() << " | thread name: " 
    << t1.getName() << " | thread status: " << t1.getStatus() << endl;
    t1.join();
    cout << "thread ID: " << t1.getID() << " | thread name: " 
    << t1.getName() << " | thread status: " << t1.getStatus() << endl;

    return 0;
}

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

(五)互斥锁的封装

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

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

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

非常简单,直接创建一个 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 "Thread.hpp"
#include "Mutex.hpp"

pthread_mutex_t mtx;
int tickets = 1000;

void threadRoutine(void *args)
{
    int sum = 0;
    const char *name = static_cast<const char*>(args);
    while(true)
    {
        // 进入临界区加锁
        {
            // 自动加锁,解锁
            LockGuard guard(&mtx);

            if(tickets > 0)
            {
                usleep(2000);
                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;
}

1. RAII 风格

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

(六)总结

在计算机科学中,“互斥”主要涉及到并发控制和资源管理的概念。具体地,互斥通常指的是在多进程或多线程环境下,当一个进程或线程在访问共享资源或执行某段关键代码时,其他进程或线程不能同时访问该资源或执行该代码。这种机制确保了资源的一致性和完整性,防止了数据竞争和不一致的状态。

在计算机术语中,互斥通常通过“互斥锁”Mutex)或“锁”Lock)来实现。当一个进程或线程需要访问共享资源时,它会先尝试获取锁。如果锁当前没有被其他进程或线程持有,那么它就可以成功获取锁并访问资源。在访问完资源后,它会释放锁,以便其他进程或线程可以获取锁并访问资源。

这种互斥机制对于保证数据完整性和程序正确性至关重要,特别是在并发环境下,多个进程或线程可能同时尝试访问或修改共享资源时。

此外,在并发编程中,互斥还涉及到临界区(critical section)的概念。临界区是一段代码,当一个线程进入临界区执行时,其他线程不能同时进入临界区执行。这确保了同一时间只有一个线程可以访问或修改共享资源,从而防止了数据不一致和其他并发问题。

总的来说,在计算机科学中,“互斥”是一个重要的概念,用于确保并发环境下资源的正确访问和程序的正确执行。

四、线程安全 VS 重入

概念

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

常见线程不安全的情况

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

常见线程安全的情况

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

常见不可重入的情况

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

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用 malloc 或 new 开辟空间
  • <
上一篇:MT6825编码器在STM32中的使用-SPI 绝对值位置读取功能


下一篇:Apache Paimon 使用之 Pulsar CDC 解析