0%

Concurrency Programming Guide:迁移线程代码

有很多方法可以调整现有的线程代码,以利用Grand Central Dispatch和操作对象的优势。虽然不是在所有情况下都能摆脱线程,但在你进行转换的地方,性能(以及代码的简单性)可以得到极大的改善。具体来说,使用调度队列和操作队列而取代线程有几个优势:

  • 减少了程序为在内存空间中存储线程堆栈的内存占用。
  • 消除了创建和配置线程所需的代码。
  • 消除了管理和安排线程工作所需的代码。
  • 减少了代码量。

本章提供了一些技巧和指南,说明如何替换现有的基于线程的代码,转而使用调度队列和操作队列来实现相同类型的行为。

用调度队列替换线程

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

  • 单一任务线程。创建一个线程来执行一个单一的任务,当任务完成后释放该线程。
  • 工作线程。创建一个或多个工作线程,每个线程都有特定的任务。定期向每个线程调度任务。
  • 线程池。创建一个通用线程池,并为每个线程设置run loop。当你有任务要执行时,从池子里取一个线程,把任务调度给它。如果没有空闲的线程,就把任务排入队列,等待可用的线程。

尽管这些看起来是截然不同的技术,但它们实际上只是同一原则的变种。在以上的每种使用方式,线程都被用来运行程序必须执行的一些任务。它们之间唯一的区别是用于管理线程和任务队列的代码。通过使用调度队列和操作队列,可以消除所有线程和线程通信的代码,让你专注于要执行的任务。

如果你正在使用上述线程模型,你应该和清楚程序要执行任务类型。与其将一个任务提交给你的一个自定义线程,不如尝试将该任务封装在一个操作对象或一个block对象中,并将其调度到适当的队列中。对于那些不是特别有争议的任务(不需要锁的任务),你应该能进行以下的直接替换:

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

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

  • 如果是必须按特定顺序执行的任务,可以把它们提交给一个串行调度队列。或使用操作对象依赖来确保以特定的顺序执行。
  • 如果目前使用锁来保护一个共享资源,创建一个串行队列来执行任何修改该资源的任务。然后,使用串行队列将取代现有的锁作为同步机制的代码。关于摆脱锁的更多技术,可参阅Eliminating Lock-Based Code
  • 如果在用线程连接来等待后台任务的完成,可以考虑使用调度组来替换。也可以使用NSBlockOperation对象或操作对象依赖来实现类似的组完成行为。关于如何跟踪执行任务的组,可参阅Replacing Thread Joins
  • 如果在使用生产者-消费者算法来管理有限资源池,可以考虑将实现改为修改生产者-消费者实现中所述的方案。
  • 如果在使用线程从描述符中读写,或监视文件操作,可以改用调度源实现。

重要的是要记住,队列并不是取代线程的万金油。队列提供的异步编程模型适用于允许延迟的场景。即使队列提供了配置任务执行优先级的方法,但较高的执行优先级并不能保证任务在特定的时间执行。因此,在需要尽可能避免延迟的情况下,线程仍然是一个更合适的选择,例如在音频和视频播放的场景。

消除基于锁的代码

对于线程代码,锁是同步访问线程间共享资源的传统方式之一。然而,锁的使用是有代价的。即使在无竞态条件的情况下,使用锁也会有性能损失。而在竞态条件的情况下,一或多个线程有可能在等待锁被释放的过程中阻塞不确定的时间。

用队列取代基于锁的代码,可以消除许多与锁相关的损耗,同时也简化了剩余的代码。你可以创建一个队列来串行访问该资源,而不是使用锁来保护一个共享资源。队列不会像锁那样带来性能损耗。例如,排队的任务不需要进入内核来获取互斥锁。

当排队任务时,你只需决定是同步还是异步进行。异步提交任务可以让当前线程在执行任务时继续运行。同步提交任务则会阻塞当前线程的运行,直到任务完成。这两个情况都有适当的用途,但只要有可能,异步提交任务肯定是更优的。

下面几节将向你展示如何用等价的基于队列的代码来替换现有的基于锁的代码。

实现异步锁

异步锁是一种保护共享资源的方式,它不会阻塞任何修改该资源的代码。当你需要修改一个数据结构,会影响其他的任务时,你可能会使用异步锁。使用传统的线程,通常的方式是为共享资源加锁,然后进行必要的修改,释放锁,然后继续完成任务。然而,使用调度队列,调用的代码可以异步地进行修改,而不必等待这些修改完成。

清单5-1显示了一个异步锁实现的例子。在这个例子中,受保护的资源定义了自己的串行调度队列。调用代码向这个队列提交一个block对象,其中包含需要对资源进行的修改。因为队列本身是串行执行block的,所以对资源的修改保证按照接收的顺序进行;但是,因为任务是异步执行的,所以调用线程不会阻塞。

清单5-1 异步修改保护的资源

1
2
3
dispatch_async(obj->serial_queue, ^{
// Critical section
});

同步执行关键代码

如果当前的代码在某个任务完成之前不能继续,你可以使用dispatch_sync函数同步提交该任务。这个函数将任务添加到一个调度队列中,然后阻塞当前线程,直到任务执行完毕。根据你的需要,调度队列本身可以是一个串行或并发队列。因为这个函数会阻塞当前线程,所以你应该只在必要时使用它。清单5-2显示了使用dispatch_sync来包装代码的关键部分的技术。

清单5-2 同步执行关键代码

1
2
3
dispatch_sync(my_queue, ^{
// Critical section
});

如果你已经在使用一个串行队列来保护共享资源,同步调度到该队列并不会比异步调度更能保护共享资源。使用同步调度的是为了阻塞当前代码,直到关键部分完成。例如,如果你想从共享资源中获取一些值并立即使用它,你就需要同步调度。如果当前代码不需要等待关键部分的完成,或者它可以简单地提交后续任务到同一个串行队列中,那么异步提交往往是首选。

改进循环代码

如果代码有循环,并且每次通过循环所做的工作与其他迭代中的工作无关,你可以考虑使用dispatch_applydispatch_apply_f函数重新实现该循环代码。这些函数把循环的每个迭代单独提交给一个调度队列进行处理。当与并发队列一起使用时,这个功能可以让你并发地执行循环迭代。

如果你的循环的每次迭代都是相互独立的话,你也许应该考虑使用 dispatch_applydispatch_apply_f 重新实现你的循环。这两个函数将每个迭代提交给队列处理。当和并行队列一起使用的时候,这个特性让你能够同时进行多个迭代。

dispatch_applydispatch_apply_f函数是同步函数调用,它们会阻塞当前执行线程,直到所有的循环迭代完成。当提交给一个并发队列时,循环迭代的执行顺序不被保证。运行每个迭代的线程可能会阻塞,导致一个给定的迭代在它周围的其他迭代之前或之后完成。因此,在为每个循环迭代使用的block对象或函数必须是可重入的。

清单5-3显示了如何用基于GCD来替换for循环。传递给dispatch_applydispatch_apply_f的block或函数必须取一个整数值,表示当前循环的迭代。在这个例子中,代码只是将当前的循环编号打印到控制台。

清单5-3 逐步替换for循环

1
2
3
4
queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(count, queue, ^(size_t i) {
printf("%u\n", i);
});

尽管前面的例子是一个简单的例子,但它展示了使用调度队列替换循环的基本技术。尽管这可能是提高基于循环的代码性能的一个好方法,但你仍必须辨证地使用这种技术。尽管调度队列的开销很低,但在一个线程上调度每个循环迭代仍有成本。因此,你应该确保你的循环代码做了足够多的工作来抵消这些成本。确切地说,需要做多少工作是你必须使用性能工具来衡量的事情。

增加每个循环迭代的工作量的一个简单方法是使用striding。使用striding重写你的block,以每次执行原始循环的多个迭代。然后,将指定给dispatch_apply函数的计数值按比例减少。清单5-4显示了如何为清单5-3中的循环代码实现striding。在清单5-4中,该block调用printf语句的次数与stride值相同,在本例中是137。(实际的stride值是你应该根据你的代码所做的工作来配置的)。因为在将总的迭代次数除以stride值时,会有剩余的部分,所以任何剩余的迭代都是直接执行的。

清单5-4 向调度的for循环增加步幅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int stride = 137;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_apply(count / stride, queue, ^(size_t idx){
size_t j = idx * stride;
size_t j_stop = j + stride;
do {
printf("%u\n", (unsigned int)j++);
}while (j < j_stop);
});

size_t i;
for (i = count - (count % stride); i < count; i++)
printf("%u\n", (unsigned int)i);

使用stride有一些明确的性能优势。尤其是当原始循环迭代次数较多时。同时调度较少的block意味着花在执行这些block的代码上的时间比调度它们的时间多。不过和任何性能指标一样,你可能要调整striding的值来达到最佳性能。

替换线程连接

线程连接允许你生成一个或多个线程,然后让当前线程等待,直到这些线程完成。为了实现线程连接,一个父线程会创建一个子线程作为可连接线程。当父线程在没有子线程的结果的情况下不能再取得进展时,它就与子线程连接。这个过程会阻塞父线程,直到子线程完成其任务并退出,这时,父线程可以从子线程中收集结果并继续原来的工作。如果父线程需要与多个子线程连接,它只能逐个进行。

调度组提供了类似于线程连接的语义,但也有一些额外的优势。与线程连接一样,调度组是一种让线程阻塞的方式,直到一个或多个子任务执行完毕。与线程连接不同,调度组同时等待其所有子任务。因为调度组使用调度队列来执行工作,所以它们非常高效。

要使用调度组来执行由可连接线程执行的相同工作,你要做的是:

  1. 使用dispatch_group_create函数创建一个调度组。
  2. 使用dispatch_group_asyncdispatch_group_async_f函数向该组添加任务。提交给组的每个任务都表示在一个可加入的线程上执行的工作。
  3. 当当前线程不能再向前推进时,调用dispatch_group_wait函数来等待该组。这个函数会阻止当前线程,直到该组中的所有任务完成执行。

如果你使用操作对象来实现你的任务,你也可以使用依赖关系实现线程连接。与其让一个父线程等待一个或多个任务完成,不如将父线程的代码移到一个操作对象中。然后,你将在父操作对象和任何数量的子操作对象之间建立依赖关系,以完成通常由可连接线程执行的工作。对其他操作对象的依赖关系可以阻塞父操作对象的执行,直到所有的操作都完成。

关于如何使用调度组的例子,可参阅Waiting on Groups of Queued Tasks。关于设置操作对象之间的依赖关系,可参阅Configuring Interoperation Dependencies

改变生产者-消费者的实现方式

生产者-消费者模型可以让你管理有限动态生产的资源。当生产者创建新的资源(或任务)时,一个或多个消费者等待这些资源(或任务)就绪,并在它们就绪时消费它们。实现生产者-消费者模型的典型机制是条件(conditions)或信号量。

使用条件,生产者线程通常做以下事情:

  1. 锁定与条件相关的互斥锁(使用pthread_mutex_lock)。
  2. 生产将被消费的资源或任务。
  3. 向条件变量发出信号,表示有资源要消耗(使用pthread_cond_signal)。
  4. 解锁互斥锁(使用pthread_mutex_unlock)。

相应的消费线程会做以下事情:

  1. 锁定与该条件相关的互斥锁(使用pthread_mutex_lock)。
  2. 设置一个while循环,做以下工作:
    1. 检查是否真的有任务要执行。
    2. 如果没有任务要执行(或者没有可用的资源),调用pthread_cond_wait来阻塞当前线程,直到有相应的信号量出现。
  3. 获取生产者提供的任务(或资源)。
  4. 解锁互斥锁(使用pthread_mutex_unlock)。
  5. 处理任务。

通过调度队列,你可以将生产者和消费者的实现简化为单一的调用:

1
2
3
dispatch_async(queue, ^{
// Process a work item.
});

当你的生产者有任务要执行时,它所要做的就是将该任务添加到队列中,让队列处理该任务。前面的代码中唯一改变的部分是队列类型。如果生产者生成的任务需要按照特定的顺序执行,就使用一个串行队列。如果生产者生成的任务可以并发执行,就把它们添加到一个并发队列中,让系统尽可能地同时执行它们。

替换信号量代码

如果你目前在使用信号量来限制对共享资源的访问,你应考虑使用调度信号量来代替。传统的信号量总是需要调用内核来测试信号量。相反,调度信号量在用户空间中快速测试信号量的状态,并且只有在测试失败和调用线程需要被阻塞时才会进入内核。这种行为的结果是,在没有竞态条件的情况下,调度信号量比传统信号量快得多。不过在其他方面,调度信号量提供了与传统信号量相同的行为。

关于如何使用调度信号的例子,可参阅Using Dispatch Semaphores to Regulate the Use of Finite Resources

替换Run-Loop代码

如果你正在使用run loop来管理一个或多个线程上执行的工作,你可能会发现队列的实现和维护要简单得多。设置一个自定义的run loop包括设置底层线程和run loop本身。run loop的代码包括设置一个或多个run loop源,并编写回调来处理到达这些源的事件。所有的这些,你可以简单地创建一个串行队列,并向其调度任务。因此,你可以用一行代码取代所有的线程和run loop创建代码。

1
dispatch_queue_t myNewRunLoop = dispatch_queue_create("com.apple.MyQueue", NULL);

因为队列会自动执行添加的任务,所以你不需要额外的代码来管理队列。你不需要创建或配置线程,也不需要创建或附加任何run loop源。此外,你可以通过简单地将任务添加到队列中来执行新的工作类型。要对run loop做同样的事情,你需要修改你现有的run loop源或创建一个新的run loop源来处理新的数据。

run loop的一个常见配置是处理异步到达网络套接字上的数据。与其为这种类型的行为配置一个run loop,你可以为所需的队列附加一个调度源。与传统的run loop源相比,调度源还提供了更多处理数据的选项。除了处理定时器和网络端口事件外,你还可以使用调度源来读写文件、监控文件系统对象、监控进程和监控信号。你甚至可以定义自定义调度源,从你代码的其他部分异步触发它们。关于设置调度源的更多信息,可参阅调度源

兼容POSIX线程

由于Grand Central Dispatch管理着你提供的任务和这些任务运行的线程之间的关系,你一般应该避免从你的任务代码中调用POSIX线程例程。如果你因为某些原因需要调用它们,你应该非常小心地对待你所调用的例程(routines)。本节为你提供了一个指南,说明哪些例程可以安全调用,哪些例程不可以从你的队列任务中调用。这个列表并不完整,但应该给你一个指示,哪些是安全的调用,哪些是不安全的。

一般来说,程序不能删除或改变不是它创建的对象或数据结构。因此,使用调度队列执行的block对象不能调用以下函数:

  • pthread_detach
  • pthread_cancel
  • pthread_join
  • pthread_kill
  • pthread_exit

尽管在任务运行时是可以修改一个线程的状态的,但你必须在你的任务返回之前将线程返回到它的原始状态。因此,只要你把线程返回到它的原始状态,调用以下函数是安全的:

  • pthread_setcancelstate
  • pthread_setcanceltype
  • pthread_setschedparam
  • pthread_sigmask
  • pthread_setspecific

用于执行一个给定block的底层线程可以在不同的调用中进行修改。因此,程序不应该依赖以下函数在block的调用之间返回可预测的结果:

  • pthread_self
  • pthread_getschedparam
  • pthread_get_stacksize_np
  • pthread_get_stackaddr_np
  • pthread_mach_thread_np
  • pthread_from_mach_thread_np
  • pthread_getspecific

重要提醒:block必须捕获并抑制在其中抛出的任何语言级异常。在block的执行过程中发生的其他错误同样应该由block来处理,或者用来通知程序的其他部分。

关于POSIX线程和本节中提到的函数的更多信息,可参阅pthread man pages。

总结

  • 异步添加任务到串行队列实现了异步锁。
  • 关键代码使用同步方式进入串行、并发队列中执行。
  • 用调度组替换线程连接。
  • 生产者-消费者模型可以直接用给队列添加任务实现。如果生产者生成的任务需要按照特定的顺序执行,就使用一个串行队列。如果生产者生成的任务可以并发执行,就把它们添加到一个并发队列中,让系统尽可能地同时执行它们。
  • run loop代码可以直接用一个串行队列或调度源实现。
  • 如果对实时性要求非常严格,那么还是建议使用线程实现。

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