个性化阅读
专注于IT技术分析

Objective-C并发编程完全解读

前言

并发性是指多个事件同时发生的概念。随着多核cpu的普及以及每个处理器中内核数量的增加,软件开发人员需要新的方法来利用它们。尽管OS X和iOS等操作系统能够并行运行多个程序,但这些程序大多在后台运行,执行的任务几乎不需要连续的处理器时间。它是当前的前台应用程序,既吸引了用户的注意力,又让计算机保持忙碌。如果应用程序有很多工作要做,但只占用可用内核的一小部分,那么这些额外的处理资源就会被浪费。

在过去,将并发引入应用程序需要创建一个或多个额外的线程。不幸的是,编写线程代码很有挑战性。线程是一种必须手动管理的低级工具。考虑到应用程序的最佳线程数量可以根据当前系统负载和底层硬件动态更改,实现正确的线程解决方案变得非常困难,如果不是不可能实现的话。此外,通常与线程一起使用的同步机制增加了软件设计的复杂性和风险,却不能保证提高性能。

与传统的基于线程的系统和应用程序相比,OS X和iOS采用了更异步的方式来执行并发任务。应用程序不需要直接创建线程,只需定义特定的任务,然后让系统执行它们。通过让系统管理线程,应用程序获得了原始线程无法获得的可伸缩性级别。应用程序开发人员还可以获得更简单、更有效的编程模型。

本文档描述了你应该在应用程序中实现并发所使用的技术,本文档中描述的技术在OS X和iOS中都可用。

本文的主要内容组织

本文档包含以下章节:

  • 并发和应用程序设计:介绍异步应用程序设计的基础知识以及用于异步执行自定义任务的技术。
  • 操作队列:向你展示如何使用Objective-C对象封装和执行任务。
  • 调度队列:向你展示如何在基于C的应用程序中同时执行任务。
  • 调度源:向你展示如何异步处理系统事件。
  • 从线程迁移:提供了一些技巧和技术,可用于将现有的基于线程的代码迁移到新的技术上。

本文档还包括定义相关术语的词汇表。

术语说明

在开始讨论并发性之前,有必要定义一些相关的术语以防止混淆。熟悉UNIX系统或较早的OS X技术的开发人员可能会发现,在本文档中使用的术语“任务”、“进程”和“线程”有所不同。本文件以下列方式使用这些术语:

  • 术语“线程”用于指代码的独立执行路径(占有独立的栈空间),OS X中线程的底层实现是基于POSIX线程API的。
  • 进程这个术语是用来指一个正在运行的可执行程序,它可以包含多个线程。
  • 术语任务是指需要执行的工作的抽象概念。

有关本文档使用的这些术语和其他关键术语的完整定义,请参见术语表。

并发和应用程序设计

在早期的计算中,计算机每单位时间所能执行的最大工作量是由CPU的时钟速度决定的。但是随着技术的进步和处理器设计的更加紧凑,热量和其他物理约束开始限制处理器的最大时钟速度。因此,芯片制造商寻找其他方法来提高芯片的整体性能。他们的解决方案是增加每个芯片上的处理器核的数量。通过增加核心的数量,单个芯片每秒可以执行更多的指令,而不需要增加CPU速度或改变芯片大小或热特性。唯一的问题是如何利用额外的核心。

为了利用多核的优势,计算机需要能够同时做多件事情的软件。对于一个现代的多任务操作系统,如OS X或iOS,在任何给定的时间都可能有100个或更多的程序在运行,因此在不同的核心上调度每个程序应该是可能的。然而,大多数这些程序要么是系统守护进程,要么是占用很少实际处理时间的后台应用程序。相反,真正需要的是单个应用程序更有效地利用额外核心的方法。

应用程序使用多核的传统方法是创建多个线程。然而,随着内核数量的增加,线程解决方案也会出现问题。最大的问题是线程代码不能很好地扩展到任意数量的核心。你不可能创建尽可能多的线程,同时又期望程序运行良好。你需要知道的是可以有效使用的内核的数量,这对于应用程序自己计算来说是一件具有挑战性的事情。即使你设法得到正确的数字,仍然存在为如此多的线程进行编程、使它们有效地运行以及防止它们相互干扰的挑战。

因此,总结一下这个问题,需要有一种方法让应用程序利用可变数量的计算机核心。单个应用程序执行的工作量还需要能够动态伸缩,以适应不断变化的系统条件。解决方案必须足够简单,以避免增加利用这些核心所需要的工作量。好消息是,苹果的操作系统为所有这些问题提供了解决方案,本章将介绍构成该解决方案的技术,以及可以利用这些技术对代码进行的设计调整。

使用异步任务

尽管线程已经存在了许多年,并且还在继续使用,但是它们并没有解决以可伸缩的方式执行多个任务的一般问题。使用线程,创建可伸缩解决方案的重担完全落在开发人员的肩上。你必须决定要创建多少个线程,并随着系统条件的变化动态调整这些线程的数量。另一个问题是,你的应用程序承担了与创建和维护它所使用的任何线程相关的大部分成本。

OS X和iOS采用异步设计方法来解决并发问题,而不是依赖线程。异步函数在操作系统中已经存在多年,通常用于启动可能需要很长时间的任务,比如从磁盘读取数据。当调用时,异步函数在后台做一些工作来启动一个正在运行的任务,但是在该任务实际完成之前返回。通常,这项工作包括获取一个后台线程,在该线程上启动所需的任务,然后在任务完成时向调用者发送通知(通常通过回调函数)。在过去,如果一个异步函数不存在,那么你就必须编写自己的异步函数并创建自己的线程。但是现在,OS X和iOS提供了技术,允许你异步执行任何任务,而不必自己管理线程。

异步启动任务的技术之一是Grand Central Dispatch (GCD)。这种技术采用你通常在自己的应用程序中编写的线程管理代码,并将代码移动到系统级。你所要做的就是定义要执行的任务,并将它们添加到适当的分派队列中。GCD负责创建所需的线程,并调度在这些线程上运行的任务。因为线程管理现在是系统的一部分,所以GCD提供了一种全面的任务管理和执行方法,提供了比传统线程更好的效率。

操作队列是Objective-C对象,它们的行为与调度队列非常相似。你定义要执行的任务,然后将它们添加到操作队列中,操作队列将处理这些任务的调度和执行。与GCD一样,操作队列为你处理所有线程管理,确保在系统上尽可能快速和高效地执行任务。

以下部分提供了有关调度队列、操作队列和其他一些相关异步技术的更多信息,你可以在应用程序中使用这些技术。

调度队列

调度队列是用于执行自定义任务的基于c的机制。分派队列可以串行执行任务,也可以并发执行任务,但总是按照先入先出的顺序执行。(换句话说,调度队列总是按照将任务添加到队列中的相同顺序退出队列并启动任务。)串行调度队列一次只运行一个任务,直到该任务完成后才退出队列并启动一个新任务。相反,并发调度队列启动尽可能多的任务,而不必等待已经启动的任务完成。

调度队列还有其他好处:

  • 它们提供了一个简单直观的编程接口。
  • 它们提供自动的和整体的线程池管理。
  • 它们提供了调整装配的速度。
  • 它们具有更高的内存效率(因为线程栈不会在应用程序内存中逗留)。
  • 它们不会在负载下捕获内核。
  • 向调度队列发送任务的异步调度不能导致队列死锁。
  • 他们在竞争中从容地扩大规模。
  • 串行调度队列为锁和其他同步原语提供了更有效的替代方案。

提交给分派队列的任务必须封装在函数或块对象中。块对象是OS X v10.6和iOS 4.0中引入的C语言特性,在概念上类似于函数指针,但有一些额外的好处。你通常在另一个函数或方法中定义块,以便它们可以从该函数或方法访问其他变量,而不是在它们自己的词法范围中定义块。块也可以移出它们的原始范围并复制到堆中,这就是将它们提交到分派队列时所发生的事情。所有这些语义使得用相对较少的代码实现非常动态的任务成为可能。

调度队列是中央调度技术的一部分,也是C运行时的一部分。有关在应用程序中使用调度队列的更多信息,请参见调度队列。有关块及其优点的更多信息,请参见块编程主题。

调度资

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

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

调度源是大中央调度技术的一部分。有关在应用程序中使用调度源接收事件的信息,请参阅调度源。

操作队列

操作队列和并发调度队列相同,由NSOperationQueue类实现。尽管调度队列总是以先入先出的顺序执行任务,但操作队列在确定任务的执行顺序时,会考虑其他因素。这些因素中最主要的是一个给定的任务是否依赖于其他任务的完成。在定义任务时配置依赖项,可以使用它们为任务创建复杂的执行顺序图。

提交给操作队列的任务必须是NSOperation类的实例。操作对象是一个Objective-C对象,它封装了你想要执行的工作和任何需要执行它的数据。因为NSOperation类本质上是一个抽象基类,你通常定义自定义子类来执行你的任务。但是,Foundation框架确实包含一些具体的子类,你可以创建并使用它们来执行任务。

操作对象生成键值观察(KVO)通知,这是监视任务进度的一种有用方法。尽管操作队列总是并发执行操作,但你可以使用依赖项来确保它们在需要时串行执行。

有关如何使用操作队列以及如何定义自定义操作对象的更多信息,请参见操作队列。

异步设计技术

在考虑重新设计代码以支持并发之前,你应该问问自己这样做是否有必要。通过确保主线程可以自由响应用户事件,并发性可以提高代码的响应能力。它甚至可以通过利用更多的内核在相同的时间内完成更多的工作来提高代码的效率。但是,它也增加了开销,增加了代码的整体复杂性,使得编写和调试代码变得更加困难。

因为并发增加了复杂性,所以在产品周期结束时不能将其移植到应用程序上。正确地执行它需要仔细考虑应用程序执行的任务以及用于执行这些任务的数据结构。如果处理不当,你可能会发现你的代码运行速度比以前更慢,并且对用户的响应更慢。因此,在设计周期开始时花一些时间来设置一些目标并考虑需要采取的方法是值得的。

每个应用程序都有不同的需求和不同的任务集。文档不可能确切地告诉你如何设计应用程序及其相关任务。但是,下面几节将提供一些指导,帮助你在设计过程中做出正确的选择。

定义应用程序的预期行为

在考虑将并发性添加到应用程序之前,应该首先定义应用程序的正确行为。理解应用程序的预期行为为你以后验证设计提供了一种方法。它还应该让你了解通过引入并发性可能获得的预期性能好处。

应该做的第一件事是枚举应用程序执行的任务以及与每个任务相关联的对象或数据结构。最初,你可能希望从用户选择菜单项或单击按钮时执行的任务开始。这些任务提供了离散的行为,并有一个定义良好的起点和终点。你还应该枚举应用程序在没有用户交互的情况下可能执行的其他类型的任务,例如基于计时器的任务。

有了高级任务列表之后,开始将每个任务进一步分解为一系列必须采取的步骤,以成功地完成任务。在这个层次上,应该主要关注需要对任何数据结构和对象进行的修改,以及这些修改如何影响应用程序的总体状态。还应该注意对象和数据结构之间的任何依赖关系。例如,如果一个任务涉及到对一个对象数组进行相同的更改,那么对一个对象的更改是否会影响到任何其他对象是值得注意的。如果对象可以独立于其他对象进行修改,则可以同时进行这些修改。

提出可执行的工作单元

从你对应用程序任务的理解来看,应该已经能够确定代码可能从并发性中受益的地方。如果更改任务中一个或多个步骤的顺序会更改结果,则可能需要连续执行这些步骤。但是,如果更改顺序对输出没有影响,则应该考虑同时执行这些步骤。在这两种情况下,都定义了表示一个或多个要执行的步骤的可执行工作单元。然后,这个工作单元将成为你使用块或操作对象封装并分派到适当队列的内容。

对于你所标识的每个可执行的工作单元,不要太担心正在执行的工作量,至少在开始阶段不要太担心。尽管启动一个线程总是要付出代价,但分派队列和操作队列的优点之一是,在许多情况下,这些代价比传统线程要小得多。因此,使用队列可以比使用线程更有效地执行更小的工作单元。当然,你应该经常衡量自己的实际表现,并根据需要调整任务的大小,但最初,任何任务都不应该被认为太小。

确定需要的队列

现在你的任务被分解为不同的工作单元,并使用块对象或操作对象封装,你需要定义将要用来执行代码的队列。对于给定的任务,请检查你创建的块或操作对象,以及为了正确执行任务而必须执行它们的顺序。

如果使用块实现任务,可以将块添加到串行或并发调度队列。如果需要特定的顺序,则总是将块添加到串行调度队列。如果不需要特定的顺序,你可以将这些块添加到并发调度队列中,或者根据需要将它们添加到几个不同的调度队列中。

如果使用操作对象实现任务,则队列的选择通常不如对象的配置有趣。要连续执行操作对象,必须配置相关对象之间的依赖关系。依赖项阻止一个操作执行,直到它所依赖的对象完成其工作。

提高效率的建议

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

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

性能影响

提供操作队列、调度队列和调度源,使你更容易并发地执行更多代码。但是,这些技术并不能保证提高应用程序的效率或响应能力。你仍然有责任以一种既能满足你的需要又不会对应用程序的其他资源造成不适当负担的方式来使用队列。例如,尽管可以创建10,000个操作对象并将它们提交到一个操作队列,但是这样做会导致应用程序分配大量的内存,这可能会导致分页和性能下降。

在向代码中引入任何数量的并发性(无论是使用队列还是线程)之前,你应该始终收集一组反映应用程序当前性能的基线指标。在引入更改之后,你应该收集其他指标,并将它们与基线进行比较,以查看应用程序的总体效率是否得到了提高。如果并发性的引入降低了应用程序的效率或响应速度,那么应该使用可用的性能工具来检查潜在的原因。

有关性能和可用性能工具的介绍,以及到更高级的与性能相关主题的链接,请参见性能概述。

并发和其他技术

将代码分解成模块化任务是尝试和改进应用程序中并发性的最佳方法。然而,这种设计方法可能不能满足每种情况下每个应用程序的需要。根据任务的不同,可能还有其他选项可以提供应用程序总体并发性的额外改进。本节概述了其他一些可以考虑作为设计一部分使用的技术。

OpenCL和并发性

在OS X中,开放计算语言(OpenCL)是一种基于标准的技术,用于在计算机的图形处理器上执行通用计算。如果你希望将一组定义良好的计算应用于大型数据集,那么OpenCL是一种很好的技术。例如,你可以使用OpenCL对图像的像素执行过滤计算,或者使用它同时对多个值执行复杂的数学计算。换句话说,OpenCL更适合那些数据可以并行操作的问题集。

尽管OpenCL很适合执行大规模数据并行操作,但它不适合更通用的计算。准备和将数据和所需的工作内核传输到显卡上,以便显卡能够被GPU操作,这需要大量的工作。类似地,检索OpenCL生成的任何结果也需要大量的工作。因此,任何与系统交互的任务通常都不推荐与OpenCL一起使用。例如,不会使用OpenCL来处理来自文件或网络流的数据。相反,使用OpenCL执行的工作必须更加自包含,这样才能将其传输到图形处理器并独立计算。

有关OpenCL以及如何使用它的更多信息,请参阅Mac的OpenCL编程指南。

何时使用线程

尽管操作队列和调度队列是并发执行任务的首选方式,但它们并不是万能的。根据应用程序的不同,可能仍然需要创建自定义线程。如果你确实创建了自定义线程,应该努力自己创建尽可能少的线程,并且应该仅将这些线程用于无法通过任何其他方式实现的特定任务。

线程仍然是实现必须实时运行的代码的好方法。调度队列尽可能快地运行它们的任务,但它们不能解决实时约束。如果你需要从后台运行的代码中获得更多可预测的行为,线程可能仍然提供更好的选择。

与任何线程编程一样,应该始终明智地使用线程,并且只在绝对必要时使用。有关线程包和如何使用它们的更多信息,请参阅线程编程指南。

操作队列

Cocoa操作是一种面向对象的方法,用于封装希望异步执行的工作。操作可以与操作队列一起使用,也可以单独使用。因为它们是基于Objective-C的,所以操作在OS X和iOS中最常用于基于cocoa的应用程序中。

下面展示如何定义和使用操作。

关于操作对象

操作对象是一个NSOperation类的实例(在Foundation框架中),你使用它来封装你想要你的应用执行的工作。NSOperation类本身是一个抽象基类,为了做任何有用的工作,它必须被子类化。尽管这个类是抽象的,但它确实提供了大量的基础设施来最小化你必须在自己的子类中完成的工作。此外,Foundation框架提供了两个具体的子类,你可以在现有代码中按原样使用它们。表2-1列出了这些类,并总结了如何使用它们。

表2-1基础框架的操作类别

ClassDescription
NSInvocationOperation你按原样使用的类,用于根据应用程序中的对象和选择器创建操作对象。可以在已有方法已经执行所需任务的情况下使用该类。因为它不需要子类化,所以你也可以使用这个类以更动态的方式创建操作对象。
有关如何使用该类的信息,请参见创建NSInvocationOperation对象。
NSBlockOperation 按原样使用的类,用于并发地执行一个或多个块对象。由于可以执行多个块,所以块操作对象使用组语义进行操作;只有当所有相关的块都已执行完毕时,操作才被认为完成了。
有关如何使用这个类的信息,请参见创建一个NSBlockOperation对象。这个类在OS X v10.6或更高版本中可用。有关块的更多信息,请参见块编程主题。
NSOperation 定义自定义操作对象的基类。子类化NSOperation可以让你完全控制自己操作的实现,包括改变操作执行和报告状态的默认方式。
有关如何定义自定义操作对象的信息,请参见定义自定义操作对象。

所有操作对象都支持以下关键特性:

  • 支持在操作对象之间建立基于图的依赖关系。这些依赖项阻止给定的操作运行,直到它所依赖的所有操作都已运行完毕。有关如何配置依赖项的信息,请参见配置互操作依赖项。
  • 支持可选的完成块,它在操作的主要任务完成后执行。(OS X v10.6及以后版本)有关如何设置完成块的信息,请参见设置完成块。
  • 支持使用KVO通知监视操作执行状态的变化。有关如何观察KVO通知的信息,请参阅键值观察编程指南。
  • 支持操作的优先次序,从而影响它们的相对执行顺序。有关更多信息,请参见更改操作的执行优先级。
  • 支持取消语义,允许在操作执行时暂停操作。有关取消操作的信息,请参阅取消操作。有关如何在自己的操作中支持取消的信息,请参见响应取消事件。

操作旨在帮助你提高应用程序中的并发级别。操作也是将应用程序的行为组织和封装为简单的离散块的好方法。不需要在应用程序的主线程上运行一些代码,你可以将一个或多个操作对象提交到一个队列,并让相应的工作在一个或多个独立的线程上异步执行。

并发操作与非并发操作

尽管通常通过将操作添加到操作队列来执行操作,但并不需要这样做。也可以通过调用操作对象的start方法来手动执行操作对象,但是这样做并不能保证操作与代码的其余部分同时运行。NSOperation类的isConcurrent方法告诉你一个操作是与调用它的start方法的线程同步运行还是异步运行。默认情况下,该方法返回NO,这意味着操作在调用线程中同步运行。

如果你想实现一个并发操作(即相对于调用线程异步运行的操作),你必须编写额外的代码来异步启动操作。例如,你可能会产生一个单独的线程,调用一个异步系统函数,或者执行任何其他操作来确保start方法启动任务并立即返回,并且很可能在任务完成之前返回。

大多数开发人员不应该需要实现并发操作对象。如果总是将操作添加到操作队列,则不需要实现并发操作。当你向操作队列提交非并发操作时,队列本身会创建一个线程,在该线程上运行你的操作。因此,向操作队列添加非并发操作仍然会导致操作对象代码的异步执行。只有在需要异步执行操作而不需要将其添加到操作队列的情况下,才需要定义并发操作。

有关如何创建并发操作的信息,请参见为并发执行和NSOperation类引用配置操作。

创建一个NSInvocationOperation对象

NSInvocationOperation类是NSOperation的一个具体子类,它在运行时调用你在你指定的对象上指定的选择器。使用此类可避免为应用程序中的每个任务定义大量自定义操作对象;特别是如果你正在修改现有的应用程序,并且已经拥有执行必要任务所需的对象和方法。你还可以在希望调用的方法可以根据情况更改时使用它。例如,可以使用调用操作来执行根据用户输入动态选择的选择器。

创建调用操作的过程非常简单。创建并初始化类的新实例,将所需的对象和选择器传递给初始化方法执行。清单2-1展示了一个自定义类的两个方法,它们演示了创建过程。方法taskWithData:创建一个新的调用对象,并为其提供另一个方法的名称,该方法包含任务实现。

清单2-1创建一个NSInvocationOperation对象

@implementation MyCustomClass
- (NSOperation*)taskWithData:(id)data {
    NSInvocationOperation* theOp = [[NSInvocationOperation alloc] initWithTarget:self
                    selector:@selector(myTaskMethod:) object:data];
 
   return theOp;
}
 
// This is the method that does the actual work of the task.
- (void)myTaskMethod:(id)data {
    // Perform the task.
}
@end

创建一个NSBlockOperation对象

NSBlockOperation类是NSOperation的一个具体子类,充当一个或多个块对象的包装。该类为已经使用操作队列且不希望创建分派队列的应用程序提供了面向对象的包装器。还可以使用块操作来利用操作依赖、KVO通知和其他可能与调度队列不可用的特性。

创建块操作时,通常在初始化时添加至少一个块;稍后你可以根据需要添加更多的块。当需要执行NSBlockOperation对象时,该对象将其所有块提交给默认优先级的并发调度队列。然后对象等待,直到所有的块完成执行。当最后一个块完成执行时,操作对象将自己标记为已完成。因此,可以使用块操作跟踪一组执行的块,这与使用线程连接合并来自多个线程的结果非常相似。不同之处在于,由于块操作本身在单独的线程上运行,所以应用程序的其他线程可以在等待块操作完成的同时继续工作。

清单2-2展示了如何创建NSBlockOperation对象的简单示例。块本身没有参数,也没有显著的返回结果。

清单2-2创建一个NSBlockOperation对象

NSBlockOperation* theOp = [NSBlockOperation blockOperationWithBlock: ^{
      NSLog(@"Beginning operation.\n");
      // Do some work.
   }];

创建块操作对象之后,可以使用addExecutionBlock:方法向其添加更多块。如果需要连续执行块,则必须将它们直接提交到所需的分派队列。

定义自定义操作对象

如果块操作和调用操作对象不完全满足你的应用程序的需要,可以直接子类化NSOperation并添加需要的任何行为。NSOperation类为所有操作对象提供一个通用的子类化点。该类还提供了大量的基础设施来处理依赖项和KVO通知所需的大部分工作。然而,有时可能仍然需要补充现有的基础设施,以确保你的操作正确运行。你需要做的额外工作取决于是实现非并发操作还是并发操作。

定义非并发操作比定义并发操作简单得多。对于非并发操作,你所要做的就是执行主要任务并适当地响应取消事件;现有的类基础结构为你完成所有其他工作。对于并发操作,必须使用自定义代码替换一些现有基础结构。下面的小节将向你展示如何实现这两种类型的对象。

执行主要任务

每个操作对象至少应该实现以下方法:

  • 自定义初始化方法
  • main

需要一个自定义初始化方法来将操作对象置于已知状态,并需要一个自定义主方法来执行任务。当然,你可以根据需要实现其他方法,如以下方法:

  • 你计划从主方法的实现中调用的自定义方法
  • 用于设置数据值和访问操作结果的访问器方法
  • NSCoding协议的方法,允许你对操作对象进行存档和反存档

清单2-3显示了一个自定义NSOperation子类的起始模板。(此清单不显示如何处理取消,但显示了通常具有的方法。有关处理取消的信息,请参阅对取消事件的响应。)该类的初始化方法将单个对象作为数据参数,并将对它的引用存储在操作对象中。在将结果返回给应用程序之前,主方法表面上会处理该数据对象。

清单2-3定义了一个简单的操作对象

@interface MyNonConcurrentOperation : NSOperation
@property id (strong) myData;
-(id)initWithData:(id)data;
@end
 
@implementation MyNonConcurrentOperation
- (id)initWithData:(id)data {
   if (self = [super init])
      myData = data;
   return self;
}
 
-(void)main {
   @try {
      // Do some work on myData and report the results.
   }
   @catch(...) {
      // Do not rethrow exceptions.
   }
}
@end

有关如何实现NSOperation子类的详细示例,请参见NSOperationSample。

响应取消事件

操作开始执行后,它将继续执行其任务,直到完成或直到你的代码显式地取消操作。取消可以在任何时候发生,甚至在操作开始执行之前。虽然NSOperation类为客户提供了一种取消操作的方法,但识别取消事件是自愿的,是必要的。如果一个操作被完全终止,可能就没有办法回收已经分配的资源。因此,操作对象应该检查取消事件,并在操作过程中优雅地退出。

要支持操作对象中的取消操作,你所要做的就是从自定义代码中定期调用该对象的iscancel方法,如果它返回YES,则立即返回。支持取消是重要的,不管你的操作的持续时间,或者你直接子类化NSOperation还是使用它的一个具体子类。iscancel方法本身是非常轻量级的,可以频繁地调用它,而不会带来显著的性能损失。当设计你的操作对象时,你应该考虑在你的代码中的以下地方调用iscancel方法:

  • 在你执行任何实际工作之前
  • 在循环的每次迭代中至少一次,如果每次迭代相对较长,则更频繁
  • 在你的代码中的任何一个相对容易中止操作的地方

清单2-4提供了一个非常简单的示例,演示如何在操作对象的主方法中响应取消事件。在这种情况下,每次通过while循环调用iscancel方法,允许在工作开始之前快速退出,然后定期再次调用。

清单2-4响应取消请求

- (void)main {
   @try {
      BOOL isDone = NO;
 
      while (![self isCancelled] && !isDone) {
          // Do some work and set isDone to YES when finished
      }
   }
   @catch(...) {
      // Do not rethrow exceptions.
   }
}

尽管前面的示例不包含清理代码,但是你自己的代码应该确保释放由你的自定义代码分配的所有资源。

为并发执行配置操作

Operation对象默认以同步方式执行——也就是说,它们在调用其start方法的线程中执行其任务。因为操作队列为非并发操作提供了线程,所以大多数操作仍然异步运行。但是,如果你计划手动执行操作,并且仍然希望它们异步运行,则必须采取适当的操作来确保它们这样做。通过将操作对象定义为并发操作,可以做到这一点。

表2-2列出了为实现并发操作而通常覆盖的方法。

表2-2用于覆盖并发操作的方法

MethodDescription
start(必选)所有的并发操作都必须覆盖此方法,并用它们自己的自定义实现替换默认行为。要手动执行操作,可以调用它的start方法。因此,此方法的实现是操作的起点,也是设置执行任务的线程或其他执行环境的地方。你的实现不能在任何时候调用super。
main(可选)此方法通常用于实现与操作对象关联的任务。虽然可以在start方法中执行任务,但是使用此方法实现任务可以使设置和任务代码更清晰地分离。
isExecuting isFinished并发操作负责设置它们的执行环境,并向外部客户报告环境的状态。因此,并发操作必须维护一些状态信息,以了解何时执行任务以及何时完成任务。然后它必须使用这些方法报告状态。从其他线程同时调用这些方法的实现必须是安全的。在更改这些方法报告的值时,还必须为预期的键路径生成适当的KVO通知。
isConcurrent(Required)若要将某个操作标识为并发操作,请覆盖此方法并返回YES。

本节的其余部分展示了MyOperation类的示例实现,它演示了实现并发操作所需的基本代码。MyOperation类只是在它创建的单独线程上执行它自己的主方法。主方法执行的实际工作是不相关的。示例的目的是演示在定义并发操作时需要提供的基础设施。

清单2-5显示了MyOperation类的接口和部分实现。MyOperation类的isConcurrent、isexecution和isFinished方法的实现相对简单。isConcurrent方法应该简单地返回YES,以表明这是一个并发操作。isexecution和isFinished方法简单地返回存储在类本身的实例变量中的值。

清单2-5定义了一个并发操作

@interface MyOperation : NSOperation {
    BOOL        executing;
    BOOL        finished;
}
- (void)completeOperation;
@end
 
@implementation MyOperation
- (id)init {
    self = [super init];
    if (self) {
        executing = NO;
        finished = NO;
    }
    return self;
}
 
- (BOOL)isConcurrent {
    return YES;
}
 
- (BOOL)isExecuting {
    return executing;
}
 
- (BOOL)isFinished {
    return finished;
}
@end

清单2-6显示了MyOperation的start方法。此方法的实现很少,以便演示必须执行的任务。在本例中,该方法只是启动一个新线程并将其配置为调用main方法。该方法还更新executing成员变量,并为isExecuting生成KVO通知,以反映该值中的更改。完成工作后,此方法将返回,让新分离的线程执行实际任务。

清单2-6 start方法

- (void)start {
   // Always check for cancellation before launching the task.
   if ([self isCancelled])
   {
      // Must move the operation to the finished state if it is canceled.
      [self willChangeValueForKey:@"isFinished"];
      finished = YES;
      [self didChangeValueForKey:@"isFinished"];
      return;
   }
 
   // If the operation is not canceled, begin executing the task.
   [self willChangeValueForKey:@"isExecuting"];
   [NSThread detachNewThreadSelector:@selector(main) toTarget:self withObject:nil];
   executing = YES;
   [self didChangeValueForKey:@"isExecuting"];
}

清单2-7显示了MyOperation类的其余实现。如清单2-6所示,主方法是新线程的入口点。它执行与操作对象关联的工作,并在工作最终完成时调用自定义的completeOperation方法。然后,completeOperation方法为isexecution和isFinished键路径生成所需的KVO通知,以反映操作状态的变化。

清单2-7在完成时更新操作

- (void)main {
   @try {
 
       // Do the main work of the operation here.
 
       [self completeOperation];
   }
   @catch(...) {
      // Do not rethrow exceptions.
   }
}
 
- (void)completeOperation {
    [self willChangeValueForKey:@"isFinished"];
    [self willChangeValueForKey:@"isExecuting"];
 
    executing = NO;
    finished = YES;
 
    [self didChangeValueForKey:@"isExecuting"];
    [self didChangeValueForKey:@"isFinished"];
}

即使某个操作被取消,你也应该始终通知KVO观察员,你的操作现在已经完成了。当操作对象依赖于其他操作对象的完成时,它将监视这些对象的isFinished键路径。只有当所有对象报告它们已经完成时,依赖操作才会发出准备运行的信号。因此,无法生成finish通知可能会阻止应用程序中其他操作的执行。

执行操作

最后,你的应用程序需要执行操作来完成相关的工作。在本节中,你将学习几种执行操作的方法,以及如何在运行时操作操作。

将操作添加到操作队列

到目前为止,执行操作最简单的方法是使用操作队列,它是NSOperationQueue类的一个实例。你的应用程序负责创建和维护它打算使用的任何操作队列。一个应用程序可以有任意数量的队列,但是在给定的时间点执行多少操作是有实际限制的。操作队列与系统一起工作,将并发操作的数量限制为适合可用内核和系统负载的值。因此,创建额外的队列并不意味着可以执行额外的操作。

要创建一个队列,你可以在你的应用程序中分配它,就像你分配其他对象一样:

NSOperationQueue* aQueue = [[NSOperationQueue alloc] init];

要将操作添加到队列,可以使用addOperation:方法。在OS X v10.6及以后版本中,你可以使用addOperations:waitUntilFinished:方法添加操作组,或者使用addOperationWithBlock:方法直接将块对象添加到队列中(没有相应的操作对象)。每个方法对一个操作(或多个操作)进行排队,并通知队列应该开始处理它们。在大多数情况下,操作是在添加到队列后不久执行的,但是操作队列可能由于以下原因而延迟队列操作的执行。具体来说,如果队列操作依赖于尚未完成的其他操作,则执行可能会延迟。如果操作队列本身被挂起或已经在执行其最大并发操作数,则执行也可能被延迟。下面的示例展示了向队列添加操作的基本语法。

[aQueue addOperation:anOp]; // Add a single operation
[aQueue addOperations:anArrayOfOps waitUntilFinished:NO]; // Add multiple operations
[aQueue addOperationWithBlock:^{
   /* Do something. */
}];

重要:

在将操作对象添加到队列之前,你应该对其进行所有必要的配置和修改,因为一旦添加了操作对象,操作可能会在任何时候运行,这对于更改来说可能为时已晚,无法达到预期的效果。

虽然NSOperationQueue类是为操作的并发执行而设计的,但可以强制单个队列一次只运行一个操作。方法允许你为操作队列对象配置最大并发操作数。将值1传递给此方法将导致队列一次只执行一个操作。虽然一次只能执行一个操作,但是执行的顺序仍然基于其他因素,例如每个操作的准备情况及其分配的优先级。因此,在Grand Central dispatch中,串行操作队列提供的行为与串行调度队列提供的行为不同。如果操作对象的执行顺序对你很重要,那么在将操作添加到队列之前,你应该使用依赖项来建立该顺序。有关配置依赖项的信息,请参见配置互操作依赖项。

有关使用操作队列的信息,请参见NSOperationQueue类引用。有关串行调度队列的更多信息,请参见创建串行调度队列。

执行手动操作

虽然操作队列是运行操作对象的最方便的方式,但是也可以在没有队列的情况下执行操作。但是,如果你选择手动执行操作,则应该在代码中采取一些预防措施。特别是,操作必须准备好运行,并且必须始终使用其start方法启动它。

在isReady方法返回YES之前,操作不能运行。isReady方法被集成到NSOperation类的依赖项管理系统中,以提供操作依赖项的状态。只有当其依赖项被清除时,操作才可以开始执行。

在手动执行操作时,应该始终使用start方法来开始执行。使用这个方法,而不是main或其他方法,因为start方法在实际运行的自定义代码之前执行了几个安全检查。特别是,默认的start方法生成操作需要正确处理依赖项的KVO通知。如果你的操作已经被取消,那么这个方法还可以正确地避免执行你的操作,如果你的操作实际上还没有准备好运行,则会抛出一个异常。

如果你的应用程序定义了并发操作对象,那么你还应该考虑在启动操作之前调用操作的isConcurrent方法。在此方法返回NO的情况下,你的本地代码可以决定是在当前线程中同步执行操作,还是先创建一个单独的线程。然而,实现这种检查完全取决于你。

清单2-8显示了手动执行操作之前应该执行的检查类型的简单示例。如果该方法返回NO,你可以调度一个计时器,稍后再调用该方法。然后,你将继续重新调度计时器,直到方法返回YES,因为操作已被取消,所以可能会发生这种情况。

清单2-8手动执行操作对象

- (BOOL)performOperation:(NSOperation*)anOp
{
   BOOL        ranIt = NO;
 
   if ([anOp isReady] && ![anOp isCancelled])
   {
      if (![anOp isConcurrent])
         [anOp start];
      else
         [NSThread detachNewThreadSelector:@selector(start)
                   toTarget:anOp withObject:nil];
      ranIt = YES;
   }
   else if ([anOp isCancelled])
   {
      // If it was canceled before it was started,
      //  move the operation to the finished state.
      [self willChangeValueForKey:@"isFinished"];
      [self willChangeValueForKey:@"isExecuting"];
      executing = NO;
      finished = YES;
      [self didChangeValueForKey:@"isExecuting"];
      [self didChangeValueForKey:@"isFinished"];
 
      // Set ranIt to YES to prevent the operation from
      // being passed to this method again in the future.
      ranIt = YES;
   }
   return ranIt;
}

调度队列

Grand Central Dispatch (GCD)调度队列是执行任务的强大工具。调度队列允许你调用方异步或同步地执行任意代码块。你可以使用分派队列来执行几乎所有在单独线程上执行的任务。分派队列的优点是,与相应的线程代码相比,它们使用起来更简单,执行这些任务的效率也更高。

本章介绍了调度队列,以及如何使用它们来执行应用程序中的一般任务。如果你想用调度队列替换现有的线程代码,你可以找到一些关于如何从线程迁移到其他线程的附加技巧。

关于调度队列

分派队列是在应用程序中异步并发执行任务的一种简单方法。任务就是应用程序需要执行的一些工作。例如,你可以定义一个任务来执行一些计算、创建或修改一个数据结构、处理从文件中读取的一些数据,或者其他任何事情。通过将相应的代码放在函数或块对象中并将其添加到分派队列中,可以定义任务。

分派队列是一种类似对象的结构,用于管理提交给它的任务。所有调度队列都是先入先出的数据结构。因此,添加到队列中的任务总是按照它们被添加的顺序启动。GCD自动为你提供一些分派队列,但是你可以为特定的目的创建其他队列。表3-1列出了应用程序可用的调度队列类型以及如何使用它们。

表3-1调度队列类型

TypeDescription
Serial 串行队列(也称为私有调度队列)按将任务添加到队列的顺序一次执行一个任务。当前正在执行的任务运行在由分派队列管理的不同线程上(不同任务之间可能有所不同)。串行队列通常用于同步对特定资源的访问。
你可以根据需要创建任意数量的串行队列,并且每个队列相对于所有其他队列并发操作。换句话说,如果创建四个串行队列,每个队列一次只执行一个任务,但最多可以同时执行四个任务,每个队列执行一个任务。有关如何创建串行队列的信息,请参阅创建串行调度队列。
Concurrent并发队列(也称为全局调度队列的一种)并发地执行一个或多个任务,但是任务仍然按照它们被添加到队列中的顺序启动。当前正在执行的任务运行在由分派队列管理的不同线程上。在任何给定点上执行的任务的确切数量是可变的,并且取决于系统条件。
在ios5及以后版本中,你可以通过指定DISPATCH_QUEUE_CONCURRENT作为队列类型来创建并发调度队列。此外,还有四个预定义的全局并发队列供你的应用程序使用。有关如何获取全局并发队列的更多信息,请参见获取全局并发调度队列。
Main dispatch queue主调度队列是一个全局可用的串行队列,它在应用程序的主线程上执行任务。此队列与应用程序的运行循环(如果存在一个)一起工作,将队列任务的执行与附加到运行循环的其他事件源的执行交织在一起。因为它运行在应用程序的主线程上,所以主队列通常用作应用程序的关键同步点。
虽然不需要创建主调度队列,但需要确保应用程序适当地耗尽它。有关如何管理此队列的更多信息,请参见在主线程上执行任务。

使用块实现任务

块对象是一种基于C的语言特性,可以在C、Objective-C和c++代码中使用。块使得定义一个自包含的工作单元变得很容易。虽然它们看起来类似于函数指针,但块实际上是由底层数据结构表示的,它类似于对象,由编译器为你创建和管理。编译器打包你提供的代码(以及任何相关的数据),并将其封装为一种可以驻留在堆中并在应用程序中传递的形式。

块的主要优点之一是它们能够从自己的词法范围之外使用变量。当你在函数或方法中定义一个块时,该块在某些方面就像传统的代码块一样。例如,一个块可以读取父范围中定义的变量的值。块访问的变量被复制到堆上的块数据结构中,以便块以后可以访问它们。当将块添加到分派队列时,这些值通常必须以只读格式保留。然而,同步执行的块也可以使用预先设置了关键字block的变量来将数据返回到父类的调用范围。

你可以使用与函数指针语法类似的语法在代码内联声明块。块和函数指针之间的主要区别是块名前面有一个插入符号(^),而不是星号(*)。与函数指针一样,可以将参数传递给块并从中接收返回值。清单3-1展示了如何在代码中同步声明和执行块。变量aBlock被声明为一个接受单个整数参数且不返回值的块。然后将匹配该原型的实际块分配给aBlock并内联声明。最后一行立即执行块,将指定的整数打印到标准输出。

清单3-1是一个简单的块示例

int x = 123;
int y = 456;
 
// Block declaration and assignment
void (^aBlock)(int) = ^(int z) {
    printf("%d %d %d\n", x, y, z);
};
 
// Execute the block
aBlock(789);   // prints: 123 456 789

创建和管理调度队列

在将任务添加到队列之前,必须决定使用哪种类型的队列以及打算如何使用它。调度队列可以串行执行任务,也可以并发执行任务。此外,如果你考虑到队列的特定用途,你可以相应地配置队列属性。下面几节将向你展示如何创建分派队列并配置它们以供使用。

获取全局并发调度队列

当你有多个可以并行运行的任务时,并发调度队列非常有用。并发队列仍然是一个队列,因为它以先入先出的顺序对任务进行排队;然而,并发队列可以在任何以前的任务完成之前对其他任务进行排队。并发队列在任何给定时刻执行的实际任务数量是可变的,并且可以随着应用程序中的条件变化而动态更改。许多因素会影响并发队列执行的任务数量,包括可用内核的数量、其他进程正在执行的工作量以及其他串行调度队列中的任务数量和优先级。

系统为每个应用程序提供四个并发调度队列。这些队列对应用程序来说是全局的,仅根据它们的优先级进行区分。因为它们是全局的,所以不需要显式地创建它们。相反,使用dispatch_get_global_queue函数请求一个队列,如下面的示例所示:

dispatch_queue_t aQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

除了默认的并发队列,你也可以指定高和低优先级队列通过DISPATCH_QUEUE_PRIORITY_HIGH和DISPATCH_QUEUE_PRIORITY_LOW常数函数,或得到一个后台队列通过DISPATCH_QUEUE_PRIORITY_BACKGROUND常数。正如你所预期的,高优先级并发队列中的任务比默认队列和低优先级队列中的任务执行得早。类似地,默认队列中的任务在低优先级队列中的任务之前执行。

注意:dispatch_get_global_queue函数的第二个参数保留给将来的扩展。现在,你应该始终为这个参数传递0。

尽管分派队列是引用计数的对象,但不需要保留和释放全局并发队列。因为它们是应用程序的全局调用,所以对这些队列的保留和释放调用将被忽略。因此,你不需要存储对这些队列的引用。你可以在需要引用dispatch_get_global_queue函数时调用它。

创建串行调度队列

当你希望任务按特定顺序执行时,串行队列非常有用。串行队列一次只执行一个任务,并且总是从队列的头部提取任务。你可以使用串行队列而不是锁来保护共享资源或可变数据结构。与锁不同,串行队列确保以可预测的顺序执行任务。只要你异步地将任务提交到串行队列,队列就永远不会死锁。

与为你创建的并发队列不同,你必须显式地创建和管理你想要使用的任何串行队列。你可以为你应用程序创建任意数量的串行队列,但是应该避免创建大量的串行队列,这仅仅是为了同时执行尽可能多的任务。如果希望并发执行大量任务,请将它们提交到全局并发队列之一。在创建串行队列时,请尝试确定每个队列的用途,如保护资源或同步应用程序的某些关键行为。

清单3-2显示了创建自定义串行队列所需的步骤。dispatch_queue_create函数接受两个参数:队列名称和一组队列属性。调试器和性能工具将显示队列名称,以帮助你跟踪任务的执行方式。队列属性保留供将来使用,应该为NULL。

清单3-2创建一个新的串行队列

dispatch_queue_t queue;
queue = dispatch_queue_create("com.example.MyQueue", NULL);

除了创建的任何自定义队列外,系统还自动创建一个串行队列并将其绑定到应用程序的主线程。有关为主线程获取队列的更多信息,请参见在运行时获取公共队列。

在运行时获取公共队列

Grand Central Dispatch提供的函数允许你从应用程序访问几个常用的调度队列:

  • 使用dispatch_get_current_queue函数进行调试或测试当前队列的标识。从块对象内部调用此函数将返回提交块的队列(现在可能正在运行该队列)。从块外部调用此函数将返回应用程序的默认并发队列。
  • 使用dispatch_get_main_queue函数获得与应用程序主线程关联的串行调度队列。这个队列是为Cocoa应用程序和在主线程上调用dispatch_main函数或配置运行循环(使用CFRunLoopRef类型或NSRunLoop对象)的应用程序自动创建的。
  • 使用dispatch_get_global_queue函数获取任何共享并发队列。有关更多信息,请参见获取全局并发调度队列。

调度队列的内存管理

分派队列和其他分派对象是引用计数的数据类型。创建串行调度队列时,它的初始引用计数为1。你可以使用dispatch_retain和dispatch_release函数根据需要增加和减少引用计数。当队列的引用计数为0时,系统异步释放队列。

保留和释放分派对象(如队列)是很重要的,以确保在使用它们时它们仍在内存中。与内存管理的Cocoa对象一样,一般的规则是,如果计划使用传递给代码的队列,那么应该在使用队列之前保留它,在不再需要时释放它。这个基本模式可以确保队列在你使用它的过程中一直保留在内存中。

注意:你不需要保留或释放任何全局调度队列,包括并发调度队列或主调度队列。任何保留或释放队列的尝试都将被忽略。

即使实现了垃圾收集应用程序,也必须保留并释放分派队列和其他分派对象。Grand Central Dispatch不支持回收内存的垃圾收集模型。

使用队列存储自定义上下文信息

所有分派对象(包括分派队列)都允许你将自定义上下文数据与该对象关联。要在给定对象上设置和获取此数据,可以使用dispatch_set_context和dispatch_get_context函数。系统不会以任何方式使用你的自定义数据,你可以在适当的时间分配和释放数据。

对于队列,你可以使用上下文数据来存储指向Objective-C对象或其他数据结构的指针,以帮助识别队列或其对代码的预期用法。你可以使用队列的终结器函数在队列被释放之前从队列中释放(或解除关联)上下文数据。清单3-3展示了如何编写清除队列上下文数据的终结器函数的示例。

为队列提供清理功能

创建串行调度队列之后,可以附加一个终结器函数,以便在释放队列时执行任何自定义清理。分派队列是引用计数的对象,你可以使用dispatch_set_finalizer_f函数来指定在队列的引用计数为零时执行的函数。你可以使用此函数来清理与队列关联的上下文数据,并且只有在上下文指针不为空时才调用该函数。

清单3-3显示了一个自定义终结器函数和一个创建队列并安装该终结器的函数。队列使用finalizer函数来释放存储在队列上下文指针中的数据。(代码中引用的myInitializeDataContextFunction和myCleanUpDataContextFunction函数是自定义函数,用于初始化和清理数据结构本身的内容。)传递给终结器函数的上下文指针包含与队列关联的数据对象。

清单3-3安装了一个队列清理函数

void myFinalizerFunction(void *context)
{
    MyDataContext* theData = (MyDataContext*)context;
 
    // Clean up the contents of the structure
    myCleanUpDataContextFunction(theData);
 
    // Now release the structure itself.
    free(theData);
}
 
dispatch_queue_t createMyQueue()
{
    MyDataContext*  data = (MyDataContext*) malloc(sizeof(MyDataContext));
    myInitializeDataContextFunction(data);
 
    // Create the queue and set the context data.
    dispatch_queue_t serialQueue = dispatch_queue_create("com.example.CriticalTaskQueue", NULL);
    dispatch_set_context(serialQueue, data);
    dispatch_set_finalizer_f(serialQueue, &myFinalizerFunction);
 
    return serialQueue;
}

将任务添加到队列

要执行任务,必须将其分派到适当的分派队列。你可以同步或异步地分派任务,也可以单独或成组地分派任务。一旦进入队列,队列就会根据其约束条件和队列中已有的任务,负责尽快执行任务。本节将向你展示一些将任务分派到队列的技术,并描述每种技术的优点。

将单个任务添加到队列中

向队列添加任务有两种方法:异步或同步。在可能的情况下,使用dispatch_async和dispatch_async_f函数的异步执行优于同步执行。当你向队列添加块对象或函数时,无法知道代码何时执行。因此,异步添加块或函数可以让你调度代码的执行,并继续从调用线程执行其他工作。如果你从应用程序的主线程调度任务—可能是响应某个用户事件,那么这一点尤其重要。

尽管你应该尽可能异步地添加任务,但有时仍然需要同步地添加任务,以防止竞争条件或其他同步错误。在这些实例中,可以使用dispatch_sync和dispatch_sync_f函数将任务添加到队列中。这些函数将阻塞当前执行线程,直到指定的任务完成执行为止。

重点:永远不要从任务中调用dispatch_sync或dispatch_sync_f函数,该任务在计划传递给该函数的同一队列中执行。这对于串行队列尤其重要,因为串行队列肯定会死锁,但对于并发队列也应该避免死锁。

下面的例子展示了如何使用基于块的变量异步和同步调度任务:

dispatch_queue_t myCustomQueue;
myCustomQueue = dispatch_queue_create("com.example.MyCustomQueue", NULL);
 
dispatch_async(myCustomQueue, ^{
    printf("Do some work here.\n");
});
 
printf("The first block may or may not have run.\n");
 
dispatch_sync(myCustomQueue, ^{
    printf("Do some more work here.\n");
});
printf("Both blocks have completed.\n");

在任务完成时执行完成块

从本质上讲,分配到队列的任务独立于创建它们的代码运行。然而,当任务完成时,你的应用程序可能仍然希望得到关于这个事实的通知,以便它可以合并结果。对于传统的异步编程,你可以使用回调机制来实现这一点,但是对于分派队列,你可以使用完成块。

完成块只是在原始任务结束时分派到队列中的另一段代码。调用代码通常在启动任务时将完成块作为参数提供。任务代码所要做的就是在指定队列完成工作时将指定的块或函数提交给它。

清单3-4显示了使用块实现的平均函数。平均值函数的最后两个参数允许调用者在报告结果时指定一个队列和一个块。求平均值函数计算其值后,将结果传递给指定的块并将其分派给队列。为了防止过早地释放队列,首先保留该队列并在分配完完成块后释放它是非常重要的。

清单3-4在任务之后执行一个完成回调

void average_async(int *data, size_t len,
   dispatch_queue_t queue, void (^block)(int))
{
   // Retain the queue provided by the user to make
   // sure it does not disappear before the completion
   // block can be called.
   dispatch_retain(queue);
 
   // Do the work on the default concurrent queue and then
   // call the user-provided block with the results.
   dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
      int avg = average(data, len);
      dispatch_async(queue, ^{ block(avg);});
 
      // Release the user-provided queue when done
      dispatch_release(queue);
   });
}

调度队列和线程安全

在调度队列上下文中讨论线程安全似乎有些奇怪,但是线程安全仍然是一个相关的主题。在应用程序中实现并发的任何时候,都应该了解以下几点:

  • 调度队列本身是线程安全的。换句话说,你可以从系统上的任何线程向调度队列提交任务,而无需首先对队列进行锁定或同步访问。
  • 不要从传递给函数调用的同一队列上执行的任务中调用dispatch_sync函数。这样做会导致队列死锁。如果需要调度到当前队列,则使用dispatch_async函数进行异步调度。
  • 避免从提交到调度队列的任务中获取锁。尽管使用来自任务的锁是安全的,但当你获得锁时,如果该锁不可用,则可能会完全阻塞串行队列。类似地,对于并发队列,等待锁可能会阻止其他任务执行。如果需要同步部分代码,请使用串行调度队列而不是锁。
  • 虽然你可以获得有关正在运行任务的底层线程的信息,但最好避免这样做。有关调度队列与线程的兼容性的更多信息,请参见与POSIX线程的兼容性。

有关如何更改现有线程代码以使用分派队列的其他技巧,请参阅从线程迁移。

调度源

无论何时与底层系统进行交互,你都必须准备好处理该任务,这将花费大量的时间。对内核层或其他系统层的调用涉及上下文的更改,与在你自己的进程中发生的调用相比,这种更改的开销相当大。因此,许多系统库提供异步接口,允许代码向系统提交请求,并在处理请求时继续执行其他工作。通过允许你提交请求并使用块和调度队列将结果报告回你的代码,Grand Central Dispatch构建于此一般行为之上。

关于调度源

分派源是一种基本数据类型,用于协调特定低级系统事件的处理。Grand Central Dispatch支持以下类型的调度源:

  • 计时器调度源生成定期通知。
  • 当UNIX信号到达时,信号分发源会通知你。
  • 描述符源通知你各种基于文件和套接字的操作,如:
    • 当数据可以读取时
    • 当可以写入数据时
    • 在文件系统中删除、移动或重命名文件时
    • 当文件元信息更改时
  • 流程调度源通知你与流程相关的事件,例如:
    • 当一个进程退出时
    • 当进程发出fork或exec类型的调用时
    • 当一个信号被传送到进程时
  • Mach端口调度源会通知你与Mach相关的事件。
  • 自定义分派源是你自己定义和触发的。

调度源替换了通常用于处理系统相关事件的异步回调函数。在配置分派源时,将指定要监视的事件以及用于处理这些事件的分派队列和代码。可以使用块对象或函数指定代码。当感兴趣的事件到达时,调度源将你的块或函数提交到指定的调度队列执行。

与手动提交到队列的任务不同,分派源为应用程序提供连续的事件源。在显式取消之前,分派源一直附加在其分派队列上。在附加时,每当发生相应的事件时,它都将其关联的任务代码提交到调度队列。有些事件(如定时器事件)按一定的时间间隔发生,但大多数只在特定条件出现时才偶尔发生。因此,分派源保留其关联的分派队列,以防止在事件可能仍然挂起时过早地释放它。

为了防止事件在调度队列中积压,调度源实现了事件合并方案。如果新事件在前一个事件的事件处理程序离开队列并执行之前到达,则调度源将来自新事件数据的数据与来自旧事件的数据合并。根据事件的类型,合并可以替换旧事件或更新其包含的信息。例如,基于信号的分派源仅提供关于最新信号的信息,还报告自最后一次调用事件处理程序以来已传递了多少信号。

从线程迁移

有许多方法可以调整现有的线程代码以利用Grand Central分派和操作对象。虽然在所有情况下都不可能从线程中移出,但是在需要切换线程的地方,性能(以及代码的简单性)可以显著提高。具体来说,使用调度队列和操作队列代替线程有以下几个优点:

  • 它减少了应用程序为在应用程序的内存空间中存储线程堆栈而付出的内存代价。
  • 它消除了创建和配置线程所需的代码。
  • 它消除了管理和调度线程上的工作所需的代码。
  • 它简化了你必须编写的代码。

本章提供了一些关于如何替换现有的基于线程的代码并使用分派队列和操作队列来实现相同类型的行为的提示和指导。

使用调度队列替换线程

为了理解如何用调度队列替换线程,首先考虑在应用程序中使用线程的一些方法:

  • 单任务线程。创建一个线程来执行单个任务,并在任务完成时释放线程。
  • 工作线程。创建一个或多个工作线程,每个工作线程都有特定的任务。定期将任务分派给每个线程。
  • 线程池。创建一个通用线程池,并为每个线程设置run循环。当你有一个任务要执行时,从池中获取一个线程并将任务分派给它。如果没有空闲线程,则将任务排队并等待线程可用。

虽然这些技术看起来有很大的不同,但它们实际上是同一原理的变体。在每种情况下,都使用一个线程来运行应用程序必须执行的一些任务。它们之间的惟一区别是用于管理线程和任务队列的代码。使用分派队列和操作队列,你可以消除所有的线程和线程通信代码,而只关注希望执行的任务。

如果你正在使用上述线程模型之一,你应该已经对应用程序执行的任务类型有了很好的了解。与其将任务提交给自定义线程,不如尝试将该任务封装到操作对象或块对象中,并将其分派到适当的队列。对于那些不是特别有争议的任务(即不需要锁的任务),你应该能够进行以下直接替换:

  • 对于单个任务线程,将任务封装在一个块或操作对象中,并将其提交到一个并发队列。
  • 对于工作线程,你需要决定是使用串行队列还是使用并发队列。如果使用工作线程同步特定任务集的执行,请使用串行队列。如果你确实使用工作线程来执行没有相互依赖关系的任意任务,请使用并发队列。
  • 对于线程池,将任务封装在一个块或操作对象中,并将它们分派到并发队列中执行。

当然,像这样的简单替换可能并不适用于所有情况。如果正在执行的任务争用共享资源,理想的解决方案是首先尝试删除或最小化争用。如果有方法可以重构或重新架构你的代码以消除对共享资源的相互依赖,那么这当然是更好的选择。然而,如果这样做是不可能的,或者效率可能更低,仍然有一些方法可以利用队列。队列的一大优点是,它们提供了一种更可预测的方式来执行代码。这种可预测性意味着仍然有方法可以同步代码的执行,而不需要使用锁或其他重量级同步机制。不使用锁,你可以使用队列执行许多相同的任务:

  • 如果你有必须按特定顺序执行的任务,请将它们提交到串行调度队列。如果希望使用操作队列,请使用操作对象依赖项来确保这些对象以特定的顺序执行。
  • 如果你当前使用锁来保护共享资源,请创建一个串行队列来执行修改该资源的任何任务。然后,串行队列将你现有的锁替换为同步机制。有关消除锁的更多信息,请参见消除基于锁的代码。
  • 如果使用线程连接来等待后台任务完成,可以考虑使用分派组。你还可以使用NSBlockOperation对象或操作对象依赖项来实现类似的组完成行为。有关如何跟踪执行任务组的更多信息,请参见替换线程联接。
  • 如果你使用生产者-消费者算法来管理有限资源池,请考虑将你的实现更改为在更改生产者-消费者实现中显示的实现。
赞(0)
未经允许不得转载:srcmini » Objective-C并发编程完全解读

评论 抢沙发

评论前必须登录!