Bootstrap

Linux系统编程:线程同步及生产与消费者模型

目录

一. 线程同步的概念及功能

二. 线程同步的实现方法

2.1 条件变量相关函数

2.2 线程同步demo代码

三. 生成与消费者模型

3.1 生产与消费者模型的概念

3.2 生产与消费者模型实现代码

四. 总结


一. 线程同步的概念及功能

为了了解线程同步的概念及实现的功能,要先明确线程互斥的缺点。

如伪代码1.1所示的情况,在加锁和解锁之中,需要对临界资源是否满足条件进行判断,如果临界资源条件满足,才会执行有效的操作,临界资源条件长时间无法得到满足,那么就会频繁执行 加锁 -> 检测 -> 解锁的操作,在不断加锁、检测、解锁的过程中,消耗的大量的计算机资源,但是并没有做实际的工作,造成了严重的资源浪费。并且,在线程互斥的条件下,如不加以控制,很可能会存在一个线程频繁申请到锁访问临界资源的情况,这样就造成了其它线程的饥饿问题。

代码1.1:线程互斥不断加锁解锁浪费线程资源问题 

void *ThreadRoutine(void *args)
{
    while(true)
    {
        // 只有在检测到临界资源count > 0时才进行有效工作
        // 如果临界资源不满足条件,那么就不断重复 上锁 -> 检测临界资源 -> 解锁 的操作
        pthread_mutex_lock(&mtx);   // 上锁
        if(临界资源条件满足)
        {
            // 。。。
            // 这里为有效代码
        }
        pthread_mutex_unlock(&mtx);
    }
}

总结,单纯的线程互斥存在的缺陷有:

  • 频繁在加锁解锁的中间,检测临界资源是否就绪,对于线程资源是一种浪费。
  • 某一特定的线程频繁申请到锁访问临界资源,造成了其它线程的饥饿问题。

为了解决线程互斥的上述缺陷,线程同步被引入了进来。线程同步的功能,就是为了解决多线程在互斥的条件下,访问临界资源的合理性问题。

线程同步的概念:让多线程按照一定的顺序,访问临界资源

二. 线程同步的实现方法

通过设置条件变量的方法,可以实现线程的同步。

2.1 条件变量相关函数

创建条件变量pthread_cond_t:

  • 在全局或局部,定义pthread_cond_t类型的对象,就完成了条件变量的创建。
  • 创建条件变量的语法为:pthread_cond_t [条件变量名]

条件变量的初始化:

  • 对于定义在全局的条件变量,可以直接使用 PTHREAD_COND_INITIALIZER 来进行初始化,具体的语法为:pthread_cond_t [条件变量名] = PTHREAD_COND_INITIALIZER。
  • 对于定义在局部的条件变量,使用函数 pthread_cond_init 来进行初始化。

pthread_cond_init 函数 -- 初始化条件变量

函数原型:int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr)

函数参数:

  • cond -- 指向被初始化的条件变量的指针 (被初始化条件变量的地址) 。
  • attr -- 初始化属性,一般采用默认属性,在使用的时候传nullptr。

返回值:函数调用成功返回0,失败返回非0错误码。

条件变量的销毁:

  • 一般来说,只有定义在局部的条件变量才需要人工去销毁,定义在全局的条件变量,在程序运行结束的时候,会自动被销毁。
  • 对于局部变量的销毁,可使用 pthread_cond_destroy。

pthread_cond_destroy 函数 -- 局部变量销毁

函数原型:int pthread_cond_destroy(pthread_cond_t *cond);

函数参数:cond -- 被销毁的条件变量的地址。

返回值:成功返回0,失败返回返回非0错误码。

条件变量的等待:

  • 通过函数pthread_cond_wait,可以实现对某种条件变量的等待。
  • 等待条件变量的代码,一定要在加锁和解锁之间,且pthread_cond_wait一定要传当前线程持有的互斥锁的地址。
  • 调用pthread_cond_wait后,线程会在当前位置为阻塞,直到其它线程将其唤醒,才可以从之前被阻塞的位置开始继续执行。

pthread_cond_wait 函数 -- 等待条件变量

函数原型:int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

函数参数:

  • cond -- 设置等待的条件变量的地址。
  • mutex -- 等到条件变量的线程当前持有的互斥锁的地址。

返回值:成功返回0,失败返回非0错误码。

演示代码2.1为 pthread_cond_wait 如何使用的规范版代码。我们希望,如果临界资源条件不满足,当前线程就要被阻塞,直到临界资源条件满足后再由其它线程唤醒。pthread_cond_wait要在加锁和解锁之间执行,且判断临界资源条件是否满足应当通过while循环来判断而不是if条件判断

代码2.1:pthread_cond_wait 的规范使用方法 

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 全局互斥锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;     // 全局条件变量

void *ThreadRoutine(void *args)
{
    while(true)
    {
        pthread_mutex_lock(&mtx);

        // 这里一定要用while轮巡检测,而不可以用if
        while(临界资源条件不满足)
        {
            // 设置条件变量阻塞等待
            // 直到临界资源就绪被唤醒
            pthread_cond_wait(&cond, &mutex);
        }

        // 对临界资源进行操作的有效代码位于此处
        // ... ...

        pthread_mutex_unlock(&mtx);
    }
}

使用while轮询检测判断临界资源条件是否满足而不采用if判断的原因如下:

  • pthread_cond_wait 函数可能执行失败。
  • 在多线程条件下被伪唤醒,即:虽然pthread_cond_wait被唤醒,但因为各种原因,造成代码执行流从上次中断位置开始执行的时候,其实条件并不满足。
  • while再进行一次判断,相当于二次确认临界资源条件满足要求,就避免了pthread_cond_wait函数执行失败和伪唤醒造成错误。

线程执行流因pthread_cond_wait被阻塞的时候,是拿锁阻塞的,那么,其他线程又为什么可以访问临界资源了呢?这里就要涉及到pthread_cond_wait的第二个参数了,第二个参数传的是互斥锁的地址。

当线程在pthread_cond_wait调用的位置被阻塞时,它会释放它所持有的第二个参数指向的互斥锁。同理,当该线程再次被唤醒时,又会去竞争互斥锁,以保证其获得访问临界资源的权限。如果线程被唤醒,但是互斥锁被其它线程占有,这种情况有可能存在,但不会出现问题,因为此时该线程会继续阻塞等待锁,拿到锁之后才会继续执行。

唤醒条件变量的等待:  

  • 通过函数pthread_cond_signal或pthread_cond_broadcast函数,可以唤醒指定的条件变量。
  • pthread_cond_signal的功能是唤醒一个等待指定条件变量的线程。
  • pthread_cond_broadcast的功能是唤醒全部等待指定条件变量的线程。

pthread_cond_signal -- 唤醒一个等待指定条件变量的线程

函数原型:int pthread_cond_signal(pthread_cond_t *cond)

函数参数:cond -- 被唤醒的条件变量的地址。

返回值:成功返回0,失败返回非0错误码。

pthread_cond_broadcast函数 -- 唤醒全部等待某个条件变量的线程

函数原型:int pthread_cond_broadcast(pthread_cond_t *cond

函数参数:cond -- 被唤醒的条件变量的地址。

返回值:成功返回0,失败返回非0错误码。

2.2 线程同步demo代码

代码2.2创建了4个子线程,通过条件变量,设置阻塞等待,让这四个线程按照先后顺序依次运行。具体的实现方法为:

  • 创建四个子线程,在线程函数中进行加锁和解锁操作,在加锁和解锁之间设置等待条件变量,以此来阻塞线程执行流执行。
  • 在主线程中,每隔1s调用一次pthread_cond_signal函数,唤醒一个子线程。

编译程序,运行代码,观察图2.1所示的运行结果,我们发现,4个线程按照先后顺序被调用了。

代码2.2:通过条件变量让多个线程依次执行

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

#define g_PTHREAD_NUM 4

struct ThreadData
{
public:
    // 构造函数
    ThreadData(const std::string& name, pthread_mutex_t *pmtx, pthread_cond_t *pcond)
        : _name(name), _pmtx(pmtx), _pcond(pcond)
    { }

    std::string _name;      // 线程名
    pthread_mutex_t *_pmtx;  // 互斥锁地址
    pthread_cond_t *_pcond;
};

void *ThreadRoutine(void *args)
{
    ThreadData* pth = (ThreadData*)args;
    while(true)
    {
        // 加锁
        pthread_mutex_lock(pth->_pmtx);

        // 设置等待条件变量
        pthread_cond_wait(pth->_pcond, pth->_pmtx);

        // 执行线程核心代码(输出线程名称)
        std::cout << "Thread Running ..., " << pth->_name << std::endl;

        // 解锁
        pthread_mutex_unlock(pth->_pmtx);
    }

    delete pth;
    return nullptr;
}

int main()
{
    // 创建并初始化互斥锁
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);

    // 创建并初始化条件变量
    pthread_cond_t cond;
    pthread_cond_init(&cond, nullptr);

    pthread_t tid[g_PTHREAD_NUM];

    // 创建子线程
    for(int i = 0; i < g_PTHREAD_NUM; ++i)
    {
        std::string name = "Thread ";
        name += std::to_string(i + 1);
        ThreadData *pth = new ThreadData(name, &mtx, &cond);

        int n = pthread_create(tid + i, nullptr, ThreadRoutine, (void*)pth);
        assert(n == 0);
    }

    // 主线程轮询唤醒子线程
    while(true)
    {
        pthread_cond_signal(&cond);
        sleep(1);
    }

    // 主线程等待子线程退出
    for(int i = 0; i < g_PTHREAD_NUM; ++i)
    {
        int n = pthread_join(tid[i], nullptr);
        assert(n == 0);
    }

    return 0;
}

三. 生成与消费者模型

3.1 生产与消费者模型的概念

如图3.1为生产与消费者模型的示意图,在该模型中,有如下的要素:

  • 两个角色:生产者和消费者,生产者负责生产数据,消费者读取数据。
  • 备一个交易场所:超市 -- 负责临时存储数据,类似于缓冲区。
  • 三种关系:消费者和消费者之间的竞争关系(类似于生活中买家竞争有限的货源)、生产者与生产者的竞争关系(现实中生成同一物品的不同厂商都希望自己占用更多的市场份额)、消费者与生产者之间的同步关系(生产者生产了产品消费者才能去消费、消费者消耗掉超市中一定量的产品后,生产者才会继续供应产品)。

总结:生产者消费者模型具备 3种关系、2个角色、1个交易场所。

图3.1 生产与消费者模型

用软件工程师的思维来理解生产与消费者模型:

  • 对于生产者和消费者之间同步关系的理解:将生产者与消费者全部视为线程,当缓冲区中(超市)的数据被生产者写满后,生产者写数据的操作就应当被暂时阻塞,类似于线程同步中的等待信号,同时,生产者生产数据之后,应当唤醒消费者线程,通知消费者线程读取数据。同理,当缓冲区中没有数据时,消费者线程读取数据的操作应当被阻塞,直到生产者向缓冲区中写入了数据唤醒消费者线程。
  • 生产者生产数据消费者才能消费,消费者将仓库库存进行一定清理后生产中才能继续生产数据,生产者和消费者所对应的线程访问缓冲区资源的顺序就有了一定的约束,因此生产者和消费者之间的关系为同步。
  • 消费者与消费者之间的互斥关系的理解:缓冲区(超市)为临界资源,为了保证线程安全,某一时刻只能有一个消费者线程获取临界资源。
  • 生产者与生产者之间的互斥关系的理解:类似于超市货架各个供应商不能无需摆放商品,即:某一时刻只能有一个生产者向临界资源中写数据,某一时刻只能有一个生产者线程访问临界资源。

实现生产者与消费者之间的同步关系,就需要依靠条件变量来实现线程同步,实现消费者与消费者、生产者与生产者之间的互斥关系,就需要依靠互斥锁来实现线程互斥。

生产者与消费者模型,具有以下的优势:

  • 实现了数据写入和数据读取的解耦,更符合软件工程高内聚低耦合的设计思想。
  • 利用线程之间的并发特性,提高了读写效率。如果仅单纯的从读写操作来看,好像并不会提高效率,但是,生产者向缓冲区写数据的数据源,可能是从网络中来的,并且消费者在获取数据后也可能对数据进行特定处理工作,生产者获取数据源、消费者处理数据都需要消耗时间,多线程的生产消费者模型,在某些线程执行数据读写的前后操作时,可以并发的进行数据读写工作,这样就实现了效率的提高。

3.2 生产与消费者模型实现代码

如图3.2所示,采用阻塞队列的方式来实现生产者与消费者之间的线程同步,假设阻塞队列最多容纳5个数据,如果阻塞队列中数据已满,那么生产者就应当等待消费者读取数据,而如果阻塞队列中数据为空,那么消费者就应当等待生产者向阻塞队列中写数据。并且,同一时刻只允许有一个生产者向阻塞队列中写数据、一个消费者从阻塞队列中读数据,这样就模拟出来了生产者与生产者、消费者与消费者之间的互斥关系。

图3.2 阻塞队列实现生成与消费者模型

代码3.1和3.2一同构成了生产者与消费者的代码,在阻塞队列class type BlockQueue中,给出了写数据函数push、删除数据函数pop、判断阻塞队列是否已满函数IsFull以及判空函数IsEmpty,在main函数中为生产者和消费者个创建两个线程,定义线程函数为consume何product,实现同步式的读取数据和写入数据。

代码3.1:阻塞队列实现的头文件BlockQueue.hpp

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

// 使用全局变量定义阻塞队列的默认容量
const int g_DEF_SIZE = 5;

template<class T>
class BlockQueue
{
public:
    // 构造函数
    BlockQueue(int capacity = g_DEF_SIZE)
        : _capacity(capacity)
    {
        // 对互斥锁和条件变量初始化
        pthread_mutex_init(&_mtx, nullptr);
        pthread_cond_init(&_full, nullptr);
        pthread_cond_init(&_empty, nullptr);
    }  

    // 判断阻塞队列是否已满的函数
    bool IsFull()
    {
        return _capacity == _bq.size();
    }  

    // 判断阻塞队列是否为空的函数
    bool IsEmpty()
    {
        return _bq.empty();
    }

    // 写数据函数(由生产者调用)
    void push(const T& val)
    {
        pthread_mutex_lock(&_mtx);   // 加锁

        // 判断阻塞队列是否已满,满了就设置条件变量进行等待
        // while是为了避免函数未成功执行以及伪唤醒问题
        while(IsFull())
        {
            pthread_cond_wait(&_full, &_mtx);
        }

        // 向阻塞队列中写数据
        _bq.push(val);

        pthread_cond_signal(&_empty);   // 唤醒消费者线程读取数据
        pthread_mutex_unlock(&_mtx);   // 解锁
    }

    // 读数据函数(由消费者调用)
    // 将数据读到pval所指向的地址中去
    void pop(T *pval)
    {
        pthread_mutex_lock(&_mtx);  // 加锁

        // 判断阻塞队列是否为空,如为空,设置对_empty条件变量的阻塞等待
        while(IsEmpty())
        {
            pthread_cond_wait(&_empty, &_mtx);
        }

        // 读取并删除队头数据
        *pval = _bq.front();
        _bq.pop();

        pthread_cond_signal(&_full);   // 唤醒生产者线程
        pthread_mutex_unlock(&_mtx);  // 解锁
    }

private:
    std::queue<T> _bq;       // 阻塞队列
    int _capacity;           // 阻塞队列容量
    pthread_mutex_t _mtx;  // 互斥锁地址
    pthread_cond_t _full;   // 用于标识阻塞队列已满的条件变量
    pthread_cond_t _empty;  // 用于标识阻塞队列为空的条件变量
};

代码3.2:ConProd.cc -- 生成消费者模型源文件代码

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "BlockQueue.hpp"

void *consume(void *args)
{
    BlockQueue<int> *pbq = (BlockQueue<int>*)args;

    int val = 0;
    while(true)
    {
        pbq->pop(&val);
        std::cout << "消费者获取了一个数据: " << val << std::endl;
        sleep(1);
    }

    return nullptr;
}

void *product(void *args)
{
    BlockQueue<int> *pbq = (BlockQueue<int>*)args;

    int a = 0;
    while(true)
    {
        pbq->push(a);
        std::cout << "生产者向阻塞队列中写入一个数据:" << a++ << std::endl;    
    }

    return nullptr;
}

int main()
{
    pthread_t c[2], p[2];   // 消费者与生产者线程id

    // 创建互斥锁
    pthread_mutex_t mtx;

    // 创建条件变量
    pthread_cond_t isFull;
    pthread_cond_t isEmpty;

    BlockQueue<int> *pbq = new BlockQueue<int>();

    // 创建两个生产者线程
    pthread_create(c, nullptr, consume, (void*)pbq);
    pthread_create(c + 1, nullptr, consume, (void*)pbq);

    // 创建两个消费者线程
    pthread_create(p, nullptr, product, (void*)pbq);
    pthread_create(p + 1, nullptr, product, (void*)pbq);

    // 主线程阻塞等待子线程退出
    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);

    delete pbq;

    return 0;
}

四. 总结

  • 单纯的线程互斥存在单个线程频繁多次申请到锁造成其他线程饥饿,以及临界资源不就绪的时候频繁申请和释放锁造成线程资源浪费的问题。
  • 引入线程同步可以解决上面的问题,线程同步就是让多个线程按照特定的次序访问临界资源。
  • pthread_cond_init、pthread_cond_wait、pthread_cond_signal、pthread_cond_broadcast 这几个函数配合使用,可以实现线程的同步。
  • 生产与消费者模型可以实现数据写端和读端的同步、实现写端和读端的解耦,充分利用多线程的并发特性提高效率。
;