操作系统:线程互斥|线程同步|锁的概念

目录

前言

1.线程互斥

1.1.互斥量|锁的使用

1.2.锁的本质

1.3.死锁

1.3.1.什么是死锁

1.3.2.死锁产生的4个必要条件

1.3.3.如何避免死锁

2.线程同步

2.1.知识引入

2.2.条件变量

2.2.1.为什么需要条件变量

2.2.2.条件变量接口


前言

进行这一章节的学习之前,我们需要回顾一下操作系统:进程间通信 | System V IPC-CSDN博客这篇博客的3.2.信号量部分

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

以上4个知识非常重要!!! 

1.线程互斥

线程互斥用于确保在任意时刻只有一个线程可以访问某个特定的资源或代码段。这种机制可以防止多个线程同时修改同一数据,从而避免数据不一致或数据损坏的问题。

接着我们通过一个例子,来体会一下多线程下数据不一致或数据损坏的问题

int ticket = 1000;
void *ThreadTicket(void *args)
{
    pthread_detach(pthread_self());
    // 给每个线程进行死循环抢票
    while (1)
    {
        const char *name = (const char *)args;
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s get a ticket: %d\n", name, ticket--);
        }
        else
            break;
    }
}
int main()
{
    pthread_t tid1;
    pthread_create(&tid1, nullptr, ThreadTicket, (void*)"Thread 1");
    pthread_t tid2;
    pthread_create(&tid2, nullptr, ThreadTicket, (void*)"Thread 2");
    pthread_t tid3;
    pthread_create(&tid3, nullptr, ThreadTicket, (void*)"Thread 3");

    while (1)
    {
        if (ticket < 0)
        {
            cout << "主线程结束抢票" << endl;
            sleep(5);
            break;
        }
    }
}

这段代码我们希望实现4个线程同时抢票,所以我们定义了一块共享资源ticket,并且4个线程同时对ticket进行减一……

然而我们实际运行程序时会发现,ticket输出不合理为负数,也就是数据出现了损害!那么为什么会出现这种情况呢?

  • 简单来说:CPU调度最后一次时,可能该时间片结束,需要等待下次调度来完成减减,然而CPU又调度了其他线程,那么这里就会出现两次或多次减减……
  • 根本原因:首先if语句判断为真的同时,线程可以并发切换到其他线程。其次我们通过unsleep模拟抢票登记信息,在这段时间内也会有很多线程被CPU调度。最后ticket减减并不是原子性的操作,在中间过程可能会出现线程切换!

 如何理解ticket减减不是原子的呢?首先我们知道原子的,即为完成或者失败!在CPU调度过程中,需要将共享变量ticket从内存加载到寄存器中,接着更新寄存器里面的值,执行-1操作,最后将新值从寄存器写回共享变量ticket的内存地址。也就是我们语言层面的一条语句,在底层需要3个动作实现,而每个动作是原子的,结合起来ticket减减不是原子的(6种情况)。

因为我们无法实现原子性,所以我们需要控制线程需要有互斥行为,当代码进入临界区执行时,不允许其他线程进入该临界区。一般情况下,我们对临界区进行加锁来实现互斥!

1.1.互斥量|锁的使用

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

在上面的讲解中,我们得出:多个线程并发的操作共享变量,会带来一些问题。因此我们需要实现某一线程访问并修改共享资源时,需要对其他线程进行限制,加锁保护当前线程,实现合理修改。

那么我们如何使用互斥量,即如何在多线程并发场景进行加锁么?

// 定义全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

首先我们需要定义一个锁,可以是局部的和全局的,这里我们先介绍全局锁,定义完锁之后,对锁的基本操作有:上锁和解锁

// 上锁
pthread_mutex_lock(&mutex);
// 解锁
pthread_mutex_unlock(&mutex);

 当我们学会了上锁和解锁,我们就需要探究在哪里加锁和解锁!我们知道互斥量是为了避免“多个线程并发操作临界资源”而存在的,所以我们加锁解锁是需要在临界区中的!以我们抢票的例子:

int ticket = 1000;
// 定义全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 全局使用锁
void *ThreadTicketGlobal(void *args)
{
    pthread_detach(pthread_self());
    // 给每个线程进行死循环抢票
    while (1)
    {
        // 进行加锁
        pthread_mutex_lock(&mutex);

        const char *name = (const char *)args;

        if (ticket > 0)
        {
            usleep(1000);
            printf("%s get a ticket: %d\n", name, --ticket);
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            // 在break前需要解锁,因为会直接跳出该模块
            pthread_mutex_unlock(&mutex);    
            break;
        }
    }
}

所以我们加锁的位置是在if判断之前,因为if区域是我们的临界区,不允许多线程同时访问,需要保证其他线程无法访问。解锁时,就在函数模块结束时进行解锁。这样我们就实现了锁的使用。

当我们在bash中运行可以发现:数据不会出现负数的情况,程序的运行速度变慢,这是因为加锁的本质就是将临界区的访问由并行设置为串行。


另外需要注意的点有:

  • 每个线程访问临界资源时,一定需要加锁,将并行转化为串行

  • 任何时刻只允许一线程成功申请锁!多个线程申请锁失败,需要在mutex中进行等待!

  • 加锁后,线程也是可以进行线程切换的,加锁保证其他线程在切换时无法访问临界区。

然而当我们运行时,可能也会出现:某个线程为抢票的“主体” ,即多线程运行下,会出现个别线程抢夺锁的能力过强,导致其他线程无法获得锁资源(因为锁本身也是一种共享资源),出现“线程饥饿”问题,为了解决饥饿问题,我们需要让线程运行具有一定顺序,即实现线程同步。


ps:我们如何进行临界锁的使用

int ticket = 1000;

template <class T>
class ThreadObject
{
public:
    ThreadObject(const T &data, pthread_mutex_t *lock)
        : _data(data), _mutex_lock(lock)
    {
    }

    T GetData() const { return _data; }
    pthread_mutex_t *GetLock() const { return _mutex_lock; }

private:
    T _data;
    // 内部定义一个锁
    pthread_mutex_t *_mutex_lock;
};

// 局部使用锁
void *ThreadTicketPart(void *args)
{
    ThreadObject<string> *td = (ThreadObject<string> *)args;
    // 获取对象内部的锁
    pthread_mutex_t *obj_lock = td->GetLock();
    // 获取对象的name
    const char *name = td->GetData().c_str();

    pthread_detach(pthread_self());
    // 给每个线程进行死循环抢票
    while (1)
    {
        // 每个线程访问临界资源时,一定需要加锁,将并行转化为串行
        // 任何时刻只允许一线程成功申请锁!多个线程申请锁失败,需要在mutex中进行等待!
        pthread_mutex_lock(obj_lock);

        // 当加锁后,线程也是可以进行线程切换的,
        // 而切换时加锁可以保证其他线程无法访问。

        if (ticket > 0)
        {
            usleep(1000);
            printf("%s get a ticket: %d\n", name, --ticket);
            pthread_mutex_unlock(obj_lock);
        }
        else
        {
            pthread_mutex_unlock(obj_lock);
            break;
        }
    }
}

// 锁的局部使用|传入对象参数
int main()
{
    pthread_mutex_t mutex_lock;
    // 对锁初始化
    pthread_mutex_init(&mutex_lock, nullptr);
    
    // 给这些线程数据传入同一个锁
    ThreadObject<string> *td1 = new ThreadObject<string>("Thread 1", &mutex_lock);
    ThreadObject<string> *td2 = new ThreadObject<string>("Thread 2", &mutex_lock);
    ThreadObject<string> *td3 = new ThreadObject<string>("Thread 3", &mutex_lock);
    ThreadObject<string> *td4 = new ThreadObject<string>("Thread 4", &mutex_lock);

    pthread_t tid1;
    pthread_create(&tid1, nullptr, ThreadTicketPart, (void *)td1);
    pthread_t tid2;
    pthread_create(&tid2, nullptr, ThreadTicketPart, (void *)td2);
    pthread_t tid3;
    pthread_create(&tid3, nullptr, ThreadTicketPart, (void *)td3);
    pthread_t tid4;
    pthread_create(&tid4, nullptr, ThreadTicketPart, (void *)td4);

    while (1)
    {
        if (ticket <= 0)
        {
            cout << "主线程结束抢票" << endl;
            sleep(5);
            cout << "ticket num = " << ticket << endl;
            break;
        }
    }
    // 销毁这个锁
    pthread_mutex_destroy(&mutex_lock);
}

和全局使用锁不同,局部锁,需要主线程在函数块中创建、初始化、销毁,在使用时因为上锁的区域为新线程的函数块,所以需要传入锁的地址,这里我们通过ThreadObject间接传入,不过这里需要注意需传入同一把锁!!! 

 讲到这里我们大致学会了锁的使用,我们也可以再创建一个新的对象,作为ThreadObject中的data,这样子就能够继续封装!另外我们也可以实现对锁的封装,实现单次循环内的自动化的加锁、解锁!这里大家可以自己实现一下!

1.2.锁的本质

我们在上面提及了“锁本身就是一个共享资源”,那为什么锁可以作为另一个共享资源的保护?答案是:锁的实现是原子的!那锁的本质是什么,锁是如何实现的呢?

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一 个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

而我们程序的语句:例如if判断、i++都不是原子的,需要从内存转到CPU,再到内存中……,所以我们实现原子性就只需要把多条指令转变为一条指令,这样就是原子的!而锁的实现就是通过xchgb这个指令来完成的……

如图为加锁的简要原理图:

  1. 首先我们先看一下这段伪代码,进入CPU中的al寄存器,初始化值为0,在外部设置一个mutex默认值为1
  2. 当线程申请锁时,一定会进入lock的代码段
  3. 当第一个线程进行调度时,交换该线程中保存的上下文数据中的al和mutex,这样我们就把1,转移到了该线程的上下文数据中,并且能够进行返回,而mutex的值变为0。
  4. 当我们调度第二个线程时,它保存al的值为0,而mutex此时的值也为0,交换后al仍为0,所以无法返回持续等待。 

因为每一个线程都拥有一份自己的CPU上下文数据,而第一个调度的线程把mutex的1给保存在自己的上下文数据中,导致其他线程无法进入 al>0 这个判断体中,那么如果在共享资源访问的角度,是不是我们就实现了只让单个线程进行资源的访问了呢?


解锁示意图:

解锁的本质就是:将mutex的值设置为1,表示其他线程可以申请锁。

这里我们可能会考虑那之前的线程中申请的1怎么办?本质上这个问题的产生就是错误的,当我们申请到这个锁后,这个线程就退出了,而执行临界区的代码不是申请锁的线程!当我们临界区的代码结束后,我们会主动的解锁,就是运行unlock模块,将1给回mutex…… 


 加锁和解锁的实现,本质上是为了使得锁这一个可以被所有线程访问的共享资源获得保护!!!并且加锁和解锁需要满足:谁加锁,就需要谁解锁,不建议用不同的线程来加锁和解锁

1.3.死锁

1.3.1.什么是死锁

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

简单来说:死锁就是程序员写的一个bug,在这个bug中线程、线程因为需要等待锁资源锁出现了互相等待资源的情况。

int ticket = 1000;
// 定义全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 全局使用锁
void *ThreadTicketGlobal(void *args)
{
    pthread_detach(pthread_self());
    // 给每个线程进行死循环抢票
    while (1)
    {
        // 进行加锁
        pthread_mutex_lock(&mutex);

        const char *name = (const char *)args;

        if (ticket > 0)
        {
            usleep(1000);
            printf("%s get a ticket: %d\n", name, --ticket);
        }
        else
        {
            // 不进行解锁
            break;
        }
    }
}

这里我们以:全局锁为例,在加锁后我们不进行解锁, 最终程序会卡住,处于永久的等待状态,这里也体现出:单个锁也可能出现死锁问题

1.3.2.死锁产生的4个必要条件

四个必要条件是什么意思呢?也就是产生死锁需要以下4个条件同时满足! 

互斥条件:一个资源每次只能被一个执行流使用。

换句话说:死锁的本质就是我们需要通过多线程来安全地访问共享资源,就是需要通过加锁来访问临界区,那么死锁的条件是我们用了锁。


请求与保持条件:一个执行流因请求资源而阻塞时,不释放已获得的资源。

如上图:A、B线程均申请另一个线程锁的资源,而这两个线程均不释放当前的锁,导致A在请求锁2并保持锁1,B在请求锁1并保持锁2。


不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺

如上图:A、B线程自身的锁并不会被另一个线程给抢夺


循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

因为这两个线程都在互相等待对方资源的释放,而自身的资源不释放,出现互相等待的现象,先入了头尾相接的循环!


1.3.3.如何避免死锁

核心:破坏死锁产生的必要条件之一即可

破坏互斥条件:非必要不使用锁,来避免死锁。

破坏请求与保持条件:当我们申请到锁资源后,再次申请锁失败后,释放到我们以往申请到的锁。并且让所有线程支持这个原则

破坏不剥夺条件:抢夺被其他线程申请到的当前线程需要的锁。即我们可以通过线程通信,修改另一个线程的某个值然后进入if判断,进行锁的释放来实现?

破坏循环等待条件:尽量按照同样的顺序来申请锁,比如A、B线程均按照申请锁1、2这个顺序来进行锁的申请


2.线程同步

2.1.知识引入

在1.1.中我们提及了线程饥饿问题和线程同步,实际场景中可能会出现个别线程不断申请、释放锁,并且总是申请、释放成功,但是他不对资源进行访问。因此为了解决这种情况,我们需要维护一个队列来进行线程对锁的申请,当某个线程释放锁时,插入到队尾等待后续锁的申请。

线程同步:在临界资源安全的前提下,让多线程执行具有一定的顺序性!实现高效、合理的使用共享资源!

2.2.条件变量

2.2.1.为什么需要条件变量

是利用线程间共享的变量进行同步的一种机制,主要用于多线程程序中实现“等待-唤醒”逻辑。当某个条件不满足时,线程会将自己加入等待队列,并释放持有的互斥锁;当其他线程满足了该条件时,会调用条件变量的通知(notify)或广播(broadcast)函数来唤醒一个或多个等待中的线程。

为了解决线程B无法确定共享资源内线程A是否完成写入而不断地加锁、解锁访问出现的资源浪费问题,于是pthread库实现了条件变量,作为线程A完成写入后,和线程B的通知方式!


首先我们再次借助抢票的代码,来模拟一下不断加锁、解锁访问造成的锁资源浪费!

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int ticket = 1000;
void *ThreadFunc(void *args)
{
    string name = (const char *)args;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0)
        {
            usleep(1000);
            cout << "i am " << name.c_str() << ", ticket num = " << --ticket << endl;
        }
        else
        {
            cout << "票卖完了,正在浪费锁资源" << endl;
            sleep(1);
        }

        pthread_mutex_unlock(&mutex);
    }
}

int main()
{
    // 主线程

    pthread_t tid1, tid2, tid3;

    pthread_create(&tid1, nullptr, ThreadFunc, (void *)"Thread one");
    pthread_create(&tid2, nullptr, ThreadFunc, (void *)"Thread two");
    pthread_create(&tid3, nullptr, ThreadFunc, (void *)"Thread three");

    while (1)
    {
        sleep(1);
    }

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
}

首先,我们先明确一个问题:在需要加锁进行访问临界区的情况下,线程知不知道ticket的值为多少?显然是无法知道的,因为需要加锁访问,那么当加锁访问后解锁出去,其他线程是否知道现在的ticket的值为多少?还是无法得知,所以当我们实际运行时会出现如图的现象,也就是线程需要不断的加锁、解锁来访问才能得知结果。

类比生活就是你微信登了一下,你想知道是不是你crush给你发消息,那么你需要亮屏查看,发现不是然后息屏,结果又登了一下,你又要继续……


接着我们引入一下条件变量,观察代码运行的结果!

// 定义全局变量 cond
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int ticket = 1000;

void *ThreadFuncCond(void *args)
{
    string name = (const char *)args;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0)
        {
            usleep(1000);
            cout << "i am " << name.c_str() << ", ticket num = " << --ticket << endl;
        }
        else
        {
            cout << name.c_str()<<" say: 票卖完了,正在浪费锁资源" << endl;
            
            // 当票卖完时,我们需要阻塞锁资源的浪费
            pthread_cond_wait(&cond, &mutex);   
        }

        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
    // 主线程

    pthread_t tid1, tid2, tid3;

    pthread_create(&tid1, nullptr, ThreadFuncCond, (void *)"Thread one");
    pthread_create(&tid2, nullptr, ThreadFuncCond, (void *)"Thread two");
    pthread_create(&tid3, nullptr, ThreadFuncCond, (void *)"Thread three");

    sleep(5);
    // 实现条件队列下进行排队
    while (1)
    {
        // 1.首先什么操作都不进行,运行程序观察现象    

        // 2.然后测试这部分代码
        
        // 释放信号唤醒一个处于条件等待下的线程
        // pthread_cond_signal(&cond);
        // sleep(1);

       
        // 3.最后再测试这部分代码
        
        // sleep(10);
        // // 实现每隔10s放出一批票,需要加锁
        // pthread_mutex_lock(&mutex);
        // ticket+=100;
        // pthread_mutex_unlock(&mutex);
        // // 接着发出信号,解除等待条件的阻塞
        // pthread_cond_broadcast(&cond);
        
    }
}
  1. 首先在新线程模块中,我们增加了pthread_cond_wait函数,实现了当某个线程运行到该行语句时,将这个线程阻塞,等待条件变量的唤醒。
  2. 另外,我们在main函数区的while循环处,分别注释了三块代码,我们可以分成三种情况来分别运行这三个代码块。

当我们体会了这三个代码块的结果,我们发现条件变量的阻塞和唤醒可以解决我们对锁资源的浪费问题,然而大家可能会对结果产生疑问,学习完接下来的模块,疑问就会解决了,请不要停下! 

2.2.2.条件变量接口

条件变量定义、初始化、销毁接口:

//  创建局部的条件变量
pthread_cond_t cond;
// 初始化局部的条件变量
pthread_cond_init(pthread_cond_t *restrict cond,
                  const pthread_condattr_t *restrict attr);
// 销毁局部的条件变量
pthread_cond_destroy(pthread_cond_t *cond)

// ----------------------------分割线----------------------------

// 创建全局的条件变量
pthread_cond_t cond_global = PTHREAD_COND_INITIALIZER;

 我们发现这些接口跟pthread_mutex的接口几乎一致!这也表明了他们之间存在关系


条件变量进行阻塞等待:

// 实现某一个线程进行阻塞等待条件唤醒
pthread_cond_wait(pthread_cond_t *restrict cond,    // 等待的条件变量
                  pthread_mutex_t *restrict mutex); // 该临界区的锁

我们在结合引入条件变量后的抢票代码时,我们发现pthread_cond_wait函数进行阻塞等待条件变量唤醒时,是处于加锁和解锁之间,也就是等待期间并没有主动进行解锁。

这样就产生了一个问题:一共就一把锁,而这把锁我已经用来阻塞等待了,那么其他线程就无法在我等待后,进入临界区了。

但是当我们运行代码观察实验现象的过程中,发现并不是这样的,其他线程也可以进入临界区,不过在1.模块中,只要进入临界区,就直接永久阻塞了。

 并且,当我们通过模块3.,我们在这三个线程阻塞时,通过主线程也对这个锁进行了加锁解锁,所以侧面应证了,pthread_cond_wait并没有带着锁进行等待,而是将锁释放了!

  1. 当我们调用pthread_cond_wait函数时,会将当前临界区的锁给释放。
  2. 并且从等待区被唤醒时,是在临界区被唤醒,这时会获得锁。
  3. 被唤醒后,也需要参与锁的竞争。

那么之前代码的实验现象也就不奇怪了!


唤醒等待的接口:

注意这里操作的对象,是调用了pthread_cond_wait而进入阻塞等待的线程

// 唤醒所有处于等待的线程
int pthread_cond_broadcast(pthread_cond_t *cond); 
// 唤醒单个处于等待的线程
int pthread_cond_signal(pthread_cond_t *cond);

还记得我们在之前提及过:在申请锁资源时,可能会有部分线程抢占锁资源的能力较强,导致多线程并发时,主要为这些线程进行执行,产生其他线程饥饿问题。

那么我们就可以通过在子线程调度的函数区,不断的通过pthread_cond_wait将线程阻塞等待,然后在主线程区循环通过pthread_cond_signal,进行等待的解除,这样就能实现线程一个一个不断的进行调度,从而解决线程饥饿问题。

  • 27
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值