文章

并发编程指南

并发编程指南

官方地址:ConcurrencyProgrammingGuide

并发就是同时执行多个任务,随着多核CPU的发展与单处理器核心的增加。程序开发者也需要新的技术来利用这些硬件优势。现代操作系统都能并行运行多个程序,多数程序都是运行在后台,它处理任务需要一段连续的处理器时间。前台程序会同时响应用户事件和保持电脑忙碌,如果一个程序有很多任务需要处理,其应该尽可能利用处理器资源。

过去,介绍并发编程需要创建额外的线程。但是直接创建线程并不能很好的保证性能提升,因为线程是低等级工具,需对其手动管理。并且受限于系统负载和底层硬件很难创建合适数量的线程。加上多线程同步机制也会增加代码复杂性。

OSX 和 iOS 都采用了更加异步的方式来处理并发任务,而不是直接创建线程。对于 App 来说,只需要创建指定的任务,并由操作系统来执行即可。 让操作系统管理线程,App 可以获得更好的伸缩性,系统会根据负载情况和硬件条件创建合适数量的线程,且开发者也能获得更简单高效的编程模型。

并发和程序设计

介绍异步程序的设计 & 异步执行的自定义任务的技术

过去,计算机单位时间内执行任务的最大量由芯片的时钟速度决定。随着技术进步和处理器设计更加紧密,发热和其他物理因素限制了处理器的最大时钟速度。之后芯片制造开始向着多核心发展,使得单块芯片能在同规格下执行更多的指令,而我们就是要跟好的利用这些额外的核心。

要利用多核,需要能同时执行多任务的软件。 OSX 和 iOS 就是多任务操作系统,能同时执行上百或者更多的程序,系统层面的守护程序或者后台任务消耗时间较少,我们需要让 App 能更高效利用多核。

传统方法是直接创建多线程,但是手动创建线程在面对不同硬件的伸缩性较差,自己很难创建合适数量的线程,并且多线程各自高效执行和交互也会增加代码复杂度。

面对手动创建线程的问题,苹果系统提供了解决方案,采用官方方案即可依靠系统创建合适数量的线程,并由系统处理任务,简单高效。

远离线程

直接创建线程的问题就是无法面对不同硬件环境创建合适数量的线程,并且你的程序会承担创建和维护线程的大部分成本。

异步函数通常用来处理耗时任务,调用时异步函数在后台开启一个任务,并在执行完成之后进行回调,通常这个工作需要获取一个后台线程,并在该线程执行任务,完成之后给调用者发送一个通知(通常使用一个回调函数)。过去如果没有现成的一部分函数,你就得自己实现。现在苹果提供了能执行任意异步任务且无需自己管理线程的方案。

其中之一就是 GCD,使用 GCD 你的任务就是写指定任务并添加到合适的派发队列中。GCD 负责创建线程并执行指定任务,由于此刻线程是系统管理,所以它比你手动在 App 内创建并管理线程更加高效。

Operation queues 是类似 dispatch queues 的 OC 对象,你的任务是创建要执行的任务并放到 operation queues 中,同 GCD 一样 dispatch queues 管理线程并尽可能保障任务快且高效的执行

Dispatch Queues

Dispatch Queues 是 C 语言级别的任务执行机制。 它可以串行或并行执行任务。串行队列一个一个的执行任务,前面任务执行完成才会执行后续任务,并发队列则同时执行多个任务而无需等待其他任务完成。优点如下:

  • 提供简单明了的编程接口
  • 提供自动且完整的线程池管理
  • 提供汇编级别的速度
  • 内存效率更高(因为线程的栈没有在App的内存中)
  • 它们装载的时候不会卡内核
  • 异步派发的任务不会造成队列死锁
  • 系统层面管理根据收缩性,根据不同硬件创建合适数量的线程
  • 串行队列可以更好的替代锁或其他线程同步机制

放入到队列的任务必须被包装成函数或者 Block 对象,这使得代码非常简洁。

Dispatch Sources

Dispatch Sources 是 C 语言级异步别执行特定系统事件的机制。在指定事件发生时 Dispatch Sources 会包装特定系统事件并提交指定 block 或者函数到 dispatch queue。你可以使用 dispatch sources 来监听以下系统事件

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

Operation Queues

operation queue 是基于 NSOperationQueue 实现的等效 Dispatch Queues 的队列。dispatch queues 按照先进先出执行任务,operation queue 则可以设置任务之间的依赖关系,这样让你自定义创建复杂的执行顺序。

提交到 operation queue 的任务必须是 NSOperation 对象, 它封装了要执行的任务和所需数据。Foundation 中提供了几个 NSOperation 实体类。

Operation objects 会生成 KVO 通知,这样就可以方便监听到任务执行进度。虽然 Operation queues 通常是并发执行,但如果需要串行执行,你可以使用操作的依赖关系来确保其串行执行。

异步设计技术

使用多并发技术之前,要先考虑是否有必要使用并发。任务并发能让代码更高效执行,并让主线程可以更好的响应用户事件。它通过利用更多的核心来提升代码效率,同时也增加了代码的复杂性,让调试也更加困难。

并发会增加代码复杂度,所以要更加细心考虑 App 要处理的任务,和要处理任务需要依赖的数据结构。如果处理错误,程序可能会运行更慢,更卡。所以在设计之初要仔细考虑采用何种方案来实现功能。

下面几点关于并发的指导可能对你有用

定义好 App 预期的功能和行为

App 支持并发之前,要先想好 App 功能的正确行为,理解了 App 的行为才能做出正确的设计,才能在引入并发的时候获得预期的性能收益。

首先要列举 App 需要执行的任务和每个任务所关联的对象/数据结构。例如用户操作触发某个事件,例如非用户操作触发的事件,定时器事件。

有了高级的任务列表,下面就是要拆分,将每个任务完成的必要步骤列出来。其中主要关心对关联对象和数据结构的修改,以及修改后对App整体状态的影响。对于所操作对象和数据结构之间的依赖关系也要格外注意。例如,涉及任务对对象数组进行相同的修改,需要注意,对同一个对象的修改是否会影响其他对象,如果对对象的修改是独立的,则可以对对象数组执行并发修改操作

分解除可执行的工作单元

对于已知的 App 的任务,如果任务步骤的执行顺序修改会影响最终结果,那么就应该使用顺序执行。相反,如果任务步骤的执行顺序修改不会影响最终结果,那么就考虑使用并发执行。不管哪种情况,你需要将任务中的一步一步操作定义为可执行单元。通常你需要用 block 或者 operation object 来封装操作任务并将其派发到合适的操作队列中去执行。

对于你封装的任务,无需担心是否会因为任务太小而效率较低。虽然底层都会有创建线程的消耗,但是 dispatch queue 和 operation queue 因为本身依赖系统队列,在多数情况下消耗都会比传统手动创建线程的消耗低。所以无需担心自己创建的任务是否太小。当然,你仍旧需要时刻测量真实性能并调整对应的任务。

确定你需要的队列

现在你的 Tasks 已经被分解成不同的子任务,并且已经使用 blocks/operation objects。 你现在需要定义操作队列来执行你的代码。对于给定的任务,你需要做的是检测并确保正确的执行顺序。

如果你用 Block 执行任务,可以将 block 放到串行或并行队列。 如果需要特定顺序,你就需要将 block 任务放到 serial dispatch queue。反之,如果不依赖执行顺序,就可以将 block 放到 concurrent dispatch queue 或者根据需要添加到多个不同的 dispatch queue 中。

如果使用 operation objects 来实现,则主要需要配置 operation object 之间的依赖关系,它们之间的依赖能保证队列中操作对象的执行顺序。

关于性能提升的小 tip

  • 如果内存使用是一个因素,请考虑直接在任务中计算值。直接计算比从主内存中获取效率更高(当内存受限的时候)
  • 较早定位串行任务,并尽可能将其改造成可并行执行。比如有些任务需要串行执行是依赖共享资源,可以考虑更改架构删除该共享资源。
  • 避免使用。 由于 dispatch queues 和 operation queues 的支持,在很多场景上是不再需要锁。与其使用锁来保护某些共享资源,不如指定一个串行队列(或使用操作对象依赖关系)以正确的顺序执行任务。
  • 尽可能依赖系统框架来实现并发。如果要实现并发功能,优先查找系统框架内是否已经有对应功能的实现,使用系统 API 会节省你很大精力并为你提供最大的并发性能

性能影响

Operation queues, dispatch queues, 和 dispatch sources 是系统提供的并发方案。但是具体能否让它们发挥最大的效率和响应性能,还是取决于你的使用方式。例如:你可以创建 10000 个 operation objects 并提交它们到 operation queue,到那时这样会创建大量内存,会降低 App 的内存寻址能力和性能。

在使用队列或线程向代码引入任何并发性之前,你应该始终收集一组反映应用程序当前性能的基线度量。引入更改后,你应该收集额外的度量,并将其与基线进行比较,以查看应用程序的整体效率是否有所提高。如果并发的引入降低了应用程序的效率或响应速度,则应使用可用的性能工具检查潜在原因。

关于性能相关的话题,请参考Performance Overview

并发和其他技术

将代码分解为模块化任务是尝试提高应用程序并发性的最佳方法。但这并不能适应所有程序,你需要根据你的需要来确定要选择什么技术

OpenCL 和 Concurrency

OpenCL(Open Computing Language) 是一种基于标准的技术,用于在计算机图形处理器上执行通用计算。如果您有一组定义良好的计算,并且希望应用于大型数据集,那么OpenCL是一种很好的技术。例如,您可以使用OpenCL对图像的像素执行过滤计算,或者使用它同时对多个值执行复杂的数学计算。换句话说,OpenCL更倾向于数据可以并行操作的问题集。

尽管OpenCL适合于执行大规模数据并行操作,但它不适用于更通用的计算。准备数据和所需的工作内核并将其传输到图形卡,以便GPU可以对其进行操作,这需要付出大量的努力。类似地,检索OpenCL生成的任何结果所需的工作量也不小。因此,与系统交互的任何任务通常不建议与OpenCL一起使用。例如,您不会使用OpenCL来处理文件或网络流中的数据。相反,使用OpenCL执行的工作必须更加独立,以便可以将其传输到图形处理器并独立计算

OpenCL 参考OpenCL Programming Guide for Mac

什么时候使用 Thread 呢?

尽管 operation queues 和 dispatch queues 是并发执行的首选方式。但你的程序可能仍需要创建线程,但你应该创建尽可能少的线程,并应该仅及那个这些线程用于无法以任何其他方式实现的特定任务。

线程仍然是实现实时代码的好方法。 Dispatch queues 尽可能快的执行任务,但它并不解决实时约束,如果你要从后台代码中获得更可预测的行为,线程仍然可以提供更好的选择。

end

本文由作者按照 CC BY 4.0 进行授权