《C++并发实例 - 设计并发代码》 8.1 线程间划分工作的技术

本章介绍
线程间划分数据的技术
影响并发代码性能的因素
性能因素如何影响数据结构的设计
多线程代码中的异常安全
可扩展性
几种并行算法的示例实现

前面的大部分章节都重点介绍了新的 C++11 工具箱中用于编写并发代码的工具。在第 6 章和第 7 章中,我们了解了如何使用这些工具来设计多个线程并发访问安全的基本数据结构。就像木匠制作橱柜或桌子不仅仅需要知道如何构建铰链或接头一样,设计并发代码也不仅仅是基本数据结构的设计和使用。现在,您需要着眼于更广泛的背景,以便可以构建更大的结构来执行有用的工作。我将使用一些 C++ 标准库算法的多线程实现作为示例,但相同的原则适用于所有规模的应用程序。

就像任何编程项目一样,仔细考虑并发代码的设计至关重要。然而,对于多线程代码,需要考虑比顺序代码更多的因素。您不仅必须考虑封装(encapsulation)、耦合(coupling)和内聚(cohesion)等常见因素(在许多软件设计书籍中对此进行了详细描述),而且还需要考虑要共享哪些数据,数据如何同步访问,哪些线程需要等待其他线程完成特定操作,等等。

在本章中,我们将重点讨论这些问题,从高层(但基本)考虑要使用多少个线程、在哪个线程上执行哪些代码以及这如何影响代码的清晰度,到如何构建共享数据以获得最佳性能的底层细节。

让我们首先看看在线程之间划分工作的技术。

8.1 线程间划分工作的技术

想象一下,您的任务是建造一座房子。为了完成这项工作,您需要挖掘地基、建造墙壁、安装管道、添加布线等等。理论上,经过足够的训练,你可以自己完成这一切,但这可能需要很长时间,而且你会根据需要不断地切换任务。或者,您可以聘请其他一些人来帮忙。您现在必须选择雇用多少人并决定他们需要什么技能。例如,你可以雇佣几个具有通用技能的人,让每个人都参与所有事情。你们仍然会根据需要切换任务,但现在事情可以更快地完成,因为你们的人数更多了。

或者,您可以聘请一个专家团队:例如,泥瓦匠、木匠、电工和水管工。您的专家只是做他们的专长,所以如果不需要管道,您的管道工就坐在周围喝茶或咖啡。事情仍然比以前完成得更快,因为有更多的人,水管工可以安装厕所,而电工给厨房接线,但当某个专家没有工作时,就会有更多的人等待。即使有空闲时间,您也可能会发现专家的工作比普通杂工团队的工作完成得更快。您的专家不需要不断更换工具,而且他们可能比普工更快地完成任务。是否是这种情况取决于具体情况——你必须尝试一下才知道。

即使您雇用了专家,您仍然可以选择雇用不同数量的专家。例如,砌砖工可能需要比电工多。此外,如果您必须建造多栋房屋,您的团队组成和整体效率可能会发生变化。即使您的水管工可能没有很多工作要做,但如果您同时建造许多房屋,您可能有足够的工作让他一直忙碌。此外,如果您在专家没有工作可做时无需向他们支付报酬,那么即使您同时只有相同数量的人员,您也可能能够负担得起更大的团队。

好的,关于构建就足够了;这一切与线程有什么关系?嗯,对于线程来说,同样的问题也适用。您需要决定使用多少个线程以及它们应该执行哪些任务。您需要决定是使用“通用”线程来完成任何时间点所需的任何工作,还是使用“专业”线程来很好地完成一件事,或者进行某种组合。无论使用并发的驱动原因是什么,您都需要做出这些选择,并且如何执行此操作将对代码的性能和清晰度产生至关重要的影响。因此,了解这些选项至关重要,以便您在设计应用程序结构时能够做出适当的明智决策。在本节中,我们将研究几种用于划分任务的技术,首先在进行任何其他工作之前在线程之间划分数据。

8.1.1 开始操作前在线程之间划分数据

最容易并行化的算法是像 std::for_each 这样对数据集中的每个元素执行操作的简单算法。为了并行化这样的算法,您可以将每个元素分配给一个处理线程。如何划分元素以获得最佳性能在很大程度上取决于数据结构的细节,正如您将在本章后面看到讨论的性能问。

划分数据的最简单方法是将前 N 个元素分配给一个线程,将接下来的 N 个元素分配给另一个线程,依此类推,如图 8.1 所示,但也可以使用其他模式。无论数据如何划分,每个线程都只处理分配给它的元素,而不与其他线程进行任何通信,直到完成处理。


Figure 8.1 Distributing consecutive chunks of data between threads

对于任何使用消息传递接口 (MPI) 或 OpenMP 框架进行编程的人来说,这种结构都会很熟悉:一个任务被拆分为一组并行任务,工作线程独立运行这些任务,并将结果合并到最终的缩减(reduction)步骤。这是 2.4 节中的 accumulate 示例所使用的方法;在这种情况下,并行任务和最终的缩减步骤都是累积(accumulations)。对于简单的 for_each,最后一步是无操作,因为没有要减少的结果。

将最后一步确定为缩减很重要;诸如列表 2.8 之类的简单实现会将这种缩减作为最后的串行步骤(serial step)来执行。然而,这个步骤通常也可以并行化(parallelized);累积(accumulations)实际上本身就是一个缩减操作(reduction operation),因此,当线程数大于线程上要处理的最小项目数时,列表2.8可以修改为递归地调用自身。或者,可以让工作线程在每个工作线程任务完成时执行一些缩减步骤,而不是每次都生成新线程。

虽然这项技术很强大,但它并不适用于所有情况。有时,数据无法预先整齐地划分,因为只有在处理数据时,必要的划分才会变得明显。这对于快速排序等递归算法尤其明显;因此,他们需要采取不同的方法。

8.1.2 递归划分数据

快速排序算法有两个基本步骤:将数据划分为最终排序中某个元素(主元 the pivot)之前或者之后节点,并递归排序两个“半(halves)”。您不能简单地预先划分数据来并行处理,因为只有通过处理您才知道它们进入哪一“半”的节点。如果您要并行化这样的算法,您需要使用递归性质。对于每个递归级别,都会有更多的对 fast_sort 函数的调用,因为您必须对属于主元之前以及之后的元素进行排序。这些递归调用是完全独立的,因为它们访问不同的元素集,因此是并发执行的主要候选者。图 8.2 显示了这种递归划分。


Figure 8.2 Recursively dividing data

在第 4 章中,您看到了这样的实现。您不只是对较高和较低的块执行两次递归调用,而是使用 std::async() 在每个阶段为较低的块生成异步任务。通过使用 std::async(),您可以要求 C++ 线程库决定何时在新线程上实际运行任务以及何时同步运行它。

这很重要:如果您要对大量数据进行排序,则为每个递归生成一个新线程将很快导致大量线程。正如您在查看性能时会看到的那样,如果线程太多,实际上可能会降低应用程序的速度。如果数据集非常大,也可能会耗尽线程。像这样以递归方式划分整个任务的想法是一个很好的想法;您只需要更严格地控​​制线程数量即可。 std::async() 可以在简单的情况下处理这个问题,但这不是唯一的选择。

一种替代方法是使用 std:: thread::hardware_concurrency() 函数来选择线程数,就像您对列表 2.8 中的 accumulate() 的并行版本所做的那样。然后,您不必为递归调用启动一个新线程,而是只需将要排序的块推送到线程安全栈上,例如第 6 章和第 7 章中描述的栈之一。如果线程没有其他事情可做,则可以因为它已经完成了所有块的处理,或者因为它正在等待一个块被排序,所以它可以从堆栈中取出一个块并对其进行排序。

下面的列表展示了该技术的示例实现。

Listing 8.1 Parallel Quicksort using a stack of pending chunks to sort

template<typename T>
struct sorter           - [1]
{
    struct chunk_to_sort
    {
        std::list<T> data;
        std::promise<std::list<T> > promise;
    };

    thread_safe_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()
    {
        boost::shared_ptr<chunk_to_sort > chunk=chunks.pop();   - [7]
        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);
        while(new_lower.wait_for(std::chrono::seconds(0)) !=
              std::future_status::ready)            - [13]
        {
            try_sort_chunk();                   - [14]
        }

        result.splice(result.begin(),new_lower.get());
        return result;
    }

    void sort_chunk(boost::shared_ptr<chunk_to_sort > const& chunk)
    {
        chunk->promise.set_value(do_sort(chunk->data));         - [15]
    }

    void sort_thread()
    {
        while(!end_of_data)                 - [16]
        {
            try_sort_chunk();               - [17]
            std::this_thread::yield();      - [18]
        }
    }
};

template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)        - [19]
{

    if(input.empty())
    {
        return input;
    }
    sorter<T> s;

    return s.do_sort(input);             - [20]
}

这里,parallel_quick_sort 函数 [19] 将大部分功能委托给 sorter 类 [1],它提供了一种对未排序块 [2] 的栈和线程集 [3] 进行分组的简单方法。主要工作在 do_sort 成员函数 [9] 中完成,它执行通用的数据划分 [10]。这一次,它不是为一个块生成一个新线程,而是将其推送到栈上 [11] 并在您仍有空闲处理器时生成一个新线程 [12]。因为较低的块可能由另一个线程处理,所以您必须等待它准备好 [13]。为了帮助事情顺利进行(如果你是唯一的线程或所有其他线程已经很忙),你在等待的同时尝试处理该线程上栈中的块 [14]。 try_sort_chunk 只是从栈中弹出一个块 [7] 并对其进行排序 [8],将结果存储在 promise 中,准备被执行推送到栈的线程拾取。

在循环中生成新的线程,并尝试对栈中的块进行排序 [17],而 end_of_data 标志未被设置 [16]。在检查中,它们让步给其他线程 [18],使它们有机会在栈上放置更多任务。此代码依赖于 sorter 类 [4] 的析构函数来整理这些线程。当所有数据都已排序后,do_sort将返回(即使工作线程仍在运行),因此您的主线程将从parallel_quick_sort [20] 返回,从而销毁您的 sorter 对象。这会设置 end_of_data 标志 [5] 并等待线程完成 [6]。设置该标志会终止线程函数中的循环 [16]。

通过这种方法,您不会遇到使用 spawn_task 启动新线程时出现的无限线程问题,并且您不再依赖 C++ 线程库来为您选择线程数,就像 std::async() 那样。相反,您可以将线程数限制为 std:: thread:: hardware_concurrency() 的值,以避免过多的任务切换。然而,您确实有另一个潜在的问题:这些线程的管理以及它们之间的通信给代码增加了相当多的复杂性。此外,尽管线程正在处理单独的数据元素,但它们都访问栈以添加新块并删除块以进行处理。即使您使用无锁(因此非阻塞)栈,这种严重的竞争也会降低性能,原因您很快就会看到。

这种方法是线程池的特殊版本——有一组线程,每个线程从待处理的工作列表中获取工作,完成工作,然后回到列表获取更多工作。第 9 章介绍了线程池的一些潜在问题(包括工作列表上的竞争)以及解决这些问题的方法。本章稍后将更详细地讨论将应用程序扩展到多个处理器的问题(请参阅第 8.2 节) 。

执行处理前划分数据和递归地划分数据都假设数据本身事先是固定的,而您只是在寻找划分它的方法。但情况并非总是如此。如果数据是动态生成的或来自外部输入,则此方法不起作用。在这种情况下,按任务类型划分工作可能比根据数据划分更有意义。

8.1.3 按任务类型划分工作

通过向每个线程分配不同的数据块在线程之间划分工作(无论是预先还是在处理过程中递归)仍然基于以下假设:线程将对每个数据块执行本质上相同的工作。划分工作的另一种方法是让线程成为专家,每个线程执行不同的任务,就像建造房屋时水管工和电工执行不同的任务一样。线程可能会或可能不会处理相同的数据,但即使它们可以,也是出于不同的目的。

这种分工是基于并发分离考虑。每个线程都有不同的任务,并且独立于其他线程执行。有时其他线程可能会向它提供数据或触发它需要处理的事件,但通常每个线程都专注于做好一件事。就其本身而言,这是基本的良好设计;每段代码都应该有一个单一的职责。

按任务类型划分工作以分离关注点

单线程应用程序必须解决多任务同时并发运行时与单一职责原则的冲突,或者应用程序需要及时处理传入事件(例如用户按键或传入的网络数据),即使其他任务正在进行时。在单线程世界中,您最终需要手动编写代码来执行任务 A 的一部分,执行任务 B 的一部分,检查按键情况,检查传入的网络数据包,然后循环回来执行任务 A 的另一部分。这意味着任务 A 的代码最终会变得复杂,因为需要定期保存其状态并将控制权返回到主循环。如果向循环添加太多任务,事情可能会减慢太多,并且用户可能会发现响应按键需要很长时间。我相信你们都在某些应用程序或其他应用程序中看到过这种极端形式:当您执行某些任务,界面会冻结直到它完成该任务。

这就是线程发挥作用的地方。如果您在单独的线程中运行每个任务,操作系统会为您处理这个问题。在任务 A 的代码中,您可以专注于执行任务,而不必担心保存状态并返回主循环或者在执行此操作之前花费多长时间。操作系统会自动保存状态并在适当的时候切换到任务B或C,如果目标系统有多个内核或处理器,任务A和B很可能能够真正并发运行。用于处理按键或网络数据包的代码现在将及时运行,每个人都会受益:用户得到及时的响应,并且作为开发人员,您可以拥有更简单的代码,因为每个线程可以专注于执行与功能相关的操作。而不会与控制流和用户交互混淆。

这听起来是一个美妙的愿景。真的可以这样吗?与所有事情一样,这取决于细节。如果一切真的都是独立的,并且线程之间不需要相互通信,那么事情真的很容易。不幸的是,世界很少如此。这些漂亮的后台任务通常会执行用户请求的操作,并且它们需要通过以某种方式更新用户界面来让用户知道它们何时完成。或者,用户可能想要取消任务,因此需要用户界面以某种方式向后台任务发送消息,告诉其停止。这两种情况都需要仔细的思考和设计以及适当的同步,但关注点仍然是分开的。用户界面线程仍然只处理用户界面,但当其他线程要求时,它可能必须更新它。同样,运行后台任务的线程仍然只关注该任务所需的操作;恰好其中一个操作是“允许任务被另一个线程停止”。在这两种情况下,线程都不关心请求来自哪里,只关心需要执行的请求和直接相关任务。

将关注点与多个线程分离有两大危险。首先,你最终会分离出错误的关注点。要检查的症状是线程之间的大量共享数据或者不同线程最终相互等待;这两种情况都归结为线程之间的通信过多。如果发生这种情况,就需要考虑通信的原因。如果所有通信都涉及同一问题,也许这应该是单个线程的关键职责,并从引用它的所有线程中提取出来。或者,如果两个线程彼此之间的通信量很大,但与其他线程之间的通信量却少得多,那么也许它们应该合并为一个线程。

当按任务类型跨线程划分工作时,您不必将自己限制在完全孤立的情况下。如果多组输入数据需要执行相同的操作步骤(sequence of operation),您可以划分工作,以便每个线程执行整个步骤中的一个阶段。

在线程间划分任务步骤(sequence of tasks)

如果您的任务包括许多独立的数据项的相同操作步骤,则可以使用管道(pipeline)来利用系统的可用并发性。这类似于物理管道:数据在一端通过一系列操作(管道)流入,并在另一端流出。

要以这种方式划分工作,您需要为管道中的每个阶段创建一个单独的线程——即为操作的每一步创建一个线程。当操作完成时,数据元素被放入队列中以供下一个线程获取。这允许执行步骤中执行第一步的线程在处理下一个数据元素时,执行第二步的线程正处理第一个元素。

这是第 8.1.1 节中所述的仅在线程之间划分数据的替代方法,并且适用于操作开始时输入数据本身并不完全已知的情况。例如,数据可能通过网络传入,或者步骤中的第一步可能是扫描文件系统以便识别要处理的文件。

当序列中的每个操作都非常耗时时管道表现也很好;通过在线程之间划分任务而不是数据,您可以更改性能配置文件。假设您在四个核心上有 20 个数据项需要处理,每个数据项需要四个步骤,每个步骤需要 3 秒。如果将数据分配给四个线程,则每个线程有 5 个项目要处理。假设没有其他处理可能影响计时,12 秒后您将处理 4 个项目,24 秒后处理 8 个项目,依此类推。所有 20 个项目将在 1 分钟后完成。有了管道,事情就不同了。您可以将四个步骤分配给每个处理核心。现在第一个项目必须由每个核心处理,因此仍然需要整整 12 秒。事实上,12 秒后你只处理了一项,这不如按数据划分好。然而,一旦管道启动,事情的进展就会有所不同。第一个核心处理完第一个项目后,它会继续处理第二个项目,因此一旦最终核心处理完第一个项目,它就可以对第二个项目执行其步骤。现在,您每 3 秒处理一个项目,而不是每 12 秒分批处理四个项目。

处理整个批次的总时间需要更长的时间,因为您必须等待 9 秒才能最终核心开始处理第一个项目。但在某些情况下,更顺畅、更稳定的处理可能是有益的。例如,考虑一个用于观看高清数字视频的系统。为了使视频可供观看,通常每秒至少需要 25 帧,最好更多。此外,观看者需要这些稳定刷行以达到连续运动的观感;每秒解码 100 帧的应用程序这种情况下没有用:如果暂停一秒,然后显示 100 帧,然后再暂停一秒,再显示 100 帧。另一方面,观众在开始观看视频时可能很乐意接受几秒钟的延迟。在这种情况下,使用以良好稳定速率输出帧的管道进行并行化可能是更好的选择。

了解了在线程之间划分工作的各种技术之后,我们来看看影响多线程系统性能的因素以及它们如何影响您对技术的选择。

posted @ 2024-11-11 19:11  李思默  阅读(100)  评论(0)    收藏  举报