并发编程(二)并发和应用程序设计

在计算机的早期,计算机的每单位时间的最大工作量是由CPU的时钟速度决定的。但是随着技术的进步和处理器的设计变得更加紧凑,热和其他物理限制开始限制处理器的最大时钟速度。因此,芯片制造商寻找其他方法来提高芯片的总体性能。他们解决的解决方案是增加每个芯片上的处理器核心数量。通过增加核心的数量,一个芯片可以在不增加CPU速度或改变芯片尺寸或热特性的情况下每秒执行更多指令。唯一的问题是如何利用额外的核心。
为了利用多个核心,计算机需要可以同时执行多项操作的软件。 对于像OS X或iOS这样的现代多任务操作系统,可以在任何给定的时间运行一百个或更多的程序,因此在不同的核心上安排每个程序变得可能。 然而,这些程序中的大多数是系统守护进程或后台应用程序,它们的处理时间很少。 相反,真正需要的是个人应用程序更有效地利用额外的核心的方式。
应用程序使用多个内核的传统方式是创建多个线程。 然而,随着核心数量的增加,线程解决方案存在问题。 最大的问题是线程代码不会很好地扩展到任意数量的内核。 您不能创建与核心一样多的线程,并期望程序运行良好。 您需要知道的是可以有效使用的核心数量,这对于应用程序自行计算是一件很有挑战性的事情。 即使您设法使数字正确,仍然存在针对这么多线程进行编程的挑战,使它们有效运行,并防止它们彼此干扰。
因此,为了解决这个问题,需要有一种方法来让应用程序利用可变数量的计算机核心。单个应用程序执行的工作量还需要能够动态伸缩以适应不断变化的系统条件。而且解决方案必须足够简单,这样就不会增加利用这些核心所需的工作量。好消息是,苹果的操作系统提供了所有这些问题的解决方案,本章着眼于包含此解决方案的技术,以及您可以对您的代码进行优化的设计调整。

远离线程

虽然线程已经存在了很多年,并可继续使用,但它们不能解决可伸缩性执行多任务的一般问题。对于线程,创建可伸缩解决方案完全依赖于开发人员。你必须决定要创建多少线程,并随着系统条件的变化动态地调整该数字。另一个问题是,你的应用程序承担了创建和维护它所使用的任何线程而产生的大部分消耗。
OS X和IOS不依赖于线程,而是采用异步设计方法来解决并发问题。异步函数在操作系统中已经存在很多年了,通常用于启动可能需要很长时间的任务,例如从磁盘读取数据。当调用时,异步函数会在后台运行一些任务来启动一个任务,但在该任务实际上完成之前返回。通常情况下,这项工作需要获得一个后台线程,该线程上启动所需的任务,然后当任务完成会发送一个通知给调用者(通常是通过一个回调函数)。在过去,如果您想做的事情不存在一个异步函数,你不得不编写自己的异步函数并创建自己的线程。但是现在,OS X和IOS提供了技术,允许你异步地执行任何任务,而不必自己管理线程。
异步启动任务的技术之一是Grand Central Dispatch(GCD)。 该技术采用你通常在自己的应用程序中编写的线程管理代码,并将该代码移至系统级别。你所要做的就是去确定要执行的任务,并将它们添加到适当的调度队列。 GCD负责创建所需的线程,并调度您的任务以在这些线程上运行。 因为线程管理现在是系统的一部分,GCD为任务管理和执行提供了一个整体的方法,相较于传统线程提供了更好的效率。
操作队列是Objective-C对象,非常像调度队列。 你定义要执行的任务,然后将它们添加到操作队列中,该操作队列处理这些任务的调度和执行。 像GCD一样,操作队列为你处理所有的线程管理,确保在系统上尽可能快地执行任务。
以下部分提供有关调度队列,操作队列以及可在应用程序中使用的其他相关异步技术的更多信息。

调度队列

调度队列是基于C语言的用来执行自定义任务的机制。 调度队列可以串行或并行执行任务,但总是以先进先出的顺序执行。 (换句话说,调度队列总是按照任务被添加到队列中的顺序来从队列中取出并开始任务。)串行调度队列一次只运行一个任务,等待该任务完成,然后才能从队列中取出并启动新的一个任务。 相比之下,并发调度队列可以启动尽可能多的任务,而无需等待已启动的任务完成。
调度队列有以下好处:

  • 提供一个直观而简单的编程接口。
  • 提供自动和全面的线程池管理。
  • 提供了调谐组装的速度。
  • 内存更加高效(因为线程堆栈确实不在应用程序内存中留存)。
  • 不会在负载下陷入内核。
  • 将任务异步分派到调度队列不会使队列死锁。
  • 资源竞争下优雅的伸缩性
  • 串行队列提供了一个更加有效的替代锁和其他同步函数的方案。
    您提交到调度队列的任务必须封装在函数或块对象中。 块对象是在OS X v10.6和iOS 4.0中引入的C语言功能,在概念上与功能指针类似,但具有一些其他好处。 您通常在另一个函数或方法中定义块,而不是在自己的词法范围中定义块,以便它们可以从该函数或方法访问其他变量。 块也可以移出其原始作用域并复制到堆中,这是当你将其提交到调度队列时会发生的情况。 所有这些语法使得你可以使用相对较少的代码来实现非常动态的任务。
    调度队列是Grand Central Dispatch技术的一部分,也是C运行时的一部分。

调度源

调度源是基于C语言的、用于异步处理特定类型的系统事件的机制。 调度源封装有关特定类型的系统事件的信息,并在发生事件时将特定块对象或函数提交给调度队列。 您可以使用调度源来监视以下类型的系统事件:

  • 计时器
  • 信号处理
  • 描述符相关事件
  • 进程相关事件
  • 内核端口事件
  • 触发的自定义事件

操作队列

操作队列在OS X和iOS环境下等效于并发调度队列,由NSOperationQueue类实现。 而调度队列总是以先进先出的顺序执行任务,而在确定任务的执行顺序时,操作队列会考虑其他因素。 这些因素中的主要因素是指定任务是否取决于其他任务的完成。 在定义任务时配置依赖关系,并可以使用它们为您的任务创建复杂的执行顺序图。
提交到操作队列的任务必须是NSOperation类的实例。 一个操作对象是一个Objective-C对象,它封装了你要执行的工作和执行它所需的任何数据。 因为NSOperation类本质上是一个抽象基类,你通常定义自定义子类来执行你的任务。 但是,基础框架确实包含一些具体的子类,您可以按照执行任务来创建和使用它们。
操作对象生成键值观察(KVO)通知,这可以是监视任务进度的有用方法。 虽然操作队列总是同时执行操作,但是可以使用依赖关系来确保在需要的时候执行它们。

异步设计技术

在您甚至考虑重新设计代码以支持并发之前,您应该问自己是否需要这样做。 并发可以通过确保主线程可以自由地响应用户事件来提高代码的响应能力。 甚至可以通过利用更多核心在相同的时间内进行更多的工作来提高代码的效率。 然而,它也增加了开销,并增加了代码的整体复杂性,使得编写和调试代码变得更加困难。
因为增加了应用程序的复杂程度,并发不是可以在产品周期结束时移植到应用程序上的功能。 正确执行它需要仔细考虑你的应用程序执行的任务和用于执行这些任务的数据结构。 如果并发行完成的不正确,你可能会发现你的代码运行速度比以前更慢,并且对用户的响应更迟缓。 因此,在设计周期开始时需要花一些时间来设定一些目标,并考虑您需要采取的方法。
每个应用程序都有不同的要求和一系列不同的要执行的任务。 文档无法准确地告诉你如何设计应用程序及其相关任务。 但是,以下部分将尝试提供一些指导,帮助你在设计过程中做出更好的选择。

明确应用程序的预期行为

在考虑向应用程序添加并发性之前,应该首先定义你认为应用程序的正确行为。理解应用程序的预期行为为以后验证设计提供了一种方法。它还应该让你对通过引入并发性可能获得的预期性能好处有所了解。
你应该做的第一件事是列举你的应用程序执行的任务以及与每个任务相关联的对象和数据结构。 最初,您可能希望从用户选择菜单项或单击按钮时执行的任务开始。 这些任务是离散的行为,并具有明确的开始和结束点。 您还应该枚举您的应用程序可以在没有用户交互的情况下执行的其他类型的任务,例如基于定时器的任务。
在列出高级别任务之后,开始将每个任务进一步分解为为了成功完成任务必须采取的一系列步骤。 在这个级别上,你应该主要关心对任何数据结构和对象所需的修改,以及这些修改对应用程序的整体状态的影响。 你还应注意对象和数据结构之间的任何依赖关系。 例如,如果任务涉及对对象数组进行相同的更改,则值得注意的是对一个对象的更改是否与其他对象相似。如果对象可以相互独立地修改,那么可能是一个可以同时进行这些修改的地方。

分解可执行工作单元

从对应用程序任务的理解中,你应该已经能够识别代码可能从并发中受益的地方。如果更改任务中一个或多个步骤的顺序影响了结果,则可能需要连续执行这些步骤。但是,如果更改执行顺序对结果没有影响,则应该考虑同时执行这些步骤。在这两种情况下,你都可以定义工作的可执行单元,代表要执行的步骤。然后,这个工作单元将使用一个块或一个操作对象封装,并发送到相应的队列。
对于你识别的每个可执行工作单元,至少在最初不需要太担心正在执行的工作量。尽管运转线程总是有成本,但调度队列和操作队列的优点之一是在许多情况下,这些成本比传统线程要小得多。因此,你可以使用队列比使用线程更有效地执行较小的工作单元。当然,你应该始终衡量你的应用程序实际表现,并根据需要调整任务的大小,但最初,任务不应规划的太小。

确定需要的队列

现在,您的任务分解成不同的工作单元,并使用块对象或操作对象封装,你需要定义要用于执行该代码的队列。 对于给定的任务,检查你创建的块或操作对象及其执行顺序以正确执行任务。
如果使用块实现任务,则可以将块添加到串行或并发调度队列中。如果需要特定的顺序,则总是将块添加到串行调度队列中。如果不需要特定的顺序,你可以根据你的需要添加块到一个并发调度队列或将它们添加到几种不同的调度队列。
如果你使用操作对象执行任务,对象的配置会比队列的选择更加有趣。为了按顺序执行操作对象,你必须配置相关对象的依赖关系。依赖关系会阻止一个操作执行,直到它所依赖的对象完成工作。

提高效率的提示

除了简单地将代码分解成更小的任务并将其添加到队列中,还有其他方法可以使用队列来提高代码的整体效率:

  • 如果内存使用是一个影响因素,考虑在任务中直接计算值。如果应用程序已经内存绑定,现在直接计算值可能比从主存储器加载缓存值要快一些。计算值直接使用给定处理器核心的寄存器和缓存,它们比主存快得多。当然,只有在测试表明这是性能更好,才应该这样做。
  • 早期识别串行任务,并尽可能地使它们更加并发。 如果任务必须连续执行,因为它依赖于某些共享资源,请考虑更改架构以删除该共享资源。 你可以考虑为需要的每个客户端复制资源,或者完全消除资源。
  • 避免使用锁。 由调度队列和操作队列提供的支持使锁在大多数情况下是不必要的。 不要使用锁来保护某些共享资源,而是指定一个串行队列(或使用操作对象依赖关系)以正确的顺序执行任务。
  • 尽可能的依靠系统框架。 实现并发性的最佳方式是利用系统框架提供的内置并发功能。 许多框架在内部使用线程和其他技术来实现并发行为。 定义任务时,查看现有框架是否定义了一个完全符合你想要的功能或方法,确实能够并发执行。 使用该API可能会为你节省成本,更有可能为你提供最大的并发性。

性能意义

操作队列,调度队列和调度源,使你更轻松地实现更多代码的并发性。但是,这些技术并不能保证你的应用程序中能够提升效率或响应能力。你仍然有责任以满足你需求的方式使用队列,并且不会对应用程序的其他资源造成不必要的负担。例如,虽然你可以创建10,000个操作对象并将其提交到操作队列,但这样做可能会导致应用程序分配大量的内存,从而导致内存分页和性能降低。
在向你的代码引入任意数量的并发性之前,无论使用队列还是线程,你都应该总是收集一组反映应用程序当前性能的基准度量。在介绍你的更改后,你应该收集其他指标,并将其与你的基准进行比较,以查看应用程序的整体效率是否得到改善。如果引入并发性并没有提高你的应用程序效率或者响应能力,那么你应该使用可用的性能工具来检查潜在的原因。

并发和其他技术

将代码分解为模块化任务是尝试改进应用程序并发数量的最佳方式。 然而,这种设计方法可能无法满足每种应用的需要。 根据你的任务,可能还有其他选择可以在应用程序的整体并发性方面进一步改进。 本节概述了你作为设计的一部分考虑使用的其他一些技术。

OpenCL和并发

在OS X中,开放式计算语言(OpenCL)是一种基于标准的技术,用于在计算机的图形处理器上执行通用计算。如果你有一套精确定义的应用于大型数据集的计算,那么OpenCL是一种很好的技术。例如,你可以使用OpenCL对图像的像素执行过滤器计算,或者使用它来一次对多个值执行复杂的数学计算。换句话说,OpenCL更适合于数据可以并行运行的问题集。
虽然OpenCL很适合进行大规模数据并行操作,但不适用于更通用的计算。将一个数据和所需的工作内核都准备好并传送到一张图形卡上,以便在GPU上进行操作,需要有大量的工作量。同样地,检索OpenCL生成的任何结果都需要一个非常大的工作量。因此,与系统进行交互的任何任务通常不推荐用于OpenCL。例如,你不能使用OpenCL从文件或网络流中处理数据。相反,使用OpenCL执行的工作必须更加自包含,以便将其传输到图形处理器并独立计算。

什么时候使用线程

虽然操作队列和调度队列是并行任务的首选方式,但它们不是灵丹妙药。 根据你的应用程序,可能还需要创建自定义线程。 如果创建自定义线程,你应该创建尽可能少的线程,并且只有当以其他方式无法实现的特定任务的时候才能使用这些线程。
线程仍然是实现必须实时运行的代码的好方法。调度队列可以尽可能快地运行任务,但它们不处理实时约束。如果在后台运行的代码需要更多可预测的行为,线程可能仍然是更好的选择。
与任何线程编程一样,你应该审慎的使用线程,并且只有在绝对必要的时候。

你可能感兴趣的:(并发编程(二)并发和应用程序设计)