阿里的iOS协程库 coobjc 源码解析(综述)——协程是什么?

2,342

本系列是笔者解析阿里的开源协程库 coobjc 的开篇之作,主要聊一聊协程是什么以及如何在iOS的开发过程中为我们带来帮助。

本篇还不会涉及到 coobjc 的代码细节解读,详见后续哈~

协程是什么?

这是一个好问题,让我们可以思考,在我们有了进程、线程这样完善的系统资源分配和调度机制后,为什么我们还需要协程这个东西。

简单来说,协程是比线程的调度粒度更小的单位,就像线程之于进程,一个进程内可以有多条线程同时在运行——而多个协程可以同时运行在同一条线程中。

image.png

如果你需要更细致地了解,可以参考一下维基百科的解释

在移动端,为什么我们需要协程?

根据奥卡姆剃刀原理,我们不妨思考下——如果没有协程,现有的并发模型,是怎么样的。

线程的熵增——无序的线程膨胀

假设我们开发一款聊天类型的软件,APP的首页可能是一个聊天列表,这里面也许会涉及到 HTTP 请求、数据库的读写、文件读写、UI展示、长连接等模块。

因为目前还没有引入协程,所以我们的并发调度,最细粒度,也只能到线程,所以暂时我们的并发,都指代用线程进行并发的情况。

image.png

而在经年累月的维护之下,随着功能的不断变多,业务越来越繁杂,我们可能会引入很多新的 HTTP 请求、数据库的读写、文件读写、UI展示、长链接等。

就是在这些大的模块方向里,我们因为新业务的接入,而不断地衍生出了子模块。

image.png

一种可能的情况是,这些不同的子模块,它们都有自己的网络请求模块,或者长连接模块、数据库模块、文件读写模块等,它们都各自随意地在项目里用线程发起并发,只为了完成自己的任务。

随着业务的膨胀和庞大化,线程的并发情况,在某些时刻也许就会膨胀到我们无法接受——某些极端时刻,APP内同时在运行的线程超过二三十条,甚至超过50条、上百条——远超CPU核心数量。但实际上,有很多的并发实际上是无效的——并发没有带来效率上的提升,反而降低了效率。

诚然,这种情况比较极端,往往大型项目都有Code Review,以及某些编码限制,来防止诸如此类的情况发生;其次是大型项目,通常也会有专门的UI库、网络库、缓存库等来整合这些大型模块的能力。

但是,线程的随意使用,本身是无序而且难以控制的,因为它只是工具的一种——它本质上解决了:我们有一个任务要执行,但我不想让它卡住当前正在执行的任务的这种问题。

线程并发任务的优先级控制问题

回到我们的聊天软件,进入一个新的聊天窗后,我们需要拉取新的消息。

在这种情况下,我们可能会面临的情况是,用户点进又退出了几个聊天窗,最后停留在了最后一个打开的聊天窗里。

我们可能的实现,有两种情况:

  1. 每进入一个新的窗口,我们都发起一条异步线程,来拉取新的消息,示意图如下:

image.png

已经发起了聊天消息拉取的窗口,我们就不管了,我们只需要等待当前窗口的消息回来就可以完成任务了——毕竟别的窗口的消息拉取和当前窗口的消息拉取是并发运行的,相互之间不会阻塞。

这样做确实能完成任务,但它也有它固有的缺点,就是它没办法确定哪个窗口的消息拉取的优先级是最高的。也就是说,实际上别的窗口的优先级并不如当前打开的窗口的优先级那么高——但它们却和当前窗口占有同样的资源。

  1. 利用NSOperationNSOperationQueue来完成这个任务。

利用NSOperationNSOperationQueue,我们可以更好的管理哪个窗口优先拉取消息——通过暂停或取消别的窗口的operation实现。

但这同时也会带来新的问题——我们需要在单个任务里管理错综复杂的暂停以及取消状态——不断地在NSOperation里的每一步添加if判断,来确认任务是否暂停或取消了,从而进行一些决断。

线程同步的问题

用锁

每一个窗口的消息拉取,落到实处,除了有网络请求在前,大概还会有缓存的写入在后。比如这样:

而通常多个窗口的缓存,都会存放在同一个数据库里——如果是比较久以前的项目或模块,缓存是直接用sqliteC语言接口实现的,面对多线程的情况,我们需要为缓存的读写加锁,来确保缓存的正确性。

image.png

但如果并发的线程多了,我们缓存读写的过程出现了多个锁碰撞,会导致CPU不断切换到一个锁住的线程里,来查看锁的状态,从而导致并发效率奇低。

将缓存的操作,统一封装到一个指定的线程里

后来,很多封装得较好的数据库型工具库——如FMDBCore Data等,都倾向于将数据库读写,放到同一线程里,以避免数据库的竟态条件问题。

这样,我们通过牺牲了一定的并发度,但换来了总体更安全和稳定的缓存读写机制。

引入协程再来看上面的问题

协程能对抗熵增吗?

哈哈,协程也不能对抗熵增,也就是协程也无法对抗无序扩张的并发,因为工具毕竟是拿来用的,协程就是用来并发的,我们只要有需求,协程就是需要增加的。

我们确实可以通过一些手段,有效的优化或者减缓这个过程。但是我们没法抵抗不断膨胀的需求和业务。

所以如果有一种并发的手段,可以让并发的代价更小,我们显然是可以欣然接受的。

协程就是了,对比线程,它的上下文切换的代价了等于无;它可以分配更小的栈空间,更省内存——但其本质和线程做的事无异,都是为了并发。

只不过协程不可以并行——更准确的来说,是同一线程内的协程无法并行,所以我们如果用协程进行开发,我们首先需要从更大的角度考虑,每个线程应该负责一个怎么样的模块,然后在这个模块底下,我们再如何通过协程来完成模块里的小任务。

协程具有更方便且高效的优先级控制

协程的体积很小,创建和销毁以及切换的代价,都非常小。其次是协程可以在执行到任意的地方进行yield,让出调度权限——让优先级更高的协程可以立刻进行,在执行完成后,又resume回来。

也就是前面提到的,聊天窗口的消息拉取过程,我们可以很轻松的将其改造为一个后进先出的状态。

image.png

利用协程并发,直接就能解决并发同步的问题

如上图所示,我们所有的聊天窗消息拉取任务,都运行在同一线程里,它们不存在并行的情况,我们可以很轻松地解决掉竟态条件的问题。

也就是我们可以安心地进行缓存读写,而不需要考虑锁的问题。

其次是,如果我们需要多个协程之间进行更精细的协作,我们可以利用消息通道来进行实现。这类似于经典的生产者消费者模型——总而言之,都是可以解决的。

总结

协程给了我们更细粒度的调度方式,以避免我们在执行一些高并发任务时,滥用线程——如果我们逢并发必用线程,很可能到了最后,我们会创建了过多的线程——如果团队庞大,业务繁杂,就更容易造成这种情况,过多的线程将会导致CPU为了满足这些过多的线程的需求,进行了频繁的上下文切换,从而降低CPU的运行效率。

这,本质上就是因为我们低估了一条线程所能承受的工作,而导致我们无论大事小事,用线程并发就完事了。

实际上,很多时候,同质的任务——譬如数据库读写、文件读写等,或者同一业务下的任务,有时甚至只需要一条线程,兴许都能完成得不错。

诚然,笔者也不会自负到认为一条线程能完全对应上一个大模块,只是我希望协程的出现,可以引起大家的关注和思考,重新审视一条线程所能承担的职责。

其次是,我们是否能善用线程和协程,来打造更加美好的并发环境呢?