[Common c/c++] 生产者消费者模型 using mutex/cv/semaphore

前言:

生产者消费者模型是老生常谈的话题,实现手段也是各种各样,不同的手段的 运行效率也是天壤之别。代码简洁度,数据安全性,运行稳定性,运行性能等等要素很难做到兼顾。




基础模型 -> 大粒度锁 + 忙等(高效 , 高cpu)

组件:

mutex

概述:

优点:代码简洁易懂,方便阅读和修改,逻辑清晰。

缺点:

1)cpu和运行效率无法兼得,要么cpu忙(这往往是绝对无法接收的);

2)要么运行效率无法得到保障(sleep间隔长了则效率低,短了则cpu忙);

3)竞争数据的加锁粒度大,一次性把整个list都锁住了。不过这一点不是太大的问题,而且优化起来难度较高,一般属于无锁编程范畴。不属于严重的缺点。

4)有多个消费者时,多个消费者之间会因为加锁的问题互相阻塞。

代码:

#include <thread>
#include <mutex>
#include <list>
#include <unistd.h>
#include <stdio.h>

std::list<long> FIFO;
std::mutex lock;
long consumer_v = -1;
long producer_v = 9999999;

void consumer(){
	static long times=0;
	while(consumer_v!=0){
		std::unique_lock<std::mutex> ul(lock);
		if(!FIFO.empty()){
			consumer_v =std::move(FIFO.front());
			FIFO.pop_front();
			times++;
		}else{
            //usleep(1);   //降低轮询次数以节省cpu
			times++;
		}
	}
	printf("consumer times : %ld\n" , times);
}

void producer(){
	static long times=0;
	while(producer_v--!=0){
		std::unique_lock<std::mutex> ul(lock);
		FIFO.push_back(producer_v);
		times++;
	}
	printf("producer times : %ld\n" , times);
}

int main()
{
	std::thread cons(consumer);
	std::thread prod(producer);
	cons.join();
	prod.join();
}

以上代码中,cpu通常会达到200%,原因是 consumer 中需要判断FIFO 中是否有数据,如果没有数据要再次加锁和判断,因此这数据 busy check 代码结构,这个过程会非常耗费 cpu 。

通过top命令查看:

  %CPU
 200.0

可以通过usleep来降低轮询频率从而降低cpu ,但是弊端代码就是执行时间会变长。

$ time ./1
producer times : 9999999
consumer times : 10002394

real    0m16.661s
user    0m19.614s
sys     0m13.541s

每次运行上述代码都会发现输出结果中,consumer times 的值会有很大波动,有时比 producer times 大几百,有时大几千,这些就是无用轮询的次数。




改善CPU的基础模型-> 大粒度锁 + 多消费者-多生产者(低效率 , 低CPU)

组件:

semaphore

概述:

上一个模型除了忙等的缺点外,还有一个问题就是实际上只有一个消费者能够同时跳出阻塞状态,多个消费者会互相阻塞在 mutex 的 lock 上,如果共享资源可以通过读写锁进行访问,那么这就不是一个好的实现。

需要注意的是,生产者消费者模型中,semaphore只是扮演通知者的角色,共享资源的保护还是要使用 mutex-like 组件进行。虽然说 mutex 是 semaphore 的二元化,但正是这二元化促成了排他性访问

代码1:

#include <thread>
#include <mutex>
#include <list>
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>

#include <fcntl.h>           /* For O_* constants */
#include <sys/stat.h>        /* For mode constants */
#include <semaphore.h>

const char* sempname = "test";
#define FLAG O_CREAT
#define DEFAULT_SEMP_CNT 0




std::list<long> FIFO;
std::mutex lock;
sem_t* sem;
long consumer_v = -1;
long producer_v = 99999;

void clearsemp(sem_t* sem)
{
  int value=0;
  do{
    sem_trywait(sem);
    sem_getvalue(sem,&value);
  }while(value!=DEFAULT_SEMP_CNT);
}


void consumer(){
        static long times=0;
        while(consumer_v!=0){
                sem_wait(sem);
                std::unique_lock<std::mutex> lk(lock);
                consumer_v = std::move(FIFO.front());
                FIFO.pop_front();
                lk.unlock();
                times++;
                printf("[%d]consumer times : %ld\n" ,gettid(), times);
        }
}

void producer(){
        static long times=0;
        while(producer_v--!=0){
                std::unique_lock<std::mutex> lk(lock);
                FIFO.push_back(producer_v);
                sem_post(sem);
                lk.unlock();
                times++;
                printf("producer times : %ld\n" , times);
        }
}

int main()
{
        umask(0);
        sem = sem_open(sempname,FLAG,0777,DEFAULT_SEMP_CNT);
        if(SEM_FAILED==sem){
          return 0;
        }
        clearsemp(sem);

        std::thread cons(consumer);
        std::thread cons1(consumer);
        std::thread prod(producer);
        cons.join();
        cons1.join();
        prod.join();

        clearsemp(sem);
}

以上代码的运行效率并不会比忙等要快,但是同样不会占用高CPU。信号量在某些场景下能发挥很好的效果,比如多生产者-多消费者模型。

上面的代码并没有解决大粒度锁的问题,不论是 cons ,cons2 还是 prod 线程,他们都会互相阻塞在获取 lock 处。

但是 cons线程在sem_wait处是有机会早点进入lock竞争的,使用这一点,可以减少同时竞争 lock 的 cons线程数量。

优化代码1:

注意:上述代码中,生产者在 post 信号的时候是没有束缚的,如果不控制生产量的话,会导致系统资源被耗尽。

一种方法是判断 FIFO 的尺寸,如果 FIFO 已经满了,则停止本次生产,接着sleep一定时间等待消费者从队列中取走数据,然后判断队列是否为空或者是否降到一定阈值,如果满足则继续填充队列。这种方法有一个问题,那就是如果消费者突然间在短时间内把数据都取走了,那么生产者sleep的就是影响了效率,如果缩减sleep的周期,那么又会导致cpu升高。

另外一种方法是使用两个信号量,此时不再使用一个信号量来管理整个队列的计数,而是预先把队列的上限确定下来,然后用两个信号量分别表示队列中空余(empty)位置的数量 和 已被使用(filled/full)位置的数量,这两个值的和是队列的上限。

生产者的逻辑为 :

1)wait 是否有empty位置可用(sem_wait(empty_sem)) 。

2)一旦跳出阻塞则说明有被标记为 empty 的位置可用,即有未被填充的单元,那么 lock 队列(mutex_lock/sem_wait(二元sem),这里可以选择mutex,也可以用二元sem,mutex 也同时支持 线程和进程级别。

3)填充队列。

4)unlock 队列。

5)post 增加一个 filled/full 可用位置(sem_post(filled_sem))。

消费者逻辑为:

1)wait 是否有 filled 位置可用(sem_wait(filled_sem))。

2)  一旦跳出阻塞则说明有标记为的 filled 的位置可用,即有已经被填充的单元,那么 lock 队列(mutex_lock/sem_wait(二元sem),这里可以选择mutex,也可以用二元sem,mutex 也同时支持 线程和进程级别。

3)从队列中取数据。

4)unlock 队列

5)post 增加一个 empty 可用位置(sem_post(empty_sem))。

mutex = 1 
Full = 0 // Initially, all slots are empty. Thus full slots are 0 
Empty = n // All slots are empty initially 



//Solution for Producer – 

do{

//produce an item

wait(empty);
wait(mutex);

//place in buffer

signal(mutex);
signal(full);

}while(true)



//Solution for Consumer – 

do{

wait(full);
wait(mutex);

// consume item from buffer

signal(mutex);
signal(empty);


}while(true)




改善CPU的基础模型 -> 大粒度锁 + 休眠唤醒 (低效率,低CPU)

为了改善 cpu 忙等问题,可以使用休眠唤醒机制。把唤醒工作交给内核,达到在不进行 busy check 的前提下,还可以来提升等待线程的响应效率的目的。

组件: 

conditional variable

代码1:

#include <thread>
#include <mutex>
#include <list>
#include <unistd.h>
#include <stdio.h>
#include <condition_variable>

std::list<long> FIFO;
std::mutex lock;
std::condition_variable cv;
long consumer_v = -1;
long producer_v = 99999;

void consumer(){
        static long times=0;
        while(consumer_v!=0){
                std::unique_lock<std::mutex> lk(lock);
                cv.wait(lk,[]{return !FIFO.empty();});
                consumer_v = std::move(FIFO.front());
                FIFO.pop_front();
                lk.unlock();
                times++;
                printf("consumer times : %ld\n" , times);
        }
}

void producer(){
        static long times=0;
        while(producer_v--!=0){
                std::unique_lock<std::mutex> lk(lock);
                FIFO.push_back(producer_v);
                cv.notify_one();
                lk.unlock();
                times++;
                printf("producer times : %ld\n" , times);
        }
}

int main()
{
        std::thread cons(consumer);
        std::thread prod(producer);
        cons.join();
        prod.join();
}

99999次的运行时间为:

producer times : 99999
consumer times : 99931
...

consumer times : 99999

real    0m8.045s
user    0m0.899s
sys     0m2.625s

以上代码运行时,cpu的使用率很低,consumer times 的值和 producer times 的值完全一直,这说明不会出现无效的轮询。

代码2:

#include <thread>
#include <mutex>
#include <list>
#include <unistd.h>
#include <stdio.h>
#include <condition_variable>
#include <sys/types.h>

std::list<long> FIFO;
std::mutex lock;
std::condition_variable cv;
long consumer_v = -1;
long producer_v = 99999;

void consumer(){
        static long times=0;
        while(consumer_v!=0){
                std::unique_lock<std::mutex> lk(lock);
                cv.wait(lk,[]{return !FIFO.empty();});
                consumer_v = std::move(FIFO.front());
                FIFO.pop_front();
                lk.unlock();
                times++;
                printf("[%d]consumer times : %ld\n" ,gettid(), times);
        }
}

void producer(){
        static long times=0;
        while(producer_v--!=0){
                std::unique_lock<std::mutex> lk(lock);
                FIFO.push_back(producer_v);
                cv.notify_all();
                lk.unlock();
                times++;
                printf("producer times : %ld\n" , times);
        }
}

int main()
{
        std::thread cons(consumer);
        std::thread cons1(consumer);
        std::thread prod(producer);
        cons.join();
        cons1.join();
        prod.join();
}

上述代码启动了2个消费者线程,如果消费者运行缓慢的话,这种模式可以有效地提高效率。

优缺点:

优点:代码简洁移动,方便阅读和修改,逻辑清晰。通过睡眠的方式代替忙等,避免cpu占用过高。不会出现无效的消费轮询次数。

缺点:避免cpu高负载忙等的代价是运行速度缓慢。对比忙等,可发现消耗的时间是忙等的 50倍。

更多条件变量的介绍见:[modern c++] std::condition_variable 条件变量的使用_condition_variable里的stop_waiting是什么含义-CSDN博客文章浏览阅读357次。代码片段:std::mutex mut;std::queue data_queue;std::condition_variable data_cond;void data_preparation_thread(){while (more_data_to_prepare()){data_chunk const data = prepare_data();std::lock_guard lk(mut);_condition_variable里的stop_waiting是什么含义https://blog.csdn.net/ykun089/article/details/114735322




其他:

当我们锁住列表的时候,释放锁的时机要控制好,建议通过 std::move 把需要处理的数据从 FIFO 中拿出来,或者 通过拷贝的方式拷贝拿出来,然后立刻就把锁释放掉,这样不会影响其他线程加锁。不可以在锁住状态中执行耗时操作,除非你有充分的理由或者知道自己在干啥。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值