Threads(七)远离线程

有许多方法可以使现有的线程代码适合于利用Grand Central Dispatchoperation objects。尽管并非在所有情况下都可能远离线程,但是在你进行切换的地方,性能(以及代码的简单性)可能会显着提高。具体来说,使用dispatch queuesoperation queues代替线程具有多个优点:

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

本章提供了一些技巧和指南,说明有关如何替换现有的基于线程的代码,而使用dispatch queuesoperation queues来实现相同类型行为。

用 Dispatch Queues 替换 Threads

若要了解如何用 dispatch queues 替换线程,请首先考虑目前在应用程序中使用线程的一些方式:

  • Single task threads(单任务线程)。创建一个线程来执行单个任务,并在任务完成后释放该线程。

  • Worker threads(工作线程)。创建一个或多个辅助线程,并为每个线程指定特定的任务,定期将任务分配给每个线程。

  • Thread pools(线程池)。创建一个通用线程池,并为每个线程设置run loops。当你有任务要执行时,请从池中获取一个线程,然后将任务分派给该线程。如果没有可用线程,则将任务排队并等待线程可用。

尽管这些技术看似截然不同,但它们实际上只是同一原理的变体。在每种情况下,都使用线程来执行应用程序必须执行的某些任务。它们之间的唯一区别是用于 管理线程任务排队 的代码。使用 dispatch queuesoperation queues,可以消除所有线程和线程通信代码,而只专注于要执行的任务。

如果你正在使用上述线程模型之一,则应该已经对应用程序执行的任务类型有一个很好的了解。尝试将任务封装在一个operation object或一个block object中,然后将其分派到适当的queue中,而不是将任务提交给你的自定义线程。对于不是特别有争议的任务(即不带locks的任务),应该能够进行以下直接替换:

  • 对于单个任务线程,将任务封装在blockoperation object中,然后将其提交到concurrent queue(并发队列)。
  • 对于辅助线程,你需要确定是使用serial queue还是concurrent queue。如果使用工作线程来同步特定任务集的执行,请使用serial queue。如果确实使用工作线程执行没有相互依赖性的任意任务,请使用concurrent queue
  • 对于thread pools,将你的任务封装在一个blockoperation object中,然后将它们分派到concurrent queue中以执行。

当然,像这样的简单替换不一定在所有情况下都有效。如果你正在执行的任务争用共享资源,那么理想的解决方案是尝试首先删除或最小化该争用。如果有一些方法可以重构或重新构造代码以消除对共享资源的相互依赖性,那当然更好。

但是,如果这样做是不可能的或效率可能较低,则仍然可以利用queues的方法。queues的一大优点是它们提供了一种更predictable(可预测)的方式来执行代码。这种可预测性意味着,仍有许多方法可以在不使用locks或其他重量级同步机制的情况下同步代码的执行。除了使用locks,你还可以使用queues来执行许多相同的任务:

  • 如果有必须按特定顺序执行的任务,请将其提交给serial dispatch queue。如果你更喜欢使用operation queues,请使用operation object dependencies(依赖项)来确保这些对象按特定顺序执行。

  • 如果当前正在使用locks来保护共享资源,请创建一个serial queue以执行任何修改该资源的任务。然后,serial queue将你现有的locks替换为同步机制。

  • 如果使用线程联接来等待后台任务完成,请考虑改用dispatch groups。还可以使用NSBlockOperation对象或operation object dependencies(依赖项)来实现类似的组完成行为。

  • 如果您使用 producer-consumer algorithm(生产者-消费者算法) 来管理有限的资源池,请考虑将实现更改为如下 Changing Producer-Consumer Implementations

  • 如果要使用线程从descriptors(描述符)读取、写入,或监视文件操作,请使用dispatch sources

一定要记住,queues不是替换threads的灵丹妙药。queues提供的asynchronous programming model(异步编程模型)在延迟不是问题的情况下是合适的。即使queues提供了配置队列中任务执行优先级的方法,但较高的执行优先级并不能保证在特定时间执行任务。因此,在需要最小延迟的情况下(例如在音频和视频播放中),线程仍然是一个更合适的选择。

消除 Lock-Based Code

对于线程代码,locks是同步访问线程之间共享的资源的传统方法之一。但是,使用locks是有代价的。即使在无争议的情况下,也总是会受到与取locks相关的性能处罚。并且在有争议的情况下,一个或多个线程在等待lock释放时可能会被无限时间阻塞。

queues替换lock-based code(基于锁的代码)可以消除许多与locks相关的惩罚,还可以简化剩余的代码。与其使用使用lock来保护共享资源,不如创建一个queue来序列化访问该资源的任务。Queues 不会施加与locks相同的惩罚。例如,任务排队不需要捕获到内核中以获取互斥。

在对任务进行排队时,你必须做出的主要决定是synchronously(同步)还是asynchronously(异步)进行。异步提交任务可以使当前线程在执行任务时继续运行。同步提交任务会阻塞当前线程,直到任务完成。这两个选项都有适当的用途,尽管在可能的情况下异步提交任务无疑是有利的。

实现 Asynchronous Lock

asynchronous lock(异步锁)是一种保护共享资源方法,它不阻止任何修改该资源的的代码。当需要修改数据结构时,可以使用asynchronous lock,作为代码正在执行的其他一些工作的副作用。使用传统线程,通常实现此代码的方式是对共享资源进行锁定,进行必要的更改,释放该锁定,然后继续执行任务的主要部分。但是,使用dispatch queues,调用代码可以异步进行修改,而无需等待这些更改完成。

如下是一个asynchronous lock实现的示例。在此示例中,受保护的资源定义了自己的serial dispatch queue。调用代码将一个block object提交到此queue,其中包含需要对资源进行的修改。因为queue本身是串行执行blocks的,所以可以确保对资源的更改按接收顺序进行;但是,由于任务是异步执行的,因此调用线程不会阻塞。

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

同步执行关键部分

如果在给定任务完成之前当前代码无法继续,则可以使用dispatch_sync函数同步提交任务。此函数将任务添加到dispatch queue中,然后阻塞当前线程,直到任务完成执行。dispatch queue本身可以是serial(串行)或concurrent(并发)队列,具体取决于您的需求。 但是,由于此函数会阻塞当前线程,因此仅应在必要时使用它。

1
2
3
dispatch_sync(my_queue, ^{
// 关键部分
});

如果你已经在使用serial queue来保护共享资源,则与异步分发相比,同步分派到该队列不会更多地保护共享资源。同步分派的唯一原因是防止当前代码在关键部分结束之前继续执行。例如,如果您想从共享资源中获取一些值并立即使用它,则需要同步调度。如果当前代码不需要等待关键部分完成,或者可以简单地将其他后续任务提交到同一串行队列,则通常首选异步提交。

改进循环代码

如果代码有循环,并且每次循环执行的工作都与其他迭代中完成的工作无关,则可以考虑使用dispatch_applydispatch_apply_f函数重新实现该Loop Code(循环代码)。这些函数将循环的每次迭代分别提交给dispatch queue进行处理。与concurrent queue一起使用时,此功能使你可以同时执行循环的多次迭代。

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

如下用dispatch-based的等效项替换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);
});

尽管前面的示例是一个简单的示例,但是它演示了使用dispatch queues替换循环的基本技术。尽管这可能是提高基于循环的代码性能的好方法,但仍必须谨慎地使用此技术。尽管dispatch queues的开销非常低,但是在线程上调度每个循环迭代仍然会产生成本。因此,应该确保你的循环代码能够完成足够的工作以保证成本。你到底需要做多少工作,这是您必须使用性能工具来衡量的。

在每个循环迭代中增加工作量的一个简单方法是使用striding(跨步)。使用striding(跨步),你可以重写block代码以执行原始循环的多个迭代。然后,可以按比例减少为dispatch_apply函数指定的计数值。

如下向for循环添加striding(跨步)

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);

使用strides(跨步)有一定的性能优势。特别是,相对于stride(跨度),当循环迭代的原始数量较高时,strides(跨度)会带来好处。并发调度更少的blocks意味着执行这些块的代码所花费的时间比调度它们所花费的时间要多。但是,与任何性能指标一样,您可能必须使用striding value(跨步值)才能为你的代码找到最有效的值。

替换 Thread Joins

Thread Joins(线程连接)允许生成一个或多个线程,然后让当前线程等待这些线程完成。为了实现thread join,父线程创建一个子线程作为joinable thread(可连接线程)。如果父线程在没有子线程的结果就无法继续进行时,它与子线程连接。该过程将阻塞父线程,直到子线程完成其任务并退出为止,此时,父线程可以从子线程收集结果并继续自己的工作。如果父级需要与多个子线程联接,则一次只连接一个子线程。

Dispatch groups(调度组)提供的语义类似于thread joins(线程连接)的语义,但具有一些其他优势。与thread joins一样,dispatch groups是阻塞线程直到一个或多个子任务完成执行的一种方式。与thread joins不同,dispatch group同时等待其所有子任务。并且由于dispatch group使用dispatch queues执行工作,所以效率很高。

要使用dispatch group来执行joinable threads执行的相同工作,请执行以下操作:

  1. 使用dispatch_group_create函数创建一个新的dispatch group
  2. 使用dispatch_group_asyncdispatch_group_async_f函数将任务添加到组中。提交给组的每个任务代表你通常在joinable thread上执行的工作。
  3. 当当前线程无法再向前推进时,请调用dispatch_group_wait函数以在组上等待。该功能将阻塞当前线程,直到该组中的所有任务完成执行为止。

如果要使用 operation objects 来实现任务,则还可以使用dependencies(依赖项)来实现thread joins(线程连接)。无需让父线程等待一个或多个任务完成,你可以将父代码移动到operation object。然后,你将在父operation object和任意数量的子operation object之间建立依赖关系,以建立可连接线程通常执行的工作。依赖于其他operation object会阻止父operation object在所有操作完成之前执行。

更改 Producer-Consumer 实现

producer-consumer model(生产者-消费者模型)使你可以管理有限数量的动态产生的资源。当producer(生产者)创建新的资源(或工作)时,一个或多个consumers(消费者)等待这些资源(或工作)准备就绪并消费它们。 实现producer-consumer model的典型机制是conditions(条件)或 semaphores(信号量)。

1、使用conditions(条件),生产者线程 通常执行以下操作:

  • Lock(锁定)与condition(条件)关联的互斥锁(使用pthread_mutex_lock)。
  • 生产要消耗的资源或工作。
  • 向条件变量发出要消耗的Signal(信号)(使用pthread_cond_signal
  • 解锁互斥锁(使用pthread_mutex_unlock)。

2、反过来,相应的 消费者线程 执行以下操作:

  • Lock(锁定)与条件关联的互斥锁(使用pthread_mutex_lock)。

  • 设置while循环以执行以下操作:

1
2
a、检查是否确实有工作要做。
b、如果没有工作要做(或没有可用资源),请调用 pthread_cond_wait 阻止当前线程,直到出现相应的信号
  • 获取生产者提供的工作(或资源)。
  • 解锁互斥锁(使用pthread_mutex_unlock)。
  • 处理工作。

使用dispatch queues,你可以将生产者和使用者实现简化为一个调用:

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

producer(生产者)有工作要做时,所有要做的就是将工作添加到queue中,并让queue处理该项目。前面代码中唯一更改的部分是队列类型。如果producer生成的任务需要按特定顺序执行,则可以使用serial queue。如果生产者生成的任务可以同时执行,则可以将它们添加到concurrent queue中,并让系统同时执行尽可能多的任务。

替换 Semaphore 代码

如果当前正在使用semaphores(信号量)来限制对共享资源的访问,则应考虑使用dispatch semaphores。传统的semaphores总是要求调用内核来测试semaphore(信号量)。相反,dispatch semaphores(调度信号量)会在用户空间中快速测试semaphore(信号量)状态,并仅在测试失败并且需要阻塞调用线程时才捕获到内核中。在无争议的情况下,此行为导致dispatch semaphores比传统semaphores快得多。但是,在所有其他方面,dispatch semaphores(调度信号量)提供的行为与传统semaphores(信号量)相同。

替换 Run-Loop Code

如果你使用run loops来管理一个或多个线程上正在执行的工作,则可能会发现queues(队列)的实现和维护更加简单。设置自定义run loop涉及设置底层线程和run loop本身。run-loop code包括设置一个或多个run loop sources(运行循环源)以及编写回调以处理到达这些sources的事件。与其所有这些工作,不如简单地创建一个serial queue并分派任务到它。因此,你可以用一行代码替换所有线程和run-loop创建代码:

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

因为queue自动执行添加到queue中的所有任务,所以不需要额外的代码来管理queue。你不必创建或配置线程,也不必创建或附加任何run-loop sources。此外,你只需将任务添加到queue中,即可在queue上执行新类型的工作。要对run loop执行相同的操作,将需要修改现有的run loop source或创建一个新的run loop source来处理新数据。

run loops的一种常见配置是处理异步到达network socket(网络套接字)的数据。可以将dispatch source(调度源)附加到所需的queue,而不是为此类型的行为配置run loop。与传统的run loop sources相比,Dispatch sources还提供了更多的数据处理选项。除了处理timernetwork port events之外,还可以使用dispatch sources来读取和写入文件,监视文件系统对象,监视进程以及监视信号。甚至可以定义自定义dispatch sources,并从代码的其他部分异步触发它们。

与 POSIX Threads 的兼容性

因为 Grand Central Dispatch 管理着你提供的任务和运行这些任务的线程之间的关系,所以通常应该避免从任务代码中调用 POSIX thread 例程。如果确实由于某种原因需要调用它们,则应非常谨慎地调用哪些例程。本节向你说明在排队的任务中哪些例程可以安全调用,哪些例程不安全 该列表并不完整,但是应该告诉您什么是安全的,什么不是安全的。

通常,你的应用程序不得删除或更改它未创建的对象或数据结构。因此,使用dispatch queue执行的块对象不得调用以下函数:

1
2
3
4
5
pthread_detach
pthread_cancel
pthread_join
pthread_kill
pthread_exit

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

1
2
3
4
5
pthread_setcancelstate
pthread_setcanceltype
pthread_setschedparam
pthread_sigmask
pthread_setspecific

用于执行给定块的基础线程可以在调用之间变化。 因此,您的应用程序不应依赖以下函数,这些函数在块的调用之间返回可预测的结果:

1
2
3
4
5
6
7
pthread_self
pthread_getschedparam
pthread_get_stacksize_np
pthread_get_stackaddr_np
pthread_mach_thread_np
pthread_from_mach_thread_np
pthread_getspecific

Blocks必须捕获并抑制其中抛出的任何language-level exceptions(语言级别的异常)。 在执行Blocks期间发生的其他错误应类似地由该块处理或用于通知应用程序的其他部分。

学习博客

Migrating Away from Threads

文章作者: Czm
文章链接: http://yoursite.com/2020/10/24/Threads-%E4%B8%83-%E8%BF%9C%E7%A6%BB%E7%BA%BF%E7%A8%8B/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Czm