学习笔记:C++多线程(2)

转载于极客学院:https://wiki.jikexueyuan.com/project/cplusplus-concurrency-action/content/chapter2/2.3-chinese.html

线程管理基础

有趣的是,提供的参数可以"移动"(move),但不能"拷贝"(copy)。移动是指原始对象中的数据转移给另一对象,而转移的这些数据就不再在原始对象中保存了(译者:比较像在文本编辑时"剪切"操作)。 std::unique_ptr就是这样一种类型(译者:C++11中的智能指针),这种类型为动态分配的对象提供内存自动管理机制(译者:类似垃圾回收)。同一时间内,只允许一个 std::unique_ptr 实现指向一个给定对象,并且当这个实现销毁时,指向的对象也将被删除。移动构造函数(move constructor)和移动赋值操作符(move assignment operator)允许一个对象在多个 std::unique_ptr 实现中传递。使用"移动"转移原对象后,就会留下一个空指针(NULL)。移动操作可以将对象转换成可接受的类型,例如:函数参数或函数返回的类型。当原对象是一个临时变量时,自动进行移动操作,但当原对象是一个命名变量,那么转移的时候就需要使用std::move() 进行显示移动。

下面的代码展示了 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§ ,big_object对象的所有权就被首先转移到新创建线程的的内部存储中,之后传递给process_big_object函数。标准线程库中 std::unique_ptr 和 std::thread 在所属权上有相似语义的类型。虽然, std::thread 实例不会如 std::unique_ptr 去占有一个动态对象所有权,但是它会占用一部分资源的所有权:每个实例都管理一个执行线程。 std::thread 所有权可以在多个实例中互相转移,因为这些实例是可移动(movable)且不可复制(aren’t copyable)。在同一时间点,就能保证只关联一个执行线程;同时,也允许程序员能在不同的对象之间转移所有权。

转移线程所有权

假设要写伊崔格在后台启动线程的函数,想通过新线程返回的所有权去调用这个函数而不是等待线程结束再去调用;或完全与之相反的想法:创建一个线程,并在函数中转移所有权,都必须要等待线程结束。总之,新线程的所有权都需要转移。

这就是移动引用std::thread的原因,C++标准库中有很多资源的占用类型,比如std::ifstream, std::unique_ptr, std::thread都是可移动,但不可拷贝。这就说明执行线程的所有权可以在std::thread实例中移动,下面将展示一个例子。例子中创建两个执行线程,并且在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
//std::thread t3 = std::move(t2);
t3 = std::move(t2);//5
t1 = std::move(t3);//6

当显式使用std::move()创建t2后,t1的所有权就转移给t2.之后,t1和执行线程就已经没有什么关联,执行some_functiond的函数现在与t2关联。然后,与一个临时std::thread对象相关的线程启动了。为什么不显式调用std::move()转移所有权呢?因为,所有者是一个临时对象–移动操作将会隐式的调用。
t3使用默认构造方式创建④,与任何执行线程都没有关联。调用 std::move() 将与t2关联线程的所有权转移到t3中⑤,显式的调用 std::move() ,是因为t2是一个命名对象。移动操作⑤完成后,t1与执行some_other_function的线程相关联,t2与任何线程都无关联,t3与执行some_function的线程相关联。最后一个移动操作,将执行some_function线程的所有权转移⑥给t1。这时,t1已经有了一个关联的线(执行some_other_function的线程),所以这里可以直接调用 std::terminate() 终止程序继续运行。终止操作将调用 std::thread 的析构函数,销毁所有对象(与C++中异常的处理方式很相似)。2.1.1节中,需要在线程对象被析构前,显式的等待线程完成,或者分离它;进行复制时也需要满足这些条件(说明:不能通过赋一个新值给 std::thread 对象的方式来"丢弃"一个线程)。std::thread 支持移动,就意味着线程的所有权可以在函数外进行转移,就如下面程序一样

#include <iostream>
#include <thread>

using namespace std;

class scoped_thread{
    thread t;
public:
    explicit scoped_thread(thread //1t_):t(move(t_)){
        if(!t.joinable()){//2
            throw logic_error("NO thread") ;
        }
    }
    
    ~scoped_thread(){
        t.join();//3
    }
    
    
    scoped_thread(scoped_thread 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(thread(func(some_local_state)));	//4
    do_something_in_current_thread();//5
}

不过这里新线程是直接传递到scoped_thread中④,而非创建一个独立的命名变量。当主线程到达f()函数的末尾时,scoped_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)); // 对每个线程调用join()
}

我们经常需要线程去分割一个算法的总工作量,所以在算法结束的之前,所有的线程必须结束。上面例子线程所做的工作都是独立的,并且结果仅会受到共享数据的影响。如果f()有返回值,这个返回值就依赖于线程得到的结果。在写入返回值之前,程序会检查使用共享数据的线程是否终止。

将 std::thread 放入 std::vector 是向线程自动化管理迈出的第一步:并非为这些线程创建独立的变量,并且将他们直接加入,可以把它们当做一个组。创建一组线程(数量在运行时确定),可使得这一步迈的更大,而非像上面例子那样创建固定数量的线程。

运行时决定线程数量

std::thread::hardware_concurrency()在新版标准库中是一个很有用的函数。这个函数将返回能同时并发在一个程序的线程数量。例如,多核系统中,返回值可以使CPU核芯的数量。返回值也仅仅是一个提示,当系统信息无法获取时,函数也会返回0。但是,这也无法掩盖这个函数对启动线程数量的把帮助。

下面例子实现一个并行的std::accumulate。代码中将整体工作拆分成小人物交给每个线程去做,其中设置最小任务数。是为了避免产生太多的线程。程序可能会在操作数量为0的时候抛出异常。比如,std::thread构造函数无法启动一个执行线程,就会抛出一个异常。

原生版的:

#include <iostream>
#include <thread>
#include <numeric>
#include <vector>
#include <algorithm>
#include <functional>

using namespace std;


template <>
template<typename Iterator,typename T>
struct accumulate_block
{
    void operator()(Iterator first,Iterator last,T& result)
    {
        result=std::accumulate(first,last,result);      //accumulate():first,last表示求和起始的范围,resulr表示初始值
    }
};


template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
    unsigned long const length=std::distance(first,last);//distance():求first 到 last 之间的距离
    if(!length) // 1
        return init;
    
    unsigned long const min_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();    //返回并发线程数。若该值非良定义或不可计算,则返回 ​0​ 
    
    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
}

函数看起来很长,但不复杂。如果输入的范围为空①,就会得到init的值。反之,如果范围内多于一个元素时,都需要用范围内元素的总数量除以线程(块)中最小任务数,从而确定启动线程的最大数量②,这样能避免无谓的计算资源的浪费。比如,一台32芯的机器上,只有5个数需要计算,却启动了32个线程。计算量的最大值和硬件支持线程数中,较小的值为启动线程的数量③。因为上下文频繁的切换会降低线程的性能,所以你肯定不想启动的线程数多于硬件支持的线程数量(称为超额认购(oversubscription))。当std::thread::hardware_concurrency() 返回0,你可以选择一个合适的数作为你的选择;在本例中,我选择了"2"。你也不想在一台单核机器上启动太多的线程,因为这样反而会降低性能,有可能最终让你放弃使用并发。每个线程中处理的元素数量,是范围中元素的总量除以线程的个数得出的④。

现在,确定了线程个数,通过创建一个 std::vector<T> 容器存放中间结果,并为线程创建一个 std::vector<std::thread> 容器⑤。这里需要注意的是,启动的线程数必须比num_threads少1个,因为在启动之前已经有了一个线程(主线程)。使用简单的循环来启动线程:block_end迭代器指向当前块的末尾⑥,并启动一个新线程为当前块累加结果⑦。当迭代器指向当前块的末尾时,启动下一个块⑧。启动所有线程后,⑨中的线程会处理最终块的结果。对于分配不均,因为知道最终块是哪一个,那么这个块中有多少个元素就无所谓了。

当累加最终块的结果后,可以等待 std::for_each ⑩创建线程的完成,之后使用 std::accumulate 将所有结果进行累加?。结束这个例子之前,需要明确:T类型的加法运算不满足结合律(比如,对于float型或double型,在进行加法操作时,系统很可能会做截断操作),因为对范围中元素的分组,会导致parallel_accumulate得到的结果可能与 std::accumulate 得到的结果不同。同样的,这里对迭代器的要求更加严格:必须都是向前迭代器(forward iterator),而 std::accumulate 可以在只传入迭代器(inputiterators)的情况下工作。对于创建出results容器,需要保证T有默认构造函数。对于算法并行,通常都要这样的修改;不过,需要根据算法本身的特性,选择不同的并行方式。需要注意的:因为不能直接从一个线程中返回一个值,所以需要传递results容器的引用到线程中去。另一个办法,通过地址来获取线程执行的结果。

当线程运行时,所有必要的信息都需要传入到线程中去,包括存储计算结果的位置。不过,并非总需如此:有时候这是识别线程的可行方案,可以传递一个标识数,例如上述例子的i。不过,当需要标识的函数在调用栈的深层,同时其他线程也可调用该函数,那么标识数就会变的捉襟见肘。好消息是在设计C++的线程库时,就有预见了这种情况,在之后的实现中就给每个线程附加了唯一标识符。

本博文只是作为自己学习笔记记录而用,若有侵权联删!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值