C++并发编程1 - 让我们开始管理多线程

博客原文:www.freehacker.cn

C++11中引入了多线程编程,一般教科书中都没有涉及到这个概念,但是在工作中多线程却又是必不可少的。本文会从最简单的hello world入手,细述如何创建管理线程。

Hello World


经典的Hello World式开端。

#include <iostream>
#include <thread>

void hello()
{
    std::cout << "Hello world" << std::endl;
}

int main()
{
    std::thread t(hello);
    t.join(); // 没有这句话,会Debug Error的
    return 0;
}

这段代码很简单,如果用过boost多线程编程,那么应该对这个了如指掌了。首先包含线程库头文件<thread>,然后定义一个线程对象t,线程对象负责管理以hello()函数作为初始函数的线程,join()等待线程函数执行完成——这儿是阻塞的。

创建线程


上文中的经典hello world例子使用了最基本的线程创建方法,也是我们最常用的方法。std::thread对象的构造参数需要为Callable Object,可以是函数、函数对象、类的成员函数或者是Lambda表达式。接下来我们会给出这四种创建线程的方法。

以函数作为参数

上文中的Hello C++ Concurrency程序,就是最好的以函数为参数构造std::thread的例子,这里不再赘述。

以函数对象作为参数

函数对象利用了C++类的调用重载运算符,实现了该重载运算符的类对象可以当成函数一样进行调用。如下例:

#include <iostream>
#include <thread>

class hello
{
public:
    hello(){ }
    void operator()()const
    {
        std::cout << "Hello world" << std::endl;
    }
};

int main()
{
    hello h;
    std::thread t1(h);
    t1.join();
    return 0;
}

这里需要注意一点:如果需要直接传递临时的函数对象,C++编译器会将std::thread对象构造解析为函数声明:

std::thread t2(hello()); // error, compile as std::thread t2(hello(*)()); 
std::thread t3((hello())); // ok
std::thread t4{ hello() }; // ok
t2.join();   // compile error: expression must have class type
t3.join();   // ok
t4.join();   // ok

以类的成员函数作为参数

为了作为std::thread的构造参数,类的成员函数名必须唯一,在下例中,如果world1()和world2()函数名都是world,则编译出错,这是因为名字解析发生在参数匹配之前。

#include <iostream>
#include <thread>
#include <string>

class hello
{
public:
    hello(){ }
    void world1()
    {
        std::cout << "Hello world" << std::endl;
    }
    void world2(std::string text)
    {
        std::cout << "Hello world, " << text << std::endl;
    }
};

int main()
{
    hello h;
    std::thread t1(&hello::world1, &h);
    std::thread t2(&hello::world2, &h, "lee");
    t1.join();
    t2.join();
    return 0;
}

以lambda对象作为参数

#include <iostream>
#include <thread>
#include <string>
 
int main()
{
    std::thread t([](std::string text){
        std::cout << "hello world, " << text << std::endl;
    }, "lee");
    t.join();
    return 0;
}

创建线程对象时需要切记,使用一个能访问局部变量的函数去创建线程是一个糟糕的注意。

等待线程


join()等待线程完成,只能对一个线程对象调用一次join(),因为调用join()的行为,负责清理线程相关内容,如果再次调用,会出现Runtime Error

std::thread t([](){ 
               std::cout << "hello world" << std::endl;
});
t.join(); // ok
t.join(); // runtime error
if(t.joinable()) 
{
               t.join(); // ok
}

对join()的调用,需要选择合适的调用时机。如果线程运行之后父线程产生异常,在join()调用之前抛出,就意味着这次调用会被跳过。解决办法是,在无异常的情况下使用join()——在异常处理过程中调用join()

#include <iostream>
#include <thread>
#include <string>

int main()
{
    std::thread t([](std::string text){ 
        std::cout << "hello world, " << text << std::endl;
    }, "lee");
    try
    {
        throw std::exception("test");
    }
    catch (std::exception e)
    {
        std::cout << e.what() << std::endl;
        t.join();
    }
    if (t.joinable())
    {
        t.join();
    }
    return 0;
}

上面并非解决这个问题的根本方法,如果其他问题导致程序提前退出,上面方案无解,最好的方法是所谓的RAII。

#include <iostream>
#include <thread>
#include <string>

class thread_guard
{
public:
    explicit thread_guard(std::thread &_t)
        : t(std::move(_t))
    {
                if(!t.joinable())
                    throw std::logic_error("No Thread");
    }

    ~thread_guard()
    {
        if (t.joinable())
        {
            t.join();
        }
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const &) = delete;
private:
    std::thread t;
};

void func()
{
    thread_guard guard(std::thread([](std::string text){
        std::cout << "hello world, " << text << std::endl;
    }, "lee"));
    try
    {
        throw std::exception("test");
    }
    catch (...)
    {
        throw;
    }
}

int main()
{
    try
    {
        func();
    }
    catch (std::exception e)
    {
        std::cout << e.what() << std::endl;
    }
    return 0;
}

分离线程

detach()将子线程和父线程分离。分离线程后,可以避免异常安全问题,即使线程仍在后台运行,分离操作也能确保std::terminate在std::thread对象销毁时被调用。

通常称分离线程为守护线程(deamon threads),这种线程的特点就是长时间运行;线程的生命周期可能会从某一个应用起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。

#include <iostream>
#include <thread>
#include <string>
#include <assert.h>

int main()
{
    std::thread t([](std::string text){
        std::cout << "hello world, " << text << std::endl;
    }, "lee");

    if (t.joinable())
    {
        t.detach();
    }
    assert(!t.joinable());

    return 0;
}

上面的代码中使用到了joinable()函数,不能对没有执行线程的std::thread对象使用detach(),必须要使用joinable()函数来判断是否可以加入或分离。

线程传参


正常的线程传参是很简单的,但是需要记住下面一点:默认情况下,即使我们线程函数的参数是引用类型,参数会先被拷贝到线程空间,然后被线程执行体访问。上面的线程空间为线程能够访问的内部内存。我们来看下面的例子:

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

即使f的第二个参数是引用类型,字符串字面值"hello"还是被拷贝到线程t空间内,然后被转换为std::string类型。在上面这种情况下不会出错,但是在下面这种参数为指向自动变量的指针的情况下就很容易出错。

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将会被拷贝到线程t空间内,这个时候很可能函数oops结束了,buffer还没有被转换为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)); 
    t.detach();
}

由于上面所说,进程传参时,参数都会被进行一次拷贝,所以即使我们将进程函数参数设为引用,也只是对这份拷贝的引用。我们对参数的操作并不会改变其传参之前的值。看下面例子:

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);
    display_status();
    t.join();
    process_widget_data(data);
}

线程t执行完成之后,data的值并不会有所改变,process_widget_data(data)函数处理的就是一开始的值。我们需要显示的声明引用传参,使用std::ref包裹需要被引用传递的参数即可解决上面问题:

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,std::ref(data));
    display_status();
    t.join();
    process_widget_data(data);
}

对于可以移动不可拷贝的参数,譬如std::unqiue_ptr对象,如果源对象是临时的,移动操作是自动执行的;如果源对象是命名变量,必须显式调用std::move函数

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::thread对象之间转移线程所有权使用sd::move函数。

void some_function();
void some_other_function();
std::thread t1(some_function);           // 1
std::thread t2=std::move(t1);            // 2
t1=std::thread(some_other_function);     // 3 临时对象会隐式调用std::move转移线程所有权
std::thread t3;                          // 4
t3=std::move(t2);                        // 5
t1=std::move(t3);                        // 6 赋值操作将使程序崩溃
t1.detach();
t1=std::move(t3);                        // 7 ok

这里需要注意的是临时对象会隐式调用std::move转移线程所有权,所以t1=std::thread(some_other_function);不需要显示调用std::move。如果需要析构thread对象,必须等待join()返回或者是detach(),同样,如果需要转移线程所有权,必须要等待接受线程对象的执行函数完成,不能通过赋一个新值给std::thread对象的方式来"丢弃"一个线程。第6点中,t1仍然和some_other_function联系再一次,所以不能直接转交t3的所有权给t1。

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;
}

当所有权可以在函数内部传递,就允许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));
}

利用这个特性,我们可以实现线程对象的RAII封装。

class thread_guard
{
public:
    explicit thread_guard(std::thread &_t)
        : t(std::move(_t))
    {
        if (!t.joinable())
            throw std::logic_error("No Thread");
    }

    ~thread_guard()
    {
        if (t.joinable())
        {
            t.join();
        }
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const &) = delete;
private:
    std::thread t;
};
struct func;
void f() {
    int some_local_state;
    scoped_thread t(std::thread(func(some_local_state)));
    do_something_in_current_thread();
}

利用线程可以转移的特性我们可以用容器来集中管理线程,看下面代码:

void do_work(unsigned id);
void f() {
    std::vector<std::thread> threads;
    for(unsigned i=0;i<20;++i)
    {
        threads.push_back(std::thread(do_work,i));
    }
    std::for_each(threads.begin(),threads.end(),
                  std::mem_fn(&std::thread::join));
}

线程相关


线程数量

std::thread::hardware_concurrency()函数返回一个程序中能够同时并发的线程数量,在多核系统中,其一般是核心数量。但是这个函数仅仅是一个提示,当系统信息无法获取时,函数会返回0。看下面并行处理的例子:

识别线程

线程标识类型是std::thread::id,可以通过两种方式进行检索。

  • 通过调用std::thread对象的成员函数get_id()来直接获取。
  • 当前线程中调用std::this_thread::get_id()也可以获得线程标识。

上面的方案和线程sleep很相似,使用上面一样的格式,get_id()函数替换成sleep()函数即可。
std::thread::id对象可以自由的拷贝和对比:

  • 如果两个对象的std::thread::id相等,那它们就是同一个线程,或者都“没有线程”。
  • 如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。

std::thread::id实例常用作检测特定线程是否需要进行一些操作,这常常用在某些线程需要执行特殊操作的场景,我们必须先要找出这些线程。


关注微信公众号FreeHacker
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,924评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,781评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,813评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,264评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,273评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,383评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,800评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,482评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,673评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,497评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,545评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,240评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,802评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,866评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,101评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,673评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,245评论 2 341

推荐阅读更多精彩内容