【转载】ThreadPool源码c++11

前言

偶然发现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 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  #include 
  ​
  class ThreadPool {
  public:
      ThreadPool(size_t);
      template
      auto enqueue(F&& f, Args&&... args) 
          -> std::future::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 > 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 task;
  ​
                      {
                          std::unique_lock 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
  auto ThreadPool::enqueue(F&& f, Args&&... args) 
      -> std::future::type>
  {
      using return_type = typename std::result_of::type;
  ​
      auto task = std::make_shared< std::packaged_task >(
              std::bind(std::forward(f), std::forward(args)...)
          );
          
      std::future res = task->get_future();
      {
          std::unique_lock 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 lock(queue_mutex);
          stop = true;
      }
      condition.notify_all();
      for(std::thread &worker: workers)
          worker.join();
  }
  ​
  #endif

example.cpp是对线程池ThreadPool.h的调用

  #include 
  #include 
  #include 
  ​
  #include "ThreadPool.h"
  ​
  int main()
  {
      
      ThreadPool pool(4);
      std::vector< std::future > 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 > tasks 用于存放任务的队列,用queue队列进行保存。任务类型为std::function。因为 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
      auto enqueue(F&& f, Args&&... args) 
          -> std::future::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 > 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 task;
  ​
                      {
                          std::unique_lock 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表示线程池中要创建多少个线程。

初始化成员变量stopfalse,即表示线程池启动着。

然后进入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 task;
  ​
          {
              std::unique_lock 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 lock(this->queue_mutex);

可以在退出作用区域时自动解锁,无需显式解锁。所以,{}起的作用就是在退出 } 时自动回释放线程池的queue_mutex。

在{}中,我们先对任务队列加锁,然后根据条件变量判断条件是否满足。

  void
  wait(unique_lock& 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++ 有三种方式可以创建/传递一个可以被调用的对象:

  1. 函数指针
  2. 仿函数(Functor)
  3. Lambda 表达式

函数指针

函数指针是从 C 语言老祖宗继承下来的东西,比较原始,功能也比较弱:

  1. 无法直接捕获当前的一些状态,所有外部状态只能通过参数传递(不考虑在函数内部使用 static 变量)。
  2. 使用函数指针的调用无法 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 } 
  1. capture-list:捕获列表。前面的例子 auto Plus = [](int a, int b) { return a + b; }; 没有捕获任何变量。
  2. params:和普通函数一样的参数。
  3. mutable:只有这个 Lambda 表达式是 mutable 的才允许修改按值捕获的参数。
  4. exception:异常标识。暂时不必理解。
  5. attribute:属性标识。暂时不必理解。
  6. ret:返回值类型,可以省略,让编译器通过 return 语句自动推导。
  7. 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 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 uptr = std::make_unique(123);
auto callback = [uptr]() {                               // 编译错误,uptr is move-only
    std::cout << *uptr << std::endl;
};  

按引用捕获虽然可以编译通过,但往往是不符合要求的。比如下面的例子,离开作用域之后 uptr 会被析构掉。但是 callback 对象已经被传给另一个线程。

std::unique_ptr uptr = std::make_unique(123);
auto callback = [&uptr]() {                               
    std::cout << *uptr << std::endl;
};  
// ... 将 callback 传给另一个线程
// return => uptr delete 掉指向的内存

通过捕获列表初始化,完成 Move-Only 对象的“Capture by Move”。

std::unique_ptr uptr = std::make_unique(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 的模式:

  1. [=] 表示 default capture by value。按值捕获可见范围内的所有局部变量。
  2. [&] 表示 default capture by reference。按引用捕获可见范围内的所有局部变量。

比如:

int a = 1;
std::string s = "hello";
std::vector 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状态。

【转载】ThreadPool源码c++11_第1张图片

(借用 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
  auto ThreadPool::enqueue(F&& f, Args&&... args) 
      -> std::future::type>
  {
      using return_type = typename std::result_of::type;
  ​
      auto task = std::make_shared< std::packaged_task >(
              std::bind(std::forward(f), std::forward(args)...)
          );
          
      std::future res = task->get_future();
      {
          std::unique_lock 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
  auto ThreadPool::enqueue(F&& f, Args&&... args) 
      -> std::future::type>

equeue是一个模板函数,其类型形参为F与Args。其中class... Args表示多个类型形参。

auto用于自动推导出equeue的返回类型,函数的形参为(F&& f, Args&&... args),其中&&表示右值引用。表示接受一个F类型的f,与若干个Args类型的args。

-> std::future::type>

表示返回类型,与lambda表达式中的表示方法一样。

返回的是什么类型呢?

  typename std::result_of::type   //获得以Args为参数的F的函数类型的返回类型
  std::future::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++中的引用是一种别名,可以通过一个变量名访问另一个变量的值。

【转载】ThreadPool源码c++11_第2张图片

上图中,变量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)

到此,我们先简单对右值和右值引用做个小结:

  1. 像字面值常量、表达式结果、临时对象等这类无法通过&符号获取变量内存地址的,称为右值。
  2. 右值引用是一种引用类型,表示对右值进行引用,通常使用&&符号定义。

右值引用主要解决一下两个问题:

  1. 实现移动语义
  2. 实现完美转发

这一节我们先详细讲讲右值是如何实现移动效果的,以及相关的注意事项。完美转发篇幅有点多,我们留到下节讲。

复制 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

x1x2初始化时,都会执行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_px1.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" <

大家先思考下以下两个函数哪个性能比较高?

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然后将其复制出去。很明显,这比在本地构造后移动效率更快。

以下是foobar的输出:

// 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_backinsertreserveresize 等函数导致内存重分配时,如果元素提供了一个noexcept的移动构造函数,vector会调用该移动构造函数将元素移动到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过去。

总结

今天我们主要学了C++中右值引用的相关概念和应用场景,并花了很大篇幅讲解移动语义及其相关实现。

右值引用主要解决实现移动语义和完美转发的问题。我们下节接着讲解右值是如何实现完美转发。欢迎关注,及时收到推送~

【往期推荐】

01 C++如何进行内存资源管理

02 脱离指针陷阱:深入浅出 C++ 智能指针

03 手撸C++智能指针实战教程

 

所以,最终返回的是放在std::future中的F(Args…)返回类型的异步执行结果。

举个简单的例子来理解吧:

  // 来自 packaged_task 的 future
  std::packaged_task task([](){ return 7; }); // 包装函数,将lambda表达式进行包装
  std::future 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::type d = 3.14; // d 拥有 double 类型,等价于double d = 3.14
  std::result_of::type x = 3.14; // x 拥有 float 类型,等价于float x = 3.14

经过上述两个简单的小例子可以知道:

  -> std::future::type>
  //等价于
  //F(Args...) 为  int f(args)
  //std::result_of::type  表示为 int
  //std::future f1
  //return f1
  //在后续我们根据f1.get就可以取出存放在里面的int值
  //最终返回了一个F(Args...)类型的值,而这个值是存储在std::future中,因为线程是异步处理的

接着分析:

 using return_type = typename std::result_of::type;

表示使用return_type表示F(Args...)的返回类型。

  auto task = std::make_shared< std::packaged_task >(
              std::bind(std::forward(f), std::forward(args)...)
          );

由上述小例子,我们已经知道std::packaged_task是一个包装函数,所以

  auto sp = std::make_shared(12);   --->   auto sp = new C(12)  //创建一个智能指针sp,其指向一个用12初始化的C类对象
      
  std::packaged_task   //表示包装一个返回值为return_type的函数
      
  auto task = std::make_shared< std::packaged_task > (std::bind(std::forward(f), std::forward(args)...)   //创建一个智能指针task,其指向一个用std::bind(std::forward(f), std::forward(args)... 来初始化的 std::packaged_task 对象
  ​
  //即  std::packaged_task t1(std::bind(std::forward(f), std::forward(args)...)
  //然后task指向了t1,即task指向了返回值为return_type的f(args)
      
  std::packaged_task task(std::bind(f, 2, 11));    //将函数f(2,11)打包成task,其返回值为int

所以最终,task指向了传递进来的函数。

   std::future res = task->get_future();
  //res中保存了类型为return_type的变量,有task异步执行完毕才可以将值保存进去

所以,res会在异步执行完毕后即可获得所求。

  {
      std::unique_lock 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 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 > results;    //创建一个保存std::future的数组,用于存储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::type>

其中,F&& flambda表达式(或者说fun)的形参,而参数为0。

std::future::type>

则用来保存 i*i 

对应的

std::result_of::type    //int型

上述是简要的分析。

你可能感兴趣的:(c++,开发语言)