坑点总结
1、启动了线程,你需要明确是要等待线程结束(调用join),还是让其自主运行(调用detach)。如果std::thread对象销毁之前还没有做出决定,程序就会终止(std::thread的析构函数会调用std::terminate())。因此,即便是有异常存在,也需要确保线程能够正确的加入(joined)或分离(detached)。。需要注意的是,必须在std::thread对象销毁之前做出决定,否则你的程序将会终止(std::thread的析构函数会调用std::terminate(),这时再去决定会触发相应异常)。
如果不等待线程,就必须保证线程结束之前,可访问的数据的有效性。这不是一个新问题——单线程代码中,对象销毁之后再去访问,也会产生未定义行为——不过,线程的生命周期增加了这个问题发生的几率。
这种情况很可能发生在线程还没结束,函数已经退出的时候,这时线程函数还持有函数局部变量的指针或引用。下面的清单中就展示了这样的一种情况。
class func1
{
public:
func1(int &a) : i(a) {}
void operator() ()
{
//在这里调用i变量
}
private:
int &i;
};
void oops()
{
int local_variable = 0;
func1 my_func(local_variable);
std::thread my_thread(my_func);
my_thread.detach(); //这里将线程分离,可能local_vriable变量已经销毁该线程还在运行
}
- 这个例子中,已经决定不等待线程结束(使用了detach() ),所以当oops()函数执行完成时,新线程中的函数可能还在运行。如果线程还在运行,它就会访问已经销毁的变量。如同一个单线程程序——允许在函数完成后继续持有局部变量的指针或引用;当然,这从来就不是一个好主意——这种情况发生时,错误并不明显,且会使多线程更容易出错。
- 处理这种情况的常规方法:使线程函数的功能齐全,将数据复制到线程中,而非复制到共享数据中。如果使用一个可调用的对象作为线程函数,这个对象就会复制到线程中,而后原始对象就会立即销毁。但对于对象中包含的指针和引用还需谨慎,如上程序所示。使用一个能访问局部变量的函数去创建线程是一个糟糕的主意(除非十分确定线程会在函数完成前结束)。此外,可以通过join()函数来确保线程在函数完成前结束。
2、
class thread_guard
{
public:
explicit thread_guard(std::thread &_t) : t(_t) {}
~thread_guard()
{
if(t.joinable()) //1
{
t.join(); //2
}
}
thread_guard(thread_guard const &) = delete; //删除拷贝构造函数 //3
thread_guard& operator=(thread_guard const &) = delete; //删除拷贝赋值函数
private:
std::thread &t; //线程的引用
};
void f()
{
int local_variable = 0;
func1 my_func(local_variable);
std::thread t(my_func);
thread_guard g(t);
//调用别的函数,如果产生异常,thread_guard的析构函数也会使得线程调用join()加入
do_something_in_current_thread(); //4
}
当线程执行到④处时,局部对象就要被逆序销毁了。因此,thread_guard对象g是第一个被销毁的,这时线程在析构函数中被加入②到原始线程中。即使do_something_in_current_thread抛出一个异常,这个销毁依旧会发生。
在thread_guard的析构函数的测试中,首先判断线程是否可加入①,如果没有会调用join()②进行加入。这很重要,因为join()只能对给定的对象调用一次,所以对给已加入的线程再次进行加入操作时,将会导致错误。
拷贝构造函数和拷贝赋值操作被标记为=delete③,是为了不让编译器自动生成它们。直接对一个对象进行拷贝或赋值是危险的,因为这可能会弄丢已经加入的线程。通过删除声明,任何尝试给thread_guard对象赋值的操作都会引发一个编译错误。想要了解删除函数的更多知识,请参阅附录A的A.2节。
如果不想等待线程结束,可以分离_(_detaching)线程,从而避免异常安全(exception-safety)问题。不过,这就打破了线程与std::thread对象的联系,即使线程仍然在后台运行着,分离操作也能确保std::terminate()在std::thread对象销毁才被调用。
3、std::thread传递参数的规则
buffer 是一个指针变量,指向局部变量,然后此局部变量通过 buffer 传递到新线程中。此时,函数 oops 很有可能会在 buffer 转换成std::string对象之前结束,从而导致一些未定义的行为。因为此时无法保证隐式转换的操作和 std::thread 构造函数的拷贝操作按顺序进行,有可能 std::thread 的构造函数拷贝的是转换前的变量(buffer 指针),而非字符串。解决方案就是在传递到std::thread构造函数之前就将字面值转化为std::string对象:
void f1(int i, std::string const &s);
char buffer[20] = "hello word!";
std::thread t(f1, 3, buffer);
//修改后的
std::thread t(f1, 3, std::string(buffer)); //避免悬垂指针
如果我们想传递一个参数的引用,要使用std::ref或者std::cref。
void f(int &num)
{
++num;
}
int num = 0;
std::thread t(f2, std::ref(num));
t.join();
std::cout << "num = " << num << "\n";
运行结果如图所示:
4.使用互斥量保护代码的问题
使用互斥量来保护数据,并不是仅仅在每一个成员函数中都加入一个std::lock_guard对象那么简单;一个指针或引用,也会让这种保护形同虚设。不过,检查指针或引用很容易,只要没有成员函数通过返回值或者输出参数的形式,向其调用者返回指向受保护数据的指针或引用,数据就是安全的。如果你还想深究,就没这么简单了。确保成员函数不会传出指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也是很重要的(尤其是这个操作不在你的控制下时)。函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就很危险。更危险的是:将保护数据作为一个运行时参数。
class some_data
{
int a;
std::string b;
public:
void do_something();
};
class data_wrapper
{
private:
some_data data;
std::mutex m;
public:
template<typename Function>
void process_data(Function func)
{
//std::lock_guard<std::mutex>;作用是将锁在运行时锁住,当退出作用域时进行析构,也就会释放掉互斥锁
std::lock_guard<std::mutex> l(m);
func(data); // 1 传递“保护”数据给用户函数
}
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
unprotected=&protected_data;
}
data_wrapper x;
void foo()
{
x.process_data(malicious_function); // 2 传递一个恶意函数
unprotected->do_something(); // 3 在无保护的情况下访问保护数据
}
例子中process_data看起来没有任何问题,std::lock_guard对数据做了很好的保护,但调用用户提供的函数func①,就意味着foo能够绕过保护机制将函数malicious_function传递进去②,在没有锁定互斥量的情况下调用do_something()。
这段代码的问题在于根本没有保护,只是将所有可访问的数据结构代码标记为互斥。函数foo()中调用unprotected->do_something()的代码未能被标记为互斥。这种情况下,C++线程库无法提供任何帮助,只能由开发者使用正确的互斥锁来保护数据。从乐观的角度上看,还是有方法可循的:切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数中去。
虽然这是在使用互斥量保护共享数据时常犯的错误,但绝不仅仅是一个潜在的陷阱而已。下一节中,你将会看到,即便是使用了互斥量对数据进行了保护,条件竞争依旧可能存在。
持续更新…