C++多线程之线程管控

目录

一、创建线程

1.1 以普通函数为参数创建线程

1.2 以函数对象为参数创建线程

1.3 如何避免创建线程被解释为函数声明

1.3.1 加入额外的圆括号,以防语句被解释成函数声明

1.3.2 使用列表初始化

1.3.3 使用lambda表达式

二、分离线程在后台运行线程

三、等待线程完成

       3.1、可以使用try/catch 防止因抛出异常而导致的应用程序终结。

        3.2、 用标准的RAII手法,在其析构函数中调用join()

四、向线程函数传递参数

4.1 普通参数传递

4.2 传递指针

4.3 传递引用

4.4 类成员函数做线程入口函数如何传参

4.5 传递unique_ptr

五、移交线程归属权

5.1 将线程归属权在实例之间多次转移

5.2  std::thread做函数返回值

5.3  std::thread作为函数参数

5.4 std::jthread

5.4.1 jthread的基础使用

5.4.2  jthread的复杂应用

六、运行时选择线程数量

七、线程ID

7.1 线程ID的获取方式

7.2 线程ID的作用


一、创建线程

        在C++标准库中,线程通过构建std::thread对象而启动,该对象指明线程要运行的任务。线程管控需要包含头文件<thread>。与 C++标准库中的许多类型相同,任何可调用类型(callable type)都适用于std::thread。

1.1 以普通函数为参数创建线程

        

void do_some_work();
std::thread my_thread(do_some_work);

1.2 以函数对象为参数创建线程

class background_task
{
public:
    void operator()() const
    {
        do_something();
        do_something_else();
    }
};
background_task f;
std::thread my_thread(f);

1.3 如何避免创建线程被解释为函数声明

例如

std::threadmy_thread(background_task());//本意是发起新线程,却被编译器解释成函数声明

为解决上述问题有以下几种方案:

1.3.1 加入额外的圆括号,以防语句被解释成函数声明

std::thread my_thread((background_task()));

1.3.2 使用列表初始化

std::thread my_thread{background_task()};

1.3.3 使用lambda表达式

std::thread my_thread([]{
    do_something();
    do_something_else();
});

二、分离线程在后台运行线程

        调用std::thread对象的成员函数detach(),会令线程在后台运行,遂无法与之直接通信。UNIX操作系统中,有些进程叫作守护进程(daemon process)​,它们在后台运行且没有对外的用户界面;沿袭这一概念,分离出去的线程常常被称为守护线程(daemon thread)​。

        若要分离线程,则需在std::thread对象上调用其成员函数detach()。调用完成后,std::thread对象不再关联实际的执行线程,故它变得从此不可汇合。

std::thread t(do_background_work);
t.detach();
assert(!t.joinable());

三、等待线程完成

        若需等待线程完成,那么可以在与之关联的std::thread实例上,通过调用成员函数join()实现。对于某个给定的线程,join()仅能调用一次;只要std::thread对象曾经调用过join(),线程就不再可汇合(joinable)​,成员函数joinable()将返回false。

  • 在出现异常的情况下等待

        在std::thread对象被销毁前,我们需确保已经调用join()或detach()。如果线程启动以后有异常抛出,而join()尚未执行,则该join()调用会被略过。

       3.1、可以使用try/catch 防止因抛出异常而导致的应用程序终结。

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

try/catch块稍显冗余,还容易引发作用域的轻微错乱,故它并非理想方案。

        3.2、 用标准的RAII手法,在其析构函数中调用join()

class thread_guard
{
    std::thread& t;
public:
    explicit thread_guard(std::thread& t_):
        t(t_)
    {}
    ~thread_guard()
    {
        if(t.joinable())   
        {
            t.join();   
        }
    }
    thread_guard(thread_guard const&)=delete;   
    thread_guard& operator=(thread_guard const&)=delete;
};  
struct func;   
void f()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    thread_guard g(t);
    do_something_in_current_thread();
}

        当主线程执行到f()末尾时,按构建的逆序,全体局部对象都会被销毁。因此类型thread_guard的对象g首先被销毁,在其析构函数中,新线程汇合。即便do_something_in_current_thread()抛出异常,函数f()退出,以上行为仍然会发生。

        函数抛出异常,运行时系统接管,搜寻匹配的 catch 块过程中,若当前函数没有匹配的catch块,则会释放当前函数内创建的局部对象(调用其析构函数),然逆着调用链向上回溯到上一层(stack unwinding),直到找到匹配的catch块,将控制权转交给它。
        若一直回溯到最顶层的main函数或线程的entry point函数仍是uncatch,则std::terminate()被调用,直接终止程序。
        RAII(将资源封装在对象内,对象销毁时,资源也被释放)和 stack unwinding 可以保证即使在出现异常的情况下,资源也能被正确释放,不出现资源泄露。

        复制构造函数和复制赋值操作符都以“=delete”标记,限令编译器不得自动生成相关代码。复制这类对象或向其赋值均有可能带来问题,因为所产生的新对象的生存期也许更长,甚至超过了与之关联的线程。

四、向线程函数传递参数

4.1 普通参数传递

        直接向std::thread的构造函数增添更多参数即可,例如:

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

        线程具有内部存储空间,参数会按照默认方式先复制到该处,新创建的执行线程才能直接访问它们。然后,这些副本被当成临时变量,以右值形式传给新线程上的函数或可调用对象。

        请注意,尽管函数f()的第二个参数属于std::string类型,但字符串的字面内容仍以指针char const*的形式传入,进入新线程的上下文环境以后,才转换为std::string类型

4.2 传递指针

        如果给新线程传递的参数是指针,而新线程在使用指针的数据之前该指针就可能已经销毁,就会引发悬空指针问题。

例如:

void f(int i,std::string const& s);
void oops(int some_param)
{
    char buffer[1024];                  
    sprintf(buffer, "%i",some_param);
    std::thread t(f,3,buffer);         
    t.detach();
}

        向新线程传递的是指针buffer(数组在传参时退化为指针),指向一个局部数组变量buffer。我们原本设想,buffer会在新线程内转换成std::string对象,但在此完成之前,oops()函数极有可能已经退出,导致局部数组被销毁而引发未定义行为。这一问题的根源在于:我们本来希望将指针buffer隐式转换成std::string对象,再将其用作函数参数,可惜转换未能及时发生,原因是std::thread的构造函数原样复制所提供的值,并未令其转换为预期的参数类型。解决方法是,在buffer传入std::thread的构造函数之前,就把它转化成std::string对象。

void f(int i,std::string const& s);
void not_oops(int some_param)
{
    char buffer[1024];
    sprintf(buffer,"%i",some_param);
    std::thread t(f,3,std::string(buffer));   // 使用std::string避免悬空指针
    t.detach();
}

4.3 传递引用

        线程库的内部代码会直接复制参数的值到线程内部的存储空间,并把参数的副本当成move-only型别(只能移动,不可复制)​,并以右值的形式传递。这就导致如果要直接传递引用的话,线程函数的参数只能是const引用(非const引用不能接受右值传递)。

例如下例代码,会编译失败。

void update_data_for_widget(widget_id w,widget_data& data);  //   
void oops_again(widget_id w)
{
    widget_data data;
    std::thread t(update_data_for_widget,w,data);  // 预期接受非const引用,编译失败
    display_status();
    t.join();
    process_widget_data(data);
}

        解决方法就显而易见:若需按引用方式传递参数,只要用std::ref()函数加以包装即可。把创建线程的语句改写成:

std::thread t(update_data_for_widget,w,std::ref(data));

        那么,传入update_data_for_widget()函数的就不是变量data的临时副本,而是指向变量data的引用,代码遂能成功编译。但是与传递指针相同,同样会遇到一个变量在两个线程中的生存期可能不同的问题。

4.4 类成员函数做线程入口函数如何传参

        若要将某个类的成员函数设定为线程函数,我们则应传入一个函数指针,指向该成员函数。此外,我们还要给出合适的对象指针,作为该函数的第一个参数。例如:

class X
{
public:
    void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work,&my_x);

        上述对象指针由my_x的地址充当,这段代码将它传递给std::thread的构造函数,因此新线程会调用my_x.do_lengthy_work()。我们还能为成员函数提供参数:若给std:: thread的构造函数增添第3个参数,则它会传入成员函数作为第1个参数,以此类推.

4.5 传递unique_ptr

        这是一种值得提倡的参数传递方式,不仅利用unique_ptr的移动语义节省了复制开销,也解决了变量的归属权和生存期问题。例如:

void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object,std::move(p));

        在调用std::thread的构造函数时,依据std::move(p)所指定的操作,big_object对象的归属权会发生转移,先进入新创建的线程的内部存储空间,再转移给process_big_object()函数。

五、移交线程归属权

        虽然std::thread类的实例并不拥有动态对象(这与std::unique_ptr不同)​,但它们拥有另一种资源:每份实例都负责管控一个执行线程。因为std::thread类的实例能够移动(movable)却不能复制(not copyable)​,故此线程的归属权可以在其实例之间转移。这就保证了,对于任一特定的执行线程,任何时候都只有唯一的std:::thread对象与之关联,还准许程序员在其对象之间转移线程归属权。

5.1 将线程归属权在实例之间多次转移

void some_function();
void some_other_function();
std::thread t1(some_function);    
std::thread t2=std::move(t1);   
t1=std::thread(some_other_function);   
std::thread t3;    
t3=std::move(t2);  
t1=std::move(t3);  //该赋值操作会终止整个程序

        在最后一次转移中,运行some_function()的线程的归属权转移到t1,该线程最初由t1启动。但在转移之时,t1已经关联运行some_other_function()的线程。因此std::terminate()会被调用,终止整个程序。该调用在std::thread的析构函数中发生,目的是保持一致性.

5.2  std::thread做函数返回值

        std::thread支持移动操作的意义是,函数可以便捷地向外部转移线程的归属权,

std::thread f()
{
    void some_function();
    return std::thread(some_function);
}
std::thread g()
{
    void some_other_function(int);
    std::thread t(some_other_function,42);
    return t;
}

5.3  std::thread作为函数参数

        若归属权可以转移到函数内部,函数就能够接收std::thread实例作为按右值传递的参数,如下所示:

void f(std::thread t);
void g()
{
    void some_function();
    f(std::thread(some_function));
    std::thread t(some_function);
    f(std::move(t));
}

5.4 std::jthread

        jthread是c++20所支持的新的线程类型,jthread = joinable thread, 即可以自动join的线程。

        c++11中thread对象如果在销毁之前处于可join的状态,却没有join的话,将会引发一个异常.另外,td::thread的析构时执行join有时不仅可能导致程序表现异常,还可能导致程序挂起。解决方案是此类程序应该和异步执行的lambda通信,告诉它不需要执行了,可以直接返回,但是C++11中不支持可中断线程(interruptible threads)。

jthread的由来就很好理解了,jthread除了拥有std::thread 的行为外,主要增加了以下两个功能:

  • jthread 对象被析构时,会自动调用join,等待其所表示的执行流结束。
  • jthread支持外部请求中止(通过 get_stop_source、get_stop_token 和 request_stop )。
5.4.1 jthread的基础使用

        在jthread对象销毁时没有join也不会引发异常

#include <iostream>
#include <thread>

int main(){

    std::cout << std::endl;
    std::cout << std::boolalpha;

    std::jthread thr{[]{ std::cout << "Joinable std::thread" << std::endl; }};
    std::cout << "thr.joinable(): " << thr.joinable() << std::endl;
    std::cout << std::endl;
}
5.4.2  jthread的复杂应用

        使用request_stop向线程发出停止运行的请求

#include <thread>
#include <iostream>

using namespace std::chrono_literals;

void test_jthread01() {
        std::jthread jt{ [](std::stop_token stoken) {
                while (!stoken.stop_requested()) { 
                        std::cout << "Doing work\n";
                        std::this_thread::sleep_for(1s);
                }
        }};
        sleep(5);
        jt.request_stop(); // 请求线程停止,因有响应停止请求而终止线程
        jt.join();
}
int main()
{
    test_jthread01();
}

        注意lambda函数的入参中需要添加std::stop_token类型的入参, 并且需要在线程中添加检查stop_token的代码

        使用ThreadRAII来保证在std::thread的析构时执行join有时不仅可能导致程序表现异常,还可能导致程序挂起。

        jthread同样可以解决上述问题,jthread对象在析构时会调用request_stop,jthread的析构函数伪代码可能是下面这样的:

~jthread() {
    if(joinable()) {
        request_stop(); //More on stop request below.
        join();
    }
}

        而在线程的内部可以使用std::condition_variable_any去进行配合。condition_variable_any和一样条件变量不同的是, 其wait时会接受一个stop_token,当收到request_stop时,wait会直接返回,不再进行等待。下面是condition_variable_any进行wait时的伪代码:

template<class Lock, class Predicate>
bool wait(Lock& lock, std::stop_token stoken, Predicate stop_waiting){
    while (!stoken.stop_requested()) {
        if (stop_waiting()) return true;
        wait(lock);
    }
    return stop_waiting();
}

        结合jthread和condition_variable_any就可以很好的解决上述问题。如果发出了request_stop,那么condition_variable_any类型的条件变量将会唤醒。下面是一个使用jthread和condition_variable_any的完整例子,jthread对象析构时,不再会陷入无穷的等待中。

#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
#include <condition_variable>

using namespace std::chrono_literals;
std::condition_variable_any cond;

int main()
{
    std::jthread waiting_worker([](std::stop_token stoken) {
        std::mutex mutex;
        std::unique_lock lock(mutex);
        std::cout << "wait" << std::endl;
        cond.wait(lock, stoken,
            [] { return false; });
        if (stoken.stop_requested()) {
            std::cout << "Waiting worker is requested to stop\n";
            return;
        }
    });

    std::this_thread::sleep_for(100s);
    std::cout << "destroy jthread object, and call request_stop" << std::endl;
    // Or automatically using RAII:
    // waiting_worker's destructor will call request_stop()
    // and join the thread automatically.
}

六、运行时选择线程数量

        C++标准库的std::thread::hardware_concurrency()函数,在多核系统上,该值可能就是CPU的核芯数量。下列代码是并行版的std::accumulate()的简单实现。下列代码将工作分派给各线程,并设置一个限定量,每个线程所负责的元素数量都不低于该限定量,从而防止创建的线程过多,造成额外开销。

template<typename Iterator,typename T>
struct accumulate_block
{
    void operator()(Iterator first,Iterator last,T& result)
    {
        result=std::accumulate(first,last,result);
    }
};
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
    unsigned long const length=std::distance(first,last);
    if(!length)
        return init;    ⇽---  ①
    unsigned long const min_per_thread=25;
    unsigned long const max_threads=
        (length+min_per_thread-1)/min_per_thread;    ⇽---  ②
    unsigned long const hardware_threads=
        std::thread::hardware_concurrency();
    unsigned long const num_threads=
        std::min(hardware_threads!=0?hardware_threads:2,max_threads);    ⇽---  ③
    unsigned long const block_size=length/num_threads;    ⇽---  ④
    std::vector<T> results(num_threads);
    std::vector<std::thread>  threads(num_threads-1);    ⇽---  ⑤
    Iterator block_start=first;
    for(unsigned long i=0;i<(num_threads-1);++i)
    {
        Iterator block_end=block_start;
        std::advance(block_end,block_size);    ⇽---  ⑥
        threads[i]=std::thread(    ⇽---  ⑦
            accumulate_block<Iterator,T>(),
            block_start,block_end,std::ref(results[i]));
        block_start=block_end;    ⇽---  ⑧
    }
    accumulate_block<Iterator,T>()(
        block_start,last,results[num_threads-1]);    ⇽---  ⑨

    for(auto& entry: threads)
           entry.join();    ⇽---  ⑩
    return std::accumulate(results.begin(),results.end(),init);    ⇽---  ⑪
}

            接下来给出代码解释

    • 假如传入的区间为空①,就直接返回初始值,该值由参数init给出。这样区间至少含有一个元素
    • 我们将元素总量除以每个线程处理元素的最低限定量,得出线程的最大数量②
    • 对比算出的最小线程数量和硬件线程数量,较小者即为实际需要运行的线程数量③
    • 将目标区间的长度除以线程数量,得出各线程需分担的元素数量④
    • 需要发起的线程数量比前面所求的num_threads少一个⑤,因为主线程本身就算一个(parallel_accumulate()函数在其上运行)​。
    • 每轮循环中,迭代器block_end前移至当前小块的结尾⑥,新线程就此启动,计算该小块的累加结果⑦。下一小块的起始位置即为本小块的末端⑧。
    • 发起全部线程后,主线程随之处理最后一个小块⑨。我们知道,无论最后的小块含有多少元素,迭代器last必然指向其结尾,这正好解决了上述无法整除的问题。
    • 最后小块的累加一旦完成,我们就在一个循环里等待前面所有生成的线程⑩
    • 调用std::accumulate()函数累加中间结果⑪

    七、线程ID

    7.1 线程ID的获取方式

            线程ID所属型别是std::thread::id,实例常用于识别线程,以判断它是否需要执行某项操作,它有两种获取方法。

    • 首先,在与线程关联的std::thread对象上调用成员函数get_id(),即可得到该线程的ID。
    • 当前线程的ID可以通过调用std::this_thread::get_id()获得,函数定义位于头文件<thread>内。

    7.2 线程ID的作用

    •  用作关联容器(associative container)的键值,或用于排序。
    •  也可以用作新标准的无序关联容器(unordered associative container)的键值。
    • 令数据结构聚合std::thread::id型别的成员,以保存当前线程的ID,作为操作的要素以控制权限。凡涉及该数据结构的后续操作,都要对比执行线程的ID与预存的ID,从而判断该项操作是否得到了许可,或是否按要求进行。

    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值