前面文章
2024.3.27记——C++多线程系列文章(一)
2024.3.28记——C++多线程系列文章(二)之向线程函数传递参数
归属权转移
每个std::thread对象都负责管控一个执行线程。该对象只能够移动却不能复制,故线程的归属权可以在实例之间转移(切忌复制)。这就保证了,对于任一特定的执行线程,任何时候都只有唯一的std:::thread对象与之关联,还准许程序员在其对象之间转移线程归属权。
假设我们要编写函数,功能是创建线程,并置于后台运行,但该函数本身不等待线程结束,而是将其归属权向上移交给函数的调用者;或相反地,我们想创建线程,遂将其归属权传入某个函数,由它负责等待该线程结束。两种操作都需要转移线程的归属权。两种操作都需要转移线程的归属权。这正是std::thread支持移动语义的缘由。
以下例子创建2个执行线程和3个std::thread实例t1、t2、t3,并将线程归属权在实例之间多次转移。
void some_function();
void some_other_function();
std::thread t1(some_function); ⇽--- ①
std::thread t2=std::move(t1); ⇽--- ②
t1=std::thread(some_other_function); ⇽--- ③
std::thread t3; ⇽--- ④
t3=std::move(t2); ⇽--- ⑤
t1=std::move(t3); ⇽--- ⑥该赋值操作会终止整个程序
首先,我们启动新线程①,并使之关联t1。
接着,构建t2,在其初始化过程中调用std::move(),将新线程的归属权显式地转移给t2②。在②之前,t1关联着执行线程,some_function()函数在其上运行;及至②处,新线程关联的变换为t2。
然后,启动另一新线程③,它与一个std::thread类型的临时对象关联。新线程的归属权随即转移给t1。这里无须显式调用std::move(),因为新线程本来就由临时变量持有,而源自临时变量的移动操作会自动地隐式进行。
t3按默认方式构造④,换言之,在创建时,它并未关联任何执行线程。
在⑤处,t2原本关联的线程的归属权会转移给t3,而t2是具名变量,故需再次显式调用std::move(),先将其转换为右值。经过这些转移,t1与运行some_other_function()的线程关联,t2没有关联线程,而t3与运行some_function()的线程关联。
在最后一次转移中⑥,运行some_function()的线程的归属权转移到t1,该线程最初由t1启动。但在转移之时,t1已经关联运行some_other_function()的线程。因此std::terminate()会被调用,终止整个程序。该调用在std::thread的析构函数中发生,目的是保持一致性。在std::thread对象析构前,我们必须明确:是等待线程完成还是要与之分离。不然,便会导致关联的线程终结。赋值操作也有类似的原则:只要std::thread对象正管控着一个线程,就不能简单地向它赋新值,否则该线程会因此被遗弃。
std::thread支持移动操作的意义是,函数可以便捷地转移线程的归属权,示例代码如下所示。
// 从函数内部返回std::thread对象
std::thread f() {
void some_function();
return std::thread(some_function);
}
// 从函数内部返回std::thread对象
std::thread g() {
void some_other_function(int);
std::thread t(some_other_function,42);
return t;
}
// 将thread对象作为参数传入到函数中去。右值
void h(std::thread t);
void p()
{
void some_function();
h(std::thread(some_function)); // 临时变量隐式转换成右值
std::thread t(some_function);
h(std::move(t)); // std::move将左值转成右值
}
以下示例生成多个线程,并等待它们完成运行,这初步形成多线程完成工作的例子。
void do_work(unsigned id);
void f()
{
std::vector<std::thread> threads;
for(unsigned i=0;i<20;++i)
{
threads.emplace_back(do_work,i); // 生成线程
}
for(auto& entry: threads) // 依次在各线程上调用join()函数
entry.join();
}
线程识别
前面文章中已经使用了线程ID概念,这里再多说一句。
线程ID所属型别是std::thread::id,它有两种获取方法。
- 在与线程关联的std::thread对象上调用成员函数get_id(),即可得到该线程的ID。
- 当前线程的ID可以通过调用std::this_thread::get_id()获得,函数定义位于头文件内。
如果std::thread对象没有关联任何执行线程,调用get_id()则会返回一个std::thread::id对象,它按默认构造方式生成,表示“线程不存在”。
std::thread::id型别的对象作为线程ID,可随意进行复制操作或比较运算;否则,它们就没有什么大的用处。如果两个std::thread::id型别的对象相等,则它们表示相同的线程,或者它们的值都表示“线程不存在”;如果不相等,它们就表示不同的线程,或者当中一个表示某个线程,而另一个表示“线程不存在”。
#include <iostream>
#include <thread>
void print(int n) {
for (int i = 0; i < n; ++i) {
std::cout << i << " ";
}
std::cout << std::endl;
// 函数内部调用get_id,并打印线程
std::cout << "print id:" << std::this_thread::get_id() << std::endl;
}
int main() {
std::thread t;
// t未指定任何线程前调用get_id
std::cout << "default thread id:" << t.get_id() << std::endl;
t = std::thread(print, 5);
// join前调用get_id
std::cout << "main before join t thread id:" << t.get_id() << std::endl;
t.join();
// join后调用get_id
std::cout << "main after join t thread id:" << t.get_id() << std::endl;
// main线程调用get_id
std::cout << "main thread id:" << std::this_thread::get_id() << std::endl;
}
运行结果如下:
default thread id:thread::id of a non-executing thread
main before join t thread id:140395914720832
0 1 2 3 4
print id:140395914720832
main after join t thread id:thread::id of a non-executing thread
main thread id:140395921580992
C++标准库容许我们随意判断两个线程ID是否相同,没有任何限制;
std::thread::id型别具备全套完整的比较运算符,比较运算符就所有不相等的值确立了全序(total order)关系。这使得它们可以用作关联容器的键值,或用于排序,或只要我们认为合适(从程序员的视角评判),它们还能参与任何用途的比较运算。
就所有不相等的std::thread::id的值,比较运算符确立了全序关系,它们的行为与我们的预期相符:若a<b且b<c,则有a<c,以此类推。
标准库的hash模板能够具体化成std::hashstd::thread::id,因此std::thread::id的值也可以用作新标准的无序关联容器(unordered_set/unordered_map)的键值。
std::thread::id实例常用于识别线程,以判断它是否需要执行某项操作。
后续
我们前面学习了如何开辟多个线程以及线程归属权如何管理,我们基本上(暂且这样认为)可以实现将某个任务分成多个子任务,然后将每个子任务分别在不同线程上运行。最后进行汇总工作。如果每个子任务有独立的变量,也就是每个任务之间不存在共享数据,我们先前学习的基本上可以完成该任务,但是对于有共享数据的情况,可能还是鞭长莫及。这是由于如果对于同一个数据而言,如果一个线程在写的同时,如果另一个线程在读,可能会出现意想不到的现象。互斥可以解决该问题。另外我们只介绍了无返回值的线程启动函数,如果有返回值我们该怎么办?后续系列文章将逐一解答。
- 线程间共享数据
- 并发操作的同步