前言
偶然发现github上有个ThreadPool项目(GitHub - progschj/ThreadPool: A simple C++11 Thread Pool implementation ),star数居然3k+,里面也就两个文件,一个ThreadPool.h
,一个example.cpp
。
看了一下,项目代码是cpp11写的。老实说,代码极其简洁又难懂。
下面是ThreadPool.h
代码
#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#include <vector>
#include <queue>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>
class ThreadPool {
public:
ThreadPool(size_t);
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>;
~ThreadPool();
private:
// need to keep track of threads so we can join them
std::vector< std::thread > workers;
// the task queue
std::queue< std::function<void()> > tasks;
// synchronization
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
};
// the constructor just launches some amount of workers
inline ThreadPool::ThreadPool(size_t threads)
: stop(false)
{
for(size_t i = 0;i<threads;++i)
workers.emplace_back(
[this]
{
for(;;)
{
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,
[this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
}
);
}
// add new work item to the pool
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
{
using return_type = typename std::result_of<F(Args...)>::type;
auto task = std::make_shared< std::packaged_task<return_type()> >(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
// don't allow enqueueing after stopping the pool
if(stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
tasks.emplace([task](){ (*task)(); });
}
condition.notify_one();
return res;
}
// the destructor joins all threads
inline ThreadPool::~ThreadPool()
{
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for(std::thread &worker: workers)
worker.join();
}
#endif
example.cpp
是对线程池ThreadPool.h
的调用
#include <iostream>
#include <vector>
#include <chrono>
#include "ThreadPool.h"
int main()
{
ThreadPool pool(4);
std::vector< std::future<int> > results;
for(int i = 0; i < 8; ++i) {
results.emplace_back(
pool.enqueue([i] {
std::cout << "hello " << i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "world " << i << std::endl;
return i*i;
})
);
}
for(auto && result: results)
std::cout << result.get() << ' ';
std::cout << std::endl;
return 0;
}
看了以上代码应该是要劝退不少人了,反正我的第一感觉是这样的。
ThreadPool分析
ThreadPool类中有:
5个成员变量
-
std::vector< std::thread > workers 用于存放线程的数组,用vector容器保存
-
std::queue< std::function<void()> > tasks 用于存放任务的队列,用queue队列进行保存。任务类型为std::function<void()>。因为 std::function是通用多态函数封装器,也就是说本质上任务队列中存放的是一个个函数
-
std::mutex queue_mutex 一个访问任务队列的互斥锁,在插入任务或者线程取出任务都需要借助互斥锁进行安全访问
-
std::condition_variable condition 一个用于通知线程任务队列状态的条件变量,若有任务则通知线程可以执行,否则进入wait状态
-
bool stop 标识线程池的状态,用于构造与析构中对线程池状态的了解
3个成员函数
-
ThreadPool(size_t) 线程池的构造函数
-
auto enqueue(F&& f, Args&&... args) 将任务添加到线程池的任务队列中
-
~ThreadPool() 线程池的析构函数
class ThreadPool {
public:
ThreadPool(size_t);
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>;
~ThreadPool();
private:
// need to keep track of threads so we can join them
std::vector< std::thread > workers;
// the task queue
std::queue< std::function<void()> > tasks;
// synchronization
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
};
构造函数解析
inline ThreadPool::ThreadPool(size_t threads)
: stop(false)
{
for(size_t i = 0;i<threads;++i)
workers.emplace_back(
[this]
{
for(;;)
{
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,
[this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
}
);
}
构造函数定义为inline
接收参数threads表示线程池中要创建多少个线程。
初始化成员变量stop为false,即表示线程池启动着。
然后进入for循环,依次创建threads个线程,并放入线程数组workers中。
在vector中,emplace_back()成员函数的作用是在容器尾部插入一个对象,作用效果与push_back()一样,但是两者有略微差异,即emplace_back(args)中放入的对象的参数,而push_back(OBJ(args))中放入的是对象。即emplace_back()直接在容器中以传入的参数直接调用对象的构造函数构造新的对象,而push_back()中先调用对象的构造函数构造一个临时对象,再将临时对象拷贝到容器内存中。
我们知道,在C++11中,创建线程的方式为:
std::thread t(fun); //fun为线程的执行函数
所以,上述workers.emplace_back()中,我们传入的lambda表达式就是创建线程的fun()函数。
下面来分析下该lambda表达式:
[this]{
for(;;)
{
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,
[this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
}
lambda表达式的格式为:
[ 捕获 ] ( 形参 ) 说明符(可选) 异常说明 attr -> 返回类型 { 函数体 }
所以上述lambda表达式为 [ 捕获 ] { 函数体 } 类型。
该lambda表达式捕获线程池指针this用于在函数体中使用(调用线程池成员变量stop、tasks等)
分析函数体,for(;;)为一个死循环,表示每个线程都会反复这样执行,这其实每个线程池中的线程都会这样。
在循环中,,先创建一个封装void()函数的std::function对象task,用于接收后续从任务队列中弹出的真实任务。
在C++11中,
std::unique_lock<std::mutex> lock(this->queue_mutex);
可以在退出作用区域时自动解锁,无需显式解锁。所以,{}起的作用就是在退出 } 时自动回释放线程池的queue_mutex。
在{}中,我们先对任务队列加锁,然后根据条件变量判断条件是否满足。
void wait(unique_lock<mutex>& lock, _Predicate p) { while (!p()) wait(lock); }
为条件标量wait的运行机制, wait在p 为false的状态下,才会进入wait(lock)状态。当前线程阻塞直至条件变量被通知。
this->condition.wait(lock,[this]{ return this->stop || !this->tasks.empty(); });
Lambda 表达式(Lambda Expression)是 C++11 引入的一个“语法糖”,可以方便快捷地创建一个“函数对象”。
从 C++11 开始,C++ 有三种方式可以创建/传递一个可以被调用的对象:
- 函数指针
- 仿函数(Functor)
- Lambda 表达式
函数指针
函数指针是从 C 语言老祖宗继承下来的东西,比较原始,功能也比较弱:
- 无法直接捕获当前的一些状态,所有外部状态只能通过参数传递(不考虑在函数内部使用 static 变量)。
- 使用函数指针的调用无法 inline(编译期无法确定这个指针会被赋上什么值)。
// 一个指向有两个整型参数,返回值为整型参数的函数指针类型 int (*)(int, int); // 通常我们用 typedef 来定义函数指针类型的别名方便使用 typedef int (*Plus)(int, int); // 从 C++11 开始,更推荐使用 using 来定义别名 using Plus = int (*)(int, int);
仿函数
仿函数其实就是让一个类(class/struct)的对象的使用看上去像一个函数,具体实现就是在类中实现 operator()。比如:
class Plus { public: int operator()(int a, int b) { return a + b; } }; Plus plus; std::cout << plus(11, 22) << std::endl; // 输出 33
相比函数指针,仿函数对象可通过成员变量来捕获/传递一些状态。缺点就是,写起来很麻烦(码字比较多)。
Lambda 表达式
Lambda 表达式在表达能力上和仿函数是等价的。编译器一般也是通过自动生成类似仿函数的代码来实现 Lambda 表达式的。上面的例子,用 Lambda 改写如下:
auto Plus = [](int a, int b) { return a + b; };
一个完整的 Lambda 表达式的组成如下:
[ capture-list ] ( params ) mutable(optional) exception(optional) attribute(optional) -> ret(optional) { body }
- capture-list:捕获列表。前面的例子
auto Plus = [](int a, int b) { return a + b; };
没有捕获任何变量。- params:和普通函数一样的参数。
- mutable:只有这个 Lambda 表达式是 mutable 的才允许修改按值捕获的参数。
- exception:异常标识。暂时不必理解。
- attribute:属性标识。暂时不必理解。
- ret:返回值类型,可以省略,让编译器通过 return 语句自动推导。
- body:函数的具体逻辑。
除了捕获列表,Lambda 表达式的其它地方其实和普通的函数基本一样。
Lambda 表达式的捕获,其实就是将局部自动变量保存到 Lambda 表达式内部(Lambda 表达式不能捕获全局变量或 static 变量)。
Lambda 表达式最常用的地方就是和标准库中的算法一起使用。下面我们用一个简单的例子来说明 Lambda 表达式的用法。
假设有一个书本信息的列表,定义如下。我们想要找出其中 title 包含某个关键字(target)的书本的数量,可以通过标准库中的 std::count_if + Lambda 表达式来实现。
struct Book { int id; std::string title; double price; }; std::vector<Book> books; std::string target = "C++"; // 找出其中 title 包含“C++”的书本的数量
Lambda 表达式的最基本的两种捕获方式是:按值捕获(Capture by Value)和按引用捕获(Capture by Reference)。
- 按值捕获
auto cnt = std::count_if(books.begin(), books.end(), [target](const Book& book) { return book.title.find(target) != std::string::npos; });
[target]
表示按值捕获 target。Lambda 表达式内部会保存一份 target 的副本,名字也叫 target。
- 按引用捕获
auto cnt = std::count_if(books.begin(), books.end(), [&target](const Book& book) { return book.title.find(target) != std::string::npos; });
[&target]
表示按引用捕获 target——不会复制多一份副本。
- 捕获列表初始化(Capture Initializers)
C++ 14 支持 lambda capture initializers。比如:
// 按值捕获 target,但是在 Lambda 内部的变量名叫做 v auto cnt = std::count_if(books.begin(), books.end(), [v = target](const Book& book) { return book.title.find(v) != std::string::npos; }); // 按引用捕获 target,但是在 Lambda 内部的名字叫做 r auto cnt = std::count_if(books.begin(), books.end(), [&r = target](const Book& book) { return book.title.find(r) != std::string::npos; });
Lambda 捕获列表初始化最最最重要的一点是“支持 Capture by Move”。在 C++14 之前,Lambda 是不支持捕获一个 Move-Only 的对象的,比如:
std::unique_ptr<int> uptr = std::make_unique<int>(123); auto callback = [uptr]() { // 编译错误,uptr is move-only std::cout << *uptr << std::endl; };
按引用捕获虽然可以编译通过,但往往是不符合要求的。比如下面的例子,离开作用域之后 uptr 会被析构掉。但是 callback 对象已经被传给另一个线程。
std::unique_ptr<int> uptr = std::make_unique<int>(123); auto callback = [&uptr]() { std::cout << *uptr << std::endl; }; // ... 将 callback 传给另一个线程 // return => uptr delete 掉指向的内存
通过捕获列表初始化,完成 Move-Only 对象的“Capture by Move”。
std::unique_ptr<int> uptr = std::make_unique<int>(123); auto callback = [uptr = std::move(uptr)]() { // 将 uptr 移动给 Lambda 表达式中的参数 std::cout << *uptr << std::endl; }; // ... 将 callback 传给另一个线程 // return => uptr 是 nullptr
通过捕获列表初始化,我们还可以捕获一个指针“Capture by Pointer”。
auto cnt = std::count_if(books.begin(), books.end(), [p = &target](const Book& book) { return book.title.find(*p) != std::string::npos; });
[p = &target]
表示捕获 target 的指针,命名为 p。
- Default Capture
Lambda 表示支持两种 default capture 的模式:
[=]
表示 default capture by value。按值捕获可见范围内的所有局部变量。[&]
表示 default capture by reference。按引用捕获可见范围内的所有局部变量。比如:
int a = 1; std::string s = "hello"; std::vector<int> v; auto default_capture_by_value = [=]() { // 按值捕获了 a、s 和 v }; auto default_capture_by_reference = [&]() { // 按引用捕获了 a、s 和 v };
不建议直接使用 [&] 或 [=] 捕获所有参数,而是按需显示捕获。
- 按值捕获的类型是 const 的。
int i = 100; auto func = [i]() { i = 200; // 编译错误:assignment of read-only variable ‘i’ };
如果要修改按值捕获的参数,需要将 Lambda 表达式声明为 mutable 的。
int i = 100; auto func = [i]() mutable { i = 200; };
- 捕获 this 指针 在成员函数中的 Lambda 表达式可以捕获当前对象的 this 指针,让 Lambda 表达式拥有和当前类成员同样的访问权限,可以修改类的成员变量,使用类的成员函数。
class Foo { public: Foo(const std::string& s, int i) : s_(s), i_(i) {} void Print() { auto do_print = [this](){ std::cout << s_ << std::endl; std::cout << i_ << std::endl; }; do_print(); } void Update(const std::string& s, int i) { auto do_update = [this, &s, i](){ s_ = s; i_ = i; }; do_update(); } private: std::string s_; int i_; };
最后,this 指针只能按值捕获
[this]
,不能按引用捕获[&this]
。
所以p表示上述代码中的lambda表达式[this]{ return this->stop || !this->tasks.empty(); },其中this->stop为false, !this->tasks.empty()也为false。即其表示若线程池已停止或者任务队列中不为空,则不会进入到wait状态。
由于刚开始创建线程池,线程池表示未停止,且任务队列为空,所以每个线程都会进入到wait状态。
(借用 Linux条件变量pthread_condition细节(为何先加锁,pthread_cond_wait为何先解锁,返回时又加锁)_LupinLeo的博客-CSDN博客 一张图便于说明wait的过程)
在线程池刚刚创建,所有的线程都阻塞在了此处,即wait处。
若后续条件变量来了通知,线程就会继续往下进行:
if(this->stop && this->tasks.empty()) return;
若线程池已经停止且任务队列为空,则线程返回,没必要进行死循环。
task = std::move(this->tasks.front()); this->tasks.pop();
这样,将任务队列中的第一个任务用task标记,然后将任务队列中该任务弹出。(此处线程实在获得了任务队列中的互斥锁的情况下进行的,从上图可以看出,在条件标量唤醒线程后,线程在wait周期内得到了任务队列的互斥锁才会继续往下执行。所以最终只会有一个线程拿到任务,不会发生惊群效应)
在退出了{ },我们队任务队列的所加的锁也释放了,然后我们的线程就可以执行我们拿到的任务task了,执行完毕之后,线程又进入了死循环。
至此,我们分析了ThreadPool的构造函数。
添加任务函数解析
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
{
using return_type = typename std::result_of<F(Args...)>::type;
auto task = std::make_shared< std::packaged_task<return_type()> >(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
// don't allow enqueueing after stopping the pool
if(stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
tasks.emplace([task](){ (*task)(); });
}
condition.notify_one();
return res;
}
添加任务的函数本来不难理解,但是作者增加了许多新的C++11特性,这样就变得难以理解了。
template<class F, class... Args> auto ThreadPool::enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type>
equeue是一个模板函数,其类型形参为F与Args。其中class... Args表示多个类型形参。
auto用于自动推导出equeue的返回类型,函数的形参为(F&& f, Args&&... args),其中&&表示右值引用。表示接受一个F类型的f,与若干个Args类型的args。
-> std::future<typename std::result_of<F(Args...)>::type>
表示返回类型,与lambda表达式中的表示方法一样。
返回的是什么类型呢?
typename std::result_of<F(Args...)>::type //获得以Args为参数的F的函数类型的返回类型 std::future<typename std::result_of<F(Args...)>::type> //std::future用来访问异步操作的结果
引言
大家好,我是只讲技术干货的会玩code,今天是【重学C++】的第四讲,在前面《03 | 手撸C++智能指针实战教程》中,我们或多或少接触了右值引用和移动的一些用法。
右值引用是 C++11 标准中一个很重要的特性。第一次接触时,可能会很乱,不清楚它们的目的是什么或者它们解决了什么问题。接下来两节课,我们详细讲讲右值引用及其相关应用。内容很干,注意收藏!
左值 vs 右值
简单来说,左值是指可以使用
&
符号获取到内存地址的表达式,一般出现在赋值语句的左边,比如变量、数组元素和指针等。int i = 42; i = 43; // ok, i是一个左值 int* p = &i; // ok, i是一个左值,可以通过&符号获取内存地址 int& lfoo() { // 返回了一个引用,所以lfoo()返回值是一个左值 return i; }; lfoo() = 42; // ok, lfoo() 是一个左值 int* p1 = &lfoo(); // ok, lfoo()是一个左值
相反,右值是指无法获取到内存地址的表达是,一般出现在赋值语句的右边。常见的有字面值常量、表达式结果、临时对象等。
int rfoo() { // 返回了一个int类型的临时对象,所以rfoo()返回值是一个右值 return 5; }; int j = 0; j = 42; // ok, 42是一个右值 j = rfoo(); // ok, rfoo()是右值 int* p2 = &rfoo(); // error, rfoo()是右值,无法获取内存地址
左值引用 vs 右值引用
C++中的引用是一种别名,可以通过一个变量名访问另一个变量的值。
上图中,变量a和变量b指向同一块内存地址,也可以说变量a是变量b的别名。
在C++中,引用分为左值引用和右值引用两种类型。左值引用是指对左值进行引用的引用类型,通常使用
&
符号定义;右值引用是指对右值进行引用的引用类型,通常使用&&
符号定义。class X {...}; // 接收一个左值引用 void foo(X& x); // 接收一个右值引用 void foo(X&& x); X x; foo(x); // 传入参数为左值,调用foo(X&); X bar(); foo(bar()); // 传入参数为右值,调用foo(X&&);
所以,通过重载左值引用和右值引用两种函数版本,满足在传入左值和右值时触发不同的函数分支。
值得注意的是,
void foo(const X& x);
同时接受左值和右值传参。void foo(const X& x); X x; foo(x); // ok, foo(const X& x)能够接收左值传参 X bar(); foo(bar()); // ok, foo(const X& x)能够接收右值传参 // 新增右值引用版本 void foo(X&& x); foo(bar()); // ok, 精准匹配调用foo(X&& x)
到此,我们先简单对右值和右值引用做个小结:
- 像字面值常量、表达式结果、临时对象等这类无法通过
&
符号获取变量内存地址的,称为右值。- 右值引用是一种引用类型,表示对右值进行引用,通常使用
&&
符号定义。右值引用主要解决一下两个问题:
- 实现移动语义
- 实现完美转发
这一节我们先详细讲讲右值是如何实现移动效果的,以及相关的注意事项。完美转发篇幅有点多,我们留到下节讲。
复制 vs 移动
假设有一个自定义类
X
,该类包含一个指针成员变量,该指针指向另一个自定义类对象。假设O
占用了很大内存,创建/复制O
对象需要较大成本。class O { public: O() { std::cout << "call o constructor" << std::endl; }; O(const O& rhs) { std::cout << "call o copy constructor." << std::endl; } }; class X { public: O* o_p; X() { o_p = new O(); } ~X() { delete o_p; } };
X
对应的拷贝赋值函数如下:X& X::operator=(X const & rhs) { // 根据rhs.o_p生成的一个新的O对象资源 O* tmp_p = new O(*rhs.o_p); // 回收x当前的o_p; delete this->o_p; // 将tmp_p 赋值给 this.o_p; this->o_p = tmp_p; return *this; }
假设对
X
有以下使用场景:X x1; X x2; x1 = x2;
上述代码输出:
call o constructor call o constructor call o copy constructor
x1
和x2
初始化时,都会执行new O()
, 所以会调用两次O
的构造函数;执行x1=x2
时,会调用一次O
的拷贝构造函数,根据x2.o_p
复制一个新的O
对象。由于
x2
在后续代码中可能还会被使用,所以为了避免影响x2
,在赋值时调用O
的拷贝构造函数复制一个新的O
对象给x1
在这种场景下是没问题的。但在某些场景下,这种拷贝显得比较多余:
X foo() { return X(); }; X x1; x1 = foo();
代码输出与之前一样:
call o constructor call o constructor call o copy constructor
在这个场景下,
foo()
创建的那个临时X
对象在后续代码是不会被用到的。所以我们不需要担心赋值函数中会不会影响到那个临时X
对象,没必要去复制一个新的O
对象给x1
。更高效的做法,是直接使用
swap
交换临时X
对象的o_p
和x1.o_p
。这样做有两个好处:1. 不用调用耗时的O
拷贝构造函数,提高效率;2. 交换后,临时X
对象拥有之前x1.o_p
指向的资源,在析构时能自动回收,避免内存泄漏。这种避免高昂的复制成本,而直接将资源从一个对象"移动"到另外一个对象的行为,就是C++的移动语义。
哪些场景适用移动操作呢?无法获取内存地址的右值就很合适,我们不需要担心后续的代码会用到该右值。
最后,我们看下移动版本的赋值函数
X& operator=(X&& rhs) noexcept { std::swap(this->o_p, rhs.o_p); return *this; };
看下使用效果:
X x1; x1 = foo();
输出结果:
call o constructor call o constructor
右值引用一定是右值吗?
假设我们有以下代码:
class X { public: // 复制版本的赋值函数 X& operator=(const X& rhs); // 移动版本的赋值函数 X& operator=(X&& rhs) noexcept; }; void foo(X&& x) { X x1; x1 = x; }
类
X
重载了复制版本和移动版本的赋值函数。现在问题是:x1=x
这个赋值操作调用的是X& operator=(const X& rhs)
还是X& operator=(X&& rhs)
?针对这种情况,C++给出了相关的标准:Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.也就是说,只要一个右值引用有名称,那对应的变量就是一个左值,否则,就是右值。
回到上面的例子,函数
foo
的入参虽然是右值引用,但有变量名x
,所以x
是一个左值,所以operator=(const X& rhs)
最终会被调用。再给一个没有名字的右值引用的例子
X bar(); // 调用X& operator=(X&& rhs),因为bar()返回的X对象没有关联到一个变量名上 X x = bar();
这么设计的原因也挺好理解。再改下
foo
函数的逻辑:void foo(X&& x) { X x1; x1 = x; ... std::cout << *(x.inner_ptr) << std::endl; }
我们并不能保证在
foo
函数的后续逻辑中不会访问到x
的资源。所以这种情况下如果调用的是移动版本的赋值函数,x
的内部资源在完成赋值后就乱了,无法保证后续的正常访问。std::move
反过来想,如果我们明确知道在
x1=x
后,不会再访问到x
,那有没有办法强制走移动赋值函数呢?C++提供了
std::move
函数,这个函数做的工作很简单:通过隐藏掉入参的名字,返回对应的右值。X bar(); X x1 // ok. std::move(x1)返回右值,调用移动赋值函数 X x2 = std::move(x1); // ok. std::move(bar())与 bar()效果相同,返回右值,调用移动赋值函数 X x3 = std::move(bar());
最后,用一个容易犯错的例子结束这一环节
class Base { public: // 拷贝构造函数 Base(const Base& rhs); // 移动构造函数 Base(Base&& rhs) noexcept; }; class Derived : Base { public: Derived(Derived&& rhs) // wrong. rhs是左值,会调用到 Base(const Base& rhs). // 需要修改为Base(std::move(rhs)) : Base(rhs) noexcept { ... } }
返回值优化
依照惯例,还是先给出类
X
的定义class X { public: // 构造函数 X() { std::cout << "call x constructor" <<std::endl; }; // 拷贝构造函数 X(const X& rhs) { std::cout << "call x copy constructor" << std::endl; }; // 移动构造函数 X(X&& rhs) noexcept { std::cout << "call x move constructor" << std::endl }; }
大家先思考下以下两个函数哪个性能比较高?
X foo() { X x; return x; }; X bar() { X x; return std::move(x); }
很多读者可能会觉得
foo
需要一次复制行为:从x
复制到返回值;bar
由于使用了std::move
,满足移动条件,所以触发的是移动构造函数:从x
移动到返回值。复制成本 > 移动成本,所以bar
性能更好。实际效果与上面的推论相反,
bar
中使用std::move
反倒多余了。现代C++编译器会有返回值优化。换句话说,编译器将直接在foo
返回值的位置构造x
对象,而不是在本地构造x
然后将其复制出去。很明显,这比在本地构造后移动效率更快。以下是
foo
和bar
的输出:// foo call x constructor // bar call x constructor call x move constructor
移动需要保证异常安全
细心的读者可能已经发现了,在前面的几个小节中,移动构造/赋值函数我都在函数签名中加了关键字
noexcept
,这是向调用者表明,我们的移动函数不会抛出异常。这点对于移动函数很重要,因为移动操作会对右值造成
破坏
。如果移动函数中发生了异常,可能会对程序造成不可逆的错误。以下面为例class X { public: int* int_p; O* o_p; X(X&& rhs) { std::swap(int_p, rhs.int_p); ... 其他业务操作 ... std::swap(o_p, rhs.o_p); } }
如果在「其他业务操作」中发生了异常,不仅会影响到本次构造,
rhs
内部也已经被破坏
了,后续无法重试构造。所以,除非明确标识noexcept
,C++在很多场景下会慎用
移动构造。比较经典的场景是
std::vector
扩缩容。当vector
由于push_back
、insert
、reserve
、resize
等函数导致内存重分配时,如果元素提供了一个noexcept
的移动构造函数,vector
会调用该移动构造函数将元素移动
到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过去。总结
今天我们主要学了C++中右值引用的相关概念和应用场景,并花了很大篇幅讲解移动语义及其相关实现。
右值引用主要解决实现移动语义和完美转发的问题。我们下节接着讲解右值是如何实现完美转发。欢迎关注,及时收到推送~
【往期推荐】
所以,最终返回的是放在std::future中的F(Args…)返回类型的异步执行结果。
举个简单的例子来理解吧:
// 来自 packaged_task 的 future std::packaged_task<int()> task([](){ return 7; }); // 包装函数,将lambda表达式进行包装 std::future<int> f1 = task.get_future(); // 定义一个future对象f1,存放int型的值。此处已经表明:将task挂载到线程上执行,然后返回的结果才会保存到f1中 std::thread(std::move(task)).detach(); // 将task函数挂载在线程上运行 f1.wait(); //f1等待异步结果的输入 f1.get(); //f1获取到的异步结果 struct S { double operator()(char, int&); float operator()(int) { return 1.0;} }; std::result_of<S(char, int&)>::type d = 3.14; // d 拥有 double 类型,等价于double d = 3.14 std::result_of<S(int)>::type x = 3.14; // x 拥有 float 类型,等价于float x = 3.14
经过上述两个简单的小例子可以知道:
-> std::future<typename std::result_of<F(Args...)>::type> //等价于 //F(Args...) 为 int f(args) //std::result_of<F(Args...)>::type 表示为 int //std::future<int> f1 //return f1 //在后续我们根据f1.get就可以取出存放在里面的int值 //最终返回了一个F(Args...)类型的值,而这个值是存储在std::future中,因为线程是异步处理的
接着分析:
using return_type = typename std::result_of<F(Args...)>::type;
表示使用return_type表示F(Args...)的返回类型。
auto task = std::make_shared< std::packaged_task<return_type()> >( std::bind(std::forward<F>(f), std::forward<Args>(args)...) );
由上述小例子,我们已经知道std::packaged_task是一个包装函数,所以
auto sp = std::make_shared<C>(12); ---> auto sp = new C(12) //创建一个智能指针sp,其指向一个用12初始化的C类对象 std::packaged_task<return_type()> //表示包装一个返回值为return_type的函数 auto task = std::make_shared< std::packaged_task<return_type()> > (std::bind(std::forward<F>(f), std::forward<Args>(args)...) //创建一个智能指针task,其指向一个用std::bind(std::forward<F>(f), std::forward<Args>(args)... 来初始化的 std::packaged_task<return_type()> 对象 //即 std::packaged_task<return_type()> t1(std::bind(std::forward<F>(f), std::forward<Args>(args)...) //然后task指向了t1,即task指向了返回值为return_type的f(args) std::packaged_task<int()> task(std::bind(f, 2, 11)); //将函数f(2,11)打包成task,其返回值为int
所以最终,task指向了传递进来的函数。
std::future<return_type> res = task->get_future(); //res中保存了类型为return_type的变量,有task异步执行完毕才可以将值保存进去
所以,res会在异步执行完毕后即可获得所求。
{ std::unique_lock<std::mutex> lock(queue_mutex); // don't allow enqueueing after stopping the pool if(stop) throw std::runtime_error("enqueue on stopped ThreadPool"); tasks.emplace([task](){ (*task)(); }); //(*task)() ---> f(args) }
在新的作用于内加锁,若线程池已经停止,则抛出异常。
否则,将task所指向的f(args)插入到tasks任务队列中。需要指出,这儿的emplace中传递的是构造函数的参数。
condition.notify_one(); //任务加入任务队列后,需要去唤醒一个线程 return res; //待线程执行完毕,将异步执行的结果返回
经过上述分析,这样将每个人物插入到任务队列中的过程就完成了。
析构函数解析
inline ThreadPool::~ThreadPool() { { std::unique_lock<std::mutex> lock(queue_mutex); stop = true; } condition.notify_all(); for(std::thread &worker: workers) worker.join(); }
在析构函数中,先对任务队列中加锁,将停止标记设置为true,这样后续即使有新的插入任务操作也会执行失败。
使用条件变量唤醒所有线程,所有线程都会往下执行:
if(this->stop && this->tasks.empty()) return;
在stop设置为true且任务队列中为空时,对应的线程进而跳出循环结束。
for(std::thread &worker: workers) worker.join();
将每个线程设置为join,等到每个线程结束完毕后,主线程再退出。
主函数解析
ThreadPool pool(4); //创建一个线程池,池中线程为4 std::vector< std::future<int> > results; //创建一个保存std::future<int>的数组,用于存储4个异步线程的结果 for(int i = 0; i < 8; ++i) { //创建8个任务 results.emplace_back( //一次保存每个异步结果 pool.enqueue([i] { //将每个任务插入到任务队列中,每个任务的功能均为“打印+睡眠1s+打印+返回结果” std::cout << "hello " << i << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << "world " << i << std::endl; return i*i; }) ); } for(auto && result: results) //一次取出保存在results中的异步结果 std::cout << result.get() << ' '; std::cout << std::endl;
需要对主函数中的任务函数进行说明:
[i] { //将每个任务插入到任务队列中,每个任务的功能均为“打印+睡眠1s+打印+返回结果” std::cout << "hello " << i << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << "world " << i << std::endl; return i*i; }
这个lambda表达式用来表示一个匿名函数,该函数分写执行 打印-睡眠-打印-返回结果。
pool.enqueue(fun);
对应于类中的
auto ThreadPool::enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type>
其中,F&& f 是lambda表达式(或者说fun)的形参,而参数为0。
而
std::future<typename std::result_of<F(Args...)>::type>
则用来保存 i*i 。
对应的
std::result_of<F(Args...)>::type //int型
上述是简要的分析。