Loading

C++并发编程实战学习笔记之—— 管理线程

管理线程

1、基本线程管理

1.1 启动线程

线程是通过std::thread对象来开始的,该对象指定了线程上要运行的任务。

void do_some_work();
std::thread my_thread(do_some_work);
  • std::thread可以与任何可调用(callable)类型一同工作,所以可以将一个带有函数调用操作符的类的实例传递给std::thread的构造函数来进行代替;
class backgrouon_task {
    public:
    	void operator() () const{
            do_something();
            do_something_else();
        }
};
backgrouon_task f;
std::thread my_thread(f);
  • 所提供的函数对象被复制到属于新创建的执行线程的存储器中,并从哪里调用;

如果传递一个临时的且未命名的变量,那么其语法可能与函数声明一样。这种情况下,编译器会将其解析成如下这样,而非对象定义;

std::thread my_thread(background_task());
  • 声明了函数 my_thread ,它接受单个参数(参数类型是指向不接收参数同时返回background_task对象的函数指针),并返回std::thread对象,而不是启动一个新线程。

可以像前面通过函数对象来避免上述情况,通过使用一组额外的括号,或使用新的统一初始化语法,例如:

std::thread my_thread((background_task()));	// --->1
std::thread my_thread{background_task()};	// --->2
  • 在例子1中,额外的括号避免其解释为函数声明,从而让my_thread被声明为std::thread类型的变量。;

  • 在例子2中,使用新的统一初始化语法,用大括号而不是括号,同样也是声明一个变量;

  • 在C++11中的一项新的功能,lambda表达式:

    • 允许你编写一个局部函数,并可能捕捉一些局部变量,同时避免传递额外参数的需求。(详情找lambda贴)
std::thread my_thread([] {
    do_something();
    do_something_else();
});
  • 一旦开始了线程,需要显式的决定是要等待它完成,还是让他自行运行;
  • 如果在std::thread对象被销毁前未作决定,那么你的程序会被终止(std::thread的析构函数调用std::terminate());
  • 如果不等待线程完成,需要确保该线程访问的数据是有效的,直到线程完成为止;

清单2.1 当线程仍然访问局部变量时返回的函数

struct func {
    int& i;
    func(int& i_):i(i_) {}
    void operator() {
        for(unsigned j=0;j<1000000;++j) {
            do_something(i);		// --->1  对悬空引用可能的访问
        }
    }
};
void oops() {
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread my_thread(my_func);
    my_thread.detach();				// --->2  不等线程完成
}									// --->3  新线程可能仍在运行

  • 当oops退出3时与my_thread相关联的新线程可能仍然在运行,因为调用detach()说明已经显式的决定不等待它;
  • 如果线程仍然在运行,则在下次调用do_something(i)时就会访问一个已被销毁的变量;
  • 允许对局部变量的指针或引用持续函数退出之后容易发生错误;

常见的处理方式是使线程函数自包含,并且把数据复制到该线程中而不是共享数据;

如果你为线程函数使用了一个可调用对象,该对象本身被复制到该线程中,那么原始对象就可以立即销毁;

例如:

为线程函数使用了一个可调用对象,该对象本身被复制到该线程中,那么原始对象就可以立即被销毁。但是你仍然需要警惕包含有指针或引用的对象;

另外,通过结合(joining)线程,可以确保在函数退出前,该线程执行完毕;

1.2 等待线程完成

通过相关联的std::thread实例上调用join()来实现;

在清单2.1的情况下,将对my_thread.detach()的调用替换为my_thread.join();

在这种情况下,意味着在独立的线程上运行函数是没什么意义的,因为线程在此期间将做不了任何有用的事情;

join()很简单暴力——要么等待一个线程完成要么就不等。

1.3 异常情况下等待

  • join()
    • 直接不等待线程执行完成;
  • detach()
    • 通常在线程启动后就可以立即调用,但是如果打算等待线程,就需要仔细的选择什么时候调用join();
    • 如果线程开始后,在调用join()之前引发了异常,对join()的调用就会被跳过;

清单2.2 等待线程结束

struct func {
    int& i;
    func(int& i_):i(i_) {}
    void operator() {
        for(unsigned j=0;j<1000000;++j) {
            do_something(i);		
        }
    }
};
void f() {
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    try {
        t.join();		// --->1
        throw;
    }
    t.join();			// --->2
}
  • 使用了try/catch块,以确保访问局部状态的线程在函数退出前结束
    • ​ 无论函数是否正常退出2还是异常1中断。

然而上述代码并不理想;


确保线程必须在函数退出前完成;

无论是因为对其他局部变量的引用或者其他原因;

确保所有可能的退出路径的情况很重要,无论正常还是异常;

方法之一是使用标准的资源获取即初始化(RAII)


清单2.3 使用RAII等待线程完成

class thread_guard {
    std::thread& t;
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=(threard_guard const&)=delete;
};
struct func {
    int& i;
    func(int& i_):i(i_) {}
    void operator() {
        for(unsigned j=0;j<1000000;++j) {
            do_something(i);		
        }
    }
};
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();
}								// --->4
  • 当前线程的执行未达到末尾[4]时,局部对象会按照析构函数的逆序被销毁;
  • thread_guard对象g首先被销毁,并且析构函数[2]中线程被结合,即便是当函数因do_something_in_current_thread引发异常退出的情况下也会发生;
  • 清单2.3中的析构函数在调用join()[2]前首先测试了thread_guard的析构函数是不是joinable()[1]的。
  • 给定的执行线程join()只能被调用一次;
  • 拷贝构造函数和拷贝赋值运算符被标记为=delete[3],以确保他们不会由编译器自动提供;
  • 如果无需等待线程完成,可以通过分离(detaching)来避免异常安全问题。这打破了线程与std::thread对象联系并确保当std::thread对象被销毁时std::terminate()不会被调用,即使线程仍在后台运行。;

1.4 在后台运行线程

在std::thread对象上调用detach()会把线程丢在后台运行,也没有直接的方法与之通信。也不再可能等待该线程完成;如果一个线程成为分离的,获取一个引用他的std::thread对象也是不可能的;

分离的线程确实时在后台运行,所有权和控制器被转交给C++运行时库,以确保与线程相关联的资源在线程推出后能够被正确的收回;

参照UNIX的守护进程(daemon process)概念,被分离的线程通常被称为守护线程(daemon threads)

为了从一个std::thread对象中分离线程,必须有一个线程供分离。你不能在一个没有与执行线程相关联的std::thread对象上调用detach(),对于join()也有类似的要求;

所以以对象t为例,在调用t.detach()之前,需要通过t.joinable()返回true判定后才能执行。


清单2.4 分离线程以处理其他文档

void edit_document(std::string const& filename) {
    open_document_and_display_gui(filename);
    while(!done_editing()) {
        user_command cmd=get_user_input();
        if(cmd.type ==  open_new_document) {
            std::string const new_name=get_filename_from_user();
            std::thread t(edit_document,new_name);			// --->1
            t.detach();				// --->2
        }else{
            process_user_input(cmd);
        }
    }
}
  • 如果用户选择打开一个新文档,它会提示其有文档打开,启动新线程开打开该文档[1],然后分离它[2];
  • 因为操作相同,只是打开文件不同,可以重用同一个函数(edit_document),用选定的文件名作为参数;
  • [1]中,不仅将函数名传递给线程,还将参数传递给了线程;

2、传递参数给线程函数

如清单2.4所示,传递参数可调用对象函数,基本上就是简单的将额外的参数传递给std::thread构造函数。但重要的是,参数会以默认的方式被复制(copied)内部存储空间,在那里新创建的执行线程可以访问它们,即便函数中的相应参数期待着引用;

例如:

void f(int i,std::string const& s);
std::thread t(f,3,"hello");
  • 创建了一个新的与t相关联的执行线程,被成为f(3,”hello”);

  • 字符串字面值仅在新线程的上下文中才会作为char const*传送,并且转换为std::string;

  • 尤其重要的是当提供的参数是一个自动变量的指针时,

例如:

void f(int i,std::string const& s);
void oops(int some_param) {
   char buffer[1024];			// -->1
   sprintf(buffer,"%i",some_param);
   std::thread t(f,3,buffer);	// --->2
   t.detach();
}
  • 局部变量buffer[1]的指针被传递给新的线程[2];

  • 函数oops会在缓冲在新线程上被转换为std::string之前退出,从而导致未定义的行为;

  • 解决之道时在将缓冲传递给std::thread的构造函数之前转换为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,std::string(buffer));		// --->1	
   t.detach();
}
  • 在[1]中使用std::string避免漂浮指针;

  • 同样也有可能出现:

  • std::thread构造函数原样复制了所提供的值,但并未转换为期望的参数类型;

  • 对象被复制,而你想要的时引用;(这可能发生在线程正在更新一个通过引用传递来的数据结构时),例如:

void update_data_for_widget(widget_id w, widget_data& data);// --->1
void oops_again(widget_id w) {
    widget_data data;
    std::thread t(update_data_for_widget, w, data); // --->2
    t.join();
    process_widget_data(data);	// --->3
}
  • 尽管update_data_for_widget[1]希望通过引用传递第二个参数,std::thread的构造函数[2]却不知道;

  • 线程无视函数所期望的类型,盲目的复制了所提供的值;

  • 当它调用update_data_for_widget时,它最后将传递data在内部的副本的引用而非对data自身的引用;

  • 解决方法之一是可以使用std::bind,通过std::ref来包装确实需要被引用的参数;

    • 将[3]更改为:
    std::thread t(update_data_for_widget, w, std::ref(data));
    
  • 你可以传递一个成员函数的指针作为函数,前提是提供一个合适的对象指针作为第一个参数。

class X {
public:
   void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work, &my_x);	// --->1
  • 这段代码在新的线程上调用my_x.do_lengthy_work(),因为my_x的地址是作为对象指针[1]提供的。也可以提供参数给这样的成员函数调用:std::thread构造函数的第三个参数将作为成员函数的第一个参数;

提供参数的另外一个场景,这里的参数不能被复制但只能被移动(moved):

  • 一个对象内保存的数据被转移到另一个对象,使原来的对象变成了“空壳”。这种类型的一个例子是std::unique_ptr,它提供了动态分配对象的自动内存管理。
  • 只有一个std::unique_ptr实例可以在某一时刻指向一个给定的对象,当该实例被销毁,其指向的对象将被删除。
  • 移动构造函数(move constructor)移动赋值运算符(move assignment operator)允许一个对象的所有权在std::unique_ptr实例之间进行转移。
    • 这种转移给源对象留下一个NULLL指针。这种值的移动使得该类型的对象作为函数的参数被接受或从函数返回值。
    • 在源对象是临时的场合,移动是自动的,但在源是一个命名值的地方,此转移必须直接通过调用std::move()来请求。

实例展示运用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::move(p),big_object的所有权先被转移进新创建的线程内部存储中,然后进入process_big_object;

标准线程库中的一些类表现出与std::unique_ptr相同的所有权语义,std::thread就是其中之一。虽然std::thread实例并不拥有与std::unique_ptr同样方式的动态对象,但他们却拥有资源,每一个实例负责管理一个执行线程。这种所有权可以在实例之间进行转移,因为std::thread的实例是可移动的,即使他们不是可复制的。这确保了在允许程序员选择在对象之间转换所有权 的时候,在任意时刻只有一个对象与某个特定的执行线程相关;

3、转移线程的所有权

假设编写一个函数,它创建一个在后台运行的线程,但是向调用函数回传新线程的所有权,而非等待其完成,又或者你想要反过来;

  • std::ifstream和std::unique_ptr是可移动的(movable),而非可复制的(copyable);

该示例展示了创建两个执行线程,以及在三个std::thread实例t1、t2和t3之间对那些线程的所有权进行转移

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::thread t3;						// --->4
t3=std::move(t2);					// --->5
t1=std::move(t3);					// --->6	此赋值将终结程序!
  1. 首先启动一个新线程[1]并与t1相关联;

  2. 当t2构建完成时所有权被转移给t2,通过调用std::move()来显式地转移所有权[2]。此刻,t1不再拥有相关联的执行线程,运行some_function的线程现在与t2相关联;

  3. 启动一个新的线程并与一个临时的std::thread对象相关联[3];

    ……

  • 最后一次移动[6]将运行some_function的线程的所有权转回t1。但是t1已经有了一个相关联的线程(运行着some_other_function),所以会调用std::terminate()来终止程序;

std::thread支持移动意味着所有权可以很容易地从一个函数中被转移出,如下所示:

程序清单2.5 从函数中返回std::thread

std::thread f() {
    void some_function();
    return std::thread(some_function);
}
std::thrad g() {
    void some_other_funciton(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_funciton);
    f(std::move(t));
}


程序清单2.6 scoped_thread和示例用法

class scoped_thread {
    std::thread t;
public:
    explicit scoped_thread(std::tread t_):		// --->1
    	t(std::move(t_)){
        	if(!t.joinable())			// --->2
                throw std::logic_error("No thread");
    	}
    ~scoped_thread() {
        t.join();		// --->3
    }
    scoped_thread(scoped_thiread const&)=delete;
    scoped_thread& operator=(scoped_thread const&)=delete;
};
struct func {
    int& i;
    func(int& i_):i(i_) {}
    void operator() {
        for(unsigned j=0;j<1000000;++j) {
            do_something(i);		// --->1  对悬空引用可能的访问
        }
    }
};
void f() {
    int some_local_state;
    scoped_thread t(std::thread(func(some_local_state)));// --->4
    
    do_something_in_current_thread();
}		// --->5


清单2.7 生成一批线程并等待它们完成

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(threas.begin(), threads.entd(),
                 std::mem_fn(&std::thread::join));
}

将std::thread对象放到std::vector中时线程迈向自动管理的第一步;


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

C++标准库中,对此特性有帮助的是std:🧵:hardware_currency();

  • 此函数返回一个对于给定程序执行时能够正在并发运行的线程数量的指示;

清单2.8 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)		// --->1
        return init;
    
    unsigned long const mim_per_thread=25;
    unsigned long const max_threads=
        (length+min_per_thread-1)/min_per_thread;	// --->2
    
    unsigned long const hardware_threads=
        std::thread::hardware_concurrency();
    unsigned long const num_threads=	// --->3
        std::min(hardware_threads!=0 ? hardware_threads:2,max_threads);
    
    unsigned long const block_size=length/num_threads;	// --->4
    
    std::vector<T> results(num_threads);
    std::vector<std::thread> threads(num_threads-1);	// --->5
    
    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);	// --->6
        threads[i]=std::thread(	// --->7
        	accumulate_block<Iterator,T>(),
        	block)start, block_end,std::ref(results[i]));
        block_start=block_end;	// --->8
    }
    accumulate_block<Iterator,T>() (
        block_start, last, results[num_threads-1]);	// --->9
    std::for_each(threads.begin(), threads.end(),
                 std::mem_fn(&std::thread::join));	// --->10
    
    return std::accumulate(results.begin(), results.end(), init);  // --->11
}

详解:

  • [1]如果输入范围为空,只返回初始值init;
  • [2]获取线程最大值;
  • [3]设置要运行的线程数;超额订阅不能比硬件支持的线程数量多
  • [4]每个待处理的线程的条目数量时范围的长度除以线程的数量;
  • [5]需要启动比num_threads少一个的线程,因为已经有一个了;
  • [6]递进block_end迭代器到当前块的结尾;
  • [7]启动一个新的线程来累计;
  • [8]下一块的开始时这一块的结束;
  • [9]这个线程就可以处理最后的块;
  • [10]等待使用std::for_each生成的线程;
  • [11]调用std::accumulate将结果累加起来;

5、标识线程

  • 线程标识符是std:🧵:id类型的,并且有两种获取方式:

    • 线程的标识符可以通过从与之相关联的std::thread对象中通过调用get_id()成员函数来获得;
      • 如果std::thread对象没有相关联的执行线程,对get_id()的调用返回一个默认构造的std:🧵:id对象,表示没有线程;
    • 对于当前线程的标识符,通过调用std::this_thread::get_id()获得,这是定义在头文件中的;
  • 标准库还提供了std::hash<std::thread::id>,使得std::thread::id类型的值可在新的无序关系型容器中作为主键;

  • std::thread::id的实例常被用来检查一个线程是否需要执行某些操作,例如:

    • 可以在启动其他线程之前存储std::this_thread::get_id()的结果,然后算法的核心部分(这对所有线程都是公共的),可以对照所有存储的值来检查自己的线程ID;
    std::thread::id master_thread;
    void some_core_part_of_algorithm() {
        if(std::this_thread_get_id()== master_thread) {
            do_master_thread_work();
        }
        do_common_work();
    }
    
  • 大多数情况下,std::thread::id足以作为线程的通用标识符;

  • 主要是对调试和日志有用;

posted @ 2020-03-26 20:28  平头猿小哥  阅读(318)  评论(0)    收藏  举报