每个C++程序必须至少有一个线程(main()的线程),由C++运行时库(C++ runtime)启动,随后可以发动多线程,它们以别的函数作为入口(entry point),当mian返回时,程序会退出,同理,线程中的入口函数返回时,线程也随之终结。
目录
1.线程的发起
线程通过构建std::thread对象启动,对象指明线程要运行的任务。任何可调用类型(callable type)都适用于std::thread。因此,可以以重载函数对象操作符的类对象作为参数。
函数调用操作符是(),因此,此操作符的函数重载是operator()()。重载函数调用操作符的类对象称为函数对象或仿函数(functor),因为我们可以像使用函数名一样使用对象名。
函数对象比函数指针的效率高,因为可以在编译时确定调用的函数,比运行时效率高,另外可以内联展开减少调用开销。
函数对象的类继承时,若采用非虚函数,子类或子类的引用只会调用父类的方法;采用虚函数时,子类仍调用基类方法,子类引用才会调用子类本身方法。
#include<iostream>
#include<thread>
class A
{
public:
void operator()()const
{
std::cout << "A" << std::endl;
}
};
int main()
{
A a;
std::thread t(a);
t.join();
}
上述线程对象t,提供函数对象f作为参数,它被复制到属于新线程的存储空间中,并在那里被调用,由新线程执行。
但是要注意解释性问题,若传入的是匿名变量,如“std::thread t(A())”,本意是发动新线程,但会被解释为函数声明,函数名是t,返回std::thread对象,接收的参数是函数指针,函数没有参数传入,返回A对象,解决办法是多一对括号,或列表初始化或lambda表达式:
std::thread t( (A()) );
std::thread t{ A() };
std::thread t([]{ std::cout<<"A"<<std::endl; });
线程启动后,需要明确是要主线程等待其结束,还是让它独立运行。假如等到std::thread销毁之际仍未设置,析构函数会调用std::terminate()终止整个程序:
我们要在对象销毁前做出决定(注意,线程本身可能在汇合或分离前已经结束),如果选择detach,分离时线程还未结束运行,那它将继续运行,只有最终从线程函数返回才结束。
2.线程分离
分离操作为会切断线程与std::thread对象的关联,对象不再可汇合(joinable()返回false),对象销毁时也不会调用std::terminate()。
在选择detach的情况下,主线程不会等待子线程,故需要保证子线程访问的外部数据正确、有效。下面这个例子中,函数f以主线程的局部对象state的引用作为参数,子线程调用detach(),表明不等待,在opps()退出后,t可能仍在运行,但t中的do(i)函数需要调用已经销毁的局部对象state,多线程代码容易导致这种错误。
struct func
{
int& i;
func(int& i_) : i(i_) {}
void operator() ()
{
for (unsigned j = 0; j < 1000; ++j)
{
do_something(i);
}
}
};
void oops()
{
int state = 0;
func f(state);
std::thread t(f);
t.detach();
}
解决办法:令线程函数完全自含(self-contained),即将数据复制到新线程内部,而不共享数据。若可调用对象(可以像函数调用方式的触发调用的对象就是可调用对象)含有指针或引用,则需要谨慎行事。应该尽量避免:在函数中创建线程,线程分离,并让线程访问函数的局部变量引用。
2.1后台运行线程
调用std::thread的detach会使线程在后台运行,无法获得与之关联的std::thread对象,也无法与之直接通信,其归属权和控制权交由C++运行时库(runtime library)处理,线程退出后,与之关联的资源将被正确回收。
UNIX系统中,有一类线程在后台运行且没有对外的用户界面,成为守护线程(daemon process),沿袭这一概念,分离出去的线程也被称为守护线程。它们几乎在程序的整个生命周期都在运行,如:文件系统监控、清除缓存中的无用数据、优化数据结构等。另外,还可以让分离线程执行“启动后即可自主完成的任务”(a fire-and-forget task),还可以通过分离线程确认线程运行完成。
应用举例:文字处理软件,用户可以同时编辑多个文件,当打开新的文件时,创建新的线程运行该文件的编辑窗口,每个文件相互独立,此时可以使用detach线程:
void edit_document(const std::string& file_name )
{
open_document(file_name);
while (!finish_editing())
{
user_command cmd = get_user_input();
if (cmd == OPEN_NEW_DOC)
{
const std::string name = get_filename_from_user();
std::thread t(edit_document, name);
t.detach();
}
else
{
process_user_input(cmd);
}
}
}
3.等待线程完成
若选择等待新线程完成,则调用join()函数即可。对于给定的某个线程,join()仅能调用一次,成员函数bool joinable()用于判断线程是否可汇合。
3.1抛出异常
如前文所述,std::thread销毁前需要确保已经调用join()或detach()。然而在打算线程汇合时,调用join()的时机需要讲究,因为如果线程启动后有异常抛出,join()尚未执行,则join()将被忽略。我们可以在catch抛出异常前调用join()来避免。
struc func;//与前文一样的函数
void oops()
{
int state = 0;
func f(state);
std::thread t(f);
try
{
...
}
catch(...)
{
t.join();
throw;
}
t.join();
}
3.2线程安全汇合
假如代码必须保证新线程先结束,之后当前线程的函数才返回,关键在于所有的退出路径必须保证这种先后次序,这需要简洁有效的实现方式。我们可以设计一个类,在其析构函数中调用join(),在创建了新线程后构建该类的实例:
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread& t_) : t(t_) {}
~thread_guard()
{
if (t.joinable()) t.join();
}
thread_guard(thread_guard const&) = delete;
thread_guard& operator= (thread_guard const&) = delete;
};
struct func
{
int& i;
func(int& i_) : i(i_) {}
void operator() ()
{
for (unsigned j = 0; j < 1000; ++j)
{
do_something(i);
}
}
};
void oops()
{
int state = 0;
func f(state);
std::thread t(f);
thread_guard g(t);
...
}
thread_guard的析构函数会先调用joinable()判断新线程能否汇合。复制构造函数和复制赋值函数以“=delete”标记,均限定不自动生成,因为产生的新对象生存期可能更长,甚至超过与之关联的线程,导致其它问题。
当oops()执行到末尾时,全体局部对象都将被销毁,按照逆向构建的顺序,首先调用thread_guard的实例g的析构函数,使新线程得以调用join()。即便后面的“...”抛出异常导致opps()退出,依然能保证线程汇合。
4.向线程函数传递参数
4.1参数的隐式转换
void f(int i, const std::string& s);
std::thread t(f, 3, "hello");
f()第二个参数为std::string,字符指针仍以const char*形式传入,进入新线程上下文环境后,才转换为std::string。这一细节值得注意,以下例子会有隐患:
void f(int i, const std::string& s);
void oops(int param)
{
char buffer[1024];
sprintf(buffer, "%i", param);
std::thread t(f, 3, buffer);
t.detach();
}
sprintf 使用使用一些以“%”开头的格式说明符(format specifications)来指定串的格式,在后边的变参列表中提供相应的变量,函数会用相应位置的变量来替代那个说明符,产生一个调用者想要的字符串,该字符串会被打印到第一个参数对应的字符串中。
向线程t中传入的参数有指针buffer,指向char局部数组,我们希望buffer先隐式转换为std::string对象,再将其作为函数参数,然而该操作在线程内才会完成,在此操作完成前oops()函数可能已经退出,局部数组buffer将被销毁,进而引发未定义行为。解决办法是先显式转换,,避免悬空指针:
悬空指针(Dangling Pointer):一个指针的指向对象已被删除,那么就成了悬空指针。
野指针:针指向的位置是不可知的、随机的、无明确限制的。(未初始化)
void f(int i, const std::string& s);
void oops(int param)
{
char buffer[1024];
sprintf(buffer, "%i", param);
std::thread t(f, 3, std::string(buffer));
t.detach();
}
4.2传入非const引用
如果线程中函数期望接收的参数是非const引用,std::thread会忽视参数要求,直接复制提供的值,当成move-only型别(只能移动,不能复制)以右值形式传递。函数会收到右值引用,与期望的非const引用不符,遂编译失败:
右值rvalue,本质是固定不变的“值”,不接受赋值,只出现在表达式等号右边。特点是没有标识符,无法提取地址,通常是临时变量和匿名变量,满足移动语义。
void update(int id, m_data& data);
void oops(int id)
{
m_data data;
std::thread t(update, id, data);
t.join();
}
解决办法是以std::ref()函数加以包装:
void update(int id, m_data& data);
void oops(int id)
{
m_data data;
std::thread t(update, id, std::ref(data));
t.join();
}
4.3传递类的成员函数
若要将某个类的成员函数设定为线程函数,则应该传入该函数的指针,以及合适的对象指针:
class A
{
public:
void work();
};
A my_a;
std::thread t(&A::work, &my_a);
如果要为成员函数提供参数,可以在后边继续添加参数,类似于编译器的绑定机制。
在调用类的非静态成员函数时,编译器会隐式添加所操作对象的地址作为第一个参数,用于绑定对象和成员函数。例如:类A具有成员函数func(int x),而a是类A的对象,则调用func(2)等价于调用A::func(&a, 2)。具体参考《深度探索C++对象模型》
4.4动态对象归属权的转移
我们知道,对于给定对象,只能存在唯一一个std::unique_ptr指向它,若该实例被销毁,所指对象便随之删除。通过移动构造函数或移动赋值操作符可以使对象的归属权在多个std::unique_ptr间转移,移动后的源对象的值变成NULL指针。作为临时变量的对象会自动发生移动,具名变量则必须通过调用std::move()请求转移:
void process_obj(std::unique_ptr<obj>);
std::unique_ptr<obj> p(new obj);
std::thread t(process_obj, std::move(p));
std::move(p)本身只是将p转换成右值,创建线程时发生一次转移,进入线程内部存储空间(线程库内部进行复制),process_obj()开始执行时,再次转移(函数调用复制参数)。
5.线程归属权的转移
std::thread类的实例可以转移(movable)但不能复制(not copyable),线程的归属权可以在实例间转移,确保了对于每个线程,任何时候只有唯一的std::thread实例与之关联。
5.1实例间的线程归属权转移
以下是线程归属权的例子,注释有每个实例当前绑定的函数对应的线程,以及相关说明:
void func1();
void func2();
std::thread t1(func1); //t1:func1
std::thread t2 = std::move(t1); //t1:NULL; t2:func1
t1 = std::thread(func2); //t1:func2;t2:func1 隐式执行临时变量的移动操作
std::thread t3; //t1:func2;t2:func;t3:NULL
t3 = std::move(t2); //t1:func2;t2:NULL;t3:func1
t1 = std::move(t3); //t1已关联fun2,std::terminate()终止程序
注意对象管控着一个线程时,不能简单向它赋值,否则线程会被遗弃。
5.2线程在函数间转移
std::thread支持移动操作,所以函数可以便捷地向外部转移线程的归属权(函数返回std::thread对象):
std::thread f()
{
void func(int);
return std::thread(func, 1);
}
同样,归属权也可以转入到函数内部(函数参数为std::thread对象),转入时需要注意具名对象和匿名对象的区别:
void f(std::thread t);
void h()
{
void func();
//匿名对象自动调用std::move()
f(std::thread(func));
//具名对象
std::thread t(func);
f(std::move(t));
}
在线程支持移动语义的情况下,我们可以把线程的归属权转移给thread_guard,其它对象就无法执行该线程的汇合和分离操作了。
前文我们使用thread_guard类在一定程度上保证了线程退出前调用join(),但是一旦thread_guard的生命周期超过管控的线程,还是容易引发各种错误。基于移动语义,我们可以设计scop_thread类,用于确保对象在离开所在作用域前,其线程已经完成:
class scope_thread
{
std::thread t;
public:
explicit scope_thread(std::thread t_) : t(std::move(t_))
{
if (!t.joinable())
throw std::logic_error("No thread!");
}
~scope_thread()
{
t.join();
}
scope_thread(const scope_thread&) = delete;
scope_thread& operator=(const scope_thread&) = delete;
};
struct func
{
int& i;
func(int& i_) : i(i_) {}
void operator() ()
{
for (unsigned j = 0; j < 1000; ++j)
{
do_something(i);
}
}
};
void f()
{
int state;
scope_thread t{ std::thread(func(state)) };//解决匿名变量的解释问题
}
这里向scope_thread的构造函数传入新线程,此处state传入的是func而不是std::thread,所以不需要考虑非const参数,与前文的guard_theard不同,此类在构造函数中判断线程能否汇合。初始线程运行至f()末尾,对象t将被销毁,调用析构函数中的join()。在C++20中引入了std::jthread,只要执行析构函数,线程便能自动汇合。
6.使用容器装载线程
基于std::thread的移动语义特性,只要容器同样支持移动意图(move-aware),就可以装载std::thread对象,因此我们可以用vector容器装载多个std::thread对象,这种方法可以用于切分某算法的运算任务:
移动意图(move-aware)指容器能够将元素复制或移动到其内部,可以正确处理只移动对象,且能在容器内部进行移动操作。
void func(int i);
void f()
{
std::vector<std::thread> threads;
for (int i = 0; i < 20; ++i)
{
threads.emplace_back(func, i);
}
for (auto& entry : threads)
{
entry.join();
}
}
上述代码中,使用emplace_back方法直接在vector末尾创建std::thread对象,每个线程上的任务都是自含的,且操作共享数据的结果只由副作用产生。假如f()需要返回值,且返回值依赖于所有线程的结果,那么只有等所有线程执行完毕后,才能核查共享数据,以推算出返回值。
自含(self-contained)指线程内数据齐全,可独立完成子任务而不依赖外部数据。
副作用,C++标准中规定的特定术语,意思是执行状态的改变,一般是计算表达式时对存储在变量中的值进行的修改,此处线程中函数的结果仅依赖于i的值的改变。
7.运行时决定线程数量
我们可以借用C++标准库的std::thread::hardware_concurrency()函数,它返回一个指标,表示程序在各次运行中可真正并发的线程数量,若信息无法获取,则返回0,它能帮助我们完成多线程对完整任务的分解。
以下代码将工作分派给各个线程,并设置一个限定量,每个线程所负责的元素数量都不能低于限定量,从而防止线程过多(线程过饱和oversubscription)造成额外开销。假设没有任何操作抛出异常,后续章节将介绍可直接调用的库函数,此处给出大致思路:
template<typename Iterator, typename T>
struct accumulate_block
{
void operator()(Iterator first, Iterator last, T& result)
{
result = std::accumulate(first, last, result);//(累加起始位置,累加终止位置,累加初始值)
}
};
template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init)
{
unsigned long const length = std::distance(first, last);//元素总长度[first, last)
if (!length)//表明没有元素,返回起始元素
{
return Init;
}
unsigned long const min_per_thread = 25;//每个线程最少处理的元素数
unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;//最大线程数的计算方式,保证了至少有一个线程。
unsigned long const hardware_threads = std::thread::hardware_concurrency();//获取实际线程数
unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);//实际的线程数在2-max_threads之间。
unsigned long const block_size = length / num_threads;//按照各线程需要分担的元素数量,将数据切分为多块
std::vector<T> results(num_threads);//用于存放中间结果
std::vector<std::thread> threads(num_threads - 1);//用于装载线程,-1因为主线程也算一个线程
Iterator block_start = first;//初始化block_start迭代器
for (unsigned long i = 0; i < (num_threads - 1); ++i)//循环创建子线程
{
Iterator block_end = block_start;//初始化block_end迭代器
std::advance(block_end, block_size);//block_end迭代器移动至小块末尾
threads[i] = std::thread(//启动新线程
accumulate_block<Iterator, T>(),//调用函数计算小块累加结果
block_start,
block_end,
std::ref(results[i])
);
block_start = block_end;//因为STL容器左闭右开特性,下一小块的起始位置为本小块的末端
}
accumulate_block<Iterator, T>()(//主线程处理最后一个小块,直接处理到last解决余数问题
block_start, last, results[num_threads - 1]);
//等待所有线程完成
for (auto& entry : threads)
{
entry.join();
}
return std::accumulate(results.begin(), results.end(), init);//最后返回累加值
}
本例没有考虑部分类型T(如double和float)的精度损失问题;本例中的迭代器必须是前向迭代器;且为了创建vector<T> results存储中间结果,T必须支持默认构造(default-constructible)。由于无法从线程直接返回值,必须传入results[i]引用,后续章节会介绍future方式从线程返回结果。
上例中我们把变量i赋值给线程ID,若某函数需要获取线程ID就不太方便,C++标准库为每个线程内建了唯一的ID。
8.线程识别
线程ID的型别是std::thread::id,可以在与线程关联的std::thread对象上调用成员函数get_id()获得线程ID,若std::thread对象没有关联任何线程,则std::thread::get_id()将会返回默认构造的std::thread::id对象,表示“线程不存在”。
该型别可随意进行复制且具备完整的比较运算符,比较运算符就所有不相等的值确立了全序关系,用于判断线程是否相同,或排序,或参与任何用途的比较运算,更重要的是能作为关联容器(associative container,包括std::set、std::map、std::multiset、std::multimap)的键值。
全序关系(total order)关于集合的数学概念,满足3个性质:反对称、传递和完全。
我们可以用线程ID识别主线程,使主线程和子线程用不同的方式处理任务,只要在发起新线程前保存std::this_thread::get_id()的结果:
std::thread::id main_thread = std::this_thread::get_id();
void func()
{
if (std::this_thread::get_id() == main_thread)
{
do_main_work();
}
do_common_work();
}
我们可以采用关联容器,以线程ID为键值存储每个线程的相关信息,从而关联特定的数据或控制特定线程处理数据的方式。还可以打印出ID用于调试和日志:
std::cout<<std::this_thread::get_id();
9.总结
基础
std::thread对象创建新线程,创建时可用{}解决解释性问题,新线程需要调用join()或detach(),joinable()成员函数判断是否可汇合join()\分离detach(),不能重复选择join和detach。
join()要注意在抛出异常前或线程结束前调用。解决办法:1.抛出异常时catch中调用join();2.创建新类,并在析构函数中调用join(),令复制构造和复制赋值函数=delete,新类在创建新线程后构建。
detach()要注意避免:含有局部指针或引用对象时,局部对象被销毁,而线程仍在运行。
在向线程函数传入参数时
若线程分离,且参数为局部变量的指针/引用,要注意线程内隐式转换导致出现悬空指针,此时改用显示转换。
若函数接收非const引用,要使用std::ref(),按引用传递参数(默认是复制为右值)。
线程处理成员函数时,要额外提供成员对象指针/引用作为第一参数。
转移归属权
函数可以返回std::thread对象向外转移归属权,或接收std::thread对象将归属权转移到内部。(具名对象使用std::move())。
std::thread对象作为构造函数参数可以设计安全类。(保证线程及时汇合/分离)
其它
std::thread::hardware_concurrency()返回线程真正并发数量,以此为依据设计数据块。
std::this_thread::get_id()识别当前线程ID。