2 - 线程的基本使用
本文将会介绍如何用<thread>
线程库创建一个线程,包括使用函数指针,可调用对象等方式创建;以及线程的阻塞/挂起操作;线程参数的传递;一些概念等内容。
主线程与子线程
在进行线程的创建前,先区分一下主线程和子线程的概念:
- 主线程:一个正在执行的程序就是一个进程,而
main()
就是其中的主线程,一旦主线程执行完毕,整个进程就会结束。主线程也可以称为底层OS线程。 - 子线程:在一个线程执行时,可以创建出一些子线程。当主线程执行完毕时,就会强制结束所有主线程,然后结束进程。可以认为子线程是主线程的辅助线程,但其实 主线程和主线程是平级的,主线程结束后会给其他主线程发送强制结束信号。
线程的创建
线程创建的大体模板如下:
std::thread t {可调用对象}
接下来通过几个例子来了解一下线程的创建。
通过函数指针
void counter(int id, int numIterations)
{
for (int i {0}; i < numIterations; ++i)
{
cout << "Counter " << id << " has value " << i << endl;
}
}
//main
thread t1 {counter, 1, 6};
这时候运行程序可能会报错,为什么?可以发现 主线程没有等待子线程结束运行就结束了,导致报错。因此我们还得 让主线程等待子线程结束后,再让它结束运行。
一共有两种“等待”的方法:
.join()
:有些子线程和主线程有关联(如数据处理等),主线程必须等待子线程处理完毕才能继续执行。使用该函数后,主线程会进入阻塞状态,直到子线程执行完毕才能继续执行。.detach()
:有些子线程和主线程完全分离,但主线程执行完毕后会导致该子线程强制结束。使用该函数后,会将子线程与主线程分离,子线程被C++运行库接管,就算主线程执行完毕,子线程也会执行。
如果要说的更细点,就得先了解什么是线程的 可结合性(Joinable)。如果一个线程对象表示系统当前或过去的某个活动线程,则认为它是可结合的。即使这个线程执行完毕,该线程对象也依然处于可结合状态。
因此,在销毁一个可结合的线程对象前,必须调用其
join()
或detach()
方法,在这之后,线程就变成不可结合的了,可以安全销毁。如果一个仍可结合的线程对象被销毁,析构函数会调用std::terminate()
,这会 突然终止所有线程和应用程序本身。Ps:默认构造的线程对象是不可结合的。
这里适合采用.join()
,因此只需加上这行代码,程序就能正常运行了:
t1.join();
接下来我们开两个线程,看看可能运行的结果:
thread t1 {counter, 1, 6};
thread t2 {counter, 2, 4};
t1.join();
t2.join();
// 可能的结果
Counter Counter 2 has value 0
Counter 2 has value 1
Counter 2 has value 2
Counter 2 has value 1 has value 0
Counter 1 has value 13
Counter 1 has value 2
Counter 1 has value 3
Counter 1 has value 4
Counter 1 has value 5
可以发现,来自不同线程的输出发生了交错现象,这个问题可以通过之后文章讨论的同步方法加以纠正。
通过函数对象/仿函数
首先编写一个Counter
类,实现operator()
:
class Counter
{
public:
Counter(int id, int numIterations)
:m_id {id}, m_numIterations {numIterations} {}
void operator() () const
{
for (int i {0}; i < m_numIterations; ++i)
{
cout << "Counter " << m_id << " has value " << i << endl;
}
}
private:
int m_id;
int m_numIterations;
};
接下来有两种方法来创建一个线程:
统一初始化语法:
thread t1 {Counter{1, 6}};
传递一个Counter类实例:
Counter c {2, 12}; // 值传递 thread t2 {c}; // 引用传递 thread t3 {ref(c)};
这里使用std::ref()
是因为普通的引用传递会调用一次复制构造函数,导致函数无法对引用对象进行修改;而使用它可以在引用传递时不再调用复制构造函数。
通过lambda表达式
十分推荐用这个方法来创建线程:
int id {1};
int numIterations {5};
thread t1 {[id, numIterations]
{
for (int i {0}; i < numIterations; ++i)
{
cout << "Counter " << id << " has value " << i << endl;
}
}};
t1.join();
通过成员函数
还可在线程中指定要执行的类的成员函数:
class Request
{
public:
Request(int id) : m_id {id} {}
void process() const { cout << "Processing request " << m_id << endl; }
private:
int m_id;
};
int main()
{
Request req {100};
thread t {&Request::process, &req};
t.join();
}
如果有其他线程访问同一个对象,那么需要确认这种访问是线程安全的,以避免争用条件。
线程参数的传递
传递参数有三种方式:值传递,引用传递和指针传递。例子基本上都在上边的示例代码中有体现。
有了线程参数的传递,获取子线程的运行结果便变得比较容易。然而,还有一种更简单的方法可从线程获得结果:future
。通过future
也能更方便地处理线程中发生的错误。future
将在之后文章中讨论。
注意事项
- 在使用
detach()
时 不要传递指针/设置指针参数。因为指针传递直接把变量的地址传过去了,如果主线程运行结束,该变量被系统回收,此时子线程操作该变量就会报错。 - 在使用
detach()
时 不要使用隐式类型转换,因为很有可能子线程参数还没来得及转化好,主线程就结束了。 - 一般数据类型使用值传递,类对象使用引用传递。其中,类对象的引用传递应使用
std::ref()
解决复制构造被调用一次的问题。
线程的其他概念
线程ID
每个线程都有自己的ID,不管是主线程还是子线程都有自己的ID。可以通过如下方式获取线程的id:
std::thread::id this_id = std::this_thread::get_id();
线程本地存储
C++标准支持线程本地存储(Thread Local Storage, TLS)的概念。通过关键字thread_local
,可将任何变量标记为线程本地数据,即 每个线程都有这个变量的独立副本。
例如,下面的代码中定义了两个全局变量,所有线程共享一个k实例,而每个线程都有自己的n拷贝:
int k;
thread_local int n;
void func(int id)
{
cout << format("Thread {}: k = {}, n = {}\n", id, k, n);
++n, ++k;
}
int main()
{
thread t1 {func, 1}; t1.join();
thread t2 {func, 2}; t2.join();
}
// 可能的运行结果
Thread 1: k = 0, n = 0
Thread 2: k = 1, n = 0
如果thread_local
变量在函数作用域内声明,那么这个变量的行为和static
是一致的,只不过每个线程都有自己独立的副本,且仅被每个线程初始化一次。
线程的取消
C++标准没有包含在一个线程中取消另一个已运行线程的任何机制。一种解决方案是使用C++20提供的jthread
类(之后文章中讨论);其他方法就是两线程间利用共享变量进行通信,具体实现可用之后讨论的原子变量或条件变量。
参考资料
- 飘零的落花 - 现代C++详解
- C++20高级编程