写一个线程池

用C++写一个简易线程池

什么是线程池?

用一个池子管理线程。线程过多会带来额外开销,线程池维护多个线程,等待监督管理者分配可并发的任务。一方面避免了处理任务时创建线程开销的代价,另一方面避免了线程过度膨胀导致过分调度,保证内核的充分调用。
线程池的优化思路是这样的:我们先在池子里创建若干个线程,当有事件发生时,我们再去使用这个线程,这样就可以大大减少线程的创建和销毁。

线程池的设计

需要用到这些东西。

bool m_stop;
std::vector<std::thread> m_thread;
std::queue<std::function<void()> >tasks;
std::mutex m_mutex;
std::condition_variable m_cv;
auto submit(F&& f, Args&&... args)->std::future<decltype(f(args...))> 

我们有三个重要的成分:

  • 任务队列,存储任务的地方
  • 提交函数,我们需要使用一种方法把任务提交到线程池中
  • 线程池,一组线程,需要他们来干活

任务队列\(tasks\)

使用一个数据结构存储发生的任务,你可以把它理解为生产者消费者模型中的生产者。我们希望先来的任务先处理,很自然的选择使用了队列,也许在其他的任务场景下会有更好的数据结构。

这里有一个 std::function<void()>,这是C++11引入的新玩意,function是想把一个函数当作一个对象来使用,它可以用一种统一的方式处理函数、函数对象、函数指针、lambda表达式、bind对象等等。

注意:STL容器不是线程安全的,所以在emplace或者push的时候应当加锁。

提交函数\(submit\)

template <typename F,typename... Args>
auto sumit(F&& f, Args&&... args)->std::future<decltype(f(args...))> {
	auto taskPtr = std::make_shared<std::packaged_task<decltype(f(args...))()>>(
		std::bind(std::forward<F>(f),std::forward<Args>(args)...)
	);
	{
		std::unique_lock<std::mutex>lk(m_mutex);
		if (m_stop) throw std::runtime_error("submit on stopped ThreadPool");
		tasks.emplace([taskPtr]() {
			(*taskPtr)();
		});
	}
	m_cv.notify_one();
        return taskPtr->get_future();
}

这一段代码就很吓人了,用到了很多C++11的新特性。

可变模板函数

typename...这就是可变模板参数,可以传入多个参数。在这里表示我们需要一个通用参数F和一个参数包Args。

函数声明

auto submit(F&& f, Args&&... args)->std::future<decltype(f(args...))>

提前说一件事

type function(args)
和
auto function(args)-> type

都是可以作为函数声明的。
在这里使用了三个东西auto,decltype,future。我们一个一个来看。
auto可以自动推导类型,所以auto变量必须得初始化的,其次auto会忽视顶层const。
decltype可以推导一个变量的类型,使用方法就是decltype(表达式),但是推导函数类型的时候我们不能写下面这种

decltype(x+y) add(T x,T y);

而应该写

add(T x,T y) -> decltype(x+y);

然后说一说std::future,这是一种特殊类型它提供了一个访问异步操作的机制。

std::future实际上是在做这样一件事:
A线程创建了一个B线程来做一件事情,但是A线程有其他事情在做,所以我们希望在某个时间节点来get()这个结果。

一个可行的方案是:
将结果放在某个全局变量中,需要的时候调用一个线程去获取这个结果。

而future则用阻塞的方式来实现:
使用packaged_task封装一个函数,紧接着使用future的get_future()来执行这个函数,而这个get就是调用了线程B,然后我们用wait()阻塞future,直到B完成这个函数。

现在我们终于看完了函数声明。

函数体

auto taskPtr = std::make_shared<std::packaged_task<decltype(f(args...))()>>(
			std::bind(std::forward<F>(f),std::forward<Args>(args)...)
			);

这句话就是用一根智能指针指向我们的future任务。

std::make_shared

是创建一根shared_ptr的智能指针,这个不是重点。

package_task

在前面说到过,它对函数进行封装,结果就是存在std::future对象中。抽象话就是:可以异步执行的函数的包装器。

std::bind

这是一个适配器,准确来说,这是一个函数适配器
所谓适配器,就是在一个已知的容器或者函数上进行再一次封装,比如queue,他就是一个建立在deque或者vector之上的容器适配器。而bind就是一个建议在某一种函数上的函数适配器。
它的语法是这样的:

template <class Fn, class... Args> bind (Fn&& fn, Args&&... args);
template <class Ret, class Fn, class... Args> bind (Fn&& fn, Args&&... args);

一个使用的例子:

bool isBetween( int i, int min, int max) {
	return i >= min && i <= max;
}
function<bool(int)> filter = std::bind(isBetween, placeholders::_1, 20, 40);
printNumber(numbers, filter);

placeholders::_1 的意思是,这里是一个占位符,在调用的时候,将实际传递的第一个参数放到这里。
占位符的数量可以是任意多的,像这样:
std::placeholders::_1, std::placeholders::_2, …, std::placeholders::_N。
上面这个例子也表现了,std::bind的返回值就是function类。

std::forward

它有个极为抽象的名字:完美转发
std::forward()将会完整保留参数的引用类型进行转发。如果参数是左值引用(lvalue),该方法会将参数保留左值引用的形式进行转发,如果参数是右值引用(rvalue),该方法会将参数保留右值引用的形式进行转发。
事实上,我读这个东西的时候一直觉得这是useless的语法糖。
但在这个例子中是有用的:
重新看看函数声明,(F&& f, Args&&... args),这是右值引用吗?不是,这是一个很牛马的现象———万能引用

Rvalue references只能绑定到右值上,lvalue references除了可以绑定到左值上,在某些条件下还可以绑定到右值上。 这里某些条件绑定右值为:常左值引用绑定到右值,非常左值引用不可绑定到右值!

这在说什么?&&确实表示右值引用,但是左值仍然可以使用这个东西,所以我们要叫他万能引用。
而我们为了掌握引用的类型,我们需要使用一个方法——std::forward();
所以有这么一句话

在《Effective Modern C++》中建议:对于右值引用使用std::move,对于万能引用使用std::forward。

{
	std::unique_lock<std::mutex>lk(m_mutex);
	if (m_stop) throw std::runtime_error("submit on stopped ThreadPool");
	tasks.emplace([taskPtr]() {
              (*taskPtr)();
        });
}

这一段就是加入任务队列了,因为queue不是线程安全的,所以要加锁。这里使用了unique_lock这种锁,这种锁就是细粒度锁。
之后就很显然了,m_stop表示我们的线程池是否被停止了。emplace就是使用了移动构造函数,而push使用的是拷贝构造函数。

m_cv.notify_one();
return taskPtr->get_future();

这一段也就是使用条件变量唤醒一个线程。条件变量 std::condition_variable 是为了解决死锁而生,当互斥操作不够用而引入的。我认为条件变量就是破坏了死锁的循环等待的必要条件。
OK,我们解决了submit函数的所有语法糖,不难发现这个函数不算复杂,主要是语法糖太多。

线程池里的线程

现在的重点是线程的工作是怎么完成。

loop 
  if queue is not empty:
        work

不断的轮询,当然这是效率不高的,它会一直询问队列是否为空。所以让我们加个锁,来让他工作的不那么频繁。

loop 
  if queue is not empty:
        wait signal
  work

这就是代码逻辑,再来看点细的:

m_thread.emplace_back(
	[this]() {
		while (true) {
			std::function<void()> task;
			{
                                std::unique_lock<std::mutex>lk(m_mutex);
                                //使用条件变量,当线程池开启并且有任务时执行
				m_cv.wait(lk, [this]() {
					return m_stop || !tasks.empty();
				});
				if (m_stop && tasks.empty()) return;
				task = std::move(tasks.front());
				tasks.pop();
			}
			task();
		}
	}
);

终于写完了。。。。

posted @ 2022-07-21 21:54  Paranoid5  阅读(124)  评论(0编辑  收藏  举报