8.程序并发化的实现方式

Source
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/sinat_25991865/article/details/87789838


使用分而治之的思想进行多线程编程。首先需要将程序算法中只能串行的部分与可以并发的部分区分开来,然后使用专门的线程(工作者线程)去并发地执行那些可并发化的部分(可并发点)。具体来说,多线程编程中分而治之的使用主要有两种方式:基于数据的分割和基于任务的分割。前者从数据入手,将程序的输入数据分解为若干规模较小的数据,并利用若干工作者线程并发处理这些分解后的数据。后者从程序的处理任务(步骤)入手,将任务分解为若干子任务,并分配若干工作者线程并发执行这些子任务 。

1.基于数据的分割

如果程序的原始输入数据的规模比较大,比如要从几百万条日志记录中统计出我们所需的信息,那么可以采用基于数据的分割。其基本思想(如下图所示)就是将原始输入数据按照一定的规则(比如均分)分解为若干规模较小的子输入(数据),并使用工作者线程来对这些子输入进行处理,从而实现对输入数据的并发处理。对子输入的处理,我们称之为子任务。因此,基于数据的分割的结果是产生一批子任务,这些子任务由专门的工作者线程负责执行。
在这里插入图片描述
基于数据的分割的结果是产生多个同质工作者线程,即任务处理逻辑相同的线程。在实际运用中,要考虑以下问题。

  • 工作者线程数量的合理设置问题。在原始输入规模一定的情况下,增加工作者线程数量可以减小子输入的规模,从而减少每个工作者线程执行任务所需的时间 。但是线程数量的增加也会导致其他开销(比如上下文切换)增加 。
  • 工作者线程的异常处理问题。对于一个工作者线程执行过程中出现的异常,我们该如何处理呢? 比如将一个大文件进行分割下载的时候,一个下载线程执行过程中出现异常的时候,这个线程是可以进行重试(针对可恢复的故障)呢,还是说直接就算整个资源的下载失败呢?如果是算这个资源下载失败,那么此时其他工作者线程就没有必要继续运行下去了。因此,此时就涉及终止其他线程的运行问题 。
  • 原始输入规模未知问题。某些情况下我们可能无法事先确定原始输入的规模,或者事先确定原始输入规模是一个开销极大的计算 。对于这种原始输入规模事先不可知的问题,我们可以采用批处理的方式对原始输入进行分解:聚集了一批数据之后再将这些数据指派给工作者线程进行处理。在批处理的分解方式中,工作者线程往往是事先启动的,并且我们还需要考虑这些工作者线程的负载均衡问题,即新聚集的一批数据按照什么样的规则被指派给哪个工作者线程的问题。
  • 程序的复杂性增加的问题。基于数据的分割产生的多线程程序可能比相应的单线程程序要复杂。

2.基于任务的分割

为了提高任务的执行效率,我们可能使用多个线程去共同完成一个任务的执行。这就是基于任务的分割,其基本思想(如下图所示)是将任务(原始任务)按照一定的规则分解成若干子任务,并使用专门的工作者线程去执行这些子任务,从而实现任务的并发执行 。
在这里插入图片描述
按照原始任务的分解方式来划分,基于任务的分解可以分为按任务的资源消耗属性分割和按处理步骤分割这两种。

2.1 按任务的资源消耗属性分割

线程所执行的任务按照其消耗的主要资源可划分为 CPU 密集型 (CPU intensive) 任务和 I/O 密集型 (I/O-intensive) 任务。执行这些任务的线程也相应地被称为 CPU 密集型线程和 I/O 密集型线程。CPU 密集型任务执行过程中消耗的主要资源是 CPU 时间, CPU密集型任务的一个典型例子是加密和解密; I/O 密集型任务执行过程中消耗的主要资源是I/O 资源(如网络和磁盘等),典型的 I/O 密集型任务包括文件读写、网络读写等。一个线程所执行的任务实际上往往同时兼具 CPU 密集型任务和 I/O 密集型任务特征,我们称之为混合型任务。有时候,我们可能需要将这种混合型任务进一步分解为 CPU 密集型和 I/O密集型这两种子任务,并使用专门的工作者线程来负责这些子任务的执行,以提高并发性。
基于任务的分割这种并发化策略是从程序的处理逻辑角度入手,将原始任务处理逻辑分解为若干子任务,并创建专门的工作者线程来执行这些子任务。基于任务的分割结果是产生多个相互协作的异质工作者线程,即任务处理逻辑各异的线程。另外,需要注意以下几点。

  • 基于任务的分割同样可能导致程序的复杂性增加
  • 多线程程序可能增加额外的处理器时间消耗。多线程版的程序比相应的单线程版程序增加了工作者线程的创建与启动、线程之间数据传递以及额外的上下文切换等开销,多线程版的程序运行时的处理器时间消耗要比相应的单线程程序多了不少。
  • 多线程程序未必比相应的单线程程序快。这是因为影响程序性能的因素是多方面的,而多线程与单线程的差别只是其中一方面而已。
  • 考虑从单线程程序向多线程程序”进化”。考虑到多线程程序的相对复杂性以及多线程程序未必比单线程程序要快,使用多线程编程的一个好的方式是从单线程程序开始,只有在单线程程序算法本身没有重大性能瓶颈但仍然无法满足要求的情况下我们才考虑使用多线程 。

2.2 按处理步骤分割

如果程序对其输入集 {d1, d2, …, dN} 中的任何一个输入元素 di( 1 i N 1 \leq i \leq N )的处理都包含若干步骤 {Step1, Step2, …, StepM},那么为了提高程序的吞吐率,我们可以考虑为其中的每一个处理步骤都安排一个(或者多个)工作者线程负责相应的处理。这就是按处理步骤分割的基本思想。
同样,在按处理步骤分割中我们也需要注意工作者线程数的合理设置:工作者线程数量过多可能会导致过多的上下文切换,这反而降低了程序的吞吐率 。 因此,保守的设置方法是从仅为每个处理步骤设置一个工作者线程开始,在确实有证据显示有必要增加某个处理步骤的工作者线程数的情况下才增加线程数 。

3.合理设置线程数

3.1 Amdahl’s定律

Amdahl’s 定律描述了线程数与多线程程序相对于单线程程序的提速之间的关系。在一个处理器上一个时刻只能够运行一个线程的情况下,处理器的数量就等同于并行线程的数最。设处理器的数量为 N, 程序中必须串行(即无法并发化)的部分耗时占程序全部耗时的比率为 P, 那么将这样一个程序改为多线程程序,我们能够获得的理论上的最大提速 Smax 与 N 、 P 之间的关系就是 Amdahl’s 定律内容,如下所示 。
S m a x = 1 P + 1 P N S_{max}=\frac {1} {P+\frac {1-P}{N}}
可以看出,多线程程序的提速主要来自多个线程对程序中可并行化部分的耗时均摊 。由 Amdahl’s 定律的公式可知:
lim N S m a x = lim N 1 P + 1 P N = 1 P \lim_{N \to \infty }S_{max}=\lim_{N \to \infty }\frac {1} {P+\frac {1-P}{N}}=\frac {1} {P}
由此可见,最终决定多线程程序提速的因素是整个计算中串行部分的耗时比率 P, 而不是线程数 N! P的值越大,即程序中不可并行化的部分所占比率越大,那么提速越小。因此,为使多线程程序能够获得较大的提速,我们应该从算法入手,减少程序中必须串行的部分,而不是仅寄希望于增加线程数(或者处理器的数目)!

3.2 线程数设置的原则

  • 对于 CPU 密集型线程,考虑到这类线程执行任务时消耗的主要是处理器资源,我们可以将这类线程的线程数设置为 NCPU 个。因为 CPU 密集型线程也可能由于某些原因(比如缺页中断/Page Fault) 而被切出,此时为了避免处理器资源浪费,我们也可以为这类线程设置一个额外的线程,即将线程数设置为 NCPU+1。
  • 对于 I/O 密集型线程,考虑到 I/O 操作可能导致上下文切换,为这样的线程设置过多的线程数会导致过多的额外系统开销 。 因此如果一个这样的工作者线程就足以满足我们的要求,那么就不要设置更多的线程数。如果一个工作者线程仍然不够用,那么我们可以考虑将这类线程的数量设置为 2XNCPU。这是因为I/O密集型线程在等待 I/O 操作返回结果时是不占用处理器资源的,因此我们可以为每个处理器安排一个额外的线程以提高处理器资源的利用率。
    从实践上看,通常我们可以采用配置的方式(如配置文件)或者通过代码自计算的方式来设置线程数,而不是将线程数硬编码 (Hard-coded) 在代码之中。