文章目录
- 一个编译器问题
- 背景
- 解决
- 协程为什么一开始没发展成一等公民?
- 自顶向下、逐步求精(Top-down, stepwise refinement)
- 线程的出现
- 协程的雄起
- IO密集型
- 同步语义实现异步
- 发展史
- 线程和协程的关系
- 并发性
- 调度方式
- 资源占用
一个编译器问题
协程概念的出现比线程更早,可以追溯到20世纪50年代的一个问题:COBOL编译器无法做到读一次磁带就可以完成整个编译过程
背景
编译器的基本步骤包括:读取字符流、词法分析、语法分析、语义分析、代码生成器、代码优化器等,上一步的输出作为下一步的输入。
-
COBOL
程序被写在一个磁带上,而磁带不支持随机读写(fopen指针不能fseek)。 - 计算机内存小又不可能把整个磁带的内容都装进去,所以一次读取没编译完就要再从头读。
解决
将词法分析和语法分析合作运行,而不再像其他编译器那样相互独立,两个模块交织运行,编译器的控制流在词法分析和语法分析之间来回切换:
- 当词法分析模块基于词素产生足够多的词法单元Token时就控制流转给语法分析
- 当语法分析模块处理完所有的词法单元Token时将控制流转给词法分析模块
- 词法分析和语法分析各自维护自身的运行状态,并且具备主动让出和恢复的能力
协程为什么一开始没发展成一等公民?
自顶向下、逐步求精(Top-down, stepwise refinement)
自顶向下的思想
:对要完成的任务进行分解,先对最高层次中的问题进行定义、设计、编程和测试,而将其中未解决的问题作为一个子任务放到下一层次中去解决。这样逐层、逐个地进行定义、设计、编程和测试,直到所有层次上的问题均由实用程序来解决,就能设计出具有层次结构的程序。
然而协程这种相互协作调度的思想和自顶向下是不合的,在协程中各个模块之间存在很大的耦合关系,不符合高内聚低耦合的编程思想
,相比之下自顶向下的设计思想使程序结构清晰、层次调度明确,代码可读性和维护性都很不错。
线程的出现
抢占式的线程可以解决大部分的问题,也就是说协程能干的线程干得也不错,线程干的不好的地方,使用者暂时也可以接受。(抢占式任务系统依赖于CPU硬件的支持
,对硬件要求比较高,对于一些嵌入式设备来说,协同调度再合适不过了,所以协程在另外一个领域也施展了拳脚。)
协程的雄起
技术、思想等发展很大程度受时代和场景的影响
IO密集型
对于CPU来说,任务分为两大类:计算密集型和IO密集型。
IO密集型提高CPU有效利用率一直是个难点。
在抢占式调度中也有对应的解决方案:异步+回调
整个过程相比同步IO来说,原来整体的逻辑被拆分为好几个部分,各个子部分有状态的迁移,逻辑复杂,bug肯定多。
同步语义实现异步
前端经典的回调地狱:
例子:
<script>
function doSomething1() {
return 'doSomething1'
}
function doSomething2() {
return 'doSomething2'
}
function doSomething3() {
return 'doSomething3'
}
setTimeout(() => {
console.log(doSomething1())
setTimeout(() => {
console.log(doSomething2())
setTimeout(() => {
console.log(doSomething3())
}, 1000)
}, 1000)
}, 1000)
</script>
用同步的写法实现异步效果:
<script>
function doSomething1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('doSomething1')
}, 1000)
})
}
function doSomething2() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('doSomething2')
}, 1000)
})
}
function doSomething3() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('doSomething3')
}, 1000)
})
}
(async () => {
console.log(await doSomething1())
console.log(await doSomething2())
console.log(await doSomething3())
})();
</script>
发展史
- 1966 年,线程(thread)的概念被提出。
- 1968 年,Dijkstra 发表论文《GOTO 语句是有害的》,结构化编程的理念深入人心,自顶向下的程序设计思想成为主流,协程“跳来跳去”的执行行为类似 goto 语句,违背自顶向下的设计思想。
- 1979 年,Marlin 提交博士论文 Coroutines : A Programming Methodology, A Language Design, and An Implementation,是协程理论的集大成之作。
- 1980 年及之后的 20 余年,多线程成为并发编程的代名词,抢占式击败协作式成为主流的调度方式,协程逐渐淡出主流编程语言舞台。
- 2003 年,Lua v5.0 版本开始支持协程。
- 2005 年,Python 开始支持生成器和 yield/send 关键字,之后数年一直在演化。
- 2009 年,Go 语言问世,以 Goroutine 的方式支持并发编程,一代传奇拉开序幕。
- 2012 年,C# 开始支持 async 函数和 await 表达式,标志着协程王者归来。
- 2015 年,Python 支持 async/await 语法。
- 2017 年,async/await 纳入 ES2017 标准。
- 2017 年,Kotlin 另辟蹊径,以 suspend 关键字的形式实现了协程。
- 2019 年,Dart 支持 Future、async/await 语法。
- 2020 年,C++ 20 支持 co_async/co_await。
- 2022 年 3 月,JDK 19 预览版(Early-Access)中引入了一种新的并发编程模型(织布机计划)——虚拟线程,非最终版,可能随时被删除。
线程和协程的关系
协程和线程并非矛盾,协程的威力在于IO的处理,恰好这部分是线程的软肋,由对立转换为
合作
才能开辟新局面。
从进程到线程再到协程,我们对于CPU的压榨从未停止
。
linux2.6之后的线程切换耗时大概是在几微秒,协程大概是在100纳秒左右。
并发性
线程在多核的环境下是能做到真正意义上的并行
执行的,而协程是为并发
而生的。
打个简单的比方,射雕英雄传中周伯通教郭靖一手画圆,一手画方,两只手同时操作,这个就是并行。普通人大概率不能并行,却可以并发,你先左手画一笔,然后右手画一笔,同一时候只有一只手在操作,来回交替,直到完成两个图案,这就是并发,协程主要的功能。
调度方式
- 线程 – 抢占式调度,操作系统内核调度,切换涉及到
内核态和用户态的转换
,开销较大。 - 协程 – 协作式调度,由程序自身控制,切换只需要保存和恢复协程的上下文,开销较小。
协程的控制可能造成某些协程的饥饿,抢占式更加公平
协程的控制权由用户态决定可能转移给某些恶意的代码,抢占式由操作系统来调度更加安全
资源占用
- 线程 – 有自己独立的栈空间(linux默认8MB),TCB等
- 协程 – 栈空间可以根据需要
动态调整
,共享线程的内核资源,一般设置的128K