C++线程

线程

C++11 之前,C++ 语言没有对并发编程提供语言级别的支持,在编写可移植的并发程序时存在诸多的不便。
现在 C++11 中增加了线程以及线程相关的类,很方便地支持了并发编程,使得编写的多线程程序的可移植性得到了很大的提高。

C++11 中提供的线程类叫做 std::thread,基于这个类创建一个新的线程非常的简单,
只需要提供线程函数或者函数对象,并且可以同时指定线程函数的参数。
首先了解一下这个类提供的一些常用 API:

构造函数

1
2
3
4
5
6
7
8
9
// 1
thread() noexcept;
// 2
thread(thread&& other) noexcept;
// 3
template<class Function, class... Args >
explicit thread(Function&& f, Args&&... args);
// 4
thread(const thread&) = delete;

(1)构造函数1
默认构造函数。构造一个线程对象,在这个线程中不执行任何处理动作。
(2)构造函数2
移动构造函数。将 other 的线程所有权转移给新的 thread 对象。之后 other 不再表示执行线程。
(3)构造函数3
创建线程对象,并在该线程中执行函数 f 中的业务逻辑,args 是要传递给函数 f 的参数
任务函数 f 的可选类型有很多,具体如下:
普通函数、类成员函数、匿名函数、仿函数(这些都是可调用对象类型)
可以是可调用对象包装器类型,也可以是使用绑定器绑定之后得到的类型(仿函数)
(4)构造函数4
使用 =delete 删除拷贝构造,不允许线程对象之间的拷贝。

公共成员函数

get_id()

应用程序启动之后默认只有一个线程,这个线程一般称之为主线程或父线程,
通过线程类创建出的线程一般称之为子线程,每个被创建出的线程实例都对应一个线程 ID,这个 ID 是唯一的,
可以通过这个 ID 来区分和识别各个已经存在的线程实例,这个获取线程 ID 的函数 get_id(),函数原型如下:

1
std::thread::id get_id() const noexcept;

示例代码如下:

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
27
28
29
30
31
32
#include <iostream>
#include <thread>
using namespace std;

void func1(int num, string str)
{
for (int i = 0; i < 10; ++i)
{
cout << "child thread, i: " << i << ", num: "
<< num << ", str: " << str << endl;
}
}

void func2()
{
for (int i = 0; i < 10; ++i)
{
cout << "child thread, i: " << i << endl;
}
}

int main()
{
cout << "main thread, id: " << this_thread::get_id() << endl;
// 创建了子线程对象 t,func() 函数会在这个子线程中运行
thread t1(func1, 520, "hello world");
// 创建了子线程对象 t1,任务函数 func1()
thread t2(func2);
cout << "thread t, id: " << t1.get_id() << endl;
cout << "thread t1, id: " << t2.get_id() << endl;
}
// g++ get_id.cpp -o app

通过线程对象调用 get_id() 获取这个子线程的线程 ID。
示例代码中有一个 bug,在主线程中依次创建出两个子线程,打印两个子线程的线程 ID,最后主线程执行完毕就退出(主线程执行 main 函数)。默认情况下,主线程销毁时会将与其关联的两个子线程也一起销毁,但是可能子线程中的任务还没有执行完毕,最后得不到想要的结果。

当创建了一个 thread 对象之后,在这个线程结束的时候,如何回收线程所使用的资源呢?thread 库提供了两种选择:
(1)加入式,join()
(2)分离式,detach()
另外,必须在线程对象销毁之前在二者之间作出选择,否则程序运行期间就会有 bug 产生。

join()

join() 是连接一个线程,意味着主动地等待线程的终止(线程阻塞)。在某个线程中通过子线程对象调用 join() 函数,调用这个函数的线程被阻塞,但是子线程对象中的任务函数会继续执行,当任务执行完毕之后 join() 会清理当前子线程中的相关资源然后返回,调用该函数的线程解除阻塞继续向下执行。

注意:弄清楚这个函数阻塞的是哪一个线程,函数在哪个线程中被执行,函数就阻塞哪个线程。函数原型如下:

1
void join();

使用线程阻塞函数之后,就可以解决在上面的示例代码中的 bug。如果要阻塞主线程的执行,只需要在主线程中通过子线程对象调用这个方法,当调用这个方法的子线程对象中的任务函数执行完毕之后,主线程的阻塞也就随之解除。修改后的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
cout << "main thread, id: " << this_thread::get_id() << endl;
thread t1(func1, 520, "hello world");
thread t2(func2);
cout << "thread t, id: " << t1.get_id() << endl;
cout << "thread t1, id: " << t2.get_id() << endl;
t1.join();
t2.join();

return 0;
}

当主线程运行到 t.join(),根据子线程对象 t 的任务函数 func() 的执行情况,主线程会做如下处理:
(1)如果任务函数 func() 还没执行完毕:主线程阻塞,直到任务执行完毕,主线程解除阻塞,继续向下运行。
(2)如果任务函数 func() 已经执行完毕:主线程不会阻塞,继续向下运行。

为了更好地理解 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
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

void download1()
{
// 模拟下载,总共耗时 500ms,阻塞线程 500ms
this_thread::sleep_for(chrono::microseconds(500));
cout << "thread t1, id: " << this_thread::get_id() << endl;
}

void download2()
{
// 模拟下载,总共耗时 300ms,阻塞线程 300ms
this_thread::sleep_for(chrono::microseconds(300));
cout << "thread t2, id: " << this_thread::get_id() << endl;
}

void doSomething()
{
cout << "start..." << endl;
cout << "..." << endl;
cout << "end..." << endl;
}

int main()
{
thread t1(download1);
thread t2(download2);
// 阻塞主线程,等待所有子线程任务执行完毕再继续向下执行
t1.join();
t2.join();
doSomething();
}
// g++ join.cpp -o app

示例代码中最核心的处理是在主线程调用 doSomething()
之前通过子线程对象调用了 join() 方法,这样能够保证两个子线程的任务都执行完毕(此处表示文件内容已经全部下载完成),主线程再对文件进行后续处理。如果子线程的文件没有下载完毕,主线程就去处理文件,从逻辑上是有问题的。

detach()

detach() 函数的作用是进行线程分离,分离主线程和创建出的子线程。
在线程分离之后,主线程退出也会一并销毁创建出的所有子线程。在主线程退出之前,子线程可以脱离主线程继续独立的运行,任务执行完毕之后,这个子线程会自动释放自己占用的系统资源。函数原型如下:

1
void detach();

线程分离函数没有参数也没有返回值,只需要在线程成功之后,通过线程对象调用该函数,修改示例代码如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

void func1(int num, string str)
{
for (int i = 0; i < 10; ++i)
{
cout << "child thread, i: " << i << ", num: "
<< num << ", str: " << str << endl;
}
}

void func2()
{
for (int i = 0; i < 10; ++i)
{
cout << "child thread, i: " << i << endl;
}
}

int main()
{
cout << "main thread, id: " << this_thread::get_id() << endl;

thread t1(func1, 520, "hello world");
thread t2(func2);
cout << "thread t, id: " << t1.get_id() << endl;
cout << "thread t1, id: " << t2.get_id() << endl;
t1.detach();
t2.detach();
// 让主线程休眠,等待子线程执行完毕
this_thread::sleep_for(chrono::seconds(5));

return 0;
}
// g++ detach.cpp -o app

注意:线程分离函数 detach() 不会阻塞线程,子线程和主线程分离之后,在主线程中就不能再对这个子线程做任何控制,建议使用 join()

joinable()

joinable() 函数用于判断主线程和子线程是否关联(连接)状态,该函数返回一个布尔类型:
(1)返回值为 true:主线程和子线程之间有关联(连接)关系
(2)返回值为 false:主线程和子线程之间没有关联(连接)关系

1
bool joinable() const noexcept;

示例代码如下:

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
27
28
29
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

void foo()
{
this_thread::sleep_for(chrono::seconds(1));
}

int main()
{
thread t;
cout << "before starting, joinable: " << t.joinable() << endl;

t = thread(foo);
cout << "after starting, joinable: " << t.joinable() << endl;

t.join();
cout << "after joining, joinable: " << t.joinable() << endl;

thread t1(foo);
cout << "after starting, joinable: " << t1.joinable() << endl;
t1.detach();
cout << "after starting, joinable: " << t1.joinable() << endl;

return 0;
}
// g++ joinable.cpp -o app

在创建的子线程对象的时候,如果没有指定任务函数,那么子线程不会启动,主线程和这个子线程也不会进行连接。
在创建的子线程对象的时候,如果指定了任务函数,子线程启动并执行任务,主线程和这个子线程自动连接成功。
在子线程调用了 join() 函数,子线程中的任务函数继续执行,直到任务处理完毕,join() 会回收当前子线程的相关资源,所以这个子线程和主线程的连接断开。因此,调用 join() 之后再调用 joinable() 会返回 false。
子线程调用了 detach() 函数之后,父子线程分离,同时二者的连接断开,调用 joinable() 返回 false。

operator=()

线程中的资源是不能被复制的,因此通过 = 操作符进行赋值操作最终并不会得到两个完全相同的对象。

1
2
3
4
// move
thread& operator= (thread&& other) noexcept;
// copy [deleted]
thread& operator= (const other&) = delete;

通过以上 = 操作符的重载声明可知:
(1)如果 other 是一个右值,会进行资源所有权的转移。
(2)如果 other 不是右值,禁止拷贝,该函数被删除(=delete),不可用。

静态函数

thread 线程类还提供了一个静态方法,用于获取当前计算机的 CPU 核心数,根据这个结果在程序中创建出数量相等的线程。每个线程独自占有一个 CPU 核心,这些线程就不用分时复用 CPU 时间片,此时程序的并发效率是最高的。

1
static unsigned hardware_concurrency() noexcept;

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <thread>
using namespace std;

int main()
{
int num = thread::hardware_concurrency();
cout << "CPU number: " << num << endl;

return 0;
}
// g++ hardware_concurrency.cpp -o app

C线程库

C 语言提供的线程库不论在 window 还是 Linux 操作系统中都可以使用,C 语言中的线程函数和上面的 C++ 线程类使用类似(基于面向对象的思想进行了封装),但 C++ 的线程类用起来更简单一些。

参考资料

https://subingwen.cn/cpp/thread/


C++线程
https://lcf163.github.io/2021/08/24/Cpp线程/
作者
乘风的小站
发布于
2021年8月24日
许可协议