Cpp Concurrency In Action(读书笔记7)——并发代码设计

线程间划分工作的技术

  决定使用多少个线程,并且这些线程应该去做什么。还需要决定是使用“全能”的线程去完成所有的任务,还是使用“专业”线程只去完成一件事情,或将两种方法混合。使用并发的时候,需要作出诸多选择来驱动并发,这里的选择会决定代码的性能和清晰度。因此,这里的选择至关重要,所以在你设计应用程序的结构时,再作出适当的决定。

在线程处理前对数据进行划分

  使用过MPI(Message Passing Interface)和OpenMP的人对这个结构一定很熟悉:一项任务被分割成多个,放入一个并行任务集中,执行线程独立的执行这些任务,结果在会有主线程中合并。一种方式是使用递归算法(递减操作)。

递归划分

  使用栈的并行快速排序算法——等待数据块排序:

#include "threadsafe_stack.h"//省略部分头文件
template<typename T>
struct sorter // 1
{
    struct chunk_to_sort
    {
        std::list<T> data;
        std::promise<std::list<T> > promise;
    };
    threadsafe_stack<chunk_to_sort> chunks; // 2 支持在栈上简单的存储无序数据块
    std::vector<std::thread> threads; // 3 对线程进行设置
    unsigned const max_thread_count;
    std::atomic<bool> end_of_data;
    sorter() :
        max_thread_count(std::thread::hardware_concurrency() - 1),
        end_of_data(false)
    {}
    ~sorter() // 4
    {
        end_of_data = true; // 5 设置标志
        for (unsigned i = 0;i<threads.size();++i)
        {
            threads[i].join(); // 6 等待所有线程完成工作
        }
    }
    void try_sort_chunk()
    {
        // 7 从栈上弹出一个数据块
        std::shared_ptr<chunk_to_sort > chunk = chunks.pop(); 
        if (chunk)
        {
            sort_chunk(chunk); // 8 排序
        }
    }
    std::list<T> do_sort(std::list<T>& chunk_data) // 9
    {
        if (chunk_data.empty())
        {
            return chunk_data;
        }
        std::list<T> result;
        result.splice(result.begin(), chunk_data, chunk_data.begin());
        T const& partition_val = *result.begin();
        typename std::list<T>::iterator divide_point = // 10 对数据进行划分
            std::partition(chunk_data.begin(), chunk_data.end(),
                [&](T const& val) {
   return val<partition_val;});
        chunk_to_sort new_lower_chunk;
        new_lower_chunk.data.splice(new_lower_chunk.data.end(),
            chunk_data, chunk_data.begin(),
            divide_point);//此函数操作是移动元素
        std::future<std::list<T> > new_lower =
            new_lower_chunk.promise.get_future();
        chunks.push(std::move(new_lower_chunk)); // 11 将这些数据块的指针推到栈上
        if (threads.size()<max_thread_count) // 12 有备用处理器的时候,产生新线程
        {
            threads.push_back(std::thread(&sorter<T>::sort_thread, this));
            //参数传入方式
        }
        //只剩高值部分,排序由当前线程完成
        std::list<T> new_higher(do_sort(chunk_data));
        result.splice(result.end(), new_higher);
        // 13 因为小于部分的数据块可能由其他线程进行处理,那么就得等待这个线程完成
        while (new_lower.wait_for(std::chrono::seconds(0)) !=
            std::future_status::ready) //等待低值完成
        {
            // 14 当线程处于等待状态时,就让当前线程尝试处理栈上的数据
            try_sort_chunk(); 
        }
        result.splice(result.begin(), new_lower.get());
        //从期望中得到数据list<T>
        return result;
    }
    void sort_chunk(std::shared_ptr<chunk_to_sort> const& chunk)
    {
        // 15 结果存在promise中,让线程对已经存在于栈上的数据块进行提取
        chunk->promise.set_value(do_sort(chunk->data)); 
    }
    void sort_thread()
    {
        while (!end_of_data) // 16 未被设置时
        {
            try_sort_chunk(); 
            // 17 新生成的线程还在尝试从栈上获取需要排序的数据块
            std::this_thread::yield(); 
            /*18 在循环检查中,也要给其他线程机会,
            *可以从栈上取下数据块进行更多的操作。
            *Provides a hint to the implementation to 
            *reschedule the execution of threads, 
            *allowing other threads to run.
            */
        }
    }
};
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input) // 19
{
    if (input.empty())
    {
        return input;
    }
    sorter<T> s;
    // 20 当所有数据都已经排序完成,do_sort将会返回
    return s.do_sort(input); 
}
int main()
{
    std::list<int> l{ 35,3,4,44,66,22,11,222,333,55,1,0,9,6,
        35,3,4,44,66,22,11,222,333,55,1,0,9,6 };
    auto r = parallel_quick_sort(l);//18ms
    for (auto &im : r)
    {
        std::cout << im << std::endl;
    }
    system("pause");
    return 0;
}

  上面程序使用的线程安全栈(无锁栈的使用还在摸索中):

#ifndef _THREADSAFE_STACK_H
#define _THREADSAFE_STACK_H
template<typename T>
class threadsafe_stack//读书笔记5
{
private:
    std::stack<T> data;
    mutable std::mutex m;
public:
    threadsafe_stack() {}
    threadsafe_stack(const threadsafe_stack& other)
    {
        std::lock_guard<std::mutex> lock(other.m);
        data = other.data;
    }
    threadsafe_stack& operator=(const threadsafe_stack&) = delete;
    void push(T new_value)
    {
        std::lock_guard<std::mutex> lock(m);
        data.push(std::move(new_value)); // 1
    }
    std::shared_ptr<T> pop()
    {
        std::lock_guard<std::mutex> lock(m);
        if (data.empty()) return std::make_shared<T>(); 
        // 2 修改了异常处理做法,返回空指针代替抛出异常
        std::shared_ptr<T> const res(
            std::make_shared<T>(std::move(data.top()))); // 3
        data.pop(); // 4
        return res;
    }
    void pop(T& value)
    {
        std::lock_guard<std::mutex> lock(m);
        if (data.empty()) throw empty_stack();
        value = std::move(data.top()); // 5
        data.pop(); // 6
    }
    bool empty() const
    {
        std::lock_guard<std::mutex> lock(m);
        return data.empty();
    }
};
#endif // !_THREADSAFE_STACK_H

  该方案制约线程数量的值就是std::thread::hardware_concurrency() 的值,这样就能避免任务过于频繁的切换。不过,这里还有两个问题:线程管理和线程通讯。要解决这两个问题就要增加代码的复杂程度。虽然,线程对数据项是分开处理的,不过所有对栈的访问,都可以向栈添加新的数据块,并且移出数据块以作处理。这里重度的竞争会降低性能(即使使用无锁(无阻塞)栈)。
  这个方案使用到了一个特殊的线程池——所有线程的任务都来源于一个等待链表,然后线程会去完成任务,完成任务后会再来链表提取任务。这个线程池很有问题(包括对工作链表的竞争)。几种划分方法:1,处理前划分;2,递归划分(都需要事先知道数据的长度固定),还有上面的那种划分方式。事情并非总是这样好解决;当数据是动态生成,或是通过外部输入,那么这里的办法就不适用了。在这种情况下,基于任务类型的划分方式,就要好于基于数据的划分方式。

通过任务类型划分工作

  每个线程只需要关注自己所要做的事情即可。其本身就是基本良好的设计,每一段代码只对自己的部分负责。
  分离关注:多线程下有两个危险需要分离关注。一个是对错误担忧的分离,主要表现为线程间共享着很多的数据,或者是不同的线程要相互等待。这两种情况都是因为线程间很密切的交互。当这种情况发生,就需要看一下为什么需要这么多交互。当所有交互都有关于同样的问题,就应该使用单线程来解决,并将引用同一原因的线程提取出来。或者,当有两个线程需要频繁的交流,且没有其他线程时,那么就可以将这两个线程合为一个线程。当通过任务类型对线程间的任务进行划分时,不应该让线程处于完全隔离的状态。当多个输入数据集需要使用同样的操作序列,可以将序列中的操作分成多个阶段,来让每个线程执行。
  划分任务序列:当任务会应用到相同操作序列,去处理独立的数据项时,就可以使用流水线(pipeline)系统进行并发。这样可以为流水线中的每一阶段操作创建一个独立线程。

影响并发代码性能的因素

  在多线程代码中有很多因素会影响性能——对线程处理的数据做一些简单的改动(其他不变),都可能对性能产生戏剧性的效果。

有多少个处理器?

  处理器个数是影响多线程应用的首要因素。当多于机器支持线程数在运行的时候(都没有阻塞或等待),应用将会浪费处理器的运算时间在线程间进行切换。这种情况发生时,我们称其为超额认购(oversubscription)。根据前面,我们可以知道使用std::thread::hardware_concurrency()函数就能知道在给定硬件上可以扩展的线程数量了。但是要谨慎使用,因为它不会考虑其它运行在系统上的线程(除非已经将系统信息进行共享)。最坏的情况就是,多线程同时调用 std::thread::hardware_concurrency() 函数来对线程数量进行扩展,这样将导致庞大的超额认购。std::async()就能避免这个问题,因为标准库会对所有的调用进行适当的安排。同样,谨慎的使用线程池也可以避免这个问题。
  不过,即使你已经考虑到所有在应用中运行的线程,程序还要被同时运行的其他程序所影响。这里的一种选择是使用与std::async() 类似的工具,来为所有执行异步任务的线程的数量做考虑;另一种选择就是,限制每个应用使用的处理芯个数。理想算法可能会取决于问题规模与处理单元的比值。大规模并行系统中有很多的处理单元,算法可能就会同时执行很多操作,让应用更快的结束;这就要快于执行较少操作的平台,因为该平台上的每一个处理器只能执行很少的操作。随着处理器数量的增加,另一个问题就会来影响性能:多个处理器尝试访问同一个数据。

数据争用与乒乓缓存

  当两个线程并发的在不同处理器上执行,并且对同一数据进行读取,通常不会出现问题;因为数据将会拷贝到每个线程的缓存中,并且可以让两个处理器同时进行处理。不过,当有线程对数据进行修改的时候,这个修改需要更新到其他核芯的缓存中去,就要耗费一定的时间。根据线程的操作性质,以及使用到的内存序,这样的修改可能会让第二个处理器停下来,等待硬件内存更新缓存中的数据。即便是精确的时间取决于硬件的物理结构,不过根据CPU指令,这是一个特别特别慢的操作,相当于执行成百上千个独立指令。如果处理器很少需要互相等待,那么这种情况就是低竞争(low contention)。反之称为高竞争(high contention)。
  数据在每个缓存中传递若干次叫做乒乓缓存(cache pingpong),当一个处理器因为等待缓存转移而停止运行时,这个处理器就不能做任何事情,所以对于整个应用来说,这就是一个坏消息。
  随着处理器对数据的访问次数增加,对于互斥量的竞争就会增加,并且持有互斥量的缓存行将会在核芯中进行转移,因此会增加不良的锁获取和释放次数。有一些方法可以改善这个问题,其本质就是让互斥量对多行缓存进行保护,不过这样的互斥量需要自己去实现。如果乒乓缓存是一个糟糕的现象,那么该怎么避免它呢?在后面,答案会与提高并发潜能的指导意见相结合:减少两个线程对同一个内存位置的竞争。虽然,要实现起来并不简单。即使给定内存位置被一个线程所访问,可能还是会有乒乓缓存的存在,是因为另一种叫做伪共享(false sharing)的效应。

伪共享

  处理器缓存通常不会用来处理在单个存储位置,但其会用来处理称为缓存行(cache lines)的内存块。假设你有一个int类型的数组,并且有一组线程可以访问数组中的元素,且对数组的访问很频繁(包括更新)。通常int类型的大小要小于一个缓存行,同一个缓存行中可以存储多个数据项。因此,即使每个线程都能对数据中的成员进行访问,硬件缓存还是会产生乒乓缓存。每当线程访问0号数据项,并对其值进行更新时,缓存行的所有权就需要转移给执行该线程的处理器,这仅是为了让更新1号数据项的线程获取1号线程的所有权。缓存行是共享的(即使没有数据存在),因此使用伪共享来称呼这种方式。这个问题的解决办法就是对数据进行构造,让同一线程访问的数据项存在临近的内存中(就像是放在同一缓存行中),这样那些能被独立线程访问的数据将分布在相距很远的地方,并且可能是存储在不同的缓存行中。在本章接下来的内容中看到,这种思路对代码和数据设计的影响。

如何让数据紧凑?

  伪共享发生的原因:某个线程所要访问的数据过于接近另一线程的数据,另一个是与数据布局相关的陷阱会直接影响单线程的性能。
  任务切换(task switching)。如果系统中的线程数量要比核芯多,每个核上都要运行多个线程。这就会增加缓存的压力,为了避免伪共享,努力让不同线程访问不同缓存行。因此,当处理器切换线程的时候,就要对不同内存行上的数据进行重新加载(当不同线程使用的数据跨越了多个缓存行时),而非对缓存中的数据保持原样(当线程中的数据都在同一缓存行时)。
  当有超级多的线程准备运行时(非等待状态),任务切换问题就会频繁发生。这个问题我们之前也接触过:超额认购(oversubscription)。

超额认购和频繁的任务切换

  如果有很多额外线程,就会有很多线程准备执行,而且数量远远大于可用处理器的数量,不过操作系统就会忙于在任务间切换,以确保每个任务都有时间运行。

为多线程性能设计数据结构

  当为多线程性能而设计数据结构的时候,需要考虑竞争(contention),伪共享(false sharing)和数据距离(data proximity)。这三个因素对于性能都有着重大的影响,并且你通常可以改善的是数据布局,或者将赋予其他线程的数据元素进行修改。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值