0%

Concurrency Programming Guide:并发与程序设计

计算机的早期,单位时间内它可以做的工作是由 CPU 的时钟速度决定的。但随着技术的进步,处理器的设计变得更加紧凑,热量和其他物理限制开始限制处理器的最大时钟速度。于是,芯片制造商寻找其他方法来提高其芯片的总性能。他们最终选择的解决方案是增加每个芯片上的处理器内核数量。通过增加内核数量,单个芯片可以在不增加CPU速度或改变芯片尺寸或热特性的情况下每秒执行更多指令。唯一的问题是如何利用这些额外的内核。

为了利用多核,计算机需要软件能够同时做多件事。对于像 OS X 或 iOS 这样的现代操作系统,同时能有上百个程序在跑,在不同的核调度是可能的。但是了,大部分程序是系统守护程序(system daemons)或后台程序,这些程序消耗很少的资源。然而对于每个程序来说,真正需要的是如何更有效的使用多余的核。

传统的使用多核的方式是创建多个线程。然而随着核数目的提高,线程方案有其自身的问题。最大的问题是,线程代码不能很好地扩展到任意数量的内核。你不能创建与核心等量的线程,然后期望程序跑得很好。程序自身去计算使用多少核心是很高效的本身是一件很有挑战的事。即使知道了数目,给这么多线程编写代码也是很有挑战的。

总的来说,程序需要一种方式来利用可变的核心。一个程序进行的工作也需要根据变化的系统情况来自动伸缩。方便必须足够简单,不增加利用这些核心做工作的总量。Apple 的操作系统提供了这样的解决方案,这章将会讲讲构成该方案的技术,以及一些你可以使用的设计调整。

远离线程

尽管线程已经存在多年,也还有人在用,但是它们没有解决可伸缩执行多个任务的普遍问题。使用线程的话,实现可伸缩方案的负担落在了开发者自身上。你必须决定使用多少个线程,并根据系统条件的变化动态调节。另一个问题是你的程序承担着创建和维护线程的大部分成本。

OS X 和 iOS 采用了 asynchronous design approach 来解决并发的问题,而不是依赖于线程。异步函数在操作系统中已经存在多年,并被使用来启动需要长时间的任务,如从硬盘中读取数据。当被调用时,一个异步函数在幕后会做些工作来启动一个任务,并在任务真正启动前返回。往往,这些工作设计到获得一个后台线程,在这个线程上执行上述任务,当任务完成的时候发送一个通知给调用者(通常通过回调函数)。在过去,如果某个你想用的异步函数不存在的话,你就需要编写你自己的异步函数和创建你自己的线程。但是现在,OS X 和 iOS 提供了允许你执行异步任务,但不需要你管理任何线程的技术。

其中的一个启动异步任务的技术叫做 Grand Central Dispatch (GCD)。这项技术将你经常在自己程序中写的管理线程的代码提出来,移到系统的层级里。你所需要做的是定义你的任务,将这些任务添加到相应的调度队列中 (dispatch queue)。GCD 负责创建需要的线程,并在这些线程上规划这些任务。由于线程管理现在是系统的一部分,GCD提供了一个整体的任务管理和执行方案,提供了比传统线程更好的效率。

操作队列是行为跟调度队列 (dispatch queues) 非常像的 Objective-C 对象。你定义自己想要执行的任务,并把它们添加到操作队列中,操作队列会替你负责线程管理,保证任务在系统上的执行尽可能地迅速和高效。

调度队列

调度队列是基于 C 的一个执行自定义任务的机制。一个调度队列要么串行 (serially) 要么并行 (concurrently) 地执行任务,但始终是先入先出的顺序(换句话说,一个调度队列总是按照进入队列的顺序从队列中取出执行任务)。串行调度队列一次只运行一个任务,等到该任务完成后再去排队并启动新的任务。相比之下,并发调度队列会尽可能多地启动任务,而不等待已经启动的任务完成。

调度队列有些其他好处:

  • 它们提供了直接并简单的编程接口。
  • 它们提供了自动全面的线程池管理。
  • 它们提供了汇编性能优化。
  • 内存使用更高效(因为线程栈不会在程序内存中停留)。
  • 在负载下不会损害内核。
  • 异步地调度任务到调度队列不会导致死锁。
  • 在资源 contention 的时候可以自由伸缩。
  • 比锁和其他同步原语更高效。

提交给调度队列的任务必须封装在一个函数或 block 对象中。 block对象是 OS X v10.6 和 iOS 4.0 引入的一个跟函数指针概念相似的 C 语言特性,但相对于函数指针,它有其他优点。除了在 block 自身的词法域定义 block 外,你通常可以在另一个函数或方法中定义 block,这样 block 就可以访问函数或方法内的变量了。当把 block 提交到调度队列时,block 同样可以从原有的作用域中移出,并拷贝到堆中。所有这些语义使得使用较少代码实现非常动态的任务变得可能。

调度队列是Grand Central Dispatch技术的一部分,是C语言运行时的一部分。关于在程序中使用调度队列的更多信息,可参阅调度队列。关于block的更多信息和它们的优势,可参阅Blocks Programming Topics

调度源

调度源是一种基于 C 的异步处理特定系统事件的机制。一个调度源封装了一个特定系统事件类型的信息,并在该事件发生时将特定的block对象或函数提交给调度队列。你可以使用调度源来监听以下系统事件:

  • Timers
  • Signal handlers
  • Descriptor-related events
  • Process-related events
  • Mach port events
  • Custom events that you trigger

调度源是Grand Central Dispatch技术的一部分。关于使用调度源在程序中接收事件的信息,可参阅调度源

操作队列

操作队列相当于一个并发的调度队列,由NSOperationQueue类实现。尽管调度队列总是以先进先出的顺序执行任务,而操作队列在确定任务的执行顺序时会考虑到其他因素。主要因素是任务之间配置的依赖。配置依赖关系可以用其构建复杂的执行顺序。

提交给操作队列的任务必须是 NSOperation 类的实例。一个操作对象是一个你需要执行的任务和任务所需数据 Objective-C 封装的对象。因为 NSOperation 类本质上是一个抽象基类,你通常需要自定义子类来执行你的任务。但Foundation框架也提供了一些具体子类可直接使用。

操作对象会产生 KVO 通知,你可以用它来监听你的任务的进度。尽管操作队列总是并发地执行操作对象,但你可以使用依赖关系来确保它们在需要时被串行执行。

关于如何使用操作队列,以及如何定义自定义操作对象的更多信息,可参阅操作队列

异步设计技术

在你考虑重新设计你的代码来支持并发的时候,你应该问下你自己这样做是否值得。并发可以通过让你的主线程专门响应用户事件来保证程序的响应性;通过使用多个核心可以让你的代码给定时间内做更多的工作。然而,并发也会增加开销,增加代码的整体复杂性,使得代码难以编写和调试。

除了增加复杂性外,并发并不是一个你在程序的产品周期最后可以移接的特性。正确的使用它需要仔细的考虑你的程序所做的任务和这些任务需要的数据结构。做的不对的时候,反而会降低你代码的效率和响应性。因此,在设计开始的时候很有必要花些时间来设定你的目标,设计执行的方案。

每一个程序有不同的要求和不同的任务需要。几乎不可能有一个文档来告诉你怎么设计你的程序和相关的任务。不过,下面的章节试图提供一些指南,帮助你在设计过程中做出正确的选择。

定义程序的预期行为

在你开始考虑给你的应用添加并发之前,你应首先定义正确的程序行为。理解你应用的期望行为给你稍后验证你的设计可能,同样给你关于引入并发可能带来的性能提升的想法。

你应该做的第一件事是遍历程序要做的任务和每个任务所需要的对象或数据结构。这些任务可能包含用户行为引起的,也可能是定时器引起的。

之后列出优先级高的任务,细分认为到小的步骤。在这个层级,你应该主要关注你对数据结构的修改和这些对象的修改怎么影响全局状态。你应该注意到不同的对象、数据结构间的依赖。一个对象的修改是否会影响其他对象。如果这些对象可以相互独立地进行修改,这可能是一个可以同时进行修改的地方。

分解出可执行的工作单元

从你对程序任务的理解,你应该已经可以确定哪些地方你可以使用并发来优化你的代码。如果改变任务执行的步骤会影响最终的结果的话,可需要继续维持这些步骤的顺序;否则如果改变步骤不影响最终的结果的话,你可以考虑并发执行这些步骤。这两种情况下,都要定义可执行的工作单元来代替你任务中需要执行的步骤。然后使用block或操作对象封装工作单元内容,并分发到合适的队列中。

对于每个确定的可执行工作单元,一开始不必太担心工作量的大小。尽管启动线程总有一定的开销,但使用调度队列和操作队列在大多情况下,其开销会比传统的线程要小很多。因此,使用队列比使用线程可以更高效的执行这些比较小的工作单元。当然你应常测量实际性能,并根据需要调整任务的大小。但是还是那句话,开始的时候,没有任务应该被视为太小。

确定需要的队列

现在你的任务已经被分解为不同的工作单元,使用block或操作对象进行封装,你需要定义执行任务的队列。对于一个给定的任务,你需要检查创建的block或操作对象和它们的执行顺序,以正确完成任务。

如果你使用block来完成任务,你可以添加block到串行或并行调度队列。如果需要特定的顺序,则将block添加到串行调度队列。如果顺序不重要,则可以将block添加到并行调度队列,或根据你的需要,把它们添加到多个不同的调度队列中。

如果你通过操作对象来实现你的任务,队列的选择往往没配置这些对象有趣。要串行的执行这些任务,你必须配置这些对象间的依赖。依赖可以确保在依赖的操作对象完成任务时才执行后续的操作。

提高效率的技巧

除了重构你的代码成较小的任务,将任务加到队列,还有其他的方式使用队列来提高代码的整体效率:

  • 如果内存使用是关键因素,考虑直接在任务中计算值。直接计算数值会使用给定处理器内核的寄存器和缓存,这比主内存快得多。当然要经过测试确定这一优化是否能提高性能。
  • 尽早找出出串行的任务,尽可能使它们更并发。如果任务因为资源共享而必须串行,则可以考虑移除共享资源,或为每个任务分配资源的副本以消除共享。
  • 避免使用锁。有了调度队列和操作队列,锁在大多数情况下是不需要的。与其使用锁来保护一些共享资源,不如指定一个串行队列(或使用操作对象依赖)来以正确的顺序执行任务。
  • 尽可能的依赖系统框架。使用系统提供的API可以节省精力,并能最大限度地提高并发性。

性能影响

操作队列、调度队列和调度源是为了让开发者更容易地并发执行更多的代码。然而,这些技术并不保证能给提高程序的执行和响应效率。以技能有效满足需求,又不会对程序的其他资源造成过度负担的方式来使用队列,仍是开发者的任务。例如,尽管你可以创建 10,000 个操作对象,并将它们提交给操作队列,但是这么做会让程序分配大量的内存,最终降低程序的性能和体验。

在引入任何并发到你的代码之前,不论是通过队列还是线程,你都应该收集衡量影响应用当前性能的基本标准。在引入了这些机制后,你需要重新收集这些信息,然后对比以确定程序的整体效率是否得到了提高。如果引入并发导致了程序的执行和响应效率降低,则应使用性能工具来检查潜在的原因。

关于性能和可用的性能工具的介绍,以及更高级的性能相关主题的链接,可参阅Performance Overview

并发和其他技术

把代码分解成模块化的任务,是试图该缠程序并发性的最好方法。然而这种设计方法并不能满足所有的场景。根据你的任务,可能还有其他选择为程序的整体并发性提供额外的改进。

OpenCL和并发性

OS X 中 Open Computing Language (OpenCL) 是一个基于标准的技术,用来在 GPU 上进行通用计算。如果你有定义好的计算需要应用在大型数据上,OpenCL 是不错的技术。例如,你也许用 OpenCL 在图像的像素上进行滤镜操作,或者在多个值上进行复杂的数学计算。换句话说,OpenCL 更多是用于处理数据可被并行操作的问题。

尽管 OpenCL 很适合执行大规模的并行数据操作,除此之外可能并不适合其他场景的计算。需要大量的精力来准备和转移数据和 the required work kernel (不知道咋翻译) 到显卡上,以便显卡可以计算。同样需要大量的精力才能从 OpenCL 获取操作结果。因此,任何与系统交互的任务一般都不建议使用OpenCL。例如,你不会使用OpenCL来处理文件或网络流的数据。相反,使用OpenCL执行的工作必须足够的独立,以便它可以被传输到GPU并独立计算。

关于OpenCL和如何使用它的更多信息,可参阅OpenCL Programming Guide for Mac

何时使用线程

尽管操作队列和调度队列是并发执行任务的首选方式,但它们并不是万金油。根据你的程序,有时仍可能需要创建自定义线程。当你确实需要创建线程的时候,你应该尽量创建少的线程。同时你只应用线程解决那些用其他方式解决不了的问题。

线程仍是实现实时运行代码的方案。调度队列会尽可能以最快速度执行它们的任务,但它仍没有解决实时的问题。如果你需要在后台执行的代码要求更多可预测的行为,线程可能仍是更好的选择。

与任何线程编程一样,你应总是理智地使用线程,只有在绝对必要时才使用。关于线程包以及如何使用它们的更多信息,可参阅Threading Programming Guide.。

总结

  • 单位时间内的工作量是由CPU时钟速度决定的。

  • 随着技术的进步,制造商通过提高CPU核数来提高性能。

  • 使用线程最大的问题是,线程代码如何充分利用内核。

  • 对于开发者而言,使用线程的挑战:

    • 可伸缩执行多个任务的问题要开发者自行解决。
    • 程序承担着创建和维护线程的大部分成本。
  • GCD是通过由系统管理的线程池,可以替代直接使用线程实现的绝大部分功能,并提供更高的效率。开发者的任务次需要定义任务,并添加到相应的调度队列中。

  • 调度队列的调度单元是函数或block;操作队列的调度单元是操作对象。工作单元的代码都是顺序执行的。

  • 调度队列有些其他好处:

    • 它们提供了直接并简单的编程接口。
    • 它们提供了自动全面的线程池管理。
    • 它们提供了汇编性能优化。
    • 内存使用更高效(因为线程栈不会在程序内存中停留)。
    • 在负载下不会损害内核。
    • 异步地调度任务到调度队列不会导致死锁。
    • 在资源 contention 的时候可以自由伸缩。
    • 比锁和其他同步原语更高效。
  • 操作队列相当于一个并发的调度队列。其执行顺序除了队列的先进先出外,主要还考虑操作对象之间的依赖。

  • 经测试,对于异步队列,无论是使用调度队列还是操作队列,执行任务的顺序都不能依赖于任务入队的顺序。

  • OpenCL只适合并行处理大规模数据,不适合一般多线程场景。

  • 使用队列相比直接使用线程,最大的优势是可预测性。二使用线程则是为了追求实时执行。

使用技巧:

  • 提高效率的技巧(以下技巧都要经过性能测试):
    • 想要最快,就直接计算值,这会直接使用处理器内涵额的寄存器和缓存。
    • 尽可能地并发。
    • 避免使用锁。
    • 尽量用系统API。

欢迎关注我的其它发布渠道