多核与异步并行

我们在设计多线程程序时往往有很多性能指标,例如低延迟(latency),高吞吐量(throughput),高响应度(responsiveness)等。随着多核处理器上CPU核数的日益增加,如何高效地利用这些计算资源以满足这些设计目标变得越来越重要。这次向大家介绍的异步并行就是一种帮助实现低延迟、高吞吐量和高响应度的并行编程技术。

原文发表于《程序员》杂志2012年第9期,文字略有修改。

我们在设计多线程程序时往往有很多性能指标,例如低延迟(latency),高吞吐量(throughput),高响应度(responsiveness)等。随着多核处理器上CPU核数的日益增加,如何高效地利用这些计算资源以满足这些设计目标变得越来越重要。这次向大家介绍的异步并行就是一种帮助实现低延迟、高吞吐量和高响应度的并行编程技术。

让我们先来看这样一个例子。在下面的程序中,我们有一个do_something()的API,这个函数实现了将一个文件写入磁盘的功能,所以改函数比较耗时。在调用这个函数时,最简单的用法是对该函数进行同步调用,即下面程序中caller1()所采用的方式。这种写法带来的问题是,caller1需要阻塞等待do_something()的完成,期间CPU不能做任何其他的计算,从而导致CPU资源的空闲。与此相反,程序中的caller2就采用了异步调用do_something()的方式。这样,caller2在将异步调用do_something的命令发送给worker线程之后,就可以立刻返回并开始执行other_work(),不仅能将other_work()提前完成,更提高了CPU利用率。

int do_something(doc)
{
    return write_document(doc); // 耗时的I/O写操作
}

void caller1(doc) {
   result = do_something(doc); //同步调用do_something()
   other_work(); //这个操作需要等待do_something()的完成
   more_other_work();
}
void caller2() {
   worker.send(do_something_msg());//异步调用do_something()
   other_work(); //这个操作不需要等待do_something()的完成,因此提高了CPU的利用率
   more_other_work();
}

在现代计算机体系结构中,I/O设备的速度远远比不上CPU,我们在做计算时一个基本的设计原则就是在CPU等待I/O请求的同时,用足够多的计算任务将CPU跑满,从而掩盖掉I/O请求造成的延迟。在单核时代,我们使用Multiplexing的方式将I/O任务与计算任务重叠在一起进而提高程序性能,即一个进程如果进入I/O等待,操作系统会将该进程放入等待队列,并调度执行另一个进程的计算任务;多核时代来临之后,CPU上的计算资源变得越来越多,通过使用异步并行技术充分利用CPU的计算资源,提升应用程序的延迟性、吞吐量、响应度也变得越来越普遍。下面让我们通过几个典型应用来对异步并行做更多的介绍。

GUI线程的异步并行设计

GUI线程是采用异步并行设计来提高响应度的一个经典例子。一个GUI程序的典型结构是使用一个循环来处理诸如用户点击了某个按钮、系统产生了一个中断等事件。许多GUI系统还提供了诸如优先级队列等数据结构以保证优先级高的事件能得到及时的相应。下例是一个典型的GUI系统伪代码:

while( message = queue.receive() ) {
  if( it is a "保存文件" request ) {
    save_document(); // 这是一个会产生阻塞的同步调用
  }
  else if( it's a "打印文档" request ) {
    print_document(); // 这是一个会产生阻塞的同步调用
  }
else
  ...
}

这个程序有一个非常常见的性能bug:它对save_document()和print_document()这两个非常耗时的操作采用了同步调用的方式,这与GUI线程应该具备及时响应的设计初衷产生了直接矛盾。GUI线程的设计目标不仅仅是对相应的事件作出正确的响应,更重要的是这些响应必须非常及时。按照上面这个程序的逻辑,很可能会出现如下情况:用户在点击“保存文件”按钮之后,程序需要花费几秒钟才能完成save_document()调用,因此该程序在这几秒钟时间内都不能再对其他任何事件作出响应;而这时如果用户还想要调整窗口大小,这个操作在几秒钟之内都得不到响应,从而破坏用户体验。

一般来说,需要拥有高响应度的线程不应该直接执行可能带来延迟或阻塞的操作。可能带来延迟或阻塞的操作不仅仅包括保存文件、打印文件,还包括请求互斥锁、等待其他线程某个操作的完成等。

我们有三种方式来将耗时的操作从需要保持高响应度的线程中转移出去。下面让我们继续用GUI系统的例子来对这三种方法一一进行介绍,以分析它们各自适用的场景。

方式一:一个专用的工作线程

第一种将耗时操作从GUI线程中转移出去的方式是,使用一个专门的工作线程来异步地处理GUI线程发送的耗时操作请求。如下图所示,GUI线程依次将打印文档(PrintDocument)和保存文档(SaveDocument)两个异步请求发送给工作线程之后就立刻返回,从而继续对用户的其他请求做出及时的相应(例如调整窗口大小、编辑文档等);与此同时,工作线程依次对打印文档和保持文档进行顺序处理,并在并在该异步请求完成到某一进度时(或者该异步请求完成时)向GUI线程发送相应的通知信号。

图1. 使用专门的工作线程来处理GUI线程的异步请求
图1. 使用专门的工作线程来处理GUI线程的异步请求

让我们来看看这种处理方式的代码会长成什么样子:

// 第一种方式:使用一个专门的工作线程来处理GUI线程的异步请求
// GUI线程:
while( message = queue.receive() ) {
   if( it's a "保存文档" request ) {
      worker.send( new save_msg() ); // 发送异步请求
   }
   else if( it's a "保存文档" completion notification ) {
     display(“保存文档成功!”); // 接到异步请求的进度通知
   }
   else if( it's a "打印文档" request ) {
      worker.send( new print_msg() ); //发送异步请求
   }
   else if( it's a "打印文档" progress notification ) {
      if( percent < 100 ) // 接到异步请求的进度通知
         display_print_progress( percent );
      else
         display(“打印完毕!”);
   }
   else
   ...
}

// 工作线程:处理来自GUI线程的异步请求
while( message = workqueue.receive() ) {
   if( it's a "保存文档" request )
      save_document(); // 保存文档并在结束后向GUI线程发送通知
   else if( it's a "打印文档 " request )
      print_document(); // 打印文档并向GUI线程发送进度通知
   else
   ...
}

方式二:每一个异步请求分配一个工作线程

在第一种方法的基础之上,我们可以做一些相应的扩展:对每一个GUi线程的异步请求都分配一个专门的工作线程,而不是只用一个工作线程去处理所有异步请求。这个方式的好处很明显,异步请求被多个线程分别并行处理,因此提升了处理速度。值得注意的是,我们需要及时对这些工作线程进行垃圾回收操作,否则大量线程会造成内存资源的紧张。

图2. 为每个GUI线程的异步请求分配一个工作线程
图2. 为每个GUI线程的异步请求分配一个工作线程

这种模式的代码如下所示。因为对每个异步请求我们都启动一个新的线程,我们可以充分地利用多核的计算资源,更快地完成相应的任务。

// 方式二:每一个异步请求分配一个线程
while( message = queue.receive() ) {
   if( it's a "保存文档" request ) {
      ...  new Thread( [] { save_dcument(); } ); // 启动新线程对异步请求进行处理
   }
   else if( it's a "打印文档" request ) {
      … new Thread( [] { print_document(); } );/ // 启动新线程对异步请求进行处理
   }
   else if( it's a "保存文档" notification ) { ... }
                                      // 同方式一
   else if( it's a "打印文档" progress notification ) { ... }
                                      // 同方式一
   else
      ...
}

方式三:使用线程池来处理异步请求

第三种方式更进了一步:我们可以根据多核硬件资源的多少来启动一个专门的线程池,用线程池来完成GUI线程的异步请求。这种方式的好处在于,我们可以在充分利用多核的硬件资源,以及并行地对异步请求进行高效处理间取得一个很好的平衡。该方式的工作示意图如下所示:

图3. 使用线程池来处理GUI线程的异步请求
图3. 使用线程池来处理GUI线程的异步请求

让我们来看一下这种方式的伪代码。需要注意的是,线程池的具体实现每个语言各有不同,因此下面的代码只供大家参考之用。

// 方式三:使用线程池来处理异步请求
while( message = queue.receive() ) {
if( it's a "保存文档" request ) {
pool.run( [] { save_document(); } ); // 线程池的异步调用
}
else if( it's a "打印文档" request ) {
pool.run( [] { print_document(); } ); //线程池的异步调用
}
else if( it's a "保存文档" notification ) { ... }
// 同前
else if( it's a "打印文档" progress notification ) {  ... }
// 同前
else
...
}

Grand Central Dispatch的异步并行

Grand Central Dispatch(GCD)是苹果于Mac OS X 10.6和iOS4中发布的一项并行编程技术。对使用GCD的程序员来说,只需要将需要被处理的任务块丢到一个全局的任务队列中去就可以了,这个任务队列中的任务会由操作系统自动地分配和调度多个线程来进行并行处理。将需要被处理的任务块插入到任务队列中去有两种方式:同步插入和异步插入。

让我们来看看一个使用GCD异步并行的实例。在下面的程序中,analyzeDocument函数需要完成的功能是对这个文档的字数和段落数进行相关统计。在分析一个很小的文档时,这个函数可能非常快就能执行完毕,因此在主线程中同步调用这个函数也不会有很大的性能问题。但是,如果这个文件非常的大,这个函数可能变得非常耗时,而如果仍然在主线程中同步调用该方法,就可能带来很大的性能延迟,从而影响用户体验。

// 不使用GCD的版本
- (IBAction)analyzeDocument:(NSButton *)sender {
    NSDictionary *stats = [myDoc analyze];
    [myModel setDict:stats];
    [myStatsView setNeedsDisplay:YES];
    [stats release];
}

使用GCD的异步并行机制来优化这个函数非常简单。如下所示,我们只需要在原来的代码基础上,先通过dispatch_get_global_queue来获取全局队列的引用,然后再将任务块通过dispatch_async方法插入该队列即可。任务块的执行会交由操作系统去处理,并在该任务块完成时通知主线程。一般来讲,异步插入的方式拥有更高的性能,因为在插入任务之后dispatch_async可以直接返回,不需要进行额外等待。

//使用GCD异步并行的版本
- (IBAction)analyzeDocument:(NSButton *)sender
{
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
dispatch_async(queue, ^{
         NSDictionary *stats = [myDoc analyze];
         [myModel setDict:stats];
         [myStatsView setNeedsDisplay:YES];
         [stats release];
     });
}

总结

本文对多核编程时常用的异步并行技术做了相关介绍。通过使用异步并行技术,我们可以将比较耗时的操作交给其他线程去处理,主线程因此可以去处理其他有意义的计算任务(例如相应用户的其他请求,完成其他计算任务等),从而有效提高系统的延迟性、吞吐率和响应性。

《多核与异步并行》有1个想法

发表评论

电子邮件地址不会被公开。 必填项已用*标注