[TOC]
咱们不讨论定义这个问题,因为……这个东西其实也没有一个准确的定义。说到协程,我们不得不讨论到一个问题,就是并行任务。可以这么说,协程就是一个并行任务的解决方案而已。本次课程,我们会从一个感性的认知开始,一点点拨开这个谜团。我们会主要使用Kotlin语言进行示范和讲解。虽然不同的语言里面的底层实现略有不同,但是内部的原理其实大同小异。
比如说,进程、线程、锁、信号量。
要求不需要太高……会用Thread的create和start就好。当然,如果你已经会了C&C++语言中相关的操作,这个对你来讲应该不会很难。
Golang,Kotlin,Python三选一即可,当然,不限于这些语言,还有很多语言是支持协程的。
举个栗子来说,小明早上起来需要烧开水并且刷牙洗脸。那么小明可以在等水烧开的时间段内去洗脸刷牙,这样就可以提高时间的利用率。说回来计算机里面的应用,因为在CPU中,往往不止一个核心,那么,假设我要打开一个网站,里面有几十张图片,如果调度不当,我们只能利用一个核心一张一张图挨个加载,这显然是效率不高的。如果我们能利用多核心的优势,那么就可以同时加载多个图片,加载效率大大提高。如果你看不懂我说的这段话,请你用电脑打开一下淘宝网,或者京东商城的网址,你就会深刻体会到我刚刚这段话是什么意思了。
估计有同学要问了,我CPU很快啊,可以一下子就加载几十张图片呀,那为什么还要搞什么并行任务?其实大家不妨去看一下,CPU单核心性能提高是非常慢的,比如说牙膏厂,挤了几代的牙膏,CPU的单核心性能一直没有太大的进步。相比而言,我把核心数从4核做到8核,性能就可以显著提高,这显然是更加经济的做法。所以,以后的高性能应用程序,应该注重的是多核心的利用效率,而不是再用高频率、高单核心性能打天下了,已经到瓶颈了。
以前计算机操作系统还没有把多任务并行事件做的很完善,调度的粒度是以进程为单位。不同的进程拥有不同的寻址空间、拥有不同的计算资源、并且受到CPU的调度,换句大家都能听得懂的话,“进程是操作系统进行资源分配和调度的最小单位”,考研党应该DNA动了吧?
那么,如果CPU需要进行切换任务、调度的时候,就需要保存上一个进程执行的现场,并且储存在另一个区域里,然后让出CPU来执行其他任务。这个过程,叫做上下文切换(context switch)。在这个过程中,由于不同的进程之间虚拟内存地址空间的区别,内存中有大量的数据需要被交换,否则就没有办法进行寻址了。当然,此话并不绝对,因为现在的计算机内存越来越大,swap发生的次数和量也可能会越来越少,内存的使用是受到很多方面的东西影响的。
既然切换一个进程的过程非常麻烦,它的耗时和花费的代价也很大,所以,在这样的情况下实现并行任务,往往对资源占用是比较大的。但是不是说这样就不好呢?并不,因为每个并行任务之间隔离的非常好,所以一般不会出现一个任务出错整个程序崩溃的问题。
所以话说回来,现在有没有哪些程序是用这种策略实现并行任务的呢?大名鼎鼎的Chrome浏览器就是呀(忽视图片里这个edge,反正是同一个内核的),打开任务管理器,就会发现一堆的进程在后面工作,每一个页面,就是一个单独的进程。这下知道为啥chrome占内存厉害了吧?
进程实在是太大了,这样的开销很多设备是承受不起的,我们需要一个更加轻量化的东西。我们之前研究过为啥进程太大,就是因为大量的内存空间需要发生swap,导致切换的效率低。所以,解决的办法就是,让这个swap尽可能少不就好了吗?于是乎,线程就是拿来解决这个问题的。在同一个进程中,不同的线程共享同一个虚拟内存空间,所以线程的切换就比进程简单多了。我这里找了一篇文章讲的挺好,可以参考一下:https://segmentfault.com/a/1190000019750164
理论上说,基于多线程的并行任务已经很好用了,并且,我们目前能接触到的几乎所有的app,都是依靠多线程来完成并行任务的。但是线程还有什么不足的东西呢?我们又来举个栗子。假定计算机做IO操作的时候,CPU是不需要参与工作的(事实上也确实如此,有DMA这个外部设备来完成IO任务,而不是CPU自身)。那么,有一个服务,张三进来调用,服务器分配了一个线程给他,在进行调用的过程中,张三需要进行1秒的IO操作,然后再进行操作。在张三等待IO操作的过程中,李四调用该服务,服务器也分配了一个线程给他,好巧不巧,李四做了一点很简单的运算任务,就完成了。
在这个过程中,服务器实际上分配了两个线程给不同的调用者。但,这有没有必要呢?其实,只需要在张三等待IO操作的时候,把线程归还给服务器,服务器再把这个线程分配给李四,然后就可以只用一个线程完成了这里所有的任务。诶,似乎嗅到了一丝改进的机会。
很显然,纯多线程的解决方案是有缺陷的,当访问量大起来的时候,给每一个用户分配一个独立的线程会大量占用内存。关键的问题是,其实每个线程大部分的时间都还在等待IO设备,也就是说,在阻塞或者被挂起的状态。就好比一个公司里,养了很多员工,每个员工大部分时间都在摸鱼,然而公司每个月还得支付高额的薪水。这当然是不好的,我们要开始“裁员”。光裁员还不行,我们还要努力“压榨”剩下的员工,让他们尽可能所有的时间都在干活,这样的效率才能最大化嘛。没错,这个就是协程需要办到的任务,既要减小资源的消耗,还得让资源的利用达到最大化。说到这里。我相信大家应该已经有个大概印象协程是什么,协程要做到什么事情了吧?
其实讲到底,协程不是凌驾于线程和进程之外的另一个东西,协程的实现,靠的就是“线程池”。
线程池是什么?可以这么理解,线程池是为了让你有限地使用线程。以前,当一个用户访问一个服务的时候,服务器直接创建一个线程来服务,服务完之后,这个线程由操作系统进行回收。但是,一个线程的创建是比较慢的,而且,操作系统的垃圾回收往往是不够及时的,当操作系统发生回收的时候,往往系统都已经卡的不行了。而且,最大的问题是,如果有很多很多的用户进来访问的时候,如果还是那样创建线程不加约束,操作系统很快就会不堪重负。为了解决问题,一方面我们需要避免一下子创建过多的线程,另一方面,我们要避免低效的创建和回收。所以,有线程池的情况下,当有一个用户进行访问的时候,就从线程池里面直接拿一个线程进行服务,由于线程一开始就创建好了,所以这里并不会有新建线程的开销。同理,当线程池里面没有可以用的线程的时候,新的用户就不能进来,而必须进行等待。当用户服务结束之后,对应的线程直接归还给线程池(然后又可以去接下一个任务了),而不是等待操作系统来进行回收,这样就可以提高线程的利用率。
协程和线程池本质上没有差别,因为协程的底层实现也就是线程池,只不过,一个需要你自己管理,一个是自动调度的,仅此而已。那么,协程究竟办到了什么事情?为什么我们要使用协程而不是自己去定义线程池?
关键问题就在于,如何实现“挂起”和“恢复”。我们上面说到过,如果当CPU执行到一个耗时的IO任务时,把CPU的时间让出来,这样,就可以在不创建多一个线程的前提下,执行另一个任务,并且,执行的速度和使用两个线程没有太大的差别,资源消耗还减少了。当一个任务把当前占用的线程交出来,称之为“挂起”;一个被挂起的任务继续执行下去,称之为“恢复”(这两个定义不完全准确,只是为了方便大家理解而已,别杠)。
现在的编程语言还没聪明到可以自动识别出来你的代码到底需不需要进行耗时的IO任务的,更多的时候需要我们写代码的人手动指定,比如说我们手动开启一个异步任务来处理等等。虽然现在在协程里,你不需要这样的一个步骤了,但是,你得告诉编译器,我的这段代码是耗时的,记得帮我异步处理它。
所以说,一个语言里的关键字就变得很重要了,比如说Kotlin里面的suspend,Python里面的yield(当然,这个yield不完全对,只是它恰好办到了这个功能,可以让一个函数在某处暂停,然后又从该处继续执行下去,打了个擦边球)。这个关键词可以告诉编译器,我的这一段代码是耗时的,并且,挂起完之后,可以从此处进行恢复。(个人拙见,Golang缺了一个合适的关键词来表达这个意思,go关键字仅仅只是作为发起一个协程的作用,达不到自动挂起和自动恢复的功能,因而在编码的时候并不能简单多少。完美符合我的想法的语言,目前只有Kotlin。)
当然哈,一方面打代码方便了,确实真香,你看Golang直接go一个任务出去,简单粗暴还高效。
另一个方面,这个对某些特定的CPU有优化。不像电脑,所有的核心都是平等的,任务在哪个核心上执行并没有太大的区别。但手机就不一样了,手机的处理器一般都是大小核设计,一些核心性能高但是能耗大,另一些核心性能略差但是能耗低。手机的应用程序上,我们不可能让所有的计算任务都跑在性能核心上,否则功耗肯定会爆表,因而,有一些对性能不是太敏感的应用,例如网络请求,数据存储,数据读写等等任务,就没有必要放在高性能核心上来进行处理。所以,这个也就是Kotlin协程针对手机平台专门的特殊优化了,因为在Kotlin中,是可以单独指定该任务到底是属于IO型任务还是计算型任务,所以调度的时候就会对不同类型的任务做出特定的优化。
我曾经目睹过有人在争论到底协程对比起普通的多线程任务是buff还是debuff,其实本质上就是搞错了对所谓的“高性能”的定义。传统意义上,我们认为处理得更快就算是高性能,这个很有道理,原来要处理1s的东西,现在0.5s了,当然是性能更好了。但其实,从另一个维度来说,增大吞吐量,也算是一种高性能。就好比妈妈炒菜,炒半斤的菜和炒一斤的菜,所花的时间是差不多的,那你肯定不能说我等饭的时间变短了对吧?但事实上你确实在同样的时间得到了更多的菜。协程就是这个道理。
明白了上面的这段解释,其实这个问题就可以迎刃而解了。协程并不可以减少单次调用的时间,相反,因为涉及到一个任务的挂起和恢复还有各种各样调度的问题,单次调用等待的时间反而会变长。但是,就像我们上面所解释到的,在等待相应的过程中,可以把协程任务挂起,从而让出CPU的计算资源,从而让计算机的吞吐量变大。具体提升有多大呢?微信的后台就是靠大量的协程构建的,微信的并发量有多恐怖,不必多说了吧?(可以参见腾讯开源的libco库)。
显然不是,之所以协程可以提高吞吐量,有一个前提就是,IO消耗的时间远远大于CPU的运算时间,也正因为IO非常耗时,所以我们应该让出来CPU资源给其他的任务,这样才能提高并发量。所以,这个答案就很明确了,协程应该被应用在IO密集型的任务中,如果不是IO密集型任务的话,其实大部分的时间内CPU都在努力地干活,所以根本谈不上“挂起”,“恢复”这么一说了,CPU正干着活呢,你给我挂起了任务???想想也不太对劲吧。
当然,这话的意思不是说我的计算密集型的任务就不能用协程搞了,当然你还可以用它,只不过你可能收获不到预期的性能提升而已。
我不记得是在那里的一个教程中看到有人这么说:“在协程中,我们可以将网络IO也放到主线程中,这个网络请求不会卡住主线程,而是在执行发送网络请求的代码后就立刻执行主线程上的其他工作,待网络请求返回后,自动转到网络请求返回的回调中,实现在同一线程做到以往需要多个线程才能实现的效果。因为不需要切换线程,因此没有了线程开销,性能自然要比传统多线程要好。实机操作中,对于同时发起多个网络请求,以往需要同时开启多个线程,现在只需要将多个网络请求放到一个线程上即可。”
细品。你细品。好像很有道理啊?!
其实,这个就是一个缝合怪,对一些,也错一些,不妨我把他的描述改进一下,咱们再细品一品。“主线程发起一个网络请求后,由于网络请求存在耗时的操作,所以这个任务会从某处开始被挂起,等待IO然后再切换回来承载它后续的操作。那么,原先的线程在等待的时间内,完全可以再去承载一个任务,从而提高了利用的效率,自然相同的任务量就可以减少线程的数量了。”
我们注意几个说辞,“立即执行别的任务”这个完全是天方夜谭,要是这样的话还要我们写代码做什么?协程之所以可以反复横跳,就是因为有“挂起”和“恢复”的动作,而不是想怎么切换就怎么切换。可能有点难理解。来打个比方,我要做语文作业,也要做数学作业,那你觉得你可能作文才写一半就去做数学题的吗?这当然不可能啊。
再者,协程往往是不指定某一个特定的线程的,而是某一个Dispatcher或者某一个线程组。正因为之前说过的协程任务会被挂起和恢复,显然我们并不能说之前是1号线程发起的任务,挂起完恢复还是由1号线程来承载,这并不确定,因为各个不同的线程受到调度器指挥,会不断地在合适的时间分配合适的任务给他们做。就好比,我是搭着车牌为粤A 11111的公交车去上班的,难道就一定会搭同样号牌的公交车下班吗?显然这不是,相反,如果我非要等同样的这一辆车,反而可能会导致你下班回家的时间变得更晚。
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。