猪皮课堂-czp 讲协程
czp:每隔几个月来温习一遍,你就精通了
在冯诺伊曼计算机架构中,指令内存和数据内存在同一块主存储器里。也就是说,程序代码(机器码)和变量保存在同一块内存条里,所以 CPU 的工作就非常简单了。
开机时要做的事情也很简单:把程序写入 0x00 这个地址,然后把 CPU 中的 program counter 写为 0。CPU 会开始读取 0x00 地址上的指令,执行完后 program counter 自增,再读取 0x01,CPU 就开始运作了。顺序执行当然不够,所以又有了 jump 这条特殊指令,它可以改写 program counter,让 CPU 跳到另一个地方执行指令,再从那里继续自增下去。因为数据和代码都在同一块内存里,内存地址是唯一的,各种读取、写入也就很方便实现。
有些代码经常被执行,为了避免重复编码,人们发明了 函数。函数本质上就是先 jump 到指定位置,再 jump 回来,这叫一次 call(等会再讲)。于是现代计算机的理论体系就这样被搭起来了,看上去天衣无缝,非常完美,直到有一天,人们开始想:
假如我有一个按钮,无论当前程序运行到何处,只要我按下这个按钮,就希望程序特别执行一个操作,怎么办?
方案一是让 CPU 不断查询这个按钮有没有被按下,这叫 轮询。这种等待方式也叫自旋锁,会导致 CPU 一直被占用,也就是不停查询按钮有没有被按下,实际上 CPU 在一个死循环里占用率持续 100%。这在单片机上可能没什么问题,但程序越来越复杂后,这种死循环会影响正常流程。除了等待轮询按钮被按下,就没办法干别的事了。
于是有了 中断。中断是一种硬件实现,可以在某个硬件被激活时改变 program counter 的值,从而改变程序流程。在 Unix 中,系统把很多信号封装成中断,这类中断是软件实现的。程序收到 Control+C 后,可以执行自己的函数,而不是直接关闭。这是通过注册各种信号的中断函数实现的。既然中断可以改变程序原有流程,那就有了一个想法:能不能在这个时刻执行程序 a,然后中断,去执行程序 b。
对于 CPU 来说,不存在操作系统和应用程序的概念,这些只是代码而已,而中断只是简单地修改了 program counter 的值,系统上运行的程序 a、b 对 CPU 来说没有区别,这个想法是可以实现的。
历史的车轮滚滚滚向前(我倒…),直到有一天,丹尼斯从床上起来,有了一个惊人的想法:能不能在一台电脑上和基友一起玩游戏,就是那种你用 wasd,我用上下左右的游戏。这也意味着系统必须同时处理 2 个程序。很快,沉迷于电子游戏的丹尼斯实现了这个想法:Unix,人类历史上第一个真正多任务多用户操作系统。在这个系统上,CPU 会来回执行各个进程,看起来就像多个程序在同时执行。比如 0-0.01s 执行程序 a,0.01-0.05s 执行程序 b。
那一天,人类的历史迎来了新的一页,丹尼斯将 Unix 时间戳设计为 1970 年 1 月 1 日 8:00 为原点,所以古人云,1970 年 1 月 1 日,上帝创世。
这种来回执行不同程序的方案,叫做:分时,这种操作系统,就叫做 分时操作系统。很快人们发现,虽然分时可以执行多个程序,但是多个程序之间共享数据非常麻烦,于是就想着,能不能在一个进程里面,有好几个同时执行的单元,于是有了线程的概念。
一个 进程 里可以有好几个 线程,所有线程共享同样的数据内存。这样线程 a 修改了变量 var1,线程 b 就可以取得修改后的值,当然这里还有线程安全问题,后面讲。也就是说,在现代操作系统中,代码执行的逻辑单位是线程,每个线程都被独立执行,概念上我们认为它们是同时执行的。
如果一个程序只有 main 函数,那它就只有一个 Main Thread。程序必须至少有一个线程,才能是活动的,否则就是僵尸进程,僵尸进程涉及程序的父子关系,后面讲。这个线程由操作系统控制,操作系统会根据算法为所有线程分配时间片。这看起来天衣无缝,非常完美。直到有一天:互联网被发明了。
很快就出现了一个问题:网络操作非常慢。在操作完成前,程序会一直卡在网络调用上。比如访问 bilibili.com,在这个访问返回前,程序会一直停在那一行。代码可能长这样:val response = GET("[bilibili.com](http://bilibili.com/)")。假如网络奇慢无比,30s 后才返回,那么程序就会卡在这一行 30s。如果这个操作发生在 UI 按钮点击事件里,按钮一按下,程序就卡住了,界面未响应,按钮也弹不回来。这种东西叫 阻塞,所有 IO 操作都有可能阻塞。
为了解决这个阻塞,人们想到了用 多线程 来解决,简单的说:就是让阻塞发生在其他线程,而非主线程。主线程的工作应该都是不阻塞的,所以我们现在的工作流变为了:我们按下按钮,程序创建了一个新线程,去访问 bilibili,然后主线程创建完线程本身,工作就结束了,主线程将被按钮弹回,可以继续响应下一次用户操作。
在这个工作流里面,就遇到了另一个问题,主线程如何知道刚才开启的那个线程已经完成了工作? 如果主线程不停的查询这个线程是否完成了工作,就又变成先前的轮询问题了,主线程自己会因为这个而阻塞。
所以又引入了 回调函数。我们只要给之前开启的线程传入一个函数指针,当它完成工作后,自己去执行这个函数,主线程就不需要一直问它什么时候完成。用 JS 举例:fetch("[bilibili.com](http://bilibili.com/)").then(response => {}),这个 then(*) 就是回调函数,里面的内容会在 fetch 结束后执行,JS 不是多线程的,后面说。比如里面的内容是把 bilibili 返回的内容显示在主窗体上,那么一个简单浏览器就实现了。
通过多线程、回调函数,我们似乎已经可以处理一切问题,看起来完美无缺,但是很快,随着互联网的继续发展,人们遇到了更大的问题:假如一万个用户同时访问了这个网站,那么网站服务器上那个程序,就会开启一万个线程来应对这所有的链接。
我们都知道,线程是分时操作系统分配时间片的最小单元。简单来说,分时操作本身就是一个 system call,需要进入内核态完成,开销相对较大。不同线程还有自己的上下文,也会带来不小的内存开销,比如开一千个线程,就可能消耗 4GB 内存。换句话说,为每个前来连接的用户开一个线程并不现实,服务器再强也会顶不住。
我们观察到,在这个用例,实际上如果我们真的为每个用户开一个线程,每个线程大多数时间都是在阻塞,只有很少一部分时间实在传输数据,而有一些其他的场景更为夸张,例如:聊天服务器,每个用户 99.999% 的时间都是在打字,传输那一点点文本的时间消耗可以忽略不计了:
我们开出来的线程全部都在阻塞,都没在干活,所以我们想到一个惊人的操作:我们只用一个线程,用这个线程去轮询有没有 socket 里面出现数据,如果有则处理,如果没有就轮询;
虽然我们的 socket 很多,但是同时也只有几个 socket 里面有数据,比如说聊天服务器可能有一万个用户在连接,我们有一万个 socket,同时最多只有十个用户在发消息。而消息的处理非常简单,只要发给他所在的其他群员即可,数据处理及户不耗时,那么我们只用一个线程,不停的监听是否有数据出现,我们就可以几乎不延迟的处理这一万个 socket,由此节约大量的服务器资源。
在现代操作系统中,socket 事件被封装(连接成功、出现数据、连接断开,这些都是 事件),程序可以简单的获取这些事件,然后处理对应的 socket,之后继续监听事件出现。这种出现事件,程序就动一动的模式,叫做 事件驱动(event driven),与轮询对立,有了事件驱动,事情突然变得非常完美,似乎这个世界上一切的问题都可以用事件驱动来解决。
但是很快,出现了另外一个问题(我倒…):
事件驱动实际上是为每个事件定义了一个回调函数,比如 socket 出现数据,就执行这个函数,socket 断开就执行那个函数,如果这个回调函数里面,要执行另一个异步操作,而另一个异步操作也有它自己的回调函数,那么就会出现嵌套的回调函数:fetch (x).then (response=>{fetch (response.y).then…})
很快的,这个代码变得没法写了,因为太乱了,这个问题,叫做:callback hell,大多数语言都会出现这个问题。这是由于设计模式导致的。
为了解决这个问题,另外一个思路被提出,叫做:协程(coroutines),coroutines 实际上也是一个:事件 - 回调模式,但是他可以让用户看起来同步的方式写异步,举个例子:
launch {
val response=Get(..).await
val var1=Get(response.y).await
}
launch 表示以下是一个无返回值的协程语句块,协程的核心是一个 Coroutines Dispatcher(协助调度器),在程序编译时,编译时注解处理器会检查所有协程代码中存在阻塞的地方。
检查阻塞非常简单,Java 中所有会发生阻塞的地方,都会抛出 InterruptedException,这个异常本来是用来中断一个线程用的,如果你看过 jdk 源码,你会发现,Tread.destroy () 这个方法是被移除的,现在在他里面本体是直接抛出一个 unsupportrdException,这个方法是假的,是不能用的,这个方法是历史上 Java 的线程设计错误遗留下来的,在当时,线程可以直接被 Thread.destroy ()
但是很快的,一个事情被暴露出来:假设这个线程手动申请了某个锁(有一个类,叫做 Lock,可以手动申请锁,而不受到程序语法块的限制)那么这个线程一旦被销毁,就再也没有人去释放锁了,这会导致程序运行不正常,所以线程是不能被直接销毁的,线程必须自己被关掉,也就是线程里面的代码运行结束,所以设计了 InterruptedException.
前面我们讲到,在传统设计中,线程大部分时间都在阻塞,此时线程本身处于卡死的状态,代码一直停留在这一行,如果其他线程执行了 Thread.interrupt(),那么这个线程会被中断,在这一行立即抛出异常,这个异常叫做 InterruptException,所有的阻塞都有这个异常,此时的代码会进入 catch 体,线程就知道:
我被中断了,catch 里面就可以进行释放锁的操作然后 return,结束自己,因此只需要检查一个地方是否有 InterruptedException,就可以知道这里会不会阻塞。
协程在编译的时候,会检查所有阻塞的地方,然后一旦运行到阻塞的地方代码将跳回协程调度器,协程调度器会执行其他协程,一旦一个阻塞结束了,也就是返回了,那么阻塞代码下面的代码,就会成为他的回调函数;如果下面的代码继续阻塞,这个组的回调里面的阻塞的下面的代码,就会成为他的回调。
协程调度器实现了这一切。用户源码看起来是同步的,没有回调,其实运行时都变成了回调。简单说就是语法糖,让代码干净一点,程序员不用手动处理回调回来之后又回调这种事情,从而消除 callback hell。协程调度器本身在不停执行各种协程,它自己是单线程的,所以协程不是用来执行计算密集型任务的,而是用来处理大多数时间消耗在阻塞上的任务。如果一个东西是计算密集型的,那不可避免要用多线程,因为一个 CPU 核心只能执行一个线程,这就是所谓的 多核心优化。
而 Kotlin 协程提供了可嵌套式的线程跳转能力,用户可以比较简单地在多个线程里执行各种协程,代码依旧整洁如初。详见谷歌搜索 kotlin coroutines.
czp: 那,你已经精通协程了