0%

Concurrency Programming Guide:调度队列

Grand Central Dispatch(GCD)调度队列是执行任务的强大工具。调度队列让你可以相对于调用者异步或同步地执行任意的代码块。你可以使用调度队列来执行几乎所有你过去在独立线程上执行的任务。调度队列的优点是使用起来更简单,执行任务的效率相比线程代码高得多。

本章介绍了调度队列,以及如何执行程序中的一般任务。如果你想用调度队列替换现有的线程代码,可参阅迁移线程代码

关于调度队列

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

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

表3-1 调度队列类型

类型 描述
串行 串行队列(也称为私有调度队列)按照添加到队列的顺序,一次执行一个任务。当前执行的任务在一个独立的线程上运行(可以因任务而异),该线程由调度队列管理。串行队列通常用于同步访问特定的资源。
你可以根据需要创建足够多的串行队列,每个队列相对于所有其他队列都是并发执行的。换句话说,如果你创建了四个串行队列,每个队列一次只执行一个任务,但最多可以有四个任务同时执行,每个队列一个。有关如何创建串行队列的信息,可参阅Creating Serial Dispatch Queues
并发 并发队列(也被称为全局调度队列的一种类型)同时执行一或多个任务,但任务仍然按照它们被添加到队列的顺序启动。当前执行的任务在不同的线程上运行,这些线程由调度队列管理。在任何时候执行的任务的确切数量是根据系统条件而决定。
在iOS 5和更高版本中,可以通过指定DISPATCH_QUEUE_CONCURRENT队列类型,自己创建并发的调度队列。此外,还有四个预定义的全局并发队列供程序使用。关于如何获得全局并发队列的更多信息,可参阅Getting the Global Concurrent Dispatch Queues
主调度队列 主调度队列是一个全局可用的串行队列,在程序的主线程上执行任务。这个队列与程序的run loop(如果有的话)配合工作,将队列任务的执行与连接到run loop的其他事件源的执行交错进行。因为它在程序的主线程上运行,所以主队列经常被用作程序的关键同步点。
虽然你不需要创建主调度队列,但你需要确保程序适当地使用它。关于如何管理该队列的更多信息,可参阅Performing Tasks on the Main Thread

当涉及到向程序添加并发特性时,调度队列比线程有几个优势。最直接的优势是工作队列编程模型的简单性。对于线程,你必须为要执行的工作以及线程本身的创建和管理编写代码。调度队列让你专注于你真正想要执行的工作,而不必担心线程的创建和管理。相反,系统为你处理所有的线程创建和管理。这样做的好处是,系统能够比任何单个程序更有效地管理线程。系统可以根据可用的资源和当前的系统条件,动态地扩展线程的数量。此外,系统通常能够比你自己创建的线程更快地开始运行你的任务。

尽管你可能认为为调度队列重写代码会很困难,但为调度队列编写代码往往比为线程写代码要容易。编写代码的关键是设计自成一体且能够异步运行的任务。(这对线程和调度队列都是如此。)然而,调度队列的优势在于可预测性。如果你有两个访问同一共享资源的任务,但在不同的线程上运行,任何一个线程都可以先修改资源,你需要使用一个锁来确保两个任务不会同时修改该资源。有了调度队列,你可以将两个任务添加到一个串行调度队列中,以确保在任何时候只有一个任务修改资源。这种基于队列的同步比锁更有效,因为锁在有争议和无争议的情况下总是需要一个昂贵的内核陷阱(kernel trap),而调度队列主要在程序的进程空间工作,只有在绝对必要时才会向下调用内核。

尽管你会正确地指出,在一个串行队列中运行的两个任务不会并发运行,但你必须记住,如果两个线程同时取得一个锁,那么线程提供的任何并发性都会丢失或大大降低。更重要的是,线程模型需要创建两个线程,这需要占用内核和用户空间的内存。调度队列不会为它们的线程而消耗同样的内存,而且他们使用的线程会保持持续工作,不会被阻塞。

关于调度队列,需要记住的其他一些关键点:

  • 调度队列相对于其他调度队列来说,是同时执行其任务的。任务的串行是相对于一个调度队列而言的。

  • 系统决定了在任何时候执行的任务总数。因此,一个在100个不同队列中有100个任务的程序可能不会并发地执行所有这些任务(除非它有100个或更多的有效内核)。

  • 系统在选择启动哪些新任务时,会考虑到队列的优先级。关于如何设置一个串行队列的优先级,可参阅Providing a Clean Up Function For a Queue

  • 队列中的任务在被添加到队列时,必须准备好执行。(如果之前使用过Cocoa操作对象,请注意这种行为与操作对象使用的模型不同。)

  • 私有调度队列是引用计数的对象。除了在你自己的代码中保留队列外,要注意调度源也可以附加到队列上,也会增加其保留计数。因此,你必须确保所有的调度源都被取消,所有的保留调用都与适当的释放调用相平衡。关于保留和释放队列的更多信息,可参阅Memory Management for Dispatch Queues。关于调度源的更多信息,可参阅调度源

队列相关技术

除了调度队列之外,Grand Central Dispatch还提供了一些使用队列来帮助管理代码的技术。表3-2列出了这些技术,并提供了链接,你可以在那里找到关于它们的更多信息。

表3-2 使用调度队列的技术

技术 描述
调度组 调度组是一种监视一组block对象完成的方式。你可以根据你的需要同步或异步地监视这些block。对于依赖其他任务完成的代码,组提供了一种有用的同步机制。关于使用组的更多信息,可参阅Waiting on Groups of Queued Tasks
调度信号量 调度信号与传统的信号量类似,但通常更有效率。只有当调用线程因为信号量不可用而需要被阻塞时,调度信号量才会向下调用内核。如果信号量是可用的,则不会调用内核。关于如何使用调度信号量的例子,可参阅Using Dispatch Semaphores to Regulate the Use of Finite Resources
调度源 调度源在响应特定类型的系统事件时生成通知。你可以使用调度源来监控事件,如进程通知、信号和描述符事件等等。当一个事件发生时,调度源会将你的任务代码异步提交给指定的调度队列进行处理。关于创建和使用调度源的更多信息,可参阅调度源

使用Block来实现任务

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

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

你可以使用类似于函数指针的语法,在你的代码中内联地声明block。Block和函数指针的主要区别是,block名称前面有一个^而不是*。像函数指针一样,你可以向block传递参数,并从它那里接收一个返回值。清单3-1显示了如何在代码中同步声明和执行block。变量aBlock被声明为一个block,它接受一个整数参数,不返回任何值。然后,一个符合该原型的实际block被分配给aBlock,并被声明为内联。最后一行立即执行该block,将指定的整数打印到标准输出:

清单3-1 block简单示例

1
2
3
4
5
6
7
8
9
10
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

下面是你在设计block时应该考虑的一些关键准则:

  • 对于你计划使用调度队列异步执行的block,从父函数或方法中捕获标量变量并在block中使用是安全的。然而,你不应该试图捕获大型结构体或其他基于指针的变量,这些变量是由调用上下文分配和删除的。当你的block被执行时,该指针所引用的内存可能已经被回收。当然,自己分配内存(或对象)并明确地将该内存的所有权移交给block是安全的。
  • 调度队列会复制被添加到其中的block,并在执行完毕后释放block。换句话说,你不需要在将block添加到队列之前显式地复制它们。
  • 尽管队列在执行小任务时比原始线程更有效,但在队列中创建block并执行它们仍然存在开销。如果一个block做的工作太少,直接执行它可能比把它调度到队列中更节省开销。判断一个block是否做得太少的方法是使用性能工具收集每个路径的指标并进行比较。
  • 不要缓存相对于底层线程的数据,并期望该数据能从不同的block中访问。如果同一队列中的任务需要共享数据,请使用调度队列的上下文指针来代替存储数据。关于如何访问调度队列的上下文数据的更多信息,可参阅Storing Custom Context Information with a Queue
  • 如果block创建了几个以上的Objective-C对象,你可以把block的部分代码包裹在@autorelease中,以处理这些对象的内存管理。尽管GCD调度队列有他们自己的自动释放池,但他们不保证这些池何时被耗尽。如果程序有内存限制,创建自己的自动释放池可以让你在更有规律的时间间隔内释放自动释放对象的内存。

关于block的更多信息,包括如何声明和使用它们,可参阅Blocks Programming Topics。关于如何将block添加到调度队列中,可参阅Adding Tasks to a Queue

创建和管理调度队列

在你把任务添加到队列中之前,你必须确定使用的队列类型以及后续打算如何使用它。调度队列可以串行或并发地执行任务。此外,如果你对队列有一个特定的用途,你可以相应地配置队列属性。下面几节告诉你如何创建调度队列并配置它们的用途。

获得全局并发调度队列

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

系统为每个程序提供了4个并发的调度队列。这些队列对程序来说是全局性的,仅由其优先级来区分。因为它们是全局的,所以你不需要明确地创建它们。相反,你可以使用dispatch_get_global_queue函数来请求获取其中的队列,如下所示:

1
dispatch_queue_t aQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

除了获得默认的并发队列,你还可以通过向函数传递DISPATCH_QUEUE_PRIORITY_HIGHDISPATCH_QUEUE_PRIORITY_LOW常量来获得高优先级和低优先级的队列,或者通过传递DISPATCH_QUEUE_PRIORITY_BACKGROUND常量来获得一个后台队列。正如你所期望的,高优先级并发队列中的任务在默认队列和低优先级队列中的任务之前执行。同样地,默认队列中的任务在低优先级队列中的任务之前执行。

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

尽管调度队列是引用计数的对象,你不需要保留和释放全局并发队列。因为它们对程序是全局的,对这些队列的保留和释放调用应被忽略。因此,你不需要存储对这些队列的引用。你只需在需要其中一个队列的引用时调用dispatch_get_global_queue函数。

创建串行调度队列

当你想让你的任务以特定的顺序执行时,串行队列很有用。串行队列一次只执行一个任务,并且总是从队列的头部取出任务。你可以用一个串行队列代替锁来保护一个共享资源或可变数据结构。与锁不同,串行队列确保任务以可预测的顺序执行。只要你以异步方式向串行队列提交任务,该队列就不会出现死锁。

与并发队列不同,你必须明确地创建和管理你想使用的任何串行队列。你可以为程序创建任何数量的串行队列,但应避免仅仅作为一种同时执行尽可能多的任务的手段来创建大量的串行队列。如果你想同时执行大量的任务,请将它们提交给全局并发队列。在创建串行队列时,尽量为每个队列确定一个目的,如保护资源或同步程序的一些关键行为。

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

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

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

除了创建的任何自定义队列外,系统还自动创建一个串行队列,并将其绑定到程序的主线程。关于获取主线程的队列的更多信息,可参阅Getting Common Queues at Runtime

在运行时获取通用队列

Grand Central Dispatch提供了一些函数,可以让你从程序中访问几个常见的调度队列:

  • 使用dispatch_get_current_queue函数用于调试目的或测试当前队列的身份。从一个block对象内部调用这个函数,会返回该block被提交到的队列(以及它现在可能正在运行的队列)。在block外调用此函数会返回程序的默认并发队列。
  • 使用dispatch_get_main_queue函数来获取与程序主线程相关的串行调度队列。这个队列是为Cocoa程序和那些调用dispatch_main函数或在主线程上配置run loop(使用CFRunLoopRef类型或NSRunLoop对象)的程序自动创建的。
  • 使用dispatch_get_global_queue函数来获取任何共享的并发队列。更多信息,可参阅Getting the Global Concurrent Dispatch Queues

调度队列的内存管理

调度队列和其他调度对象是引用计数的数据类型。你可以使用dispatch_retaindispatch_release函数根据需要增加和减少该引用计数。当一个队列的引用计数达到0时,系统会异步地释放队列。

保留和释放调度对象,如队列,以确保它们在被使用时仍在内存中,这一点很重要。与内存管理的Cocoa对象一样,一般的规则是,如果你打算使用传递给你的代码的队列,你应该在使用它之前保留该队列,当你不再需要它时释放它。这种基本模式可以确保只要你在使用队列,它就会一直留在内存中。

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

即使你实现了一个垃圾收集的程序,你仍然必须保留和释放你的调度队列和其他调度对象。Grand Central Dispatch不支持用于回收内存的垃圾收集模型。

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

所有的调度对象(包括调度队列)都允许将自定义上下文数据与该对象相关联。为了在一个给定的对象上设置和获取这些数据,你可以使用dispatch_set_contextdispatch_get_context函数。系统不会以任何方式使用你的自定义数据,而是由你在适当的时候分配和释放该数据。

对于队列,你可以使用上下文数据来存储一个指向Objective-C对象或其他数据结构的指针,以帮助识别队列或其他预期用途。你可以使用队列的析构函数,在队列被释放之前,将上下文数据从队列中释放(或取消关联)。如何编写一个清除队列上下文数据的析构函数的例子,可参阅清单3-3

为队列提供一个清理函数

在创建了一个串行调度队列后,可以附加一个析构函数,以便在队列被释放时执行任何自定义的清理工作。调度队列是引用计数的对象,你可以使用 dispatch_set_finalizer_f 函数来指定一个当队列的引用计数达到零时要执行的函数。你用这个函数来清理与队列相关的上下文数据,只有当上下文指针不是NULL时才会调用这个函数。

清单3-3显示了一个自定义的析构函数和一个创建队列并配置析构函数的函数。队列使用析构函数来释放存储在队列上下文指针中的数据。代码中引用的myInitializeDataContextFunctionmyCleanUpDataContextFunction函数是你提供的自定义函数,用于初始化和清理数据结构本身的内容。传递给析构函数的上下文指针包含与队列相关的数据对象。

清单3-3 给队列配置清理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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_asyncdispatch_async_f函数的异步执行比同步执行要好。当你在队列中添加一个block对象或函数时,没有办法知道该代码何时执行。因此,异步添加block或函数可以让你安排代码的执行,并继续从调用线程做其他工作。如果从程序的主线程调度任务(也许是为了响应一些用户事件)这一点就特别重要。

尽管你应该尽可能地异步添加任务,但有时你仍然需要同步添加任务,以防止竞态条件或其他同步错误。在这些情况下,你可以使用dispatch_syncdispatch_sync_f函数来将任务添加到队列中。这些函数会阻塞当前的执行线程,直到指定的任务执行完毕。

重要提醒:你不应该从一个正在执行的任务中调用dispatch_syncdispatch_sync_f函数,而这个任务是你计划传递给该函数的同一个队列。这对串行队列特别重要,因为这样做必然导致死锁。同样对并发队列也应避免这样做。

下面的例子显示了如何使用基于block的变体来进行异步和同步的任务调度:

1
2
3
4
5
6
7
8
9
10
11
12
13
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");

在任务完成时执行完成Block

就其性质而言, 调度到队列中的任务是独立于创建它们的代码运行的。然而,当任务完成后,程序可能仍然希望被通知这一事实,以便它能够纳入结果。在传统的异步编程中,你可能会使用回调机制来做到这一点,但对于调度队列,你可以使用完成block实现。

完成block只是另一段普通代码而已,在原始任务结束后将其调度到队列中。调用代码通常在启动任务时提供完成block作为参数。任务代码所要做的就是在完成工作时将指定的block或函数提交到指定的队列中。

清单3-4显示了一个用block实现的求平均值函数。求平均值函数的最后两个参数允许调用者在报告结果时指定一个队列和block。在求平均值函数计算出它的值后,它将结果传递给指定的block,并将其调度给队列。为了防止队列过早地被释放,在开始的时候保留该队列并在完成block被调度时进行释放。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
});
}

并发执行循环迭代

并发调度队列可以提高性能的一个地方是,一个执行固定次数迭代的循环。例如,假设你有一个for循环,在每次循环迭代中都做一些工作:

1
2
3
for (i = 0; i < count; i++) {
printf("%u\n",i);
}

如果在每个迭代过程中执行的工作与所有其他迭代过程中执行的工作不同,并且每个连续的循环完成的顺序不重要,你可以用调用dispatch_applydispatch_apply_f函数来代替循环。这些函数在每次循环迭代时将指定的block或函数提交给一个队列。当调度到一个并发队列时,因此有可能同时执行多个循环迭代。

在调用dispatch_applydispatch_apply_f时,你可以指定一个串行队列或并发队列。传递一个并发队列允许你同时执行多个循环迭代,这是使用这些函数的最常见方式。尽管使用一个串行队列是允许的,并且对你的代码来说是正确的,但使用这样的队列与原有的循环相比没有真正的性能优势。

重要提醒:和普通的for循环一样,dispatch_applydispatch_apply_f函数在所有循环迭代完成之前不会返回。因此,当已经从队列的上下文中执行的代码中调用它们时,应该小心。如果作为参数传递给函数的队列是一个串行队列,并且是执行当前代码的同一个队列,调用这些函数将使队列陷入死锁。

因为它们实际上阻塞了当前线程,所以当你从主线程中调用这些函数时也要小心,它们可能会阻止你的事件处理循环及时地响应事件。如果循环代码需要明显的处理时间,你可能想从不同的线程调用这些函数。

清单3-5显示了如何用dispatch_apply语法替换前面的for循环。传递给dispatch_apply函数的block必须包含一个识别当前循环迭代的参数。当block被执行时,这个参数的值对于第一次迭代是0,对于第二次迭代是1,以此类推。最后一次迭代的参数值是count - 1,其中count是迭代的总次数。

清单3-5 并发执行for循环迭代

1
2
3
4
5
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_apply(count, queue, ^(size_t i) {
printf("%u\n",i);
});

你应该确保任务代码在每次迭代中都能完成合理的工作量。就像你调度到队列的任何block或函数一样,调度该代码的执行是有开销的。如果你的循环的每个迭代只执行少量的工作,调度代码的开销可能会超过你从调度它到队列中可能获得的性能优势。如果你在测试过程中发现这种情况,你可以使用striding来增加每个循环迭代中执行的工作量。通过striding,将原始循环的多次迭代合并成一个block,并按比例减少迭代次数。例如,如果你最初执行了100次迭代,但决定使用4的跨度,你现在从每个block中执行4次循环迭代,迭代次数是25。关于如何实现striding的例子,可参阅Improving on Loop Code

在主线程上执行任务

Grand Central Dispatch提供了一个特殊的调度队列,你可以用它来在程序的主线程上执行任务。这个队列是为所有程序自动提供的,任何在其主线程上设置run loop(由CFRunLoopRef类型或NSRunLoop对象管理)的程序都会自动drained。如果你没有创建一个Cocoa应用程序,并且不想明确设置一个run loop,你必须调用dispatch_main函数来明确消费主调度队列。你仍然可以向队列添加任务,但如果你不调用这个函数,这些任务就永远不会被执行。

你可以通过调用dispatch_get_main_queue函数获得程序主线程的调度队列。添加到这个队列的任务是在主线程本身上串行进行的。因此,你可以把这个队列作为一个用于同步其他部分进行的工作的同步点。

在任务中使用Objective-C对象

GCD提供了对Cocoa内存管理技术的内置支持,因此你可以在提交给调度队列的block中自由使用Objective-C对象。每个调度队列都维护它自己的自动释放池,以确保自动释放的对象在某一时刻被释放;队列不保证它们何时真正释放这些对象。

如果程序有内存限制,并且block创建了超过几个自动释放的对象,创建自己的自动释放池是确保对象被及时释放的唯一方法。如果你的block创建了数以百计的对象,你可能需要创建多个自动释放池,或者定期清空池。

暂停和恢复队列

可以通过暂停一个队列来阻止暂时执行block对象。你可以使用dispatch_suspend函数暂停一个调度队列,并使用dispatch_resume函数恢复它。调用dispatch_suspend会增加队列的暂停引用计数,而调用dispatch_resume会减少引用计数。当引用计数大于0时,队列仍然暂停。因此,你必须用一个匹配的恢复调用来平衡所有的暂停调用,以便恢复处理block。

重要提醒:暂停和恢复调用是异步的,只在block的执行之间生效。暂停队列不会停止已经执行的block。

使用调度信号量来规范有限资源的使用

如果你提交给调度队列的任务要访问一些有限的资源,你可以使用调度信号量来调节同时访问该资源的任务数量。调度信号量的工作方式与普通信号量一样,但有一个例外。当资源可用时,获取一个调度信号量的时间比获取一个传统系统信号量的时间要短。这是因为Grand Central Dispatch在这种特殊情况不会向下调用内核。唯一一次调用内核是当资源不可用时,系统需要暂停(park)线程直到发出信号。

使用调度信号量的语义如下:

  1. 创建信号量时(使用dispatch_semaphore_create函数),可以指定一个正整数,表示可用资源的数量。
  2. 在每个任务中,调用dispatch_semaphore_wait等待信号量。
  3. 当等待调用返回时,获取资源并完成工作。
  4. 使用完资源后,释放它并通过调用dispatch_semaphore_signal函数发出信号量。

关于这些步骤如何工作的例子,可以考虑系统中文件描述符的使用。每个程序都有有限的文件描述符可以使用。如果有一个处理大量文件的任务,你不希望一次打开这么多文件,以至于你的文件描述符用完。相反,你可以使用信号量来限制文件处理代码使用的文件描述符的数量。你可以在任务中加入以下的基本代码:

1
2
3
4
5
6
7
8
9
10
// Create the semaphore, specifying the initial pool size
dispatch_semaphore_t fd_sema = dispatch_semaphore_create(getdtablesize() / 2);

// Wait for a free file descriptor
dispatch_semaphore_wait(fd_sema, DISPATCH_TIME_FOREVER);
fd = open("/etc/services", O_RDONLY);

// Release the file descriptor when done
close(fd);
dispatch_semaphore_signal(fd_sema);

创建信号量时,指定可用资源的数量。该值成为信号量的初始计数变量。每次等待信号量时,dispatch_semaphore_wait函数都会将计数变量递减 1。如果结果值为负,该函数会告诉内核阻塞线程。另一方面,dispatch_semaphore_signal函数将 count 变量加 1 以指示资源已被释放。如果有任务被阻塞并等待资源,其中一个任务随后会被解除阻塞并被允许进行工作。

等待排队的任务组

调度组是一种阻塞线程,直到一个或多个任务执行完毕的方式。你可以在需要等待所有指定的任务都完成才能进行某些任务的地方使用这种行为。例如,在调度了几个任务来计算一些数据后,你可以使用一个组来等待这些任务,然后在它们完成后处理结果。使用调度组的另一种方式是作为线程连接的替代。你可以将相应的任务添加到一个调度组中,并等待整个组,而不是启动几个子线程,然后与每个子线程联合起来。

清单3-6显示了建立一个组,向其调度任务并等待结果的基本过程。没有使用dispatch_async函数将任务调度到队列,而是使用dispatch_group_async函数。这个函数将任务与组相关联,并排队等待执行。要等待一组任务的完成,你就使用dispatch_group_wait函数,传入适当的组。

清单3-6 等待异步任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();

// Add a task to the group
dispatch_group_async(group, queue, ^{
// Some asynchronous work
});

// Do some other work while the tasks execute.

// When you cannot make any more forward progress,
// wait on the group to block the current thread.
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

// Release the group when it is no longer needed.
dispatch_release(group);

调度队列和线程安全

在调度队列的背景下谈论线程安全可能看起来很奇怪,但线程安全仍然是一个相关的话题。任何时候,当你在程序中实现并发时,有几件事你应该知道:

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

关于如何将现有的线程代码改为使用调度队列的其他技巧,可参阅迁移线程代码

总结

队列:

  • 在使用上,与操作队列较大的不同的是,调度队列是基于block添加任务的,而操作队列是基于操作对象添加任务的,所以调度队列会少了一些对每个任务的控制,例如任务添加到调度队列时,必须是就绪执行的。
  • 对于操作对象之间的依赖,在调度队列中的替代方案是串行队列和调度组。
  • 对于操作队列的完成block,在调度队列中可以简单在添加的工作单元block中插入执行完成回调block的代码。
  • 如果block创建了几个以上的Objective-C对象,你可以把block的部分代码包裹在@autorelease中,以处理这些对象的内存管理。尽管GCD调度队列有他们自己的自动释放池,但他们不保证这些池何时被耗尽。如果程序有内存限制,创建自己的自动释放池可以让你在更有规律的时间间隔内释放自动释放对象的内存。
  • 全局队列除了主队列,其他4个都是并发队列,并按照优先级区分。
  • 只要以异步方式向队列提交任务,该队列就不会出现死锁。同步进入正在执行的队列,必然造成死锁。
  • 如果你想同时执行大量的任务,请将它们提交给全局并发队列。
  • 当给调度队列设置了自己的上下文数据是(dispatch_set_context),要相应地设置清理函数(dispatch_set_finalizer_f)以释放自己的上下文数据。
  • 在使用调度函数dispatch_applydispatch_apply_f优化循环时,传入并发队列才是有意义的,不然根直接执行没有区别。
  • 避免从提交给调度队列的任务重获取锁。这不仅会阻塞串行队列,也会让并发队列阻止其他任务的执行。即会让队列不可预测,要同步代码,应使用串行队列而不是锁。

使用技巧:

  • 基于队列的同步比锁更有效,因为锁在有争议和无争议的情况下总是需要一个昂贵的内核陷阱(kernel trap),而调度队列主要在程序的进程空间工作,只有在绝对必要时才会向下调用内核。
  • 对于并发队列,若不是需要操作队列,如挂起,否则使用全局并发队列即可。
  • 在创建串行队列时,尽量为每个队列确定一个目的,如保护资源或同步程序的一些关键行为。
  • 除非遇到竞态条件或其他同步错误,否则都尽可能地异步添加任务。异步添加任务到串行队列实现了异步锁。

Objective-C API -> Swift API

dispatch_barrier_async -> async设置flags值为.barrier

dispatch_after -> asyncAfter

dispatch_once -> 无

dispatch_apply -> concurrentPerform

一次执行

Swift中没有提供,可自己实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public extension DispatchQueue {
private static var _onceTracker = [String]()

class func once(file: String = #file, function: String = #function, line: Int = #line, block: () -> Void) {
let token = "\(file):\(function):\(line)"
once(token: token, block: block)
}

class func once(token: String, block: () -> Void) {
objc_sync_enter(self)
defer {
objc_sync_exit(self)
}
guard !_onceTracker.contains(token) else { return }
_onceTracker.append(token)
block()
}
}

objc_sync_enterobjc_sync_exit共同实现@sychronized递归锁。

屏障(barrier)

注意:在全局并发队列中插入屏障无效,跟普通的async效果一致,起不到阻塞的作用。

插入屏障任务对并发队列的作用:

  1. 等待在屏障任务之前的任务完成;
  2. 执行屏障任务,并等待完成;
  3. 继续执行后续其他任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
concurrentQueue.async {
DispatchQueueExp.testTask("A")
}
concurrentQueue.async {
DispatchQueueExp.testTask("B")
}
concurrentQueue.async(flags: .barrier) {
DispatchQueueExp.testTask("Barrier-C")
}
concurrentQueue.async {
DispatchQueueExp.testTask("D")
}
concurrentQueue.async {
DispatchQueueExp.testTask("F")
}

输出:

1
2
3
4
5
6
7
8
9
10
11
A开始-testTask(_:):<NSThread: 0x6000025adec0>{number = 5, name = (null)}
B开始-testTask(_:):<NSThread: 0x6000025e8340>{number = 4, name = (null)}
B结束-testTask(_:):<NSThread: 0x6000025e8340>{number = 4, name = (null)}
A结束-testTask(_:):<NSThread: 0x6000025adec0>{number = 5, name = (null)}
Barrier-C开始-testTask(_:):<NSThread: 0x6000025adec0>{number = 5, name = (null)}
Barrier-C结束-testTask(_:):<NSThread: 0x6000025adec0>{number = 5, name = (null)}
D开始-testTask(_:):<NSThread: 0x6000025adec0>{number = 5, name = (null)}
F开始-testTask(_:):<NSThread: 0x6000025962c0>{number = 3, name = (null)}
D结束-testTask(_:):<NSThread: 0x6000025adec0>{number = 5, name = (null)}
F结束-testTask(_:):<NSThread: 0x6000025962c0>{number = 3, name = (null)}

调度信号量

  • 调度信号量用于在访问一些有限资源时,用它来控制同时访问资源的任务数量。
  • 调度信号量比传统信号量有更好的性能。传统信号量总是需要调用内核来测试信号量。因为当资源可用的时候,获取一个信号量比获取传统信号量更快,因为当资源可用时调度信号量不会向下调用内核。唯一调用内核的实际时机时资源不可用时,系统暂停线程直到发出信号。
  • 如果是为了对资源加锁,那么使用串行队列可能性能更优。
  • 信号量值 ≤ 0,则阻塞当前线程进入休眠等待,直到信号量值 > 0。
  • 使用调度信号量的步骤:
    1. 创建信号量,传入可用资源数量。
    2. wait让信号量-1,表示已占用一个资源。
    3. 执行任务。完成时,调用signal让信号量+1,表释放资源。
  • 注意,若在销毁时信号量的值小于初始值,则会崩溃(BUG IN CLIENT OF LIBDISPATCH: Semaphore object deallocated while in use)。
  • 当初始值为0的信号量,可以用作锁,即调用wait马上阻塞线程,signal才解开线程。例如可以让异步操作变成同步操作。

应用:

  • 异步变同步
  • 控制并发量,Metal绘制经常使用。

控制并发量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
func doSomething(label: String, cost: UInt32, complete:@escaping ()->()){
NSLog("Start task%@",label)
sleep(cost)
NSLog("End task%@",label)
complete()
}

/////////////////////////////////////////////////////////////////////////////

let semaphore = DispatchSemaphore(value: 3)
let queue = DispatchQueue(label: "", qos: .default, attributes: .concurrent)

queue.async {
semaphore.wait()
self.doSomething(label: "1", cost: 2, complete: {
print(Thread.current)
semaphore.signal()
})
}

queue.async {
semaphore.wait()
self.doSomething(label: "2", cost: 2, complete: {
print(Thread.current)
semaphore.signal()
})
}

queue.async {
semaphore.wait()
self.doSomething(label: "3", cost: 4, complete: {
print(Thread.current)
semaphore.signal()
})
}

queue.async {
semaphore.wait()
self.doSomething(label: "4", cost: 2, complete: {
print(Thread.current)
semaphore.signal()
})
}

queue.async {
semaphore.wait()
self.doSomething(label: "5", cost: 3, complete: {
print(Thread.current)
semaphore.signal()
})
}

调度组

  • 调度组是一种在一或多个任务执行完毕之前阻塞线程的方式。

DispatchGroup两种用法:

一、调度队列调度时传入调度组

最简单的用法。

notify:对组做完成监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let group = DispatchGroup()
myQueue?.async(group: group, qos: .default, flags: [], execute: {
for _ in 0...10 {
print("耗时任务一")
}
})
myQueue?.async(group: group, qos: .default, flags: [], execute: {
for _ in 0...10 {
print("耗时任务二")
}
})
//执行完上面的两个耗时操作, 回到myQueue队列中执行下一步的任务
group.notify(queue: myQueue!) {
print("回到该队列中执行")
}

wait:阻塞线程,同步访问。

1
2
3
4
5
6
7
8
9
10
//等待上面任务执行,会阻塞当前线程,超时就执行下面的,上面的继续执行。可以无限等待 .distantFuture
let result = group.wait(timeout: .now() + 2.0)
switch result {
case .success:
print("不超时, 上面的两个任务都执行完")
case .timedOut:
print("超时了, 上面的任务还没执行完执行这了")
}

print("接下来的操作")

二、enter-leave

手动管理调度组计数,enterleave必须配对。

应用更为自由,不用给队列调用传入调度组,可在任意的队列操作调度组计数。同样最后通过notify监听完成回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let group = DispatchGroup()
group.enter()//把该任务添加到组队列中执行
myQueue?.async {
for _ in 0...10 {
print("耗时任务一")
group.leave()//执行完之后从组队列中移除
}
}
group.enter()//把该任务添加到组队列中执行
myQueue? {
for _ in 0...10 {
print("耗时任务二")
group.leave()//执行完之后从组队列中移除
}
}

//当上面所有的任务执行完之后通知
group.notify(queue: .main) {
print("所有的任务执行完了")
}

调度组和调度信号量都可以实现在异步调用中进行计数,除了用法不一样外,调度信号量只能用于阻塞,而调度组除了阻塞外也提供了异步监听完成的回调。

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