软件流水线--多核时代的高性能编程

来源:互联网 发布:网络大电影2016 编辑:程序博客网 时间:2024/05/21 11:09


1.  前言

流水线技术是把一件任务分解为若干顺序执行的子任务,不同的子任务由不同的执行机构负责执行,而这些机构可以并行工作。在任一时刻,任一任务只占用其中一个执行机构,这样就可以实现多个任务的重叠执行,以提高工作效率。自从福特汽车在工业生产中引入了流水线后,流水线这一方式就广泛应用于各种生产环节中,大大提高了生产效率。

对于IT人士来说,大家都知道Intel和AMD也在CPU中引入了流水线的概念,将取指、译码、取操作数、执行指令、保存结果等步骤通过不同的执行单元并行执行,大大提高了CPU的效率。

那么,在多核CPU已经普及的今天,软件中是否也可以采用类似的方式,形成软件流水线,从而提高程序的执行效率呢?(多核时代的多线程编程,已经由保证UI响应,变成了尽量合理、高效的利用多核CPU资源

2.  原理

在软件开发中,通常需要对多个相同类型的素材依次进行处理。如一个多媒体转换传输程序,需要对用户选择的多个文件(如静止画、视频文件、音频文件等)依次进行转换,然后传输到目的地。每种素材的操作都可能有多个步骤,如静止画需要Resize、设置Exif信息;视频、音频文件需要进行比特率、格式转换等,转换完毕后还需要传递到目的地,并更新数据库信息等。

在实际的项目开发中,任何一个了解多线程机制(不了解就不知道实现方式)并有性能意识的人(没有性能意识的话就不会“自找麻烦”使用多线程)都能想出通过采用多线程并行处理的方式,来提高程序的运行效率。

每个线程专职一个步骤(俗称一个Task),每次处理一个素材(Element),处理完毕后将素材传递给下一个线程处理,当没有新的素材或后面的线程没有处理完毕时,就暂停等待。这就是“软件流水线”的理论基础。[f1] 

3.  技术难点

从描述中可以看出,软件流水线的原理并不复杂,很多人都能写出能运行起来的代码,但往往由于不能对多线程进行很好的控制,造成实际运行时出现很多的问题。比如,如果通过使用SuspendThread 和ResumeThread[f2]  对线程的运行状态进行控制,来实现用户暂停、继续以及素材在各个Task之间的同步等功能。运行一段时间后,可能会出现运行时错误等问题,而且由于架构的限制,这种问题往往无法进行更改。

为了实现一个能正常运行起来的流水线架构,需要解决以下三个技术难点:

  1. 能进行个数控制的生产者消费者队列(俗称Queue)。当没有新的数据或空的位置时,能对调用的线程自动、安全地进行阻塞;当来了新的数据或腾出空的位置时,又能自动唤醒等待中的线程。这是数据传递的核心。

每个Task都有一个InputQueue和OutputQueue,并且其InputQueue就是流水线的第一个Queue或上一个Task的OutputQueue,其OutputQueue是流水线的最后一个Queue或下一个Task的InputQueue。Task线程依次从InputQueue获取数据,进行处理,处理完毕后放入OutputQueue,等待下一个Task处理。这样,数据就能够在各个Task的线程之间“流”起来。之所以要进行个数控制,是因为各个Task的处理速度往往是不同的,如果后面的Task处理速度慢,又没有个数控制的话,则前面Task产生的“半成品对象”会越来越多,造成程序出现问题,或CPU和磁盘空间的浪费。

  1. 能随时安全的暂停、继续、停止的线程控制类,这是流水线运行的控制核心。通常的用户交互式程序,在长时间处理时,都需要提供暂停、继续、停止的控制功能,在流水线架构中,由于是多个线程同时执行,更需要一个安全、方便、简单的线程同步机制。
  2. 能对同时处理中的多个元素的进度进行统合的进度计算类,这是UI反馈的核心。

由于在流水线中有多个Task,同时处理多个素材,每个Task对每个素材都有自己的处理进度,因此其进度需要统合,给用户反馈处理时的总进度和当前元素的处理进度[f3] 。

4.  实现

在学习和工作实践中,设计、实现出多个线程同时处理多个元素的实现方式,并在后期提炼成FTL(Fishjam Template Library)中的Pipeline模版框架,来提供通用的软件流水线支持。通过将大部分多线程相关的逻辑封装在框架中,具体的项目只需从适当的父类继承,传入模版参数,并重载适当的虚函数,将注意力集中于素材在每一个Task中的处理逻辑,即可很简单、方便的获得多线程并行处理的高性能

4.1. 五大元素

在FTL提供的Pipeline框架模版中,由五种元素组成了整个架构。

  1. ELEMENT – 代表系统中需要处理的对象(如一个多媒体文件的相关信息),该对象将作为模版参数传入流水线,之后的处理都是针对这种对象进行的。在框架中ELEMENT应该从ElementBase继承。
  2. Task – 将对一个ELEMENT的处理拆分成多个独立的操作,每一个操作由一个Task负责,多个Task可以同时运行,串行操作其中的每一个ELEMENT,当一个ELEMENT在所有的Task中都“流”一遍后,所有操作都结束后,才算处理完一个对象。
  3. Queue – 在各个Task之间传递ELEMENT的FIFO(先进先出)队列,每一个Task都有一个InputQueue和OutputQueue,且上一个Task的OutputQueue就是下一个Task的InputQueue,Task从其InputQueue中获取ELEMENT,处理完毕后放入OutputQueue中,等待下一个Task处理。当后面的Task没有及时处理完毕时,可以进行等待或流控。
  4. Pipeline – 一个Pipeline中有一或多个Task,各个Task可以并行执行,对所有的ELEMENT串行进行处理,当所有的ELEMENT处理结束后,这个Pipeline才结束。
  5. Factory – 管理多条流水线,将素材根据不同的类型,分配到不同的流水线进行处理,类似于设计模式中的策略模式。每个Pipeline 处理一种类型的素材(如 静止画 或 视频文件、音乐文件等,甚至可以根据具体的格式类型进一步划分)。这样根据ELEMENT的处理需要,通过不同的Task组建出相对应的Pipeline,从而完成整个应用程序的需要的功能。

4.2. 两种线程模式

    考虑到可能出现的特殊情况(如Pipeline中的多个Task不能并行执行时),Pipeline给用户提供了同步和异步两种线程模式。

       异步模式(默认) – 每个Task中创建一个线程,该线程依次从InputQueue中获取本Task要处理的ELEMENT,调用子类的函数处理后,加入到OutputQueue中等待下一个Task处理。直到检测到结束标志、出现错误或用户停止,才退出线程循环。

同步模式– Task中不创建线程,使用Pipeline所在的线程,由Pipeline依次取出每一个待处理ELEMENT,交给每个Task进行处理。直到检测到结束标志、出现错误或用户停止为止。

具体的应用中,可以根据需要,对pipeline选用不同的线程模式。

4.3. 相关类介绍

4.3.1.类图


4.3.2.ElementBase

这是框架中要处理的模版参数ELEMENT的数据基类,其中定义了以下几个变量供框架使用:

DWORD Id -- 作为ELEMENT的唯一标识;

INT64 Size ELEMENT的大小,主要是用于进度计算;

INT64 TotalWeightSize 计算权重后的Size,用户不需要设置,由框架计算后设置,用于进度计算。

ElementType Type该ELEMENT的标记,通常为etNormal,表明是需要处理的普通元素。每一个Pipeline要处理的素材最后一定要有一个Type为etNotifyLast 的空ELEMENT,该ELEMENT用于结束通知。

实际应用中,可以根据需要增加新的变量。

4.3.3.CFTaskBaseT

该类是实际应用程序中处理ELEMENT的工作类,子类通过实现以下几个虚函数进行实际的处理。

1. DWORD GetTaskId() – 返回Task的唯一标识;

2. DWORDhandleCurrentElement(ErrorOperation& outErrOpType) – 每次框架代码从InputQueue中成功获取到一个ELEMENT后,会将其存入名为m_CurrentElement的成员变量中,并调用handleCurrentElement进行处理。子类重载后进行具体的处理,并返回处理结果。如果处理成功,框架会将其放入OutputQueue中。在处理过程中,子类需要调用基类提供的notifyProgress函数通知当前ELEMENT的处理进度,由框架进行进度计算,函数返回前,notifyProgress的参数设置为MAX_PROGRESS,通知当前Task中的ELEMENT处理结束。

3.VOID freeUnhandledInputQueueElement(ELEMENT& unhandledElement) – 由整个框架可能在任意时候停止,这时一个Task中的InputQueue中可能有上一个Task处理到一半的ELEMENT。框架会取出所有未处理的ELEMENT,并调用该函数,由Task处理其上一个Task处理后的“半成品”,如删除生成的临时文件等。该函数对一个Task可能调用多次。

4.VOID freeUnpassedCurrentElement() – 当前Task处理ELEMENT结束后,在放入到OutputQueue前,可能由于错误或用户的停止,使得该ELEMENT无法放入OutputQueue中。因此,框架会调用该函数,由Task处理当前Task处理后的“半成品”,如删除生成的临时文件等,该函数对一个Task最多调用一次。

5.DWORD onTaskInit(ErrorOperation&outErrOpType) – 在Task初始化的时候调用,可以设置Task运行时需要的初始化信息;

6.DWORD onTaskUninit(ErrorOperation&outErrOpType) - 在Task终止前调用,可以释放Task初始化时的信息。

4.3.4.CFPipelineBaseT

该类是组织Task的Pipeline的基类,子类通过实现以下几个虚函数,创建Task组建出流水线来。

1. DWORD GetPipelineId()--返回Pipeline的唯一标识。

2. DWORD getElementHandleScore(constELEMENT& element) 计算当前Pipeline对指定ELEMENT的处理Score值,框架对每个ELEMENT都会遍历所有的Pipeline,由大于零的最大分值的 Pipeline 处理该ELEMENT。通过这种方式,可以将ELEMENT分派到合适的Pipeline中进行处理;

3. DWORD createPipelineTask new出合适的CFTaskBaseT子类,设置其TaskWeight 和ProgressPriority 等属性后,调用基类提供的 addTask 将Task依次加入Pipeline中,之后将由这些Task依次处理通过Pipeline的每一个ELEMENT。其new出来的Task将由基类负责释放。

4. VOID handleLastQueueElement(constELEMENT & element)在Pipeline处理结束后,其最后一个OutputQueue中将保存当前Pipeline所处理过的所有ELEMENT,框架将从中取出所有元素,交由Pipeline处理。这时可以进行各种信息的统计等。该函数可能调用多次。

4.3.5.CFPipelineFactoryT

该类是组织多个Pipeline的Manager类,子类必须实现以下几个虚函数。

1. DWORD createPipelines new出合适的Pipeline子类,并调用父类提供的addPipelines 方法加入Factory。之后,将依次运行这些Pipeline。其new出的Pipeline的释放由基类负责。

2. DWORD prepareElements准备需要处理的所有ELEMENT(不包括最后的结束Element),调用基类的addElements方法,将其加入Factory。

该类还提供了SetProgressObserver、Start、Pause、Resume、Stop、GetElapseTime 等方法。通常,对流水线的控制及和用户的交互就是靠这些方法,其逻辑处理都由框架进行。

4.3.6.IFProgressObserver

该接口用于UI的进度反馈。应用程序实现该接口,并通过CFPipelineFactoryT 类的SetProgressObserver方法,将接口传入流水线框架。在流水线运行时,将通过该接口返回各种进度信息。其有以下三个函数。

1. OnTotalProgress 返回总的进度和当前的“标志ELEMENT”的进度。由于同一时间有多个ELEMENT正在被处理,“标志ELEMENT”指的是Pipeline中具有最高TaskPriroty的Task正在处理的ELEMENT,通常来说,应该是Pipeline中最后一个Task正在处理的ELEMENT;

2. OnElementProgress 返回每一个ELEMENT的处理进度;

3. OnTaskProgress 返回每一个Task在处理每一个ELEMENT时的进度。

通常,后两个函数只是用于调试和演示,实际代码中不需要进行别的处理。

4.3.7.IFStatusObserver

该接口用于运行状态的反馈。应用程序实现该接口,并通过CFPipelineFactoryT类的SetStatusObserver方法,将接口传入流水线框架。在流水线运行过程中,会返回状态和错误通知。其接口有以下几个函数。

1.      OnPipelineBegin/OnPipelineEnd 在每一条流水线启动或结束时,调用该函数;

2.      OnTaskBegin/OnTaskEnd – 在每一个Task线程开始或结束时,调用该函数;

3.      OnError – 在发生错误时,调用该函数,并根据返回的结果决定进一步的处理,比如继续运行或退出等。

4.3.8.总结

综上所述,由于将多线程相关的代码完全封装在框架中,子类只需要提供必要的信息,并关注具体的ELEMENT处理逻辑即可。这样,不仅获得多线程的高性能,又减少了出现Bug的机会。

4.4. 进度计算[f4] 

由于多个Task并行运行的原因,要给用户显示进度时,需要进行统合。因此进度计算由三个层次的计算组成。

1.       单个Task的进度计算 -- 通过IFTaskProgressObserver返回每个Element中一个Task完成的百分比,这是进度计算的基础,由各个Task的子类在handleCurrentElement函数中根据具体处理情况,通过notifyProgress 函数返回。

2.       单个Element 的进度计算 – 根据各Task在整个Element的处理所占权重(Weight)计算单个Element的处理进度。各个Task都处理相同的Element,但其处理所需要的时间是不同的。比如写、读、删除同一个文件,其所花费的时间将依次减少,这些时间差距就体现在Weight中,其Weight值越高,在Element的进度中,该Task所占的比例就越大。

3.       整体进度计算 -- 按照所有需要处理的Element的Weight总和计算总体进度,并根据各个Element完成的百分比计算进度。将具有最高优先级(Priority)的Task处理的Element作为当前处理的Element。并通过IFProgressObserver接口返回给UI进行显示。

 

4.5. 暂停、继续、停止处理

为了能安全的对各个Task的线程进行控制,不使用SuspendThread、ResumeThread、TerminateThread 等函数来暂停、继续和停止线程,而是通过引入了“同步点”的概念,由各个Task、PipeLine决定可以安全暂停、退出的位置。

在CFPipelineFactoryT类中创建同步对象,并通过Pipeline和Task的构造函数依次传递到每一个Pipeline和Task。在PipeLine和Task可以安全暂停、退出的同步点,调用辅助类CFSyncChecker的GetWaitType方法检查状态,如果用户进行了暂停,会自行安全地暂停在同步点,直到用户继续或停止。子类根据返回值确认用户是否提前停止,从而进行资源的清除工作。

这也是Task的handleCurrentElement中的标准处理方式。

4.6. 资源清除

当Task进行了部分处理,由于用户终止或其他Task发生错误,造成整个Pipeline的提前结束时,必须能清除未成功处理完毕的数据,防止出现垃圾数据。框架中提供了多种情况下的清除方式。

1.       当在 Task子类调用handleCurrentElement 方法处理时终止,ELEMENT的清除工作将由handleCurrentElement函数自身完成。

2.       当前面的Task处理得快,本Task还没有来得及从InputQueue中获取数据进行处理,用户就进行了停止,其清除工作将由虚函数freeUnhandledInputQueueElement处理;

3.       当后面的Task处理得慢,本Task处理完毕的Element还没有放入OutputQueue中时,用户就进行了停止,其清除工作将由虚函数freeUnpassedCurrentElement处理。

4.7. 错误处理

  对于任何一个商业程序,错误处理是必不可少的部分,尤其在流水线这种多个线程并行执行的环境中。根据错误发生时对流程的影响,将流水线中可能出现的错误,分为三种等级:

1.       eoContinue -- 可以继续的错误,发生错误后,通过回调通知处理逻辑,但整个处理流程会继续;

2.       eoBreakDelay -- 当前Element已经失败,但需要等待其他已经运行的Element处理完成。如目标目录下只能保存100个文件,在处理第101个文件时,发现错误,将停止处理,但其前面已经处理的第100、99、98… 个文件需要处理完毕。

3.       eoBreakImmediately – 所有的Task都不能再继续处理,必须立即停止。如源数据磁盘被拔除时。

以上三种错误将由handleCurrentElement等函数返回,框架将根据返回值进行处理。

 

5.  性能分析和测试

5.1. 分析

理想情况下(CPU个数多于Task个数,而且各个Task处理Element所需要的时间相同[f5] ),异步模式所花费的时间将是同步模式所花费时间的“Task个数分之一”,即Task越多,程序运行得越快。但是实际上,由于受限于各种情况,速度提升并没有那么多:

1.       流水线技术对性能的提高程度取决于其执行顺序中最慢的Task (木桶原理);[f6] 

2.       各个Task会访问共享的数据(如进度计算,错误处理等),此时会串行阻塞处理,引起流水线效率的下降。

3.       单CPU系统上,并且如果各个Task都是高CPU消耗的操作时,反而可能因为多个线程上下文切换而造成性能下降的情况。[f7] 

5.2. 测试

      通过撰写Demo程序进行实际的测试,其程序界面和说明如图所示:


说明:

1.       “总进度”是根据权重计算 处理完毕的Element进度 + 正在处理的Elment进度。

2.       “当前ELEMENT进度”显示的是具有最高“ProgressPriority”的Task正在处理的Element。如图所示是“Task5”正在处理的第4个Element的进度。

3.       “Async”控制运行时的线程模型,选中的话(默认),将是异步模式,每个Task都会启动一个线程并行处理。不选中将是同步模式,所有操作将在Pipeline所在的线程中处理。

4.       “BusyLoop ” 在Task的handleCurrentElement函数中是模拟高CPU操作还是模拟高磁盘IO操作。如果选中,将使用for循环的BusyLoop模拟高CPU运算,否则,将使用Sleep模拟高磁盘IO操作时的等待状态。

5.       “Task速度”控制Task1~Task5的速度变化,选中“Fast->Slow” (默认)时,由于后面的Task处理速度慢,可以看出前面的Task自动等待的现象。

6.       “控制按钮”控制Pipeline 的启动、暂停、继续、停止、错误处理。

7.       “错误参数”,控制在按下“Error”按钮时的错误信息,如果选中“Delay”,将激发eoBreakDelay错误,否则将是eoBreakImmediately错误。ErrPos控制出错的Task的位置,分别是First(Task1出错)、Middle(Task2出错),Last(Task5出错)。通过组合,可以确认不同的错误处理情况。

 

经过测试,只有在单CPU测试环境[f8] 且选中“BusyLoop”的情况下,Async模式和Sync模式相比有不到1%的差距,其他情况下,Async模式都比Sync模式快,其所需时间甚至只需Sync模式的三分之一。而随着多核CPU的普及,利用Pipeline的架构,不用花费太多的力气,就能获得多核CPU的高性能,何乐而不为呢。

6.  注意事项

1.       在提取Task时,各个Task必须是独立的。比如 删除文件和删除文件后更新DB必须是一个Task。否则,有可能因为用户的终止,文件删除了而DB没有更新,从而造成数据不同步的问题。

2.       默认设置下,Queue中最多保留一个已处理并等待下一个Task要处理的ELEMENT,Task中有一个正在处理的ELEMENT。因此,当后面的Task比前面的Task慢时,Task的OutputQueue中可能会有一个处理的Element。因此,整个流水线中可能同时处理的Element个数最多时可达到 Task个数 +1 个。因此,需要特别注意多个Element同时处理所造成的影响,如临时文件所占用的磁盘空间等。

3.       由于在程序运行过程中,有多个Element在同时处理,而给用户显示的当前Element只有一个,因此会出现当前Element处理进度不是从0开始显示的。如果需要的话,需要注意和客户的沟通。

4.       由于Task的个数已经根据实际的情况确定下来了,因此,流水线方式的性能不具备线型扩展能力,不能随着CPU的个数增加而增加(有上限)[f9] 

7.  已有的并行技术分析和比较

7.1. 与TBB的比较

通过调查,发现Intel在其TBB(Threading Building Blocks)中也提供了一个流水线的处理框架。经过分析后,两者有如下区别:

       TBB–由于不像FTLPipeline考虑太多问题,内部算法更优一点,更适合于没有UI的科学计算等。

FTLPipeline – 考虑和实现的功能更多(如提供的暂停、继续、停止功能,错误处理,进度计算等),更适合用于商业的用户交互式程序。

 

7.2. 与完成端口/线程池的比较

完成端口和线程池主要用于“数据并行”方式――即每一个要处理的数据需要并行处理,而且数据处理后的先后顺序无关,常见于Web服务器等。

但流水线主要用于“数据流并行”方式――即对数据的处理流程可以分成多个步骤,每个步骤可以并行处理,而且要处理的数据往往有先后关系,常见于需要显示处理进度的客户端程序等。

8.  实际项目的数据比对

在实际的项目开发中,通过使用软件流水线架构,将原有的多个多媒体(图片、视频、音乐)处理程序统合,虽然逻辑比原有程序更复杂,但却高质、高效地完成开发任务。其数据对比如下:

对比项

原有架构

流水线架构

代码量

65K

34K

运行效率(1000个图片处理)

864秒

482秒

分析:

1.      通过使用统一的模版架构,将大量的通用处理进行合并,在减少代码量的同时,大大减少出现问题的可能性;

2.      通过架构封装多线程操作,每个开发担当只需要开发具体的业务代码,根本不需要考虑多线程的同步、互斥等问题,大大减轻代码复杂度,并减少出错的可能性;

3.      通过框架提供的多线程并行操作,充分、合理地同时利用好CPU、磁盘IO等资源,大大提升处理效率。

9.  后记

考虑到有的Task是高CPU操作,有的是高I/O操作,是否可以进一步实现某些Task的并行运行(就像流水线上比较忙的岗位上多安排几个人)?这样就相当于把线程池和流水线统一起来了。[f10] 这就是非线性流水线架构?

进一步开发时的需求:

1.      尝试将流水线分成客户端和服务器两个不同的版本,使用尽量相同的接口;

2.      客户端版本采用线性流水线架构,每一个Task只能有一个线程,从而保证处理的素材的顺序,处理的素材个数在开始时就确定,需要计算进度。适用于有UI的客户端程序(目前的PC也就2~4个CPU)。

3.      服务器版本采用非线性流水线架构,每一个Task可以有 n~m 个线程,随着处理素材的变化,可以动态调整。处理的素材是能动态增加的,不需要计算进度,适用于没有界面的服务器程序(服务器的CPU动辄4或以上,更需要好好利用)

 


 [f1]在实现该框架后,发现有一种设计模式叫管道过滤器模式,似乎就是这个。

 [f2]这两个API“绝对”是多线程编程中的禁用函数,一般是不能使用的,否则后果自负。

       替代方式请参见我另一篇博客文章: http://blog.csdn.net/fishjam/article/details/7425803

 [f3]用户是不会管你的实现是同时处理多个元素的。

 [f4]TODO:考虑加入Init、Unit等环节的进度计算

 [f5]负载均衡

 [f6]需要注意消费者和生产者间因为依赖性而引起的延迟—防止各个Task的执行速度差距过大造成整体的性能低下

 [f7]其比例非常小,多次测试后下降比例不到1%

 [f8]多核环境下可以通过任务管理器的“Set Affinity”功能模拟单核环境

 [f9]这个问题准备通过扩展成“非线性流水线架构”来解决

 [f10]非线性流水线架构。但不能要求显示进度,适用于没有界面的服务器程序