C + + - implementation code with the least thread pool

Posted by lauriedunsire on Mon, 27 Dec 2021 14:47:10 +0100

preface

During this time, I read the basic content of C + + concurrent programming practice and wanted to use the recently learned knowledge to realize a simple thread pool by myself.

thinking

Personal understanding of thread pool is to use a fixed number of threads that have been created to perform the specified tasks, so as to avoid the additional overhead caused by repeated thread creation and destruction.
In C++11, threads can be understood as corresponding to a thread object, and tasks can be understood as functions to be executed, usually time-consuming functions.
The number and order of our tasks are not fixed, so we need a method to add the specified tasks. The place where the tasks are stored should be a task queue, because the number of threads is limited, and the number of tasks executed at the same time is limited when there are many tasks. Therefore, tasks need to be queued and follow the principle of first come, first served.
When a task is to be executed, it means that the task is taken out of the queue first, and then the corresponding task is executed. The executor of the "take out" action is the thread in the thread pool, which means that our queue needs to consider the problem that multiple threads perform the "take out" operation on the same queue. In fact, the take out task operation and add task operation cannot be carried out at the same time, Otherwise, competitive conditions will arise; On the other hand, if the program itself is multi-threaded, the operation of adding tasks by multiple threads at the same time should also be mutually exclusive.
When no task can be executed, all threads should do nothing. When a task occurs, the task should be assigned to any thread for execution. In terms of implementation, we can use polling to determine whether there are tasks in the current queue and take them out if there are (even if mutex is added, it seems that the competition condition cannot be avoided?), but this will consume unnecessary CPU resources and make it difficult to select the write polling cycle. In fact, we can use condition_variable instead of polling.
The creation and extraction of the above tasks is actually a classic producer consumer model.
We encapsulate the above contents in a class named ThreadPool. Users can specify the thread pool size when constructing ThreadPool objects, and then add tasks to be executed at any time.

realization

class ThreadPool
{
public:
	ThreadPool(int n);
	~ThreadPool();

	void pushTask(packaged_task<void()> &&task);

private:
	vector<thread*> threadPool;
	deque<packaged_task<void()>> taskQueue;

	void taskConsumer();
	mutex taskMutex;
	condition_variable taskQueueCond;
};

ThreadPool::ThreadPool(int n)
{
	for (int i = 0; i < n; i++)
	{
		thread *t = new thread(&ThreadPool::taskConsumer,this);
		threadPool.push_back(t);
		t->detach();
	}
}

ThreadPool::~ThreadPool()
{
	while (!threadPool.empty())
	{
		thread *t=threadPool.back();
		threadPool.pop_back();
		delete t;
	}
}

void ThreadPool::pushTask(packaged_task<void()> &&task)
{
	{
		lock_guard<mutex> guard(taskMutex);
		taskQueue.push_back(std::move(task));
	}
	taskQueueCond.notify_one();
}

void ThreadPool::taskConsumer()
{
	while (true)
	{
		unique_lock<mutex> lk(taskMutex);
		taskQueueCond.wait(lk, [&] {return !taskQueue.empty(); });
		packaged_task<void()> task=std::move(taskQueue.front());
		taskQueue.pop_front();
		lk.unlock();
		task();
	}
}

Here I use packaged_ As a task, condition is called whenever a task is added_ variable::notify_one method, call condition_ The thread of variable:: wait will wake up and check the waiting condition. One small detail here is notify_one is executed after unlocking, so as to avoid waiting for the mutex to unlock after the thread wakes up.
Use example:

void Task1()
{
	Sleep(1000);
	cout << "Task1"<<endl;
}

void Task5()
{
	Sleep(5000);
	cout << "Task5" << endl;
}

class Worker
{
public:
	void run();
};

void Worker::run()
{
	cout << "Worker::run start" << endl;
	Sleep(5000);
	cout << "Worker::run end" << endl;
}

int main()
{
	ThreadPool pool(2);
	pool.pushTask(packaged_task<void()>(Task5));
	pool.pushTask(packaged_task<void()>(Task1));
	pool.pushTask(packaged_task<void()>(Task1));
	Worker worker;
	pool.pushTask(packaged_task<void()>(bind(&Worker::run,&worker)));
	pool.pushTask(packaged_task<void()>([&](){worker.run();}));
	Sleep(20000);
}

This thread pool currently has several disadvantages:

  1. You can only pass in functions or callable objects in the form of void(), and cannot return the value of task execution. You can only synchronize the task execution results (if any) in other ways
  2. The incoming parameters are complex and must be encapsulated with a layer of packaged_task. When calling an object method, it needs to be encapsulated by a bind or lambda expression

The above shortcomings will not be solved in the implementation of the current version, and another blog post will be written for optimization in the future.

Topics: C++ Back-end Multithreading thread pool C++11