iOS并发编程指南(4) --- Migrating Away from Threads

来源:互联网 发布:淘宝用户标签有哪些 编辑:程序博客网 时间:2024/05/01 08:52

Migrating Away from Threads

从现有的线程代码迁移到Grand Central Dispatch和Operation对象有许多方法,尽管可能不是所有线程代码都能够执行迁移,但是迁移可能提升性能,并简化你的代码。

使用dispatch queue和Operaiton queue相比线程拥有许多优点:

应用不再需要存储线程栈到内存空间

消除了创建和配置线程的代码

消除了管理和调度线程工作的代码

简化了你要编写的代码

使用Dispatch Queue替代线程

首先考虑应用可能使用线程的几种方式:

单一任务线程:创建一个线程执行单一任务,任务完成时释放线程

工作线程(Worker):创建一个或多个工作线程执行特定的任务,定期地分配任务给每个线程

线程池:创建一个通用的线程池,并为每个线程设置run loop,当你需要执行一个任务时,从池中抓取一个线程,并分配任务给它。如果没有空闲线程可用,任务进入等待队列。

虽然这些看上去是完全不同的技术,但实际上只是相同原理的变种。应用都是使用线程来执行某些任务,区别在于管理线程和任务排队的代码。使用dispatch queue和operation queue,你可以消除所有线程、及线程通信的代码,集中精力编写处理任务的代码。

如果你使用了上面的线程模型,你应该已经非常了解应用需要执行的任务类型,只需要封装任务到Operation对象或Block对象,然后dispatch到适当的queue,就一切搞定!

对于那些不使用锁的任务,你可以直接使用以下方法来进行迁移:

单一任务线程,封装任务到block或operation对象,并提交到并发queue

工作线程,首先你需要确定使用串行queue还是并发queue,如果工作线程需要同步特定任务的执行,就应该使用串行queue。如果工作线程只是执行任意任务,任务之间并无关联,就应该使用并发queue

线程池,封装任务到block或operation对象,并提交到并发queue中执行

当然,上面只是简单的情况。如果任务会争夺共享资源,理想的解决方案当然是消除或最小化共享资源的争夺。如果有办法重构代码,消除任务彼此对共享资源的依赖,这是最理想的。

如果做不到消除共享资源依赖,你仍然可以使用queue,因为queue能够提供可预测的代码执行顺序。可预测意味着你不需要锁或其它重量级的同步机制,就可以实现代码的同步执行。

你可以使用queue来取代锁执行以下任务:

如果任务必须按特定顺序执行,提交到串行dispatch queue;如果你想使用Operation queue,就使用Operation对象依赖来确保这些对象的执行顺序。

如果你已经使用锁来保护共享资源,创建一个串行queue来执行任务并修改该资源。串行queue可以替换现有的锁,直接作为同步机制使用。

如果你使用线程join来等待后台任务完成,考虑使用dispatch group;也可以使用一个 NSBlockOperation 对象,或者Operation对象依赖,同样可以达到group-completion的行为。

如果你使用“生产者-消费者”模型来管理有限资源池,考虑使用 dispatch queue 来简化“生产者-消费者”

如果你使用线程来读取和写入描述符,或者监控文件操作,使用dispatch source

记住queue不是替代线程的万能药!queue提供的异步编程模型适合于延迟无关紧要的场合。虽然queue提供配置任务执行优先级的方法,但更高的优先级也不能确保任务一定能在特定时间得到执行。因此线程仍然是实现最小延迟的适当选择,例如音频和视频playback等场合。

消除基于锁的代码

在线程代码中,锁是传统的多个线程之间同步资源的访问机制。但是锁的开销本身比较大,线程还需等待锁的释放。

使用queue替代基于锁的线程代码,消除了锁带来的开销,并且简化了代码编写。你可以将任务放到串行queue,来控制任务对共享资源的访问。queue的开销要远远小于锁,因为将任务放入queue不需要陷入内核来获得mutex

将任务放入queue时,你做的主要决定是同步还是异步,异步提交任务到queue让当前线程继续运行;同步提交任务则阻塞当前线程,直到任务执行完成。两种机制各有各的用途,不过通常异步优先于同步。

实现异步锁

异步锁可以保护共享资源,而又不阻塞任何修改资源的代码。当代码的部分工作需要修改一个数据结构时,可以使用异步锁。使用传统的线程,你的实现方式是:获得共享资源的锁,做必要的修改,释放锁,继续任务的其它部分工作。但是使用dispatch queue,调用代码可以异步修改,无需等待这些修改操作完成。

下面是异步锁实现的一个例子,受保护的资源定义了自己的串行dispatch queue。调用代码提交一个block到这个queue,在block中执行对资源的修改。由于queue串行的执行所有block,对这个资源的修改可以确保按顺序进行;而且由于任务是异步执行的,调用线程不会阻塞。

dispatch_async(obj->serial_queue, ^{

// Critical section

});

同步执行临界区

如果当前代码必须等到指定任务完成,你可以使用 dispatch_sync 函数同步的提交任务,这个函数将任务添加到dispatch queue,并阻塞当前线程直到任务完成执行。dispatch queue本身可以是串行或并发queue,你可以根据具体的需要来选择使用。由于 dispatch_sync 函数会阻塞当前线程,你只应该在确实需要的时候才使用。

下面是使用 dispatch_sync 实现临界区的例子:

dispatch_sync(my_queue, ^{

// Critical section

});

如果你已经使用串行queue保护一个共享资源,同步提交到串行queue,并不能比异步提交提供更多的保护。同步提交的唯一理由是,阻止当前代码在临界区完成之前继续执行。如果当前代码不需要等待临界区完成,或者可以简单的提交接下来的任务到相同的串行queue,就应该使用异步提交。

改进循环代码

如果循环每次迭代执行的工作互相独立,可以考虑使用 dispatch_apply 或 dispatch_apply_f 函数来重新实现循环。这两个函数将循环的每次迭代提交到dispatch queue进行处理。结合并发queue使用时,可以并发地执行迭代以提高性能。

dispatch_apply 和 dispatch_apply_f 是同步函数,会阻塞当前线程直到所有循环迭代执行完成。当提交到并发queue时,循环迭代的执行顺序是不确定的。因此你用来执行循环迭代的Block对象(或函数)必须可重入(reentrant)。

下面例子使用dispatch来替换循环,你传递给 dispatch_apply 或 dispatch_apply_f 的Block或函数必须有一个整数参数,用来标识当前的循环迭代:

queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_apply(count, queue, ^(size_t i) {

printf("%u\n", i);

});

你需要明智地使用这项技术,因为dispatch queue的开销虽然非常小,但仍然存在,你的循环代码必须拥有足够的工作量,才能忽略掉dispatch queue的这些开销。

提升每次循环迭代工作量最简单的办法是striding(跨步),重写block代码执行多个循环迭代。从而减少了 dispatch_apply 函数指定的count值。

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可以提升性能。

替换线程Join

线程join允许你生成多个线程,然后让当前线程等待所有线程完成。线程创建子线程时指定为joinable,如果父线程在子线程完成之前不能继续处理,就可以join子线程。join会阻塞父线程直到子线程完成任务并退出,这时候父线程可以获得子线程的结果状态,并继续自己的工作。父线程可以一次性join多个子线程。

Dispatch Group提供了类似于线程join的语义,但拥有更多优点。dispatch group可以让线程阻塞直到一个或多个任务完成。和线程join不一样的是,dispatch goup同时等待所有子任务完成。而且由于dispatch group使用dispatch queue来执行任务,更加高效。

以下步骤可以使用dispatch group替换线程join:

使用 dispatch_group_create 函数创建一个新的dispatch group

使用 dispatch_group_async 或 dispatch_group_async_f 函数添加任务到Group,这些是你要等待完成的任务

如果当前线程不能继续处理任何工作,调用 dispatch_group_wait 函数等待这个group,会阻塞当前线程直到group中的所有任务执行完成。

如果你使用Operation对象来实现任务,可以使用依赖来实现线程join。不过这时候不是让父线程等待所有任务完成,而是将父代码移到一个Operation对象,然后设置父Operation对象依赖于所有子Operation对象。这样父Operation对象就会等到所有子Operation执行完成后才开始执行。

修改“生产者-消费者”实现

生产者-消费者 模型可以管理有限数量动态生产的资源。生产者生成新资源,消费者等待并消耗这些资源。实现生产者-消费者模型的典型机制是条件或信号量。

使用条件(Condition)时,生产者线程通常如下:

锁住与condition关联的mutex(使用pthread_mutex_lock)

生产资源(或工作)

Signal条件变量,通知有资源(或工作)可以消费(使用pthread_cond_signal)

解锁mutex(使用pthread_mutex_unlock)

对应的消费者线程则如下:

锁住condition关联的mutex(使用pthread_mutex_lock)

设置一个while循环[list=1]

检查是否有资源(或工作)

如果没有资源(或工作),调用pthread_cond_wait阻塞当前线程,直到相应的condition触发

获得生产者提供的资源(或工作)解锁mutex(使用pthread_mutex_unlock)处理资源(或工作)使用dispatch queue,你可以简化生产者-消费者为一个调用:

dispatch_async(queue, ^{

// Process a work item.

});

当生产者有工作需要做时,只需要将工作添加到queue,并让queue去处理该工作。唯一需要确定的是queue的类型,如果生产者生成的任务需要按特定顺序执行,就使用串行queue;否则使用并发Queue,让系统尽可能多地同时执行任务。

替换Semaphore代码

使用信号量可以限制对共享资源的访问,你应该考虑使用dispatch semaphore来替换普通信号量。传统的信号量需要陷入内核,而dispatch semaphore可以在用户空间快速地测试状态,只有测试失败调用线程需要阻塞时才会陷入内核。这样dispatch semaphore拥有比传统semaphore快得多的性能。两者的行为是一致的。

替换Run-Loop代码

如果你使用run loop来管理一个或多个线程执行的工作,你会发现使用queue来实现和维护任务会简单许多。设置自定义run loop需要同时设置底层线程和run loop本身。run-loop代码则需要设置一个或多个run loop source,并编写回调来处理这些source事件到达。你可以创建一个串行queue,并dispatch任务到queue中,这样一行代码就能够替换原有的run-loop创建代码:

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

由于queue自动执行添加进来的任务,不需要编写额外的代码来管理queue。你也不需要创建和配置线程,更不需要创建或附加任何run-loop source。此外,你可以通过简单地添加任务就能让queue执行其它类型的任务,而run loop要实现这一点,必须修改现有run loop source,或者创建一个新的run loop source。

run loop的一个常用配置是处理网络socket异步到达的数据,现在你可以附加dispatch source到需要的queue中,来实现这个行为。dispatch source还能提供更多处理数据的选项,支持更多类型的系统事件处理。

与POSIX线程的兼容性

Grand Central Dispatch管理了任务和运行线程之间的关系,通常你应该避免在任务代码中使用POSIX线程函数,如果一定要使用,请小心。

应用不能删除或mutate不是自己创建的数据结构。使用dispatch queue执行的block对象不能调用以下函数:

pthread_detach

pthread_cancel

pthread_join

pthread_kill

pthread_exit

任务运行时修改线程状态是可以的,但你必须还原线程原来的状态。只要你记得还原线程的状态,下面函数是安全的:

pthread_setcancelstate

pthread_setcanceltype

pthread_setschedparam

pthread_sigmask

pthread_setspecific

特定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处理,或者通知应用


原创粉丝点击