首先要强调的是协程不是线程,如果一定要将它与线程作比较,那么可能会陷入泥潭,个人认为单纯将协程看作一种编程方式感觉更容易理解. 协程的优点包括:
- 协程更加轻量,创建成本更小,降低了内存消耗
- 减少了 CPU 上下文切换的开销
- 减少同步加锁,整体上提高了性能
- 可以按照同步思维写异步代码
协程这么厉害,那到底有什么用呢?协程有一个很重要的场景,就是IO密集型任务。以前使用同步 IO 的情况下,如果出现了 IO 操作,线程就会被阻塞从而 CPU 可以执行其他线程,等 IO 操作就绪后继续执行下面的任务。只要线程足够多,那么 CPU 就会得到充分利用。这样的编程模式符合人类的思维习惯但是由于大量的线程切换带来了大量的性能的浪费。
为了解决上面的问题,出现了 IO 复用技术,Java 中的 selector 就能解决上面的问题。只用一个线程来处理任务,如果遇到了 IO 操作,就将 IO 操作以及后续的处理(回调函数)交给 Selector 线程 ,当 IO 就绪后执行回调函数(可以在另外一个线程执行)。这样看起来向下面这个样子
Worker 线程
- Worker 线程获得任务
- Worker 线程发生 IO 操作 IO(Handler),Handler用于处理剩下的任务
- Worker 线程处理下一个任务
Master 线程
- Master 线程等待 IO 事件发生
- Master 线程拿到事件绑定的 Handler,并执行 Handler 处理剩下的任务
这个正是 Reactor 模型
- 应用程序注册 IO 就绪事件和相关联的事件处理器
- 事件分离器等待事件的发生
- 当发生读就绪事件的时候,事件分离器调用第一步注册的事件处理器
- 事件处理器首先执行实际的 IO 操作,然后根据读取到的内容进行进一步的处理
通过上面的模型虽然可以解决线程太多的问题,但是代码中会出现大量的回调函数,例如事件处理器中还有会嵌套 IO 操作,这样层层嵌套的回调函数会影响到代码的可读性。
有没有办法能用和同步看起来一样的方式完成异步操作?让 Worker 线程的流程变成下面这样子
- Worker 线程获得任务
- Worker 线程发生 IO 操作 IO()
- Worker 线程处理剩下的任务 Handler()
- 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等。这些上面简陋的协程和这些框架实现的协程可能差很远,但是也可以帮助理解协程概念。