Linux producer consumer model

Posted by Awanka on Sun, 27 Feb 2022 10:47:08 +0100

Producer consumer model

Concept of producer consumer model

Producer consumer model is to solve the strong coupling problem between producers and consumers through a container.

Producers and consumers do not communicate directly with each other, but communicate through this container. Therefore, after producing data, producers do not need to wait for consumers to process. They directly put the production data into this container. Consumers do not need to ask producers for data, but directly get data from this container. This container is equivalent to a buffer, Balancing the processing capacity of producers and consumers, this container is actually used to decouple producers and consumers.

Characteristics of producer consumer model

Producer consumer model is a classic scenario of multi-threaded synchronization and mutual exclusion. Its characteristics are as follows:

  • Three relationships: producer and producer (mutually exclusive relationship), consumer and consumer (mutually exclusive relationship), producer and consumer (mutually exclusive relationship, synchronous relationship).
  • Two roles: producer and consumer. (usually undertaken by a process or thread)
  • A trading place: usually refers to a buffer in memory. (you can organize yourself in some way)

When we code the producer consumer model, the essence is to maintain these three characteristics.

Why is there a mutually exclusive relationship between producers and producers, consumers and consumers, producers and consumers?

The container between producer and consumer may be accessed by multiple execution streams at the same time, so we need to protect the critical resource with mutual exclusion.

Among them, all producers and consumers will apply for locks competitively, so there is a mutually exclusive relationship between producers and producers, consumers and consumers, and producers and consumers.

Why is there a synchronous relationship between producers and consumers?

  • If the producer is allowed to produce all the time, then when the container is filled with the data produced by the producer, the producer's reproduction data will fail.
  • On the contrary, if consumers are allowed to consume all the time, when the data in the container is consumed, consumers will fail to consume again.

Although this will not cause any data inconsistency, it will cause hunger on the other side, which is very inefficient. We should let producers and consumers access the container in a certain order. For example, let producers produce first and then let consumers consume.

Note: the mutual exclusion relationship ensures the correctness of data, while the synchronization relationship is to make multi threads cooperate.

Advantages of producer consumer model

  • Decoupling.
  • Support concurrency.
  • Uneven busy and free time is supported.

If we call a function in the main function, then we have to wait until the function body is executed before continuing the subsequent code of the main function, so function call is essentially a tight coupling.

Corresponding to the producer consumer model, the function parameter transfer is actually the process of producer production, while the executive function body is actually the process of consumer consumption. However, the producer is only responsible for production data, and the consumer is only responsible for consumption data. During consumer consumption, the producer can produce at the same time. Therefore, the essence of the producer consumer model is a loose coupling.

Producer consumer model based on BlockingQueue

Producer consumer model based on blocking queue

In multithreaded programming, Blocking Queue is a data structure commonly used to implement producer and consumer models.


It differs from ordinary queues in that:

  • When the queue is empty, the operation of getting elements from the queue will be blocked until elements are put into the queue.
  • When the queue is full, the operation of storing elements in the queue will be blocked until an element is removed from the queue.

Knowledge connection: seeing the above description of blocking queue, we can easily think of pipeline, and the most typical application scenario of blocking queue is actually the implementation of pipeline.

Simulation and implementation of production and consumption model based on blocking queue

To facilitate understanding, let's take single producer and single consumer as an example.

BlockQueue is the trading place in the producer consumer model. We can implement it with queue in C++STL library.

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

#define NUM 5

template<class T>
class BlockQueue
{
private:
	bool IsFull()
	{
		return _q.size() == _cap;
	}
	bool IsEmpty()
	{
		return _q.empty();
	}
public:
	BlockQueue(int cap = NUM)
		: _cap(cap)
	{
		pthread_mutex_init(&_mutex, nullptr);
		pthread_cond_init(&_full, nullptr);
		pthread_cond_init(&_empty, nullptr);
	}
	~BlockQueue()
	{
		pthread_mutex_destroy(&_mutex);
		pthread_cond_destroy(&_full);
		pthread_cond_destroy(&_empty);
	}
	//Insert data into the blocking queue (producer call)
	void Push(const T& data)
	{
		pthread_mutex_lock(&_mutex);
		while (IsFull()){
			//Production cannot proceed until the blocking queue can accommodate new data
			pthread_cond_wait(&_full, &_mutex);
		}
		_q.push(data);
		pthread_mutex_unlock(&_mutex);
		pthread_cond_signal(&_empty); //Wake up the consumer thread waiting under the empty condition variable
	}
	//Get data from blocking queue (consumer call)
	void Pop(T& data)
	{
		pthread_mutex_lock(&_mutex);
		while (IsEmpty()){
			//Cannot consume until there is new data in the blocking queue
			pthread_cond_wait(&_empty, &_mutex);
		}
		data = _q.front();
		_q.pop();
		pthread_mutex_unlock(&_mutex);
		pthread_cond_signal(&_full); //Wake up the producer thread waiting under the full condition variable
	}
private:
	std::queue<T> _q; //Blocking queue
	int _cap; //Maximum number of data containers blocked
	pthread_mutex_t _mutex;
	pthread_cond_t _full;
	pthread_cond_t _empty;
};

Relevant instructions:

  • Because we implement the producer consumer model of single producer and single consumer, we do not need to maintain the relationship between producers and producers, nor the relationship between consumers and consumers. We only need to maintain the synchronous and mutually exclusive relationship between producers and consumers.
  • Template the data stored in BlockingQueue to facilitate reuse when needed in the future.
  • Here, the upper limit of the data stored in the BlockingQueue is set to 5. When five groups of data are stored in the blocking queue, the producer cannot produce. At this time, the producer should be blocked.
  • Blocking queue is a critical resource that will be accessed by producers and consumers at the same time, so we need to protect it with a mutex.
  • The producer thread needs to Push data into the blocking queue on the premise that there is space in the blocking queue. If the blocking queue is full, the producer thread needs to wait until there is space in the blocking queue.
  • The consumer thread needs to Pop data from the blocking queue on the premise that there is data in the blocking queue. If the blocking queue is empty, the consumer thread needs to wait until there is new data in the blocking queue.
  • Therefore, we need to use two condition variables here. One condition variable is used to describe that the queue is empty and the other condition variable is used to describe that the queue is full. When the blocking queue is full, the producer thread to be produced should wait under the full condition variable; When the blocking queue is empty, the consumer thread to consume should wait under the empty condition variable.
  • Whether it is a producer thread or a consumer thread, they first apply for the lock to enter the critical area, and then judge whether the production or consumption conditions are met. If the corresponding conditions are not met, the corresponding thread will be suspended. But at this time, the thread holds the lock. In order to avoid deadlock, pthread is called_ cond_ When using the wait function, you need to pass in the mutex in the current thread's hand. At this time, when the thread is suspended, it will automatically release the mutex in its hand, and when the thread is awakened, it will automatically obtain the mutex.
  • After the producer produces a piece of data, it means that there is at least one data in the blocking queue. At this time, there may be a consumer thread waiting under the empty condition variable. Therefore, after the producer produces data, it is necessary to wake up the consumer thread waiting under the empty condition variable.
  • Similarly, when the consumer consumes a piece of data, it means that there is at least one space in the blocking queue. At this time, there may be a producer thread waiting under the full condition variable. Therefore, when the consumer consumes data, it is necessary to wake up the producer thread waiting under the full condition variable.

if cannot be used to judge whether the production and consumption conditions are met, but while:

  • pthread_ cond_ The wait function is a function that makes the current execution flow wait. If it is a function, it means that the call may fail. After the call fails, the execution flow will continue to execute in the future.
  • Secondly, in the case of multiple consumers, when the producer produces a data, if pthread is used_ cond_ When the broadcast function wakes up consumers, it will wake up multiple consumers at one time, but there is only one data to be consumed. At this time, other consumers will be pseudo awakened.
  • In order to avoid the above situation, we need to let the thread wake up and judge again to confirm whether it really meets the production and consumption conditions. Therefore, we must use while to judge here.

In the main function, we only need to create a producer thread and a consumer thread, so that the producer thread can continuously produce data and the consumer thread can continuously consume data.

#include "BlockQueue.hpp"

void* Producer(void* arg)
{
	BlockQueue<int>* bq = (BlockQueue<int>*)arg;
	//Producers continue to produce
	while (true){
		sleep(1);
		int data = rand() % 100 + 1;
		bq->Push(data); //production data 
		std::cout << "Producer: " << data << std::endl;
	}
}
void* Consumer(void* arg)
{
	BlockQueue<int>* bq = (BlockQueue<int>*)arg;
	//Consumers continue to consume
	while (true){
		sleep(1);
		int data = 0;
		bq->Pop(data); //Consumption data
		std::cout << "Consumer: " << data << std::endl;
	}
}
int main()
{
	srand((unsigned int)time(nullptr));
	pthread_t producer, consumer;
	BlockQueue<int>* bq = new BlockQueue<int>;
	//Create producer and consumer threads
	pthread_create(&producer, nullptr, Producer, bq);
	pthread_create(&consumer, nullptr, Consumer, bq);

	//join producer thread and consumer thread
	pthread_join(producer, nullptr);
	pthread_join(consumer, nullptr);
	delete bq
	return 0;
}

Relevant instructions:

  • Blocking queue requires producer threads to Push data into the queue and consumer threads to Pop data from the queue. Therefore, this blocking queue must be seen by these two threads at the same time. Therefore, when creating producer threads and consumer threads, we need to pass in the blocking queue as a parameter of thread execution routine.
  • In the code, the producer production data is to Push the obtained random number to the blocking queue, and the consumer consumption data is Pop data from the blocking queue. In order to facilitate observation, we can print out the producer production data and consumer consumption data.

Producers and consumers are in step

Since the producer produces data every second in the code and the consumer consumes data every second, we can see that the execution steps of the producer and consumer are consistent after running the code.

Tip: take The file with hpp suffix is also a header file. The header file also contains the definition and implementation of classes. The caller only needs to include the hpp file. Because open source projects generally do not need protection, they are used more in open source projects.

Producers produce fast and consumers consume slowly

We can let producers keep producing and consumers consume every second.

void* Producer(void* arg)
{
	BlockQueue<int>* bq = (BlockQueue<int>*)arg;
	//Producers continue to produce
	while (true){
		int data = rand() % 100 + 1;
		bq->Push(data); //production data 
		std::cout << "Producer: " << data << std::endl;
	}
}
void* Consumer(void* arg)
{
	BlockQueue<int>* bq = (BlockQueue<int>*)arg;
	//Consumers continue to consume
	while (true){
		sleep(1);
		int data = 0;
		bq->Pop(data); //Consumption data
		std::cout << "Consumer: " << data << std::endl;
	}
}

At this time, because the producer produces quickly, the producer will fill the blocking queue immediately after running the code. At this time, if the producer wants to produce again, he can only wait under the full condition variable. Until the consumer consumes a data, the producer will be awakened and continue to produce. After the producer produces a data, he will wait again, Therefore, the pace of subsequent producers and consumers has become consistent again.

Producers produce slowly and consumers consume quickly

Of course, we can also let producers produce every second and consumers consume constantly.

void* Producer(void* arg)
{
	BlockQueue<int>* bq = (BlockQueue<int>*)arg;
	//Producers continue to produce
	while (true){
		sleep(1);
		int data = rand() % 100 + 1;
		bq->Push(data); //production data 
		std::cout << "Producer: " << data << std::endl;
	}
}
void* Consumer(void* arg)
{
	BlockQueue<int>* bq = (BlockQueue<int>*)arg;
	//Consumers continue to consume
	while (true){
		int data = 0;
		bq->Pop(data); //Consumption data
		std::cout << "Consumer: " << data << std::endl;
	}
}

Although consumers consume quickly, there is no data in the blocking queue at the beginning, so consumers can only wait under the empty condition variable. Consumers will not be awakened to consume until the producer produces a data. Consumers will wait after consuming this data, so the pace of producers and consumers is the same.

Wake up the corresponding producer or consumer when a certain condition is met

We can also wake up the consumer thread for consumption when the data stored in the blocking queue is greater than half of the queue capacity; When the data stored in the blocking queue is less than half of the queue container, the producer thread is awakened for production.

//Insert data into the blocking queue (producer call)
void Push(const T& data)
{
	pthread_mutex_lock(&_mutex);
	while (IsFull()){
		//Production cannot proceed until the blocking queue can accommodate new data
		pthread_cond_wait(&_full, &_mutex);
	}
	_q.push(data);
	if (_q.size() >= _cap / 2){
		pthread_cond_signal(&_empty); //Wake up the consumer thread waiting under the empty condition variable
	}
	pthread_mutex_unlock(&_mutex);
}
//Get data from blocking queue (consumer call)
void Pop(T& data)
{
	pthread_mutex_lock(&_mutex);
	while (IsEmpty()){
		//Cannot consume until there is new data in the blocking queue
		pthread_cond_wait(&_empty, &_mutex);
	}
	data = _q.front();
	_q.pop();
	if (_q.size() <= _cap / 2){
		pthread_cond_signal(&_full); //Wake up the producer thread waiting under the full condition variable
	}
	pthread_mutex_unlock(&_mutex);
}

We still let producers produce faster and consumers consume slower. After running the code, the producer will wait after the blocking queue is filled in an instant, but at this time, the producer thread will not be awakened when the consumer consumes a data, but will be awakened for production when the data in the blocking queue is less than half of the queue container.

Producer consumer model based on computing task

Of course, the actual use of producer consumer model is not simply to let the producer produce a number for consumers to print. We only do this to test the correctness of the code.
Because we have templated the data stored in BlockingQueue, we can store other types of data in BlockingQueue at this time.

For example, if we want to implement a producer consumer model based on computing tasks, we only need to define a Task class, which needs to contain a Run member function, which represents how we want consumers to process the data we get.

#pragma once
#include <iostream>

class Task
{
public:
	Task(int x = 0, int y = 0, int op = 0)
		: _x(x), _y(y), _op(op)
	{}
	~Task()
	{}
	void Run()
	{
		int result = 0;
		switch (_op)
		{
		case '+':
			result = _x + _y;
			break;
		case '-':
			result = _x - _y;
			break;
		case '*':
			result = _x * _y;
			break;
		case '/':
			if (_y == 0){
				std::cout << "Warning: div zero!" << std::endl;
				result = -1;
			}
			else{
				result = _x / _y;
			}
			break;
		case '%':
			if (_y == 0){
				std::cout << "Warning: mod zero!" << std::endl;
				result = -1;
			}
			else{
				result = _x % _y;
			}
			break;
		default:
			std::cout << "error operation!" << std::endl;
			break;
		}
		std::cout << _x << _op << _y << "=" << result << std::endl;
	}
private:
	int _x;
	int _y;
	char _op;
};

At this time, the data put into the blocking queue by the producer is a Task object, and after the consumer gets the Task object from the blocking queue, he can use the object to call the Run member function for data processing.

void* Producer(void* arg)
{
	BlockQueue<Task>* bq = (BlockQueue<Task>*)arg;
	const char* arr = "+-*/%";
	//Producers continue to produce
	while (true){
		int x = rand() % 100;
		int y = rand() % 100;
		char op = arr[rand() % 5];
		Task t(x, y, op);
		bq->Push(t); //production data 
		std::cout << "producer task done" << std::endl;
	}
}
void* Consumer(void* arg)
{
	BlockQueue<Task>* bq = (BlockQueue<Task>*)arg;
	//Consumers continue to consume
	while (true){
		sleep(1);
		Task t;
		bq->Pop(t); //Consumption data
		t.Run(); //Processing data
	}
}

Run the code. When the blocking queue is filled by the producer, the consumer is awakened. At this time, the consumer performs the calculation task when consuming the data. When the data in the blocking queue is consumed below a certain threshold, the producer will be awakened for production.

That is to say, when we want the producer consumer model to handle a certain Task, we only need to provide the corresponding Task class, and then let the Task class provide a corresponding Run member function to tell us how to handle the Task.

Topics: Linux Operation & Maintenance Load Balance Multithreading