了解并发编程
为什么需要并发编程?
Concurrency
(并发性)是指多个事情同时发生的概念。随着多核CPU的激增,以及意识到每个处理器中的核心数量只会增加,软件开发人员需要新的方法来利用它们。
虽然像MacOS
和iOS
这样的操作系统能够并行运行多个程序,但这些程序大多数是在后台运行,执行的任务几乎不需要连续处理器时间。当前的前台应用程序既能吸引用户的注意力,又能使计算机保持忙碌。如果应用程序有很多工作要做,但是只占用了可用内核的一小部分,那么这些额外的处理资源就会被浪费。
过去,将并发引入应用程序需要创建一个或多个附加线程。不幸的是,编写线程代码具有挑战性,线程是一种必须手动管理的低级工具。考虑到应用程序的最佳线程数量可以根据当前 系统负载 和 底层硬件动态变化,实现正确的线程解决方案变得极其困难,甚至不可能实现。此外,通常与线程一起使用的同步机制会增加软件设计的复杂性和风险,而没有任何性能改进保证。
- 与传统的基于线程的系统和应用程序相比,osx和iOS都采用了更异步的方法来执行并发任务。应用程序只需要定义特定的任务,然后让系统执行它们,而不是直接创建线程。通过让系统管理线程,应用程序获得了原始线程无法实现的可伸缩性。应用程序开发人员还可以获得更简单、更高效的编程模型。
并发和应用程序设计
在早期的计算中,计算机每单位时间内所能执行的最大工作量取决于clock speed of the CPU
(CPU的时钟速度)。但随着技术的进步和处理器设计变得更加紧凑,热量和其他物理约束开始限制处理器的最大时钟速度。因此,芯片制造商寻找其他方法来提高芯片的整体性能。他们确定的解决方案是增加每个芯片上的处理器内核数量。
- 通过增加内核数量,单个芯片每秒可以执行更多指令,而无需提高CPU速度或更改芯片大小或散热特性。唯一的问题是如何利用额外的内核。
为了利用多核的优势,计算机需要能够同时执行多项任务的软件。对于像OS X
和iOS
这样的现代多任务操作系统,在任何给定的时间都可能运行上百个或更多的程序,因此在不同的内核上调度每个程序应该是可能的。然而,这些程序中的大多数是系统守护进程,要么是占用很少实时处理时间的后台应用程序。相反,真正需要的是单个应用程序能够更有效地利用额外的内核方法。
应用程序使用多核的传统方法是创建多个线程。但是,随着内核数量的增加,线程解决方案会出现一些问题。最大的问题是线程代码无法很好地扩展到任意数量的内核。你不能创建与内核一样多的线程,并且期望程序运行良好。你需要知道的是可以高效使用的核心数量,这对于应用程序自己进行计算是一件很有挑战性的事情。即使你设法得到正确的数字,对于如此多的线程进行编程,使它们高效运行,并防止它们相互干扰,仍然存在挑战。
因此,总结一下这个问题:
- 应用程序需要有一种方法来利用数量可变的计算机内核。
- 单个应用程序执行的工作量还需要能够动态扩展,以适应不断变化的系统条件。
- 解决方案必须足够简单,以避免增加利用这些核心所需的工作量。
Apple’s operating systems
为所有这些问题提供了解决方案,下面将介绍构成该解决方案的技术。
远离线程
尽管threads
已经存在了许多年,并且继续有其用途,但它们并不能解决以可扩展的方式执行多个任务的普遍问题。使用threads
,创建可扩展解决方案的负担完全由开发人员承担。你必须决定要创建多少个线程,并随着系统条件的变化动态地调整该数量。另一个问题是,你的应用程序承担了与创建和维护其使用的任何线程相关的大部分成本。
OS X
和iOS
采用 异步设计方法 来解决并发问题,而不是依赖于threads
。Asynchronous functions
(异步函数)在操作系统中已经存在多年,通常用于启动可能需要很长时间的任务,比如从磁盘读取数据。调用时,异步函数会在幕后做一些工作来启动任务运行,但在该任务实际完成之前返回。通常,这项工作涉及获取后台线程,在该线程上启动所需的任务,然后在任务完成时向调用者发送通知(通常通过回调函数)。在过去,如果要执行的操作不存在异步函数,则必须编写自己的异步函数并创建自己的线程。但是现在,OS X
和iOS
提供了如下允许你异步执行任何任务而无需自己管理线程的技术:
Grand Central Dispatch (GCD)
,是将你通常在自己的应用程序中编写的线程管理代码带入系统级别。你只需定义要执行的任务,并将它们添加到适当的dispatch queue
(调度队列)中。GCD
负责创建所需的线程,并安排任务在这些线程上运行。由于线程管理现在是系统的一部分,GCD
为任务管理和执行提供了一种整体方法,比传统线程提供了更好的效率。Operation queue
(操作队列)是Objective-C
对象,其行为非常类似于dispatch queues
。你定义要执行的任务,然后将它们添加到Operation queue
中,Operation queue
处理这些任务的调度和执行。与GCD
一样,Operation queue
为你处理所有线程管理,确保任务在系统上尽可能快速高效地执行。
并发编程技术
GCD
Dispatch Queues(调度队列)
dispatch queue
是一种基于C
的机制,用于执行自定义任务。dispatch queue
可以 串行 或 并发地执行任务,但始终以先进先出的顺序执行(换句话说,dispatch queue
总是以添加到队列的相同顺序排队和启动任务)。serial dispatch queue
(串行调度队列)一次只运行一个任务,等待任务完成后再出列并启动新任务。concurrent dispatch queue
(并发调度队列)可以启动尽可能多的任务,而不必等待已经启动的任务完成。
Dispatch queues
的好处:
1、它们提供了一个简单明了的编程接口。
2、它们提供了自动和全面的线程池管理。
3、它们提供了调谐装配(tuned assembly
)的速度。
4、它们具有更高的内存效率(因为线程栈不会在应用程序内存中停留)。
5、它们不会在负载下捕获到内核。
6、将任务异步分派到调度队列不会导致队列死锁。
7、他们在竞争中优雅地扩展。
8、串行调度队列为锁和其他同步原语(synchronization primitives
)提供了更有效的替代方法。
提交给dispatch queues
的任务必须封装在函数或Block
对象中。Block
对象是OSXV10.6
和iOS4.0
中引入的一种C
语言特性,在概念上类似于函数指针。通常不是在自己的语法作用域中定义Block
,而是在另一个函数或方法中定义Block
,这样它们就可以访问该函数或方法中的其他变量。Block
也可以移出其原始作用域并复制到堆上,这是将它们提交到调度队列时发生的情况。 所有这些语义 使得用相对较少的代码 实现非常动态的任务成为可能。
dispatch queues
是 GCD
技术的一部分,也是C runtime
的一部分,适用于 iOS 8.0+
。
Dispatch Sources(调度源)
Dispatch Sources
是一种基于C
的机制,它是GCD
技术的一部分,用于异步处理特定类型的系统事件。Dispatch Sources
封装了关于特定类型的系统事件的信息,并在事件发生时将特定的Block
对象或函数提交给dispatch queue
。
可以使用Dispatch Sources
来监视以下类型的系统事件:
- Timers 定时器
- Signal handlers 信号处理程序
- Descriptor-related events 描述符相关事件
- Process-related events 进程相关事件
- Mach port events
- 你触发的Custom events
Operation Queues(操作队列)
Cocoa
中的Operation Queues
等价于并发的Dispatch queues
,它由NSOperationQueue
类实现,适用于iOS 2.0+
。
Dispatch queues
总是按照 first-in, first-out
(先进先出) 的顺序执行任务,而Operation Queues
在确定任务的执行顺序时会考虑其他因素。在这些因素中,最主要的是一个给定的任务是否依赖于其他任务的完成。你可以在定义任务时配置依赖项,并可以使用它们来为任务创建复杂的执行顺序图。
提交给Operation Queues
的任务必须是NSOperation
类的实例。operation
对象封装了您想要执行的工作和执行它所需的任何数据。由于NSOperation
类本质上是一个抽象基类,因此您通常会定义自定义子类来执行任务。然而,Foundation
框架确实包含一些具体的子类,可以创建并使用它们来执行任务。
Operation
对象会生成 KVO通知
,这是监视任务进度的有用方法。尽管Operation Queues
总是并发执行操作,但你可以使用依赖项确保在需要时串行执行这些操作。
异步设计技术
在考虑重新设计代码以支持并发之前,你应该问问自己是否有必要这样做。并发可以通过确保主线程可以自由响应用户事件来提高代码的响应能力。它甚至可以通过利用更多核心在相同时间内完成更多工作来提高代码的效率。但是,它也会增加开销,并增加代码的总体复杂性,使编写和调试代码变得更加困难。
因为并发性增加了复杂性,所以你不能在产品周期结束时将并发性移植到应用程序上。正确地执行它需要仔细考虑 应用程序执行的任务 以及 用于执行这些任务的数据结构。如果操作不当,你可能会发现代码比以前运行得更慢,对用户的响应也较慢。因此,在设计周期的开始阶段花一些时间来设置一些目标并考虑需要采取的方法是值得的。
每个应用程序都有不同的需求和它执行的不同任务集。文档不可能确切地告诉你如何设计应用程序及其相关任务。但是,下面的部分试图提供一些指导,以帮助你在设计过程中做出正确的选择。
定义应用程序的预期行为
在考虑向应用程序添加并发性之前,你应该始终从定义应用程序的正确行为开始。了解应用程序的预期行为,可以为你以后验证设计提供一种方法。它还应该让你了解通过引入并发性可能获得的预期性能好处。
你应该做的第一件事是枚举应用程序执行的任务以及与每个任务关联的对象或数据结构。最初,你可能希望从用户选择菜单项或单击按钮时执行的任务开始。这些任务提供离散的行为,并有一个明确的起点和终点。你还应该列举应用程序在没有用户交互的情况下可能执行的其他类型的任务,例如基于计时器的任务。
在你获得高级任务列表后,开始将每个任务进一步分解为成功完成任务必须采取的一系列步骤。在这个级别上,你应该主要关注需要对任何数据结构和对象进行哪些修改,以及这些修改如何影响应用程序的整体状态。还应该注意对象和数据结构之间的依赖关系。例如,如果一项任务涉及对对象数组进行相同的更改,那么对一个对象的更改是否会影响其他对象就值得注意了。如果对象可以相互独立地修改,那么可以在这里同时进行修改。
分解出可执行的工作单元
根据对应用程序任务的理解,你应该已经能够确定代码可以从并发中受益的地方。如果更改任务中一个或多个步骤的顺序会改变结果,则可能需要继续串行地执行这些步骤。但是,如果更改顺序对输出没有影响,则应考虑同时执行这些步骤。在这两种情况下,你都定义了表示要执行的一个或多个步骤的可执行工作单元。然后,这个工作单元将成为您使用block
或operation object
封装的内容,并分派到适当队列中。
对于您确定的每个可执行工作单元,不要过于担心被执行的工作量,至少在最初是这样。尽管启动线程(spinning up a thread
)总是有成本,但dispatch queues
和operation queues
的优势之一是,在许多情况下,这些成本比传统线程要小得多。因此,与使用线程相比,使用队列可以更有效地执行较小的工作单元。当然,您应该始终度量您的实际性能,并根据需要调整任务的大小,但在最初,任何任务都不应该被认为太小。
确定您需要的队列
现在,您的任务已经被分解为不同的工作单元,并使用block
对象或operation object
进行封装,现在你需要定义将用于执行该代码的队列。对于给定的任务,检查你创建的block
或operation object
以及正确执行任务必须执行它们的顺序。
如果使用block
实现任务,则可以将block
添加到串行或并发dispatch queues
中。如果需要特定的顺序,则始终将block
添加到串行dispatch queues
中。如果不需要特定的顺序,则可以将这些block
添加到一个并发dispatch queues
中,或者将它们添加到几个不同的dispatch queues
中,具体取决于您的需要。
如果使用operation object
实现任务,则选择队列通常比配置对象有趣。要串行地执行操作对象,必须配置相关对象之间的依赖关系。依赖关系阻止一个操作在其所依赖的对象完成其工作之前执行。
提高效率的技巧
除了简单地将代码分解成更小的任务并将它们添加到队列中,还有其他方法可以使用队列提高代码的总体效率:
- 如果内存使用是一个因素,请考虑直接在任务中计算值。
如果您的应用程序已经受到内存限制,那么现在直接计算值可能比从主内存加载缓存值快。计算值直接使用给定处理器内核的寄存器和缓存,这比主内存快得多。当然,只有在测试表明这是性能上的胜利时,才应该这样做。
- 尽早确定串行任务,尽可能让它们更加并行。
如果由于任务依赖某个共享资源而必须串行执行,请考虑更改体系结构以删除该共享资源。您可以考虑为每个需要一个资源的客户端制作资源副本,或者完全消除该资源。
- 避免使用锁
dispatch queues
和 operation queues
提供的支持使得在大多数情况下不需要锁。与其使用锁来保护某些共享资源,不如指定一个串行队列(或使用操作对象依赖关系)以正确的顺序执行任务。
- 尽可能依靠系统框架
实现并发的最佳方法是利用系统框架提供的内置并发。许多框架在内部使用线程和其他技术来实现并发行为。在定义任务时,请查看现有框架是否定义了一个函数或方法,该函数或方法完全按照您的期望并发完成。使用该API可以节省您的工作,并且更有可能为您提供尽可能高的并发性。
性能影响
Operation queues
, dispatch queues
, 和 dispatch sources
使你可以更轻松地同时执行更多代码。但是,这些技术并不能保证提高应用程序的效率或响应能力。你仍然有责任以既有效满足你的需求,又不会给应用程序的其他资源带来不当负担的方式使用队列。例如,虽然您可以创建10000个operation objects
并将它们提交到operation queue
,但这样做会导致应用程序分配潜在的大量内存,这可能会导致分页并降低性能。
在为代码引入任何数量的并发之前,无论是使用queues
还是threads
,你都应该收集一组反映应用程序当前性能的基线指标(baseline metrics
)。在引入更改之后,,你应该收集更多的指标,并将它们与基线指标进行比较,以查看应用程序的整体效率是否有所提高。如果并发性的引入降低了应用程序的效率或响应能力,则应该使用可用的性能工具来检查潜在的原因。
有关性能和可用性能工具的介绍,以及指向更高级性能相关主题的链接,请参考Performance Overview 性能概述
并发和其它技术
将代码分解为模块化任务(modular tasks
)是尝试和提高应用程序并发性最佳方法。然而,这种设计方法可能无法满足每个应用程序在每种情况下的需要。根据你的任务,可能还有其他选项可以为应用程序的整体并发性提供额外的改进。本节概述了在设计中要考虑使用的其他一些技术。
OpenCL和并发
在OSX
中,开放计算语言(OpenCL
)是一种基于标准的技术,用于在计算机的图形处理器上执行通用计算。如果您有一组定义良好、希望应用于大型数据集的计算,那么OpenCL
是一种很好的技术。例如,可以使用OpenCL
对图像的像素执行过滤计算,或者使用OpenCL
同时对多个值执行复杂的数学计算。换言之,OpenCL
更多地针对可以并行操作数据的问题集。
尽管OpenCL
很适合执行大规模数据并行操作,但它不适合更通用的计算。准备数据和所需的工作内核并将其传输到图形卡需要大量的精力,以便可以通过GPU
对其进行操作。同样,检索OpenCL
生成的任何结果都需要大量的工作。因此,任何与系统交互的任务通常不建议与OpenCL
一起使用。例如,您不会使用OpenCL
来处理来自文件或网络流的数据。相反,使用OpenCL
执行的工作必须更加自成一体,这样才能将其传输到图形处理器并独立计算。
有关OpenCL
以及如何使用它的更多信息,请参阅OpenCL Programming Guide for Mac。
何时使用线程
虽然operation queues
和dispatch queues
是并发执行任务的首选方式,但它们不是万能的。根据应用程序的不同,有时可能仍需要创建自定义线程。如果你确实创建了自定义线程,那么你应该努力自己创建尽可能少的线程,并且你应该仅将这些线程用于无法以其他方式实现的特定任务。
线程仍然是实现必须实时运行的代码的好方法。Dispatch queues
会尽可能快地运行它们的任务,但不能解决实时约束问题。如果你需要从后台运行的代码中获得更可预测的行为,线程仍然可以提供更好的选择。
与任何线程编程一样,你应该始终明智地使用线程,并且只在绝对必要时使用线程。有关线程包以及如何使用它们的更多信息,请参阅Threading Programming Guide。