Thread线程库中的资源问题

ZaynPei Lv6

在单线程程序中,对象的创建和销毁顺序是可预测的。但在多线程程序中,新线程的执行时机是不确定的。它可能在创建它的函数返回之前、之中或之后才真正开始运行。“数据未定义错误”的根源就是,程序员错误地假设了新线程会比它所需要的数据“死”得更早,但事实往往相反。

下面这些问题的本质原因在于: 线程的生命周期与其访问的数据的生命周期不匹配,导致线程访问了无效的内存

数据未定义的情况

传递临时变量

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

void foo(int& x) {
x += 1;
}

int main() {
std::thread t(foo, 1); // 传递临时变量
t.join();
return 0;
}

上述代码中,foo函数接受一个整数引用作为参数,并对其加 1。但在线程创建时,传入的1是一个临时变量,在std::thread解析参数时,该临时变量会被销毁,导致foo访问了已销毁的对象,产生未定义行为。 解决方案是使用 std::ref 传递一个持久化变量的引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <thread>
#include <functional>

void foo(int& x) {
x += 1;
}

int main() {
int x = 1;
std::thread t(foo, std::ref(x)); // 传递变量的引用
t.join();
return 0;
}
注意这里必须传入引用而不是值,否则根据线程的规则, 线程会访问一个临时对象的副本,而不是原始对象。

std::thread 的构造函数在接收参数时,默认情况下会复制 (copy)移动 (move) 传递给它的参数。它会将这些参数的副本存储在线程内部,然后在新的线程上下文中,将这些副本传递给你指定的函数。也就是说, 它是不支持通过变量名直接引用传参的, 如果想实现引用传参, 必须使用 std::ref 或 std::cref 包装一下。

传递指针或引用指向局部变量的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <thread>

void foo(int* ptr) {
std::cout << *ptr << std::endl; // 访问完全可能已经被销毁的指针
}

int main() {
int x = 1;
std::thread t(foo, &x); // 传递指向局部变量的指针
t.detach();
return 0;
}

一个解决策略是使用std::shared_ptr,避免手动管理内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <thread>
#include <memory>

void foo(std::shared_ptr<int> ptr) {
std::cout << *ptr << std::endl;
}

int main() {
auto ptr = std::make_shared<int>(1);
std::thread t(foo, ptr);
t.join();
return 0;
}
这里的 std::shared_ptr 确保了对象的生命周期与线程的生命周期同步,避免了访问已销毁对象的问题。

传递指针或引用指向已释放的内存的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <thread>

void foo(int& x) {
std::cout << x << std::endl;
}

int main() {
int* ptr = new int(1);
std::thread t(foo, *ptr); // 传递已释放的内存
delete ptr;
t.join();
return 0;
}

在线程 t 启动前,ptr有可能已被delete,导致foo访问了已释放的内存,行为未定义. 解决方法是确保在线程的生命周期内,指针或引用指向的内存不被释放, 即对调join()和delete的顺序

类成员函数作为入口函数,类对象被提前释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <thread>

class MyClass {
public:
void func() {
std::cout << "Thread " << std::this_thread::get_id() << " started" << std::endl;
// do some work
std::cout << "Thread " << std::this_thread::get_id() << " finished" << std::endl;
}
};

int main() {
MyClass obj;
std::thread t(&MyClass::func, &obj);
return 0;
} // obj 被销毁,可能导致线程崩溃

这里在 main 结束时,obj 被销毁,导致 t 访问已销毁的对象,可能崩溃。 解决方法还是使用使用 std::shared_ptr 管理生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <thread>
#include <memory>

class MyClass {
public:
void func() {
std::cout << "Thread " << std::this_thread::get_id() << " started" << std::endl;
std::cout << "Thread " << std::this_thread::get_id() << " finished" << std::endl;
}
};

int main() {
auto obj = std::make_shared<MyClass>();
std::thread t(&MyClass::func, obj);
t.join();
return 0;
}

两条时间线

正如开头所说, 这些情况的本质原因在于一个线程尝试访问一个在它访问的那个时刻,其生命周期已经结束(或者可能已经结束)的数据对象。

为了避免这种问题, 在多线程编程中,你必须在脑海里清晰地分出两条并行且速度不一的“时间线”:

数据的时间线:这是指变量或对象被创建到被销毁的整个过程。这条时间线的终点是由 C++ 的作用域和内存管理规则严格决定的。 - 局部变量/对象:其生命周期严格绑定在创建它的那个作用域 {} 内。函数返回或作用域结束,它就立刻死亡。 - 临时对象:生命周期更短,通常只在创建它的那一个完整语句内有效。语句结束,它就死亡。 - 堆对象 (new):生命周期从 new 开始,到 delete 被调用时结束。它的死亡时刻由程序员手动决定。

线程的时间线:这是指一个线程被创建到其任务执行完毕的整个过程。这条时间线的启动和结束相对于创建它的代码来说,是异步的、不确定的。你只知道它在 std::thread 对象被创建后“某个时间点”开始,在任务执行完毕后“某个时间点”结束。

而上述提到的未定义行为,都爆发于这两条时间线的交叉点上,并且是一场“死亡竞赛”:“数据的死亡” 与 “线程的访问” 之间在赛跑。如果“数据的死亡”先于“线程的访问”到达,程序就会崩溃。

传递局部变量的指针: - 数据的死亡时刻:创建局部变量的函数返回时。 - 线程的访问时刻:不确定,很可能在函数返回之后。 - 竞赛结果:数据几乎总是先死。线程访问的是无效的栈内存。

传递临时变量的指针: - 数据的死亡时刻:创建线程的语句结束时。 - 线程的访问时刻:不确定,但几乎总是在该语句结束之后。 - 竞赛结果:数据总是先死。这是最危险的情况,因为数据生命周期极短。

提前 delete 堆内存: - 数据的死亡时刻:主线程执行到 delete 时。 - 线程的访问时刻:不确定,与主线程并发。 - 竞赛结果:这是一场真正的竞赛。delete 和线程的访问哪个先发生完全不确定。只要有任何可能 delete 先发生,代码就是错误的。

类对象提前释放: - 数据的死亡时刻:类对象所在的作用域结束时。 - 线程的访问时刻:不确定,很可能在作用域结束之后。 - 竞赛结果:和局部变量一样,数据几乎总是先死。线程通过悬空的 this 指针访问成员。

通用的解决方案:强制同步生命周期

既然问题的本质是生命周期不匹配,那么解决方案的本质就是强制让它们的生命周期匹配起来。程序员的责任就是确保这场“死亡竞赛”永远不会发生。

通用的方法是:通过同步机制,确保在线程的整个生命周期内,它所访问的数据的生命周期也持续有效。具体手段包括:

延长数据的生命周期: - 按值传参:不传递指针或引用,而是直接复制一份数据给线程。这样线程就拥有了数据的独立副本,副本的生命周期和线程自身绑定,与原始数据无关。 - 使用智能指针 std::shared_ptr:将堆上的数据交由 std::shared_ptr 管理。只要线程还持有 shared_ptr 的副本,数据就不会被释放。

缩短(或同步)线程的生命周期: - 使用 thread::join():这是最核心的同步工具。join() 的作用就是强制让“创建者的代码流”停下来,等待“线程的时间线”结束。这样就保证了在函数返回、作用域结束、对象销毁之前,线程一定已经完成了对该数据的访问。