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高级编程