czp:每隔几个月来温习一遍,你就精通了

在冯诺伊曼计算机架构中:指令内存和数据内存在同一块主存储器中的,也就是说,程序的代码(机器码)和保存的变量,是在同一块内存条里面的,所以 CPU 的工作就非常简单了。

开机的时候要做的事情非常简单,那就是把程序写入到 0x00 这个地址,然后把 CPU 中的 program counter 写为 0,CPU 就会开始读取 0x00 这个地址上的指令,执行完之后,program counter 会自增,然后开始读取 0x01,你的 CPU 就开始运作了,但是顺序执行是不够的,所以就发明了 jump,这一条特殊的指令可以写入 prgram 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") 假如网络奇慢无比,这个访问 30s 后才返回,那么这个程序就会一直卡在这一行 30s,假如这个操作发生在一个 UI 上的按钮按下时,那么这个按钮一按下,程序就卡住了,程序变得未响应,并且按钮也弹不回来,这种东西,我们叫它:阻塞,所有的 IO 操作,都有可能产生阻塞。

为了解决这个阻塞,人们想到了用 多线程 来解决,简单的说:就是让阻塞发生在其他线程,而非主线程。主线程的工作应该都是不阻塞的,所以我们现在的工作流变为了:我们按下按钮,程序创建了一个新线程,去访问 bilibili,然后主线程创建完线程本身,工作就结束了,主线程将被按钮弹回,可以继续响应下一次用户操作。

在这个工作流里面,就遇到了另一个问题,主线程如何知道刚才开启的那个线程已经完成了工作?如果主线程不停的查询这个线程是否完成了工作,就又变成先前的轮询问题了,主线程自己会因为这个而阻塞。

所以就引入了一个概念:回调函数,我们只要先之前开启的那个线程传入一个函数指针,当他完成工作后,自己去执行这个函数,我们的主线程就不需要知道它什么时候完成工作了,用 js 举例: fetch ("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,就可以知道这里会不会阻塞。

协程在编译的时候,会检查所有阻塞的地方,然后一旦运行到阻塞的地方代码将跳回协程调度器,协程调度器会执行其他协程,一旦一个阻塞结束了,也就是返回了,那么阻塞代码下面的代码,就会成为他的回调函数;如果下面的代码继续阻塞,这个组的回调里面的阻塞的下面的代码,就会成为他的回调。

协程调度器实现了这一切,用户的源码,看起来是同步的,没有回调,其实在运行的时候,他们都变成了回调,简单的说就是语法糖,使得代码整洁干净,程序员不需要考虑什么回调回来之后又回调这种事情,消除了 callbak hell,协程调度器他本身是在不停执行各种协程的过程中,他自己是单线程的,所以协程并不是用来执行计算密集型任务用的,而是用来处理,大多数时间消耗在阻塞上的任务。如果一个东西是计算密集型的,那么不可避免的要使用多线程,因为一个 CPU 核心只能执行一个线程,这就是所谓的:多核心优化

而 kotlin协程 则提供了可嵌套式的线程跳转能力,用户可以简单的在多个线程执行各种协程,而代码依旧整洁如初,详见谷歌搜索 kotlin corountines.

czp: 那,你已经精通协程了