C++多线程:std::thread

最近这段时间在学习C++多线程相关的知识,打算将学习的内容记录下来,加深理解和记忆。

C++11 新标准中引入了五个头文件来支持多线程编程,他们分别是 ,,,

  • :该头文主要声明了两个类, std::atomic 和 std::atomic_flag,另外还声明了一套C风格的原子类型和与C兼容的原子操作的函数。
  • :该头文件主要声明了 std::thread 类,另外 std::this_thread 命名空间也在该头文件中。
  • :该头文件主要声明了与互斥量(mutex)相关的类,包括 std::mutex 系列类,std::lock_guard, std::unique_lock, 以及其他的类型和函数。
  • :该头文件主要声明了与条件变量相关的类,包括 std::condition_variable 和 std::condition_variable_any。
  • :该头文件主要声明了 std::promise, std::package_task 两个 Provider 类,以及 std::future 和 std::shared_future 两个 Future 类,另外还有一些与之相关的类型和函数,std::async() 函数就声明在此头文件中。

定义

C++是在C++11之后才有了线程库:std::thread。编译时需要添加选项:-std=c++11。

使用std::thread创建线程比较简单,thread实例化一个线程对象就创建完成了,示例:

#include 
#include 

void f() { 
    std::cout << "hello world"; 
}

int main() {
  std::thread t{f};
  t.join();  // 等待新起的线程退出
}

main() 函数的是主线程,将函数f()添加为std::thread的参数即可启动另一个线程,两个线程会同时运行。

构造函数

默认构造函数 thread() noexcept;
初始化构造函数 template explicit thread(Fn&& fn, Args&&… args);
拷贝构造函数 [deleted] thread(const thread&) = delete;
Move构造函数 thread(thread&& x) noexcept;
  • 默认构造函数,创建一个空的std::thread执行对象。
  • 初始化构造函数,创建一个std::thread对象,该std::thread对象可被joinable,新产生的线程会调用fn函数,该函数的参数由args给出。
  • 拷贝构造函数(被禁用),意味着std::thread对象不可拷贝构造。
  • Move构造函数,move构造函数(move语义是C++11新出现的概念),调用成功之后x不代表任何std::thread执行对象。

析构函数

~thread();

销毁thread对象。如果它还拥有关联线程(joinable() == true),则会调用std::terminate()结束程序。一般需要下列操作后,thread对象无关联的线程才可以安全销毁:

  • 被默认构造
  • 被移动(转移所有权)
  • 已调用join()
  • 已调用detach()

赋值操作函数

如果该对象还拥有关联的运行中进程(即joinable() == true),则调用std::terminate()终止程序。否则,赋值other的状态给该对象并设置other为默认构造的状态(空状态,不再执行线程)。

thread& operator=( thread&& other ) noexcept;

注意:该操作与move构造函数一样,属于“剪切”而非“拷贝”。

join与datch

join:阻塞当前线程直至thread对象所标识的线程结束其执行。

void join();

detach:从thread对象分离执行线程,允许独立地执行线程,主调线程无法再取得该线程的控制权。detach调用后不需要再调用join等待线程结束释放资源。一旦该线程退出,则自动释放所有分配的资源(它的资源会被init进程回收)。

void detach();

其它

joinable:检查对象是否还标识活跃的执行线程。具体就是,若get_id() != std::thread::id()则返回true,否则false 。默认构造的thread因为没有执行线程所以返回false。 结束执行代码,但仍未调用join函数的线程仍被当作活跃的执行线程,从而返回true。

bool joinable() const noexcept;

get_id:返回标识与当前thread对象关联的线程的std::thread::id。也就是返回线程的唯一标识。类std::thread::id是轻量的可频繁复制类,它是std::thread对象的唯一标识符。此类的实例也保留有不表示任何线程的特殊值。一旦线程结束,则std::thread::id的值可为另一线程复用。此类也可以用作有序和无序的关联容器的键值。

std::thread::id get_id() const noexcept;

native_handle:返回实现定义的底层线程句柄。允许通过使用平台相关API直接操作底层实现。具体值要依赖具体平台,对于Linux而言,即返回pthread的句柄。

native_handle_type native_handle();

hardware_concurrency:返回实现支持的并发线程数。应该只把该值当做提示。当无法获取时,函数返回0。

static unsigned int hardware_concurrency() noexcept;

基本用法

上面已经给出实例,传递一个函数指针就可以实例化一个线程,而std::thread的参数也可以使用有函数操作符类型的对象实例或者Lambda表达式进行构造:

#include 
#include 

struct A {
  // 函数操作符重载
  void operator()() const { 
      std::cout << 1; 
  }
};

int main() {
  A a;
  std::thread t1(a);  // 1 会调用 A 的操作符()函数
  std::thread t2(A());  // 2 most vexing parse,声明名为t2参数类型为A的函数
  std::thread t3{A()};  // 3
  std::thread t4((A()));  // 4
  std::thread t5{[] { std::cout << 1; }};  // 5
  t1.join();
  t3.join();
  t4.join();
  t5.join();
}

对于上面语句①,通过有函数操作符类型的实例进行构造,也就是类型A的实例a,thread会将对象a复制到新线程的存储空间中,函数对象的执行和调用都在线程的内存空间中进行。

语句②,传递了一个临时变量A(),而不是一个命名的变量。C++编译器会将其解析为函数声明,而不是类型对象的定义。即t2是一个函数,原型是std::thread t2(A (*)()),这个函数带有一个参数(函数指针指向没有参数并返回A对象的函数),返回一个std::thread对象的函数。

为了避免出现类似语句②那样的语法失误,可以使用多组括号③,或使用统一的初始化语法④都可以避免。

语句⑤,Lambda表达式也能避免这个问题。Lambda表达式是C++11的一个新特性,允许使用一个可以捕获局部变量的局部函数。

线程参数

向可调用对象或函数传递参数很简单,只需要将这些参数作为std::thread构造函数的附加参数即可。需要注意的是,这些参数会拷贝至新线程的内存空间中(同临时变量一样)。即使函数中的参数是引用的形式,拷贝操作也会执行。来看一个例子:

#include 
#include 
#include 

void f(int i, std::string const& s) {
    std::cout << s << i << std::endl;
}

int main() {
  std::thread t(f, 3, "hello");
  t.join();
}

上述代码创建了一个调用f(3, “hello”)的线程。注意,函数f需要一个std::string对象作为第二个参数,但这里使用的是字符串的字面值,也就是char const *类型,线程的上下文完成字面值向std::string的转化。

如果函数参数定义了默认实参则会被忽略,也就是必须要指定一个函数实参,即使有默认参数。

#include 
#include 

void f(int i = 1) {}

int main() {
  // std::thread t{f};  // 出错,因为默认实参会被忽略
  std::thread t{f, 42};
  t.join();
}

如果参数是引用类型也会被忽略,如下面的代码,std::thread的构造函数①并不知晓函数f需要传入引用参数,直接无视函数参数类型,盲目地拷贝已提供的变量。不过,内部代码会将拷贝的参数以右值的方式进行传递,这是为了那些只支持移动的类型,而后会尝试以右值为实参调用f()。但因为函数期望的是一个非常量引用作为参数(而非右值),所以会在编译时出错。

#include 
#include 

void f(int& x) { ++x; }

int main() {
  int i = 1;
  std::thread t{f, i}; // 1 compile error
  t.join();
  std::cout << i << std::endl;
}

问题的解决办法很简单:如果参数是引用类型要使用std::ref,使用std::ref将参数转换成引用的形式,这样函数f()就会收到i的引用,而非i的拷贝副本,例子如下:

#include 
#include 

void f(int& x) { ++x; }

int main() {
  int i = 1;
  std::thread t{f, std::ref(i)};
  t.join();
  std::cout << i << std::endl;  // 输出 2
}

thread构建也可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数:

#include 
#include 

struct A {
public:
  void do_work() {
  	std::cout << "A::do_work\n";
  }
};

int main() {
  A a;
  std::thread t(&A::do_work, &a);  // 1 
  t.join();
}

上面这段代码中,新线程将会调用a.do_work(),其中a的地址作为对象指针提供给函数。

这种情况也可以为成员函数提供参数:std::thread构造函数的第三个参数就是成员函数的第一个参数,以此类推:

#include 
#include 

struct A {
public:
  void do_work(int num) {
  	std::cout << "A::do_work\n";
  }
};

int main() {
  A a;
  int i = 1;
  std::thread t(&A::do_work, &a, i);  // 1 
  t.join();
}

另一种情况,为线程提供的入口函数的参数仅支持移动(move),不能拷贝。“移动”是指原始对象中的数据所有权转移给另一对象,从而这些数据就不再在原始对象中保存(类似文本编辑时的剪切操作)。std::unique_ptr就是这样一种类型(C++11中的智能指针),这种类型为动态分配的对象提供内存自动管理机制。同一时间内,只允许一个std::unique_ptr实例指向一个对象,并且当这个实例销毁时,指向的对象也将被删除。移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象的所有权在多个std::unique_ptr实例中传递。使用“std::move”转移对象所有权后,就会留下一个空指针。使用移动操作可以将对象转换成函数可接受的实参类型,或满足函数返回值类型要求。当原对象是临时变量时,则自动进行移动操作,但当原对象是一个命名变量,转移的时候就需要使用std::move()进行显示移动。下面的代码展示了std::move的用法,展示了std::move是如何转移动态对象的所有权到线程中去的:

#include 
#include 

struct big_object {
public:
  void prepare_data(int num) {
  	std::cout << "big_object::prepare_data\n";
  }
};

void process_big_object(std::unique_ptr up) {
   std::cout << "process_big_object\n";
}

int main() {
  std::unique_ptr p(new big_object);
  p->prepare_data(42);
  std::thread t(process_big_object, std::move(p));  // 1 
  t.join();
}

通过在std::thread构造函数中执行std::move§,big_object对象的所有权首先被转移到新创建线程的的内部存储中,之后再传递给process_big_object函数。

等待线程完成(Join)

在线程销毁前要对其调用join等待线程退出或detach将线程分离,以下程序属于使用join正常等待线程退出,join属于阻塞式接口:

#include 
#include 
#include 

std::time_t now() 
{
    auto t0 = std::chrono::system_clock::now();
    std::time_t time_t_today = std::chrono::system_clock::to_time_t(t0);
    return time_t_today;  // seconds
}

void foo()
{
  // simulate expensive operation
  std::this_thread::sleep_for(std::chrono::seconds(5));
  std::cout << now() << "-ending first helper...\n";
}

void bar()
{
  // simulate expensive operation
  std::this_thread::sleep_for(std::chrono::seconds(1));
  std::cout << now() << "-ending second helper...\n";
}

int main()
{
  std::cout << "starting first helper...\n";
  std::thread helper1(foo);

  std::cout << "starting second helper...\n";
  std::thread helper2(bar);

  std::cout << now() << "-waiting for helpers to finish..." << std::endl;
  helper1.join();
  std::cout << now() << "-join return first helper...\n";
  helper2.join();
  std::cout << now() << "-join return second helper...\n";

  std::cout << "finish!\n";
}

输出

starting first helper...
starting second helper...
1645140155-waiting for helpers to finish...
1645140156-ending second helper...
1645140160-ending first helper...
1645140160-join return first helper...
1645140160-join return second helper...
finish!

使用detach分离线程,注意分离线程可能出现空悬引用的隐患:

#include 
#include 
#include 

class A {
 public:
  A(int& x) : x_(x) {}

  void operator()() {
    std::cout << "before sleep" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "after sleep" << std::endl;
    call(x_);  // 存在对象析构后引用空悬的隐患
  }

 private:
  void call(int& x) {
      std::cout << x << std::endl;
  }

 private:
  int& x_;
};
 
void f() {
  int x = 0;
  A a{x};  // 1 x的引用传递给A.0
  std::cout << "before t" << std::endl;
  std::thread t{a};  // 2
  std::this_thread::sleep_for(std::chrono::seconds(1));
  t.detach();  // 3 不等待 t 结束
  std::cout << "after t.detach" << std::endl;
}  // 4 函数结束后 t 可能还在运行,而 x 已经销毁,a.x_ 为空悬引用

int main() {
  std::thread t{f};  // 5 导致空悬引用
  t.join();
  std::cout << "finish" << std::endl;
  std::this_thread::sleep_for(std::chrono::seconds(5)); // 6
}

输出:

before t
before sleep
after t.detach
finish
after sleep
0

如果std::thread线程结束后没有调用join释放资源,thread的析构函数会调用std::terminate终止程序,如下程序就会运行出错:

#include 

int main() {
    {
        std::thread t([] {});
        // t.join();
    }
}

// terminate called without an active exception

join会在线程结束后清理std::thread所有资源,使其与完成的线程不再关联,因此对一个线程只能进行一次 join,如果调用多次会抛出异常:

#include 

int main() {
  std::thread t([] {});
  t.join();
  t.join();  // 错误throw excaption
}

// 抛出的异常错误如下:
// minate called after throwing an instance of 'std::system_error'
// What():  Invalid argument

如果线程运行过程中发生异常(通常抛出异常要么会终止程序,要么跳转到捕获异常的位置),之后的join会被忽略,为此需要捕获异常,并在抛出异常前join:

#include 
#include 

int main() {
  std::thread t([] {});

  try {
    std::cout << "throw 0" << std::endl;
    throw 1;  // 1
  } catch (int x) {
    std::cout << "catch: " << x << std::endl;
    t.join();  // 2 处理异常前先 join()
    throw x;   // 3 再将异常抛出
  }

  std::cout << "last join" << std::endl;
  t.join();  // 4 之前抛异常,不会执行到此处
}

输出:

terminate called after throwing an instance of 'int'
throw 0
catch: 1

从上面的输出来看,根本不会执行到最后语句④,因为上面抛出异常程序就直接结束了,这个例子就是要说明thread创建的线程需要使用join()确保在线程完成后清理线程相关的资源,否则会引起程序异常。

特殊情况下的等待

对于上面发生异常导致程序终止的情况,很容易就忘记调用join,为了避免应用被抛出的异常所终止,通常我们使用另外一种方式解决该问题,封装一个类,在析构函数中使用join()。如同下面代码:

class thread_guard
{
  std::thread& t;
public:
  explicit thread_guard(std::thread& t_):
    t(t_)
  {}
  ~thread_guard()
  {
    if(t.joinable()) // 1
    {
      t.join();      // 2
    }
  }
  thread_guard(thread_guard const&)=delete;   // 3
  thread_guard& operator=(thread_guard const&)=delete;
};

void th_func() {
    std::cout << "th_func" << std::endl;
}

void f()
{
  std::thread t(th_func);
  thread_guard g(t);
  do_something_in_current_thread();
}    // 4

int main() {
    f();
}

线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。

在thread_guard析构函数的测试中,首先判断线程是否可汇入①。如果可汇入,会调用join()②进行汇入。

拷贝构造函数和拷贝赋值操作标记为=delete③,是为了不让编译器自动生成。直接对对象进行拷贝或赋值是很危险的,因为这可能会弄丢已汇入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。

如果不想等待线程结束,可以分离线程,从而避免异常。不过,分离操作即使能让线程仍然在后台运行着,也能确保在std::thread对象销毁时不调用std::terminate()。但这会打破了线程与std::thread对象的联系,也就是外部无法通过thread实例对象操作控制线程了。

转移所有权

std::thread是move-only类型,不能拷贝,只能通过移动转移所有权(复制构造函数已被删除),但不能转移所有权到joinable的线程,因为每个线程thread实例都是唯一的,没有两个std::thread对象会表示同一执行线程。

#include 
#include 
#include 

void f() {}
void g() {}

int main() {
  std::thread t1{f};  // 1
  std::thread t2 = std::move(t1);  // 2
  assert(!t1.joinable());
  assert(t2.joinable());

  t1 = std::thread{g}; // 3
  assert(t1.joinable());
  assert(t2.joinable());
//   t1 = std::move(t2);  // 4 错误,不能转移所有权到 joinable 的线程
  t1.join();
  t1 = std::move(t2);  // 5 
  assert(t1.joinable());
  assert(!t2.joinable());
  t1.join();
}

首先,新线程与t1相关联①。当显式使用std::move()创建t2后②,t1的所有权就转移给了t2。之后,t1和执行线程已经没有关联了,执行f的函数线程与t2关联。

然后,临时std::thread对象相关的线程启动了③。为什么不显式调用std::move()转移所有权呢?因为,所有者是一个临时对象——移动操作将会隐式的调用

将t2线程的所有权转移④给t1。不过,t1已经有了一个关联的线程(执行g的线程),所以这里系统直接调用std::terminate()终止程序继续运行。这样做(不抛出异常,std::terminate()noexcept函数)是为了保证与std::thread的析构函数的行为一致。需要在线程对象析构前,显式的等待线程完成,或者分离它,进行赋值时也需要满足这些条件(说明:不能通过赋新值给std::thread对象的方式来"丢弃"一个线程)。

可以看到调用join释放线程资源后就可以使用move转移所有权了⑤。

  • 移动操作同样适用于支持移动的容器
#include 
#include 
#include 

int main() {
  std::vector v;
  for (int i = 0; i < 10; ++i) {
    v.emplace_back([] {});
  }
  std::for_each(std::begin(v), std::end(v), std::mem_fn(&std::thread::join));
}

将std::thread放入std::vector是向线程自动化管理迈出的第一步:并非为这些线程创建独立的变量,而是把它们当做一个组。创建一组线程(数量可以在运行时确定)。

  • std::thread 可以作为函数返回值
#include 

std::thread f() {
  return std::thread{[] {}};
}

int main() {
  std::thread t{f()};  // f函数返回的thread移交给了t
  t.join();
}
  • std::thread 也可以作为函数参数
#include 
#include 

void f(std::thread t) { t.join(); }

int main() {
  f(std::thread([] {}));

  std::thread t([] {});
  f(std::move(t));
}

实现一个可以直接用std::thread构造的自动清理线程的类

#include 
#include 
#include 

class scoped_thread {
 public:
  explicit scoped_thread(std::thread x) :   // 1
    t_(std::move(x)) {
    if (!t_.joinable()) {  // 2
      throw std::logic_error("no thread");
    }
  }
  ~scoped_thread() { 
      t_.join();  // 3
  }
  scoped_thread(const scoped_thread&) = delete;
  scoped_thread& operator=(const scoped_thread&) = delete;

 private:
  std::thread t_;
};

void f() {
    scoped_thread t{std::thread{[] {}}};  // 4
}  // 5

int main() {
  f();
}

与上面实现的thread_guard相似,不过新线程会直接传递到scoped_thread中④,而非创建一个独立变量。当主线程到达f()末尾时⑤,scoped_thread对象就会销毁,然后在析构函数中完成汇入③。上面的thread_guard类,需要在析构中检查线程是否“可汇入”。这里把检查放在了构造函数中②,并且当线程不可汇入时抛出异常。

线程标识

线程标识为std::thread::id类型,可以通过两种方式进行检索。

  • 可以通过调用std::thread对象的成员函数get_id()来直接获取。如果std::thread对象没有与任何执行线程相关联,get_id()将返回std::thread::type默认构造值(一般是0),这个值表示“无线程”。
  • 在当前线程中调用std::this_thread::get_id()(这个函数定义在头文件中)也可以获得线程标识。

std::thread::id对象可以自由的拷贝和对比,因为标识符是唯一的。如果两个对象的std::thread::id相等,那就是同一个线程,或者都“无线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有线程。

C++线程库不会限制你去检查线程标识是否一样,std::thread::id类型对象提供了相当丰富的对比操作。比如,为不同的值进行排序。这意味着开发者可以将其当做为容器的键值做排序,或做其他比较。按默认顺序比较不同的std::thread::id:当ab时,得a,等等,标准库也提供std::hash容器,std::thread::id也可以作为无序容器的键值。

#include 
#include 
#include 

void th_func() {
    std::cout << "sub_thread id2: " << std::this_thread::get_id() << std::endl;
}

void f()
{
   std::thread t_null;
   std::cout << "null_thread id: " << t_null.get_id() << std::endl;

  std::thread t(th_func);
  std::cout << "sub_thread id1: " << t.get_id() << std::endl;
  t.join();
}    // 4

int main() {
    std::thread::id main_thread = std::this_thread::get_id();
    std::cout << "main_thread id: " << main_thread << std::endl;
    f();

    std::vector threads(3);  // 5
    for(int i=0; i < 3; ++i)
    {
        threads[i]=std::thread([i] () {
            std::cout << i << std::endl;
        });
    }

    for (auto& it : threads) {
        it.join();
    }
}

输出:

main_thread id: 140672360355648
null_thread id: thread::id of a non-executing thread
sub_thread id1: 140672360351488
sub_thread id2: 140672360351488
0
2
1

查看硬件支持的线程数量

std::thread::hardware_concurrency()在新版C++中非常有用,其会返回并发线程的数量。例如,多核系统中,返回值可以是CPU核芯的数量。返回值也仅仅是一个标识,当无法获取时,函数返回0。

#include 
#include 

int main() {
  unsigned int n = std::thread::hardware_concurrency();
  std::cout << n << " concurrent threads are supported.\n";
}

std::thread到这里基本完成了,接口不是很多,但是用法细节挺多的。学习时最好都能用一个简单例子跑一遍。

参考:

《C++ Concurrency In Action》

std::thread - C++中文 - API参考文档 (apiref.com)

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