首先要强调的是协程不是线程,如果一定要将它与线程作比较,那么可能会陷入泥潭,个人认为单纯将协程看作一种编程方式感觉更容易理解. 协程的优点包括:
协程这么厉害,那到底有什么用呢?协程有一个很重要的场景,就是IO密集型任务。以前使用同步 IO 的情况下,如果出现了 IO 操作,线程就会被阻塞从而 CPU 可以执行其他线程,等 IO 操作就绪后继续执行下面的任务。只要线程足够多,那么 CPU 就会得到充分利用。这样的编程模式符合人类的思维习惯但是由于大量的线程切换带来了大量的性能的浪费。
为了解决上面的问题,出现了 IO 复用技术,Java 中的 selector 就能解决上面的问题。只用一个线程来处理任务,如果遇到了 IO 操作,就将 IO 操作以及后续的处理(回调函数)交给 Selector 线程 ,当 IO 就绪后执行回调函数(可以在另外一个线程执行)。这样看起来向下面这个样子
Worker 线程
Master 线程
这个正是 Reactor 模型
通过上面的模型虽然可以解决线程太多的问题,但是代码中会出现大量的回调函数,例如事件处理器中还有会嵌套 IO 操作,这样层层嵌套的回调函数会影响到代码的可读性。
有没有办法能用和同步看起来一样的方式完成异步操作?让 Worker 线程的流程变成下面这样子
假设这里的 IO() 不会阻塞线程,那么无法保证 第3步一定在第2步之后执行。同样为了保证第3步一定在第2步之后执行,除了回调之外,那就只能阻塞。看起来是矛盾的,其实只要将这个两个操作剥离出去,线程不用关心这两个操作是否正常完成。因此,现在需要一种封装方法将这个两个步骤封装起来,并且保证这两步按照顺序执行,当然这里也不能让线程阻塞。
假设有一种表达式可以插入到任意位置,它内部可以包含表达式,样子像下面这样
co{
yield IO();
Handler();
...
}
之前为了实现让Handler() 在 IO() 之后执行使用的是回调如 IO(Handler) ,那么这里通用也应该通过回调实现。代码看起来是同步的,但是实际运行的时候还是利用回调实现的。这个转换工作可以在编译期完成。注意 yield 这个标记,编译就是根据这个标记将 co 里面的代码分成几个部分, 分别对应 switch 的几种条件,因此回调的时候只要根据当前执行到哪个标记,就执行对应的 case 就可以了。
上面 co 代码块即使就在被调用的线程执行也不会阻塞线程,因为这之前回调的流程本质是一样的。但是看起来就好像这个 co 是被阻塞了一样,实际上并没有 co 并阻塞,线程也没有被阻塞。
虽然看起来解释的比较粗糙, 但是 co 代码块勉强算是协程。但是其内部不能使用阻塞方法,部分还是会导致当前执行的线程被阻塞。一个通用的协程应该能包含任意类型的代码,所以我们还需要对 co 进行升级,通过 Excutor 框架将 co 代码块作为一个 task 提交,这样主线程就一定不可能被阻塞。主线程不会被阻塞,但是 co 代码块中的阻塞方法还是会导致执行 task 的线程阻塞。因此,还需要规定 yield 关键字引导的函数也会被转化为 co 代码块,在上一级 co 代码块中作为 task 提交。
以上就是对协程的一些理解,如果想了解协程更具体的实现方法可以参考很多已有的框架,如 Kotlin, Go, Fiber等。这些上面简陋的协程和这些框架实现的协程可能差很远,但是也可以帮助理解协程概念。