[C++ 并发编程] 线程管控

目录

1. 线程的基本管控

1.1 发起线程

1.1.1 线程的声明

1.1.2 汇合或分离线程

1.1.3 线程的启动

1.1.4 线程的运行

1.2 等待线程完成(join)

1.3 出现异常情况下等待线程完成

1.4 在后台运行线程

2. 向线程传递参数

2.1 参数传递过程

2.2 使用std::ref传递引用参数

2.3 使用std::move传递参数

2.4 第二个隐藏的参数

2.5 参数传递注意

3.移交线程的归属权

3.1 std::thread的唯一性

3.2  使用std::move移交线程归属权

3.3 小心线程的遗弃

4.在运行时选择线程数量

5.识别线程


1. 线程的基本管控

每个C++程序至少有一个主线程,即运行main()的线程。

1.1 发起线程

class task
{
public:
    void operator()() const
    {
        do_something();
        do_other_something();
    }
};

task f;
std::thread my_thread(f);

1.1.1 线程的声明

任何可调用对象(如函数指针、函数对象、lambda等,即能进行函数调用操作的对象)都适用于std::thread。 所以。在这里我们写了一个task类,重载运算符"()",将其写为能进行函数调用操作的类,生成其对象,初始化线程my_thread。f会被复制到属于新线程的存储空间中,并在那里被调用,由新线程执行。这里要注意一个问题,只要这个C++语句有可能被解释为函数声明,那么编译器就会把它解释为函数声明。如:

std::thread my_thread(func());
[函数返回类型] [函数名][调用参数]

这种被解释为函数申明的就会报错,解决办法:

std::thread my_thread((func()));  //使用func()函数返回的临时对象
std::thread my_thread{func()};    //列表初始化

1.1.2 汇合或分离线程

在线程对象销毁前,必须决定好,是汇合线程(join)还是分离线程(detach),否则在线程对象销毁时,将会在析构时,调用std::terminate()函数终止整个程序。

1.1.3 线程的启动

线程一旦初始化,就开始启动。 我们只需要在线程对象销毁前,决定好,就行了,不用管线程是在join或detach之前结束,还是在detach之后结束。

1.1.4 线程的运行

在线程运行中,注意未定义行为的发生。如:

struct func
{
    int& i;
    func(int *i_):i(i_){}
    void operator()()
    {
        for(int i=0;i<1000000;++i)
            do_something(i);
    }
}

void oops()
{
    int k=0;
    func my_func(k);
    std::thread my_thread(my_func);
    my_thread.detach();
}

func类里,i用的是引用类型,当初始化my_func时,i则初始化为临时变量k的引用,当my_thread线程开始后,结束前,oops函数就已经结束了,临时变量k被销毁,则线程里继续访问k的引用时,将发生未定义行为。避免此问题的发生:1.线程完全自含,所有调用的变量都由线程本身生成,2.汇合线程(join),等待此线程结束再继续执行。

1.2 等待线程完成(join)

汇合线程(join):

join()简单粗暴,会一直等待线程结束。当join()执行完成,线程结束后,会把线程的所有资源释放掉,std::thread关联的对象,将不再有效。所以,join()有且只能调用一次,可使用joinable()进行判断,没有调用过join为true,否则则为false。

if(my_thread.joinable())
{
    my_thread.join();
}

1.3 出现异常情况下等待线程完成

void f()
{
    int k=0;
    func my_func(k);
    std::thread t(my_func);
    do_something();
    t.join();
}

若do_something函数发生了异常,f()函数会终止,接着系统会销毁栈对象,首先销毁线程对象t,因为此时没有join或detach,所以整个程序结束运行。所以当异常发生时,也必须要保证,线程已经join或detach。

void f()
{
    int k=0;
    func my_func(k);
    std::thread t(my_func);
    try
    {
        do_something();
    }
    catch(...)
    {
        t.join();
        throw;
    }
    t.join();
}

关键:全部可能的退出路径都必须保证,线程对象,先join或detach再销毁,这种先后次序。

所以我们可以设计一个类来管理线程,避免发生terminate()函数:

class thread_guard
{
public:
    explicit thread_guard(std::thread& t_):t(t_){}
    ~thread_guard()
    {
        if(t.joinable())
            t.join();
    }
    thread_guard(thread_guard const &)=delete;//(1)
    thread_guard& operator=(thread_guard const&)=delete;//(2)
private:
    std::thread& t;
};

(1)、(2):避免拷贝构造和赋值操作,因为其可能会导致两个thread_guard对象管理同一个线程,一是新产生的对象生存周期可能很长,比与之关联的线程还要长,线程结束,销毁后,则此类再销毁,就会调用joinable,而此线程已被销毁,所以会发生未定义行为。二是两个对象有可能同时销毁,joinable判断为true,join被执行了两次。

1.4 在后台运行线程

使用detach把线程分离,将无法等待它完结,也不能获得和它关联的std::thread对象,其归属权和控制权都将交给C++运行时库,由此保证,一旦线程退出,那么其资源都将被正确回收。

detach和join一样,只有当joinable()返回true时,我们才能调用detach。

2. 向线程传递参数

若要向线程提供参数,只需在线程构造函数后添加更多参数即可。

2.1 参数传递过程

请牢记,线程具有自己的内部存储空间,参数会按照默认的方式先复制到该处,新创建的执行线程才可以直接访问它。然后,赋值到该处的参数会以右值形式传给指定的函数或可调用对象。即便是引用,上述过程也会发生。

void func(int i,std::string const&s);
std::thread t(func,3,"hello");

"hello"是const char*类型,会先以此类型,赋值到线程t的空间里,然后其才会作为右值的形式赋值给s,即std::string const &s="hello"。 所以,在这里作为线程绑定的函数的参数,必须为const类型,因为只有常量引用类型,才能赋值字面值字符串常量。

2.2 使用std::ref传递引用参数

如果我硬是要用非常量引用的参数呢?

void func(int i,std::string &s);
std::string str="hello";
std::thread t(f,3,std::ref(str));

那么就要先把hello从字面值常量转换为string类型,再执行std::ref函数,此函数会返回一个对象,包含给定的引用,并且此对象是可以拷贝的,这样就能使用非常量引用的参数了,其与std::bind的使用是一样的。

2.3 使用std::move传递参数

有些对象,它的值只能移动不能复制。如:unique_ptr,它指向的对象只能有唯一的unique_ptr指向它,其对象间的转移,会令上一个unique_ptr为NULL。这种动态对象,它作为线程绑定的函数的参数,要传递对象的话,就要使用std::move将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。),如:

void func(std::unique_ptr<class> a);
std::unique_ptr<class> p(new class);
std::thread t(func,std::move(p));

注:std::move(p)未进行移动操作,只是把p转换为右值,然后创建线程时,再发生拷贝,再进行右值赋值。

2.4 第二个隐藏的参数

std::thread和std::bind一样,当绑定为某个类的成员函数时,要传递该类的对象的指针。如:

class A
{
   void a();
   ...
};
A my_A;
std::thread t(&A::a,&my_A);

2.5 参数传递注意

参数的传递要注意避免悬空指针,如:

void func(int i,std::string const&s){
    ...
};
void _f()
{
    const char*data="hello";
    std::thread t(func,3,data);
    t.detach();
}

在func执行时,_f函数可能已经退出了,而此时,data可能刚复制到新线程的空间,std::string const&s=data;还没执行,而data已经被销毁,从而发生未定义行为。

3.移交线程的归属权

3.1 std::thread的唯一性

std::thread和std::unique_ptr的性质具有一定的相似性,std::thread和std::unique_ptr一样,std::thread也只能管控一个执行线程,std::thread的实例间只能移动,不能复制。任何时候都只有唯一的std::thread对象与执行函数关联,其归属权在线程间只能转移,不能复制。

3.2  使用std::move移交线程归属权

std::move将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。),转移后,原对象将失去绑定,并允许绑定另一个执行函数。

临时变量的转移,不需要使用std::move,因为其移动操作会隐式进行。如

std::thread t1=std::thread(func);

3.3 小心线程的遗弃

在std::thread对象之间转移管控的线程时,要小心线程被遗弃,因为:在转移之前,线程一定是还没有detach和join的,因为这两种行为,前者会导致线程移至后台运行,std::thread对象与其失联,后者会导致线程直接运行结束,所以如果线程被遗弃了,那么就会执行std::terminate()函数,程序终止。

4.在运行时选择线程数量

5.识别线程