Linux系统编程——线程同步互斥与线程安全

目录

一,线程互斥

1.1 互斥背景概念

1.2 模拟多线程抢票

1.3 锁

1.4 关于加锁的一些问题

1.5 锁的大致实现原理

1.6 锁的封装

二,线程安全

2.1 重入与线程安全

2.2 常见的线程不安全情况

2.3常见的线程安全的情况

2.4 常见的不可重入的情况

2.5 常见的可重入的情况

2.6 可重入VS线程安全

2.7 可重入与线程安全区别

三,死锁

3.1 死锁概念

3.2 死锁的四个必要条件

3.3 避免死锁

四,线程同步

4.1 场景

4.2 条件变量概念

4.3 条件变量函数

4.4 线程控制,锁与条件变量的综合运用


一,线程互斥

1.1 互斥背景概念

  • 临界资源:多线程多执行流共享的,也就是可以同时访问的资源(理解为内存),叫做临界资源。
  • 临界区:每个线程内部,访问临界区的那一个代码块,就叫做临界区。
  • 互斥:是一种保护机制,表示任何时候,保证有且只有一个执行流能进入临界区去访问临界资源,这算是最临界资源的一种“保护”,这种保护就被称为“互斥”。
  • 原子性:表示对于一个程序,只关心“完成”和“未完成”两种最终状态,不关心中间过程,只在乎结果的一种特性,并且该特性不会被任何调度机制影响

1.2 模拟多线程抢票

下面我们来通过具体的例子谈谈为什么要有“互斥”这个东东,我们模拟一个抢票系统,定义一个全局变量表示票数,然后我们创建多个线程,每个线程都执行一个函数,这个函数其它的什么都不做,只对全局变量进行 -- 操作,抢完票后多线程自动退出,如下代码和演示:

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

using namespace std;

int tickets = 5000; // 如果多线程访问一个全局变量,并对它进行数据计算,多线程会相互影响码?
// 并发访问时,因为时序问题导致数据不一致的问题

void *getTickets(void *args)
{
    (void)args;
    while (true)
    {
        if (tickets > 0)
        {
            usleep(2500);                               // 加上sleep,使切换的概率增加,方便测试观察,增加多线程的并发度和切换概率
            printf("%p:%d\n", pthread_self(), tickets); // 会打印-1
            tickets--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    pthread_t t1, t2, t3;
    // 多线程抢票
    pthread_create(&t1, nullptr, getTickets, nullptr);
    pthread_create(&t2, nullptr, getTickets, nullptr);
    pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    return 0;
}

 

打印有两部分,前面是线程的地址,后面就是全局变量tickets的票数,是临界资源,因为它同时被多个执行流访问,但是我们的if判断条件明明是(tickets > 0),最后出现了负数,原因有下面几点:

  • if判断条件为真后,代码可以并发切换到其它线程的执行流。
  • usleep模拟漫长的处理业务,提高线程切换的概念,便于观察。
  •  tickets-- 本身不是原子操作。

 在汇编角度我们有一个共识:如果只有一条汇编语句,我们可以认为该汇编语句是原子的

而对于tickets--操作,我们认为不是原子的,因为减减操作转化为汇编后有三条语句,如下图:

这三条指令的操作分别是:

  1. load:先将要操作的变量加载到CPU的寄存器中,
  2. update:更新寄存器里的值,执行减减操作
  3. store:将寄存器更新好的变量重新写回地址空间覆盖原先的值,完成减减操作

 如下图:

  1. 假设现在有两个线程A和B,A线程对tickets全局变量 -=100,B线程进行 -=200 操作
  2. 以上面的图为例,假设线程A刚刚执行“步骤1”,就是把内存中的“5000”加载到寄存器里的,就被切换走了;而对于把数据加载到寄存器里,含义是:把数据读到当前执行流的上下文当中,当执行流切换时,上下文数据是要保存出去的,这个过程是拷贝,不是移动,如下图:
  3. 然后线程B被调度了,开始执行,线程B正常执行完毕,对tickets全局变量 -=200,此时内存中的值变为4800,如下图:
  4. 然后,再重新把线程A拿上来,恢复的本质就是继续执行线程A的代码,此时寄存器的值被重新覆盖成5000,继续执行线程A的 -=100 的操作,最后算出结果为4900,然后再将4900写回内存,问题由此产生:

所以对一个变量进行 -- 操作不是原子的,同样的, ++ 操作也不是原子的,同样的,if(tickets > 0)判断的时候,判断操作本质也是计算操作,所以也不是原子的

  • 大部分情况,线程使用的数据都是局部变量,这样局部变量归属单个线程,其它线程无法访问
  • 但是同时,很多变量需要在线程间共享以达到线程之间的交互,这样的变量就会称为共享变量
  • 多个线程并发访问操作共享变量,就会带来“数据不一致”等等问题

所以为了解决上面的抢票出现负数的问题,Linux提供了一种解决方案:锁 

1.3 锁

锁,也称“互斥量”,作用是:多个线程执行流,对共享数据的任何访问,保证任何时刻只有一个执行流能通过临界区访问临界资源。

介绍完了锁的大致作用,下面来看下锁的接口以及使用,如下图:

 

  • 先介绍下锁的初始化函数pthread_mutex_init ,第一个参数mutex表示需要进行初始化的锁,第二个参数attr表示初始化锁的属性,这个我们一般设置为nullptr即可。
  • 锁初始化后,就要使用了,加锁的函数为pthread_mutex_lock,对应着上面的图中的lock操作,保证多个执行流中,任何时刻只有一个执行流能够越过该函数往后执行,其它的直接阻塞在这里等待,当持有锁的线程释放锁后,阻塞在这个函数的线程会相互竞争锁
  • 有加锁就有解锁:解锁函数为pthread_mutex_unlock,参数就是我们用的锁,注意:加锁一定要配合解锁使用,并且解锁必须要在加锁后面
  • 有创建,就会有销毁,锁的销毁函数为pthread_mutex_destroy,销毁时,注意:不要销毁一个已经加锁的锁,就是销毁步骤一定要在解锁之后,并且要保证后面不会再有加锁步骤
  • 最后就是上面图片的红框框里的,pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER,这是另一种初始化锁的方式,一般定义在全局变量中,代替了init和destroy,会自动完成锁的初始化和销毁。注意:这种初始化方式,和使用init和destroy初始化,两种方式只能用一种方式来初始化和销毁

 有了锁的函数的认识,我们可以来对前面的抢票系统的问题进行解决了,每一个线程进入临界区之前必须申请锁,只用申请到锁的线程才可以进入临界区对临界资源进行访问,并且出临界区时要解锁,这样才能让其余要进入临界区的线程继续竞争锁,如下代码:

#include <iostream>
#include <pthread.h> //原生线程库
#include <unistd.h>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <time.h>
#include <assert.h>

using namespace std;

#define THREAD_NUM 5 // 不一定线程越多,速度越快
class ThreadData
{
public:
    ThreadData(const std::string &n, pthread_mutex_t *p)
        : tname(n), pmtx(p)
    {
    }

public:
    std::string tname;
    pthread_mutex_t *pmtx;
};

// 加锁保护:加锁的时候,一定要保证加锁的粒度越小越好,尽量保证临界区代码的整洁性
// 1,定义一把锁,由原生线程库提供的数据类型
// pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 对访问临界资源的临界区进行加锁保护
int tickets = 5000;

void *getTickets(void *args)
{
    int myerrno = errno;
    ThreadData *td = (ThreadData *)args;
    (void)args;
    while (true)
    {
        // 抢票逻辑
        int n = pthread_mutex_lock(td->pmtx); // 只要完成了加锁,表示任何一个时刻,只允许有一个线程获得这把锁,然后向后运行,其它的没拿到锁的只能在前面阻塞等待,直到拿到锁的线程把锁释放
        assert(n == 0);
        if (tickets > 0)
        {
            usleep(rand() % 1500);
            printf("%s:%d\n", td->tname.c_str(), tickets);

            tickets--;
            pthread_mutex_unlock(td->pmtx);
        }
        else
        {
            pthread_mutex_unlock(td->pmtx); // 在循环中,不要在break后面解锁
            break;
        }
        // 一旦抢到票不是立马去抢第二张票的,还需要后续的动作,这里用睡眠代替
        usleep(rand() % 2000); // 其实也是为了提高线程切换的概率,防止一直是一个线程申请释放锁
    }
    delete td;
    errno = myerrno; // 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
    return nullptr;
}

int main()
{
    time_t start = time(nullptr);
    pthread_mutex_t mtx; // 如果不把锁定义在全局,而是定义在main里,所以需要这样定义,并且要用init和destroy初始化和释放掉 --> 互斥的特点
    pthread_mutex_init(&mtx, nullptr);

    srand((unsigned long)time(nullptr) ^ getpid() ^ 0x147);
    pthread_t t1, t2, t3;
    // 多线程抢票
    pthread_t t[THREAD_NUM];
    for (int i = 0; i < THREAD_NUM; i++)
    {
        std::string name = "thread ";
        name += std::to_string(i + 1);
        ThreadData *td = new ThreadData(name, &mtx);
        pthread_create(t + i, nullptr, getTickets, (void *)td);
    }
    for (int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
    }

    pthread_mutex_destroy(&mtx);
    time_t end = time(nullptr);
    cout << "共用时" << (end - start) << "秒" << endl;
    return 0;
}

 

  • 加锁的本质:用时间和空间换取安全,空间很小,可以忽略不记
  • 加锁的表现:线程对于临界区代码串行执行
  • 加锁原则:尽量保证临界区代码越少越好

1.4 关于加锁的一些问题

问题:加了锁之后就是串行执行了吗?加了锁之后,线程在临界区中是否会切换,如果切换,会有问题吗,如何体现原子性?

解答:加了锁就是串行执行了,会切换,并且不会有问题。理解:如果执行到临界区中时被OS切换,此时你手里依然是拿着锁的,所以其它线程想要执行临界区代码,也得先申请锁,但是被切换走的线程还未释放锁,所以其它线程只能阻塞等待锁而无法执行临界区代码,也就保证了临界资源的一致性

②如果一线程不加锁也不释放锁,只是单纯地访问临界资源不做修改,那么是可以访问加了锁的线程的临界资源的 --> 但是一般不这样搞,因为这是不好的或者说是错误的编码方式

③两个线程一个锁,在没有持有锁的线程看来,对我最有意义的情况只有两种:①线程1未持有锁,线程2就可以申请锁  ②线程1释放锁,此时我可以申请锁 --> 要么什么都没做,要么做完(原子性) --> 加了锁就是串行了吗?是的,执行临界区代码一定是串行的 --> 当前线程访问临界区的过程,对于其他线程是原子的

④要访问临界资源,线程必须申请锁,所以需要每一个线程都看到同一把锁并且访问 --> 锁本身就是共享资源 --> 锁保证了临界资源的安全,谁来保护锁的安全呢? --> 所以为了保证锁的安全,申请和释放锁必须也是原子的,如何保证?


上面的所有问题最终回到了一个问题 --> 锁是如何实现的??

1.5 锁的大致实现原理

在汇编角度我们有两个共识:

  1. 如果只有一条汇编语句,我们就认为该汇编语句的执行是原子的
  2. CPU内部给我们提供了一条汇编,swap和exchange指令,作用是以一条汇编的方式,将内存和CPU内寄存器的数据进行交换

 下面是锁的伪代码:

步骤如下:

  1.  内存里有一个锁mutex,理解为整数变量,初始化锁后该变量默认为1;同时CPU里包含一个寄存器:%al寄存器,当线程执行lock第一条汇编(movb)的时候,把%al的值改为0
  2. 问题:在执行流视角,是如何看待CPU上面的寄存器的?在纯硬件角度,CPU包含很多寄存器,可以存很多临时数据。在执行流视角,CPU内部的寄存器,本质叫做当前执行流的上下文,寄存器的空间是被所有的执行流共享的,但是寄存器的内容是被每一个执行流私有的:上下文(类似超市里的存包柜)
  3. 第一条汇编把0放进去了,属于该线程的上下文,然后调用exchange交换,把寄存器的值和锁的值交换(一行汇编),然后al寄存器里的值变成1,然后下面判断1>0满足,return返回后才表示申请锁成功。
  4. 当线程A通过movb汇编,把它寄存器的值置0后还没通过xchgb汇编和内存的mutex全局变量(初始为1)交换时,被切走了,于是线程A带着它的EIP(指令寄存器,存的是代码执行位置)和寄存器的0走了;这时候线程B来了,要申请锁,也通过第一条汇编把0放到寄存器里,然后交换,把内存中的mutex(此时为1)拿过来交换,这时候B的寄存器是1,内存中的mutex为0,还未判断时B又要切换,于是B把自己的EIP和寄存器里的1带走了,这时候A回来了,于是A把他的0再次放到寄存器里,但是此时内存中的1被B拿走了,所以A交换后还是0,于是挂起等待,又把自己的0拿走了。
  5. 然后B又回来了,把1放到寄存器,从交换的位置继续往下执行,进入判断返回0,B申请锁成功,然后去继续执行后面的代码。然后B释放锁的时候,把内存中的mutex置1,唤醒等待锁的其它线程后,B就不再申请锁了。然后A来了,执行goto lock语句,表示回到最开始,此时A就执行同样的程序拿到锁了,通过这样的方式,就保证了各个内存中锁和线程寄存器中只有一个1

 问题:怎么保证锁自己的安全呢?自己保证,通过一行汇编的方式保证原子性

交换的本质,就是把数据交换到线程的硬件上下文中,把一个共享的锁,让一个线程以一条汇编的方式,交换到自己的上下文中,由于是交换,在一个轻量级进程的相关变量中,永远只有一个1

1.6 锁的封装

我们可以把锁封装成一个类,然后把类封装成一个头文件,然后就可以通过RAII的方式减少许多复杂的加锁解锁代码,如下代码:

LockGuard.hpp:

#include <pthread.h>

class Mutex
{
public:
    Mutex(pthread_mutex_t *lock)
        : _lock(lock)
    {
    }
    ~Mutex()
    {
    }
    void Lock()
    {
        pthread_mutex_lock(_lock);
    }
    void UnLock()
    {
        pthread_mutex_unlock(_lock);
    }

private:
    pthread_mutex_t *_lock;
};

class LockGuard // RAII
{
public:
    LockGuard(pthread_mutex_t *lock)
        : _mutex(lock)
    {
        _mutex.Lock();
    }

    ~LockGuard()
    {
        _mutex.UnLock();
    }

private:
    Mutex _mutex;
};

然后我们就可以用我们封装好的锁配合RAII来优化一下上面的抢票系统了,只需要改一下抢票的主体函数即可:


mythread.cc只需要对抢票函数修改一下即可:
void *getTickets(void *args)
{
    int myerrno = errno;
    ThreadData *td = (ThreadData *)args;
    (void)args;
    while (true)
    {
        {
            LockGuard lockguard(&lock); // RAII
            if (tickets > 0)
            {
                usleep(rand() % 1500);
                printf("%s:%d\n", td->tname.c_str(), tickets);

                tickets--;
            }
            else
            {
                break;
            }
        }
        usleep(rand() % 2000); // 其实也是为了提高线程切换的概率,防止一直是一个线程申请释放锁
    }
    delete td;
    errno = myerrno; // 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
    return nullptr;
}

二,线程安全

2.1 重入与线程安全

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。但是对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题,就是“数据不一致”问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则就是不可重入函数

2.2 常见的线程不安全情况

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

2.3常见的线程安全的情况

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

2.4 常见的不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

2.5 常见的可重入的情况

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

2.6 可重入VS线程安全

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

2.7 可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

三,死锁

3.1 死锁概念

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

  1. 我们在实际用锁的时候,不一定只用了一把锁,用了两把锁,那么可能会出现这样的情况:
  2. 线程A需要申请锁1,又要申请锁2,然后线程B也要申请锁1和锁2, A的申请顺序是先锁1再锁2,B先申请锁2再锁1。假设线程A申请了锁1,B申请了锁2,当A要申请锁2时,锁2被B申请了,于是A就阻塞等待,然后B要申请锁1,然后B也等待
  3. 然后就是两个线程永久等待不退出 --> 这种情况叫做死锁(A要B的,但是B要A的,谁也不让谁)
  4. 一个锁也有极低可能性也会造成死锁,假设一个执行流申请到了锁,然后再申请到了锁之后可能因为编码错误导致有执行同样的申请锁的函数,这样也会永久等待,造成死锁

3.2 死锁的四个必要条件

  1. 互斥条件:一个资源每次只能被一个执行流使用。(用了锁)
  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。(我拿到了锁,我不会释放我的锁,我还要你的锁)
  3. 不剥夺条件:一个执行流已获得的资源,在未使用之前,不能强行剥夺。(你要我的锁,我可以不给你锁)
  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。(A要B的锁,B又要A的锁,形成了环路情况) 

3.3 避免死锁

  • 破坏死锁的任意一个条件即可
  • 严格遵循正确的加锁顺序
  • 避免锁未释放的场景
  • 资源一次性分配
  • 避免死锁的算法:银行家算法,死锁检测算法

四,线程同步

4.1 场景

  1. 前面的抢票逻辑,我们让多个线程抢票,这时候有个线程A,它的能力很强,它不断的申请锁释放锁,最后5000张票被A线程全抢了。 --> 那么,线程A错了吗?没错! --> 但它不合理
  2. 我想买个手机,然后我去店里面买,第一次去买,没货了,然后走了;第二天来了,也没货了,然后我每一天都去问,直到一个月后才有货才能买 --> 我没错,但不合理 --> 太耽误我的时间和精力

上面的不合理对应到线程,就是:

  1. 频繁地申请资源,导致别人申请不到,造成饥饿问题
  2. 频繁检测,浪费我自己和对方的资源(比如票抢完了,但我还会补票,但是线程不知道你啥时候补,所以只能一直检测申请锁退出检测申请锁退出,造成线程做大量无用功

引入同步:不是严格意义上的保护临界资源安全,强调的是解决访问临界资源的合理性问题 --> 按照一定顺序,进行临界资源的访问,叫做线程同步

4.2 条件变量概念

  1. 条件变量 --> 当我么申请临界资源前,先要做临界资源是否存在的检测 --> 买票前检测票数是否大于0 --> 做检测的本质也是访问临界资源。所以,对临界资源的检测,也一定是要在加锁解锁之间的 --> 所以常规方式要检测条件就绪,注定了我们必须频繁申请和释放锁
  2. 那么,有没有办法让我们的线程检测到资源不就绪的时候呢?①不要再让线程频繁地自己检测啦,如果条件不满足就给我等待等着  ②当条件就绪的时候,通知对应的线程,让它来进行资源申请和访问
  3. 以前面的手机店为例,我第一次来买手机,发现没有,于是店员问我要了联系方式,这之后我就不用天天来问了,一旦有货了,店员可以直接联系我,节省了我的大量时间

条件变量:是利用线程共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

  1. 以一间房间为例,这个房间有一个门,一个锁,一个铃铛,由于互斥,每次只能有一个人进入这个房间,其它线程全部等着
  2. 当一个人出来了,把锁放在了门前,但由于这个人是离锁最近的,他放下后又直接拿了起来,导致后面排队的人拿不到,但是,这个人“没错”,但是“不合理”!!于是需要有同步机制
  3. 当在里面的那个人出来了,先敲响铃铛,通知等待队列中最前面的那个人可以进了,然后把钥匙交给了那个人,再之后,如果这个人想要再次拿钥匙,就必须到等待队列中重新排队,避免了上面的情况

注意

  1. 条件变量要提供两个东西,一个是“通知机制”,一个是“等待队列”。
  2. 条件变量需要配合锁一起使用,原因在后面代码注释里会解释

4.3 条件变量函数

条件变量的操作函数如下介绍如下:

部分操作系统可能无法查看此文档,可以使用下面的命令下载:

apt-get install manpages-de  manpages-de-dev  manpages-dev glibc-doc manpages-posix-dev manpages-posix

 介绍下各函数,信号量函数和锁的函数很相似:

  1. 首先是初始化条件变量的函数,pthread_cond_init,其中第一个参数cond表示需要初始化的条件变量,第二个参数表示初始化条件变量的属性,一般设置为nullptr
  2. 然后是信号量销毁函数,pthread_cond_destroy,参数为需要销毁的信号量。
  3. 接下来是等待条件变量满足的函数,pthread_cond_wait,作用是:当条件变量不满足时,阻塞等待,第一个参数就是要等待的条件变量,第二个参数是一把锁,至于第二个参数为什么是一把锁,后面的代码注释会解释
  4. 一个线程需要等待,当然也需要被唤醒,就比如我们前面手机店的例子,有货时店员会来通知我们,函数有两个,pthread_cond_signal,与pthread_cond_broadcast,前面一个用于唤醒单个线程,后面的用于唤醒所有正在等待队列里的线程
  5. pthread_cond_t cond = PTHREAD_COND_INITIALIZER,它的作用和前面锁的第二种初始化方式一样,当用这种方式初始化条件变量后,就不能再使用init和destroy初始化和销毁了,只能存在一种

下面是一段各条件变量使用的简单代码和演示动图,各函数注意事项会在注释给出:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *Count(void *args)
{
    pthread_detach(pthread_self());
    uint64_t number = (uint64_t)args;
    cout << "pthread: " << number << " create success!" << endl;

    while (true)
    {
        pthread_mutex_lock(&mutex); // 加锁
        // 问题:我们怎么知道我们要让一个线程去休眠? 一定是临界资源不就绪,没错,临界资源也是有状态的
        // 问题:我们怎么知道临界资源是否就绪? 我们判断出来的
        // 问题:判断是访问临界资源吗? 是的,所以访问临界资源必须要加锁,这就是“条件变量要配合锁使用”的原因
        pthread_cond_wait(&cond, &mutex);                           // 条件队列等待,pthread_cond_wait在进行等待时,会自动释放锁,所以第二个参数传的是锁,5个线程都在这里等待,需要主线程进行唤醒
        cout << "pthread: " << number << " cnt: " << cnt++ << endl; // 每个线程都对全局变量做++
        pthread_mutex_unlock(&mutex);
    }
}

int main()
{
    for (int i = 0; i < 5; i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, Count, (void *)i);
        usleep(1000);
    }
    sleep(2);
    cout << "main thread ctrl begin: " << endl;

    // 主线程唤醒线程
    while (true)
    {
        cout << "signal one thread... " << endl;
        pthread_cond_signal(&cond); // 唤醒cond等待队列中等待的线程,默认都是第一个,一次只唤醒一个
        // pthread_cond_broadcast(&cond); // 唤醒所有线程
        sleep(1);
    }
    while (true)
        sleep(1);
    return 0;
}

 

该代码的逻辑大致为:先创建5个线程,每个线程都对cnt进行++操作,然后5个线程都通过信号量wait函数阻塞等待,然后主线程依次唤醒5个线程,最后就能实现cnt的正确运算了

4.4 线程控制,锁与条件变量的综合运用

 

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

#define TNUM 4

typedef void *(*func_t)(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond); //
volatile bool quit = false;

class ThreadData
{
public:
    ThreadData(const std::string &name, func_t func, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
        : _name(name), _func(func), _pmtx(pmtx), _pcond(pcond)
    {
    }

public:
    std::string _name;
    func_t _func;
    pthread_mutex_t *_pmtx;
    pthread_cond_t *_pcond;
};

void *func1(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while (!quit)
    {
        // wait一定要在加锁和解锁之间进行wait,因为要等的话就要检测临界资源是否就绪,而对临界资源的任何操作都要加锁
        pthread_mutex_lock(pmtx);
        // if(临界资源是否就绪 == 否) 由于反复检测会频繁申请释放锁,所以应该在临界资源不就绪时再等待
        pthread_cond_wait(pcond, pmtx); // 默认该线程在执行的时候,wait代码被执行,当前线程会被立即阻塞(R -> S)
        if (!quit)
            std::cout << name << "running -- 播放" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}

void *func2(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while (!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx);
        if (!quit)
            std::cout << name << "running -- 下载" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}

void *func3(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while (!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx);
        if (!quit)
            std::cout << name << "running -- 刷新" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}

void *func4(const std::string &name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
{
    while (!quit)
    {
        pthread_mutex_lock(pmtx);
        pthread_cond_wait(pcond, pmtx);
        if (!quit)
            std::cout << name << "running -- 扫描" << std::endl;
        pthread_mutex_unlock(pmtx);
    }
}
// 用一个小的中间层完成资源的强转,调用回调和释放,
void *Entry(void *args)
{
    ThreadData *td = (ThreadData *)args; // td在每一个线程自己私有的栈空间中保存,所以多线程之间不会干扰
    // 让线程调自己的方法
    td->_func(td->_name, td->_pmtx, td->_pcond); // 这是一个函数,调用完成就要返回
    delete td;
    return nullptr;
}

int main()
{
    pthread_mutex_t mtx;               // 定义一把锁
    pthread_cond_t cond;               // 定义条件变量
    pthread_mutex_init(&mtx, nullptr); // 初始化
    pthread_cond_init(&cond, nullptr);

    // 线程创建
    pthread_t tids[TNUM];
    func_t funcs[TNUM] = {func1, func2, func3, func4};
    for (int i = 0; i < TNUM; i++)
    {
        std::string name = "Thread";
        name += std::to_string(i + 1);
        ThreadData *td = new ThreadData(name, funcs[i], &mtx, &cond);
        pthread_create(tids + i, nullptr, Entry, (void *)td);
    }
    sleep(5);

    // 线程控制
    int cnt = 10;
    while (cnt)
    {
        std::cout << "resume thread run code -- " << cnt << std::endl;
        pthread_cond_signal(&cond); // 按顺序唤醒线程
        // pthread_cond_broadcast(&cond); // 唤醒所有线程
        sleep(1);
        cnt--;
    }
    quit = true;
    pthread_cond_broadcast(&cond); // 唤醒所有线程

    // 线程终止
    for (int i = 0; i < TNUM; i++)
    {
        pthread_join(tids[i], nullptr);
        std::cout << "thread: " << tids[i] << " quit" << std::endl;
    }

    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);
}

这段代码其实是个简单的“生产消费模型”,创建4个线程,阻塞等待主线程发送任务过来,主线程复杂构建,发送任务,以及通知线程读取任务,具体细节可以查看代码,或者阅读完生产消费模型文章再次查看 

  • 7
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值