linux线程同步与互斥

Linux 线程互斥与同步 和 pthread_cond_signal 不同,pthread_cond_signal 只唤醒 一个 等待的线程,而 pthread_cond_broadcast 唤醒 所有等待的线程。:当线程调用 pthread_cond_wait() 时,它会自动释放所持有的互斥锁,并将自己置于等待状态,直到被另一个线程用 pthread_cond_signal() 或 pthread_cond_broadcast() 唤醒。:条件变量的作用是协调线程的执行,确保某些条件为真时,线程才会继续执行。 阅读详情

目录

一、资源共享问题

1.1 多线程并发访问

1.2 临界资源与临界区

1.3 “锁” 

二、多线程抢票实例

2.1 并发抢票

2.2 问题溯源

三、 线程互斥

3.1 互斥锁相关操作

3.1.1 互斥锁的创建与销毁

3.1.2 添加锁

3.1.3 解开锁

3.2 抢票实战

3.3 互斥锁的原理

3.4 多线程封装

3.5 互斥锁封装

四、线程安全 和 重入

五、常见锁

5.1 死锁

六、 线程同步

6.1 什么是线程同步

6.2 同步操作

6.2.1 条件变量的创建与销毁

6.2.2 条件等待

6.2.3 线程唤醒

6.3 实例


一、资源共享问题

1.1 多线程并发访问

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

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

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

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

设 g_val 初始值为 100,如果 thread_A 想要进行 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 中的值修改为 10 后,就被操作系统切走了,此时轮到 thread_A 登场,thread_A 带着自己的之前的上下文数据,继续进行它的未尽事业(完成第3步操作),当然 thread_B 的上下文数据也会被保存

时尴尬的事情发生了:thread_A 把 g_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_A 和 thread_B 在并发访问 g_val 时,如果进行了 加锁,在 thread_A 被切走后,thread_B 无法对 g_val 进行操作,因为此时 锁 被 thread_A 持有,thread_B 只能 阻塞式等待锁,直到 thread_A 解锁(意味着 thread_A 的整个操作都完成了)

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

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

注意:

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

二、多线程抢票实例

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

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

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

2.2 问题溯源

        这其实就是 thread_A 和 thread_B 并发访问 g_val 时遇到的问题。

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

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

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

--tickets 本质上是 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;

静态分配 的优点在于 无需手动初始化和手动销毁,锁的生命周期伴随程序

                  缺点就是定义的 互斥锁 必须为 全局互斥锁

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

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

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,其中包括 name 和 pmtx

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

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

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

3.3 互斥锁的原理

在如今,大多数 CPU 的体系结构(比如 ARM、X86、AMD 等)都提供了 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 中的寄存器只有一份,被所有线程共享,但其中的内容随线程,不同线程的内容可能不同,也就是我们常说的上下文数据

  • 寄存器 != 寄存器中的内容(执行流的上下文)
  • 当线程 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

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

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

显然此时交换了个寂寞

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

此时的 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 走出 临界区

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

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

注意:

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

3.4 多线程封装

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

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

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

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

大体框架如下:

#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;
}

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

足以证明我们自己封装的 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;
}

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

四、线程安全 和 重入

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

常见线程不安全的情况

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

常见线程安全的情况

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

常见不可重入的情况

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

常见可重入的情况

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

重入与线程安全的联系

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

重入与线程安全的区别

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

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


五、常见锁

5.1 死锁

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

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

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

两个小朋友:两个不同的线程
辣条:临界资源
售价:访问临界资源需要的锁资源数量,这里需要两把锁
两个小朋友各自手里的钱:一把锁资源
僵持不下的场面:形成死锁,导致程序无法继续运行

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

死锁 产生的四个必要条件

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

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

如何避免 死锁 问题?
核心思想:破坏四个必要条件的其中一个或多个

方法1:不加锁

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

方法2:尝试主动释放锁

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

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

#include <pthread.h>

int pthread_mutex_trylock(pthread_mutex_t *mutex);

方法3:按照顺序申请锁

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

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

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

#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.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_destroy* 表示想要销毁的条件变量

返回值:成功返回 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);

6.3 实例

#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;
}

【论文+代码】ZS-N2N实现小样本零网络图像去噪 本文涵盖论文原文讲解与代码复现。 阅读详情

相关推荐

一起玩转GNU Radio:调制解调大合集

本文汇总了GNU Radio中多种常见的调制解调方法及其参考文献链接,便于今后查看,供大家参考!

无线通信实验室 1万+

Linux之线程同步

此时我们会发现唤醒这三个线程时具有明显的顺序性,因为这些线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完代码后会继续排到等待队列的尾部进行等待。这是因为如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,那么它就可以一直抢票。为了解决这个问题,我们增加一个限制:当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。3、唤醒线程去访问临界资源。

zdlynj的博客 2215

奥比中光Gemini335L SDK实战:从标定参数到3D点云转换

本文详细介绍了奥比中光Gemini 335L深度相机的SDK实战应用,重点讲解如何跳过繁琐的棋盘格标定,直接调用出厂预标定参数实现高效的3D点云转换。通过解析内参、外参获取及四种核心坐标转换模式,并结合OpenCV等工具,指导开发者快速将相机集成到机器人抓取、三维测量等项目中,实现开箱即用的3D视觉解决方案。

raspberrypi5的博客 639

Linux之线程同步一

Linux之线程同步一

m0_60600050的博客 3756

通达信量化打板选股指标公式源码副图

X_2:=3*LOW;X_13:=4;X_14:=3;X_15:=2;X_16:=1;

gpzb7的博客 4482

第七天 Labview进阶(7.4 NI Vision简单使用)

NIVision机器视觉软件使用指南 摘要:NIVision是National Instruments推出的高效机器视觉软件,具有硬件无缝集成、图形化编程和工业级部署等优势。本文介绍了NIVision的安装步骤(需Vision Acquisition和Vision Development模块)及三个典型应用案例:1)实物检测通过IMAQCreate等模块实现物体识别与坐标定位;2)OCR检测利用IMAQOCR系列模块完成字符识别;3)几何模板匹配使用IMAQLearnGeometricPattern等模块实

weixin_38637405的博客 771

Linux 线程同步

之前讲过线程互斥,互斥解决了临界资源的问题,但是又会引入新的问题,这时候同步机制就诞生出来了。所以,为什么会有新的问题?同步又是什么呢?怎么去操作呢?今天一起学习这原理吧~目录为什么要有同步机制条件变量函数接口pthread_cond_initpthread_cond_destroypthread_cond_t cond = PTHREAD_COND_INITIALIZERpthread_cond_waitpthread_cond_signalpthread_cond_broadcast生产者消费者模型第一

qq_58724706的博客 1306

1️⃣ Coze智能体基础入门教学(2025年全新版本)

不妨设想一下,倘若我们能够打造出一个数字助手,它不但能精准洞悉我们的需求,更可以独立思索、学习以及付诸行动,这便是 AI Agent 的基础理念。它恰似计算机世界里的 “智慧精灵”,能够感知周遭环境、做出判断并采取行动。AI Agent 的发展之路精彩纷呈。它走过了三个重要阶段:起初,它犹如一个被规则束缚的机器人,只能按预设好的指令开展工作。接着,它学会运用统计学方法,通过分析数据来做决策,变得越发灵活。到如今,凭借深度学习技术,它已经能够像人类一样去学习复杂的任务了。

李同学Lino的博客 4084

linux线程间同步(一)

1. 线程同步概念 假设有4个线程A、B、C、D,当前一个线程A对内存中的共享资源进行访问的时候,其他线程B, C, D都不可以对这块内存进行操作,直到线程A对这块内存访问完毕为止,B,C,D中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。 线程对内存的这种访问方式就称之为线程同步,通过对概念的介绍,我们可以了解到所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。 1.1 为什么要同步 线程需要分时复用CPU时间片,并且如果测试程序中

梅山 1569

vue2/3 - 详细对接海康监控实时视频预览和录像回放功能,vue接入使用海康视频监控播放器实时推流等功能,支持hik/rtsp/rtmp/hls/ws取流协议(详细教程与示例代码,一键复制开箱即用

vue2,vue3,海康,vue海康视频监控,海康开放平台,如何接入海康的实时监控,海康威视,Vue实现海康监控视频播放,vue3对接海康视频监控播放、回放的方法流程,实现海康H5视频插件播放视频的实例,web网页接入海康,海康威视的视频vue的录像回放和实时预览,前端vue3项目接入海康威视h5player.js播放器,vue2接入海康威视视频监控平台,使用海康监控H5视频播放,对接海康视频监控,海康视频web插件,web前端在vue中通过海康插件嵌入视频,实现实时预览,vue3海康监控播放器组件,vue

✅ 精选推荐文章 1万+

S7-1200 PLC PID功能 | 调节PID参数

S7-1200 PLC PID功能 | 调节PID参数

Robot_PLC_HMI_Automation 1万+

Linux--线程同步

二、线程方式: 1.用户级线程: 2.内核线程: 3.组合线程: 三、线程在Linux中的实现 四、线程同步

m0_59052131的博客 1212

利用R语言通过holtwinters模型进行预测

使用Holt-Winters模型进行预测的第一步是对模型进行拟合。该函数需要两个参数:您的时间序列数据和一个“seasonal”参数,该参数指示您的数据的周期性(例如,如果您的数据按月记录,则应将“seasonal”参数设置为12,因为一年有12个月)。拟合模型后,您可以使用“forecast()”函数进行预测。该函数需要两个参数:您的拟合模型以及您要进行预测的时间段的长度。要使用Holt-Winters模型,您需要安装并加载“forecast”包。以下是进行Holt-Winters模型预测的示例代码。

Mrrunsen的博客 1696

[Linux]线程的互斥与同步

在多线程编程中,互斥与同步机制至关重要。互斥,是指同一时刻只允许一个线程访问共享资源,防止数据竞争。实现互斥常依赖互斥锁接口,通过加锁、解锁操作控制对资源的访问,其本质是线程获取和释放对共享资源的独占权。然而,若使用不当,可能产生死锁,即多个线程相互等待对方释放资源而陷入僵局。同步则着重于线程间的协作,按特定顺序访问资源。条件变量是同步的重要工具,通过其接口,线程可在条件满足时被唤醒,从而实现高效的线程协作,保障多线程程序的正确运行。

m0_74207969的博客 1578

廖雪峰Python教程[完整版]

廖雪峰的python教程整理的,已经排好版了,而且还是完整的,适合小白

win10 跳过U8环境检测的IE WebControls.rar

win10 安装u8的时候 提示IE WebControls 不通过,这个工具可以欺骗u8的检查。 跳过U8环境检测的IE WebControls。提示导入失败后,重新检测通过。

【Linux】Linux线程互斥与同步

本文介绍了线程互斥与同步,互斥锁,条件变量,可重入与线程安全

qq_67582098的博客 2037

STM32F407中文手册

STM32f407中文手册 本参考手册面向应用开发人员,提供有关使用 STM32F405xx/07xx、STM32F415xx/17xx、 STM32F42xxx 和 STM32F43xxx 微控制器存储器与外设的完整信息。STM32F405xx/07xx、STM32F415xx/17xx、STM32F42xxx 和 STM32F43xxx 构成一个微 控制器系列,各产品具有不同的存储器大小、封装和外设。有关订购信息以及器件的机械与电气特性,请参见数据手册。有关 ARM Cortex™-M4F 内核的信息,请参见《Cortex™-M4F 技术参考手册》。

Linux线程-互斥与同步

本章主要讲解学习Linux中对多线程的执行中的同步与互斥

~ 1624

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

【Linux】线程同步与互斥

互斥:用锁(互斥锁、读写锁)保证共享资源的原子访问,避免数据竞争;同步:用条件变量、信号量协调线程执行顺序,满足逻辑依赖;避坑重点:锁必须成对使用,条件变量需处理虚假唤醒,同步逻辑需避免死锁。

2401_86449430的博客 1万+

【Linux】线程的同步与互斥

本文深入探讨了线程互斥与同步的概念及其在多线程编程中的应用。首先,文章回顾了互斥的基本概念,包括临界资源、临界区、互斥和原子性,并通过抢票系统的代码示例展示了多线程环境下数据不一致的问题。接着,文章引入了互斥锁(Mutex)的概念,详细介绍了其接口、使用方法以及如何通过加锁机制解决数据竞争问题。此外,文章还探讨了条件变量(Cond)的使用,解释了如何通过条件变量实现线程的同步,避免线程饥饿现象。最后,文章介绍了生产者/消费者模型,并通过阻塞队列(BlockingQueue)的实现展示了如何在实际应用中结合互

2301_81577798的博客 1万+

Linux多线程——线程互斥与同步

摘要:多线程并发访问共享资源时存在数据竞争问题,需通过互斥锁和同步机制解决。 临界资源与竞争问题:多线程同时操作全局变量(如g_val--)时,由于CPU指令的非原子性,可能导致数据不一致。例如,线程切换时未完成的操作会使变量值异常。 互斥锁保护:通过pthread_mutex_t实现加锁(pthread_mutex_lock)和解锁(pthread_mutex_unlock),确保临界区代码的原子性。锁的粒度应尽量细以减少性能损失。 线程同步:条件变量(pthread_cond_t)解决线程间执行顺序问题

2401_83603768的博客 1455

Linux线程同步与互斥

上图中,线程1因为某些原因需要线程切换,进入等待队列。那么此时线程1需要把自己的上下文数据带走,其实就是把寄存器当中保存的值带走,并且没有对内存交换的0进行写回,也就是说此时内存中的mutex是0,那么线程2在交换mutex到寄存器当中,就会进行状态检测,此时检测到状态为0,说明当前已经有人占用锁了,则线程2进入到挂起状态,后来的线程依旧会如此,直到第一个线程执行完毕将锁释放。大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。

欢迎来到阿熊的blog~~ 1159

Linux之线程同步与互斥

Linux之线程同步与互斥

2302_81120572的博客 1584

【进程与线程】Linux 线程、同步以及互斥

线程是操作系统与多线程编程的基础知识。系统为每个用户进程创建一个来描述该进程:该结构体中包含了一个指针指向该进程的虚拟地址空间映射表:实际上和地址空间映射表一起用来表示一个进程。由于进程的地址空间是私有的,因此在进程间上下文切换时,系统开销比较大,因此,为了提高系统的性能,许多操作系统规范里引入了轻量级进程的概念,也被称为线程。在同一个进程中创建的线程共享该进程的地址空间,Linux里同样用来描述一个线程。线程和进程都参与统一的调度。接下来我们会详细的介绍线程共享资源和私有资源,还有线程库如。

猫猫的小茶馆 2240

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

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

北 海的博客 3039

Linux:线程的同步与互斥

之前获取锁的线程归还锁后,唤醒该挂起线程,该线程继续尝试获取锁。每个人来要要钥匙都需要经过管理员,管理员同意后把锁给最先来的人,最先来的人学完自己的任务后,想要继续获取钥匙,此时他就必须排到队伍最后等待再次轮到他时管理员给他发钥匙。它此时需要把锁释放。等到该线程再次被唤醒时,它才会继续去获取锁,如果调用的是唤醒所有等待线程的函数,该线程会与其他线程竞争锁,成功继续运行,失败则挂起。⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。

xiuwoaiailixiya的博客 1190

Linux线程同步与互斥:【线程互斥】【线程同步】【线程池】

生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区, 平衡了生产者和消费者的处理能力。因为我在当前的进程没有进行释放锁的操作,我是持有锁被切换的,相当于拿着钥匙,钥匙不给其他线程,其他线程也只能干等着,等我回来释放锁,其他线程才能展开锁的竞争,进入临界区。• 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完 成线程之间的交互。

2301_81699364的博客 1284

Linux : 线程【同步与互斥】

线程互斥和等待相关概念理解,以及接口的介绍

2301_82256131的博客 1028
上一篇: Linux线程(二)----- 线程控制
下一篇: Linux生产者消费者模式(cp)
chen_ever
博客等级 码龄4年 497粉丝 78原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值