【Linux】线程同步

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


一、同步的相关概念

  • 同步:在保证数据安全的前提下,即同步必须要配合互斥锁来使用,然后让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题
  • 饥饿问题:执行流被永久地阻塞,无法继续执行下去,尽管它们可能处于一个可执行状态

为了加深概念的理解,举一个生活上的例子:

  • 有一个无人监管VVIP自习室,只能有一个人在里面学习。所以,为了保证任何时刻只能有一个人进来,大家都要遵守一个规则:门口有一把锁,谁获得这个锁资源,谁就有资格在里面学习。
  • 有一天你起的很早,当你来到自习室门口发现门旁有锁,于是你就把锁带进去了。当下一个同学来的时候,发现门旁没有锁,就只能在外面等待。突然某一个时刻,你饿了,想去食堂干饭。你刚刚出门准备把锁放回门旁,发现门外50m有一堆同学正在等待(竞争),为了能独享这个自习室,你就赶快拿回锁回到自习室了,然后在自习室从早到晚。 那么自习室外的这批人因为长时间得不到锁资源,导致了饥饿问题。

因此,在多线程环境中,如果某个线程长时间占用了资源,其他线程可能因为无法获得资源而长时间等待,甚至导致饥饿问题

这种情况下,为了公平和效率,需要考虑资源分配的策略。因此,学校再次规定:

  • 自习室外面的同学(线程)必须排队
  • 出来的人不能立马重新申请锁资源,必须排到队列的尾部

所以解决饥饿问题的关键是:在安全的规则下,让所有同学(线程)获取锁资源按照一定的顺序性,我们称为同步即解决饥饿问题的方法是线程同步

在这里插入图片描述

二、同步的相关操作

2.1 条件变量

线程互斥章节,我们使用了互斥锁确保了对共享资源的安全访问,即保证了数据一致性。但是并不能保证线程并发访问的顺序。这是因为线程竞争锁资源时,谁先获得锁是不确定的,取决于操作系统的调度和线程的执行情况。

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

using namespace std;

const int NUM = 5; // 线程个数
int count = 0;     // 临界资源

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 创建锁资源

void *thread_task(void *args)
{
    // 线程分离:让操作系统回收线程的资源
    pthread_detach(pthread_self());
    // 我的Linux机器默认是64位,指针是8字节,因此我使用8字节的long long
    // 而不是用int是因为会发生截断。
    long long number = (long long)args; // 线程编号1~5
    cout << "pthread" << number << "创建成功..." << endl;
    while (true)
    {
        // 加锁
        pthread_mutex_lock(&lock);
        cout << "pthread" << number << ": count = " << ++count << endl;
        // 解锁
        pthread_mutex_unlock(&lock);
    }
}

int main()
{
    for (long long i = 1; i <= NUM; i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, thread_task, (void *)i);
        // 通过适当的休眠,可以减少线程之间的竞争条件,
        // 即减少因多个线程同时被创建而可能导致的竞争和不确定性。
        sleep(1);
    }
    sleep(3); // 让所有线程都去等待队列中等待
    cout << "所有线程全部入队列" << endl;

    // 主线程不要退出,不然该进程下的所有线程都退出了
    while (true)
    {
        sleep(1);
    }

    return 0;
}

【程序结果】

在这里插入图片描述

如上,一直是pthread1使用临界资源,导致其它线程出现饥饿问题。因此,Linux中的原生线程库pthread中提供了条件变量来实现线程同步。即解决了饥饿问题,也保证了线程依次按顺序获取锁资源。

  • 条件变量:是一种用于等待的同步机制,可以实现线程间通信,它必须与互斥锁配合使用,等待某个条件的发生,然后线程才能继续执行。它通常由两部分组成:
    • 线程等待队列:条件变量包含一个等待队列,用于存放因资源不就绪而被阻塞的线程。

    • 通知机制:条件变量还包含一个用于表示资源是否就绪的状态。当条件就绪时,等待队列的线程可以被唤醒并继续执行。

    • 说明:条件变量也是要被线程库管理起来的。因为多个线程都可以通过线程库来申请条件变量,当然还有锁资源。诸如线程未来要去哪个等待队列中排队?这总得区分吧。因此,未来只要需要创建多个的东西,必定是需要进行管理的,即先描述,再组织。一般通过结构体来描述struct,所以条件变量也必定是一个结构体(包括锁资源)。

具体而言,所有线程想进入临界区访问临界资源时,都会先尝试获取互斥锁。如果临界资源未就绪,那么该线程会将自己放入条件变量的等待队列的队尾中。一旦线程在等待队列中等待时条件变量的条件得到满足,即临界资源就绪,那么该线程就会被唤醒继续执行。

2.2 条件变量初始化

作为出自原生线程库的条件变量,使用接口与互斥锁风格差不多,比如互斥锁的数据类型为pthread_mutex_t,而条件变量的类型数据类型为pthread_cond_t,这个两个数据类型都是库为用户提供的,直接使用就行

条件变量初始化也有两种方法:

  • 方法一:静态分配

直接定义在全局,并且可以直接通过初始化变量来完成。它是通过使用PTHREAD_COND_INITIALIZER宏来静态初始化一个条件变量对象

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

注意:对于静态分配的条件变量对象,是不需要显式调用pthread_cond_destroy函数来销毁它。其静态分配的对象会在程序结束时自动释放其资源,因为它们的生命周期与程序的生命周期相同

  • 方法二:动态分配。需要通过pthread_cond_init函数来进行初始化
#include <pthread.h>

// 定义一个条件变量
pthread_cond_t cond;
// 初始化 
int pthread_cond_init(pthread_cond_t *restrict cond
					, const pthread_condattr_t *restrict attr);

说明:

  • cond指向要初始化的条件变量的指针。在调用 pthread_cond_init 前,必须确保 cond 是一个未初始化的条件变量

  • attr:指向条件变量属性的指针。直接传入nullptr即可,表示使用默认属性

  • 返回值:成功返回0;失败返回非零错误码,具体的错误码可以用 errno 来获取具体的错误信息

2.3 销毁条件变量

#include <pthread.h>

int pthread_cond_destroy(pthread_cond_t *cond);

说明:

  • cond是一个指向条件变量的指针,指向要销毁的条件变量对象
  • 返回值:销毁成功返回0,失败返回非0的错误码。具体的错误码可以用errno来获取具体的错误信息

注意:条件变量在销毁前,必须确保不再被任何线程使用,否则会导致未定义的行为

2.4 条件等待

当线程访问某种资源,如果发现该资源没有就绪,就要让该线程在等待队列中排队,等待资源就绪

#include <pthread.h>

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

说明:

  • cond:这是一个指向条件变量的指针。条件变量用于线程之间的同步,允许一个或多个线程等待某个条件成立而被阻塞

  • mutex:这是一个指向互斥锁的指针。主要是确保了每个线程看到的是同一个互斥锁资源。如果不是同一个互斥锁,不同线程可能会用不同的锁来保护共享资源,这样就无法确保线程间的互斥访问,会导致数据竞争和不确定行为。而同一把互斥锁确保了所有线程在访问共享资源时的互斥性,从而避免了并发访问问题。

  • 返回值:销毁成功返回0,失败返回非0的错误码。具体的错误码可以用errno来获取具体的错误信息

这个函数的怎么用是有讲究的

我们必须要想明白以下问题:

  • 为什么要使用条件等待 — 不就是临界资源不就绪。没错,临界资源也要状态。
  • 我们怎么知道临界资源是就绪还是不就绪?一定是程序员判断出来的。而判断也是变相访问临界资源,也就是判断必须在加锁和解锁之间,即条件等待要在加锁和解锁之间使用。

因此,每一个线程都需要先尝试获取锁资源之后,即先调用函数pthread_mutex_lock让每个线程获取锁资源,再通过条件变量判断条件是否满足当条件不满足时,该线程就要在线程等待队列中排队,直到满足条件执行临界区代码

那有的人就有疑问了,条件变量资源也是在临界区的呀,如果线程获取锁资源之后,再通过条件变量判断是不满足条件,那么该线程就会带着锁资源在等待队列中等待,那其他线程就没有机会持有锁,所以整个进程就被阻塞住了,这不就是占着茅坑不拉屎嘛 ~

因此,为了避免死锁问题,条件变量是具有自动释放锁的能力。这也就是为什么该函数的第二个参数要传互斥锁地址的原因了。

【总结】

  • 当调用pthread_cond_wait时,首先会通过条件变量来判断该线程是否满足执行临界区代码的条件。如果不满足,线程则会自动释放由mutex指向的互斥锁,这是pthread_cond_wait函数内部的一部分操作。
  • 然后线程将进入线程等待队列中排队并等待条件的变化。(线程等待队列:通常是由操作系统内核管理的)
  • 在等待队列中,线程将处于阻塞状态,不会消耗CPU时间,直到另一个线程通过 pthread_cond_signalpthread_cond_broadcast唤醒它,当调用这两个函数其中一个时,通常是因为线程满足条件了,即线程将重新尝试获取之前释放的互斥锁mutex,并继续执行临界区代码。

2.5 唤醒线程

条件变量中的线程是需要被唤醒的,否则线程等待队列也不知道何时可以执行临界区的代码了

唤醒线程有两种方式

  1. pthread_cond_signal函数:只会唤醒一个正在等待在线程等待队列的线程,通常是最先进入等待队列的线程,也就是队头线程
#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);
  1. pthread_cond_broadcast函数: 用于唤醒所有等待在等待队列上的线程,挨个通知该队列中的所有线程访问临界资源。因此称为广播
#include <pthread.h>

int pthread_cond_broadcast(pthread_cond_t *cond);

说明:

  • 参数:表示想要从哪个条件变量中唤醒线程
  • 返回值:销毁成功返回0,失败返回非0的错误码。具体的错误码可以用errno来获取具体的错误信息
  • 即使没有线程在等待队列上,调用以上函数没有任何作用

2.6 简单使用线程同步相关接口

创建5个线程,然后再创建一个初始值为0的全局变量count,每一个线程都要并发对该资源做++操作。

要求:5个线程必须要按顺序执行。

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

using namespace std;

const int NUM = 5; // 线程个数
int count = 0;     // 临界资源

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 创建锁资源
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;   // 创建条件变量

void *thread_task(void *args)
{
    // 线程分离:让操作系统回收线程的资源
    pthread_detach(pthread_self());
    // 我的Linux机器默认是64位,指针是8字节,因此我使用8字节的long long
    // 而不是用int是因为会发生截断。
    long long number = (long long)args; // 线程编号1~5
    cout << "pthread" << number << "创建成功..." << endl;
    while (true)
    {
        // 加锁
        pthread_mutex_lock(&lock);
        // 条件等待
        pthread_cond_wait(&cond, &lock);
        cout << "pthread" << number << ": count = " << ++count << endl;
        // 解锁
        pthread_mutex_unlock(&lock);
    }
}

int main()
{
    for (long long i = 1; i <= NUM; i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, thread_task, (void *)i);
        // 通过适当的休眠,可以减少线程之间的竞争条件
        // 即减少因多个线程同时被创建而可能导致的竞争和不确定性。
        sleep(1);
    }
    sleep(3); // 让所有线程都去等待队列中等待
    cout << "所有线程全部入队列" << endl;

    // 主线程负责唤醒线程
    while (true)
    {
        pthread_cond_signal(&cond); // 唤醒等待队列第一个线程
        cout << "主线程唤醒了一个线程..." << endl;
        sleep(1);
    }

    return 0;
}

【运行结果】

在这里插入图片描述

如上,认真看你会发现线程1~5运行是井然有序的,像是在排队依次执行一样,这就是同步!

另外,我们可以将唤醒方式换成广播

在这里插入图片描述

【程序结果】

在这里插入图片描述

在广播之下,仍然有序~

三、代码

本篇博客的相关代码:点击跳转

  • 15
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值