[C++]C++并发编程实战Chapter2

2 线程管控

2.1 线程的基本管控

每个C++程序都含有至少一个线程,即运行main()的线程,它由C++运行时(C++ runtime)系统启动。随后,程序就可以发起更多线程,它们以别的函数作为入口(entry point)。这些新线程连同起始线程并发运行。当main()返回时,程序就会退出;同样,当入口函数返回时,对应的线程随之终结。

2.1.1 发起线程

线程通过构建 std::thread 对象启动,任何可调用类型都可以作为参数传递给该对象的构造函数。

如果在 std::thread 对象销毁的时候还没有决定汇合还是分离线程,那么析构函数会终止掉整个程序(需要注意,线程是否结束和该线程对象是否销毁没有关系,线程可能在决定汇合或者分离很久之前就已经结束运行,也可能在分离之后很久才结束运行)。

保证线程访问的外部数据有效

如果程序选择分离线程,且线程持有指针或者引用指向主线程的局部变量,如果此时主线程退出,子线程还在继续运行,会导致悬空指针or悬空引用问题。

解决方式:

  • 令线程函数完全自含(self-contained),将数据复制到新线程内部,而不是共享数据。
  • 汇合新线程
  • 也可以使用智能指针传递参数,这样可以确保局部变量在使用期间不会被销毁掉。

2.1.2 等待线程完成

若需等待线程完成,可以在与之关联的std::thread实例上,通过调用成员函数join()实现。

只要调用了join(),隶属于该线程的任何存储空间即会因此清除,std::thread对象遂不再关联到已结束的线程。事实上,它与任何线程均无关联。对于某个给定的线程,join()仅能调用一次;只要std::thread对象曾经调用过join(),线程就不再可汇合(joinable),成员函数joinable()将返回false。

2.1.3 在出现异常的情况下等待

这个问题的本质其实是,如果要汇合线程,需要保证所有可能的退出路径都汇合线程。比较理想的做法是采用RAII思想,设计一个thread_guard类,在其析构函数中调用join.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
}

如上所示,在 f 退出之前,会按照构建的逆序,销毁局部对象,因此会首先销毁thread_guard对象。

2.1.4 在后台运行线程

调用std::thread对象的成员函数detach(),会令线程在后台运行,遂无法与之直接通信。假若线程被分离,就无法等待它完结,也不可能获得与它关联的std::thread对象,因而无法汇合该线程。

然而分离的线程确实仍在后台运行,其归属权和控制权都转移给C++运行时库(runtime library,又名运行库),由此保证,一旦线程退出,与之关联的资源都会被正确回收。

UNIX操作系统中,有些进程叫作守护进程(daemon process),它们在后台运行且没有对外的用户界面;沿袭这一概念,分离出去的线程常常被称为守护线程(daemon thread)。这种线程往往长时间运行。几乎在应用程序的整个生存期内,它们都一直运行,以执行后台任务,如文件系统监控、从对象缓存中清除无用数据项、优化数据结构等。

不能凭空调用detach方法,必须确保该对象存在与其关联的线程。只有当t.joinable()返回true时,我们才能调用t.detach()。

2.2 向线程函数传递参数

若需向新线程上的函数或可调用对象传递参数,方法相当简单,直接向std::thread的构造函数增添更多参数即可。

线程具有内部存储空间,参数会按照默认方式先复制到该处,新创建的执行线程才能直接访问它们。然后,这些副本被当成临时变量,以右值形式传给新线程上的函数或可调用对象。即便函数的相关参数按设想应该是引用,上述过程依然会发生。

如果线程函数的参数是一个非常量引用,代码会无法通过编译,解决办法是用 std::ref.

对于移动操作来说,资源所有权会先转移到线程的内部存储空间,然后再转移给线程函数。

2.3 移交线程归属权

std::thread 拥有其所关联的线程,支持移动语义。

我们可以修改上面 thread_guard 类的代码得到 scoped_thread 类,该类掌管线程,防止出现 thread_guard 对象的生命期超出其管控的线程,导致不良后果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class scoped_thread
{
std::thread t;
public:
explicit scoped_thread(std::thread t_):
t(std::move(t_))
{
if(!t.joinable())
throw std::logic_error("No thread");
}
~scoped_thread()
{
t.join();
}
scoped_thread(scoped_thread const&)=delete;
scoped_thread& operator=(scoped_thread const&)=delete;
};

2.4 在运行时选择线程数量

C++标准库的std::thread::hardware_concurrency()函数,它的返回值是一个指标,表示程序在各次运行中可真正并发的线程数量。

1
2
3
4
5
int main() {
cout << thread::hardware_concurrency() << endl;
return 0;
}
// 在我的电脑上 返回值是16

2.5 识别线程

线程ID所属型别是std::thread::id,它有两种获取方法:

  • 在与线程关联的std::thread对象上调用成员函数get_id(),即可得到该线程的ID。如果std::thread对象没有关联任何执行线程,调用get_id()则会返回一个std::thread::id对象,它按默认构造方式生成,表示“线程不存在”。
  • 当前线程的ID可以通过调用std::this_thread::get_id()获得,函数定义位于头文件 <thread 内。

C++标准库容许我们随意判断两个线程ID是否相同,没有任何限制;std::thread::id型别具备全套完整的比较运算符,比较运算符就所有不相等的值确立了全序(total order)关系。


[C++]C++并发编程实战Chapter2
https://erlsrnby04.github.io/2025/01/24/C-C-并发编程实战Chapter2/
作者
ErlsrnBy04
发布于
2025年1月24日
许可协议