自从17年kotlin成为为android官方开发语言后就越来越受android开发者关注,如今kotlin已经成为android开发者的必备技能。在学习kotlin的过程中难以避免会接触到协程这个概念,许多人或许会有疑惑,协程是什么,为什么需要引入协程,直接用线程不行吗?为了揭开这些疑惑,本文对kotlin的协程起源及发展做了简单的介绍,希望能够帮助到大家。
起源
为了解答这些问题,就要先了解协程的历史。
协程的概念最早可以追溯到基于磁带存储的COBOL编译器优化问题,当时为了实现只扫描一遍程序(one-pass)的COBOL编译器,梅尔文·康威(Melvin Conway)在1960年提出了协程的概念。
现在来看,高级语言的编译器主要步骤有:词法解析、语法解析、语法树构建、以及优化和目标代码生成等。程序编译实际上就是从源码开始,依次执行上述流程,并且把每个步骤的输出作为下一步骤的输入,这在现在的计算机上不存在什么问题,编译的中间结果可以存在内存或者磁盘中。然而当时的的存储设备主要是磁带,并且当时计算机内存也十分有限,由于磁带都是顺序读的,而编译过程又需要循环读写数据,对于磁带来说就需要频繁的倒带和快进,这样的操作十分容易出错,因此实现只扫描一遍程序(one-pass)的编译器还是十分有必要的。
在康威的设计中,词法和语法解析不再是两个独立运行的步骤,而是交织在一起。当词法模块读入足够多的 token 时,控制流交给语法分析;当语法分析消化完所有 token 后,控制流交给词法分析。在这种协同工作机制下,需要参与者主动让出(yield)控制流,记住自身状态,以便在控制流返回时从上次让出的位置恢复(resume)执行。简单来说,协程设计的核心就在于控制流的主动让出(yield)和恢复(resume),更为详细的信息可自行搜索论文Design of a Separable Transition-Diagram Compiler查阅。
附---康威定律
说到康威(Melvin Conway),大家想必或多或少都听过康威定律:
沉寂
由于协程的理念并不复合自定向下的设计思想,所以在那个命令式编程语言流行的时代,协程没有用武之地。
命令式编程语言是围绕自顶向下的开发理念设计的,在这样的理念下,程序被分为一个主程序和大大小小的子模块,c系语言语言就是很好的例子,其主程序为main函数,然后在程序设计时将不同功能切分为不同的子模块,子模块中可能还有更多的子模块,这样就从main函数开始逐级往下构建出层次分明的程序。在这种层次分明的程序之中,其执行流程都是自上而下顺序完成的,main函数调用子模块,子模块完成后将结果和控制权交还main函数,因此协程对于控制流的主动让出(yield)和恢复(resume)并不符合当时的主流语言的思想,所以被冷落也是正常的。
复兴
从CPU的角度来看,可以分为计算密集型任务和IO密集型任务,其中计算密集型任务能够充分利用CPU,而IO密集型任务由于IO阻塞的问题,无法充分利用CPU。随着计算机硬件的发展,越来越多的程序使用IO,使得IO阻塞极大的降低了程序的性能,为了解决IO阻塞的问题,出现了许多方案,诸如多路复用、异步IO,还有像libev之类的事件库,其主要思路就是尽可能避免阻塞,当数据准备完毕后通过信号等机制通知程序处理数据,使得CPU不会白白耗费在等待数据的过程中。因此必然要引入callback来进行处理数据。相信有过一定编程经验的人看到callback就会想到一个词:回调地狱(callback hell),回调地狱使得代码难以阅读维护,从而导致bug频发。
在这样的情况下,协程就又得到了亮相的机会。
前面讲到协程设计的核心就在于控制流的主动让出(yield)和恢复(resume),这天然适合用于解决回调地狱的问题。执行异步IO时,可以主动yield当前协程,以便其他协程能够继续执行,当收到数据准备完成的信号时就resume之前的协程,以此实现同步风格的异步代码。
优势
- 同步编程风格实现异步性能。协程的动作集中在应用层,把线程在内核上的开销转到了应用层,从而把复杂的线程操作屏蔽在下层框架上,从而大幅降低了编程的难度,在使用同步模式的编程方式时,但却拥有了线程快速异步调用的效率。
- 用户态切换上下文。当在内核里实行上下文切换的时候,其实是将当前所有寄存器保存到内存中,然后从另一块内存中载入另一组已经被保存的寄存器。对于图灵机来说,当前状态寄存器意味着机器状态——也就是整个上下文。其余内容,包括栈上栈帧,堆上对象,都是直接或者间接的通过寄存器来访问的。 而协程切换时仅需要更换寄存器的值,不需要内存参与,因此可以在用户态完成。而线程的切换会涉及到用户模式到内核模式的切换,每次切换都涉及到中断,而int指令是一个复杂的多步指令,很耗时。
- 开销轻量级。线程的数据在堆和栈上,通常有1M以上,而协程数据主要在栈帧上,一般只有几kb到几十kb,所以在创建、销毁和切换时,寄存器需要保存和加载的数据量更小。
- 非抢占式调度。协程的非抢占式调度更有效率,因为协程是非抢占式的,前一个协程执行完毕或者堵塞,才会让出CPU,而线程则一般使用了时间片算法,会进行很多没有必要的切换,此外线程切换虽然不会切换内存,不会导致高速缓存命中率下降,但会切换堆,可能会导致某些指针或地址失效。
劣势
- 协程无法利用多核,需要配合进程来使用才可以在多CPU上发挥作用
- 线程的回调机制仍然有巨大生命力,协程无法全部替代
- 控制权需要转移可能造成某些协程的饥饿,抢占式更加公平
- 虽然协同调度适合实时或分时等对运行时间有保障的系统,但非抢占式无法满足某些实时性非常强的任务处理,还是需要抢占式的进程/线程
- 协程的控制权由用户态决定可能转移给某些恶意的代码,抢占式由操作系统来调度更加安全
综上来说,协程和线程并非矛盾,协程的威力在于IO的处理,恰好这部分是线程的软肋,由对立转换为合作才能开辟新局面。
基本概念
- 分类
- 实现举例
按调用栈分类
- 有栈协程,每一个协程都有自己的调用栈,有点类似于线程的调用栈,这种情况下的协程其实很大程度上接近线程,主要的不同体现在调度上。
- 无栈协程,协程没有自己的调用栈,挂起点的状态通过状态机或者闭包等语法来实现。
有栈协程的优点是可以再任意函数调用层级的任意位置挂起,并转移调度权。lua的协程就是一种有栈协程,go语言的go routine也可以认为是有栈协程的一个实现。kotlin协程通常被认为是一种无栈协程的实现,它的控制流转依靠对协程体本身编译生成的状态机的状态流来实现,变量保存也是通过闭包语法来实现的。
按调度方式分类
- 对称协程,任何一个协程都是相互独立且平等的,调度权可以在任意协程之间转移。
- 非对称协程,协程让出调度权的目标只能是它的调用者,即协程之间存在调用和被调用的关系。
对称协程和线程非常接近,比如go routine可通过channel实现控制权的自由转移。而非对称协程存在调用关系,也比较符合我们的思维方式,可以想象成存在一个调度中心,每次协程挂起时,都会把控制权交回给调度中心,例如lua就是非对称实现的协程。
从实现角度来看,非对称的实现更为自然,并且相对容易,并且只需要稍作修改就能实现对称协程,比如当协程挂起后控制权回到调度中心了,调度中心可以根据参数来将控制权转发到对应的协程中去,这样就实现了控制权的自由转移。
实现举例
生产者-消费者模式是经典的并发同步模式,下面用几种不同语言的协程分别实现此模式,以展示不同语言中协程的区别。
Python协程
Python中协程通过yield实现,使用yield的函数会返回一个Generator对象,调用generator的send函数会恢复(resume)协程,并将参数发送到协程中作为yield的返回值。 而yield会将当前协程挂起,其后所带参数作为send的返回值。
# 代码清单1-1
def consumer():
r = '' #---1
while True:
n = yield r #---2
if not n: #---3
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'
def produce(c):
c.send(None) #---4
n = 0
while n < 5: #---5
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n) #---6
print('[PRODUCER] Consumer return: %s' % r)
c.close()
c = consumer()
produce(c)
demo中首先调用consumer创建一个genarator(即协程),接着将generator对象传到produce中,下面分析一下它的执行流程:
- 首先代码执行到注释
---4中,此时会挂起produce,并启动协程,开始执行consumer函数 - 当consumer执行到注释
---2处会把控制权让给produce,并将r传递出去 - produce从注释
---4处恢复执行,忽略consumer返回的r值,此时进入while循环,直到注释---6处,通过send恢复consumer执行,将n传递过去 - 此时consumer从注释
---2处恢复,并获得produce传过来的n,进入下一轮循环 - 在新的循环中,consumer仍旧在注释
---2处把控制权让给produce - produce从注释
---6处恢复,获取到consumer传递过来的r值,继续执行,进入下一轮循环 - produce进入新的循环后,仍在注释
---6处恢挂起,并恢复consumer,回到第四步 - 如此重复执行,直到produce退出循环,将generator关闭
- 此时consumer恢复,并且进入注释
---3处的条件,关闭协程
其执行结果如下:
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK
lua
lua协程的基本语法如下:
| 方法 | 描述 |
|---|---|
| coroutine.create() | 创建coroutine,返回coroutine, 参数是一个函数,当和resume配合使用的时候就唤醒函数调用 |
| coroutine.resume() | 重启coroutine,和create配合使用 |
| coroutine.yield() | 挂起coroutine,将coroutine设置为挂起状态,这个和resume配合使用能有很多有用的效果 |
| coroutine.status() | 查看coroutine的状态 注:coroutine的状态有三种:dead,suspend,running,具体什么时候有这样的状态请参考下面的程序 |
| coroutine.wrap() | 创建coroutine,返回一个函数,一旦你调用这个函数,就进入coroutine,和create功能重复 |
| coroutine.running() | 返回正在跑的coroutine,一个coroutine就是一个线程,当使用running的时候,就是返回一个corouting的线程号 |
其生产者-消费者模型如下:
-- 代码清单1-2
function producer()
local i = 0
while i <= 5 do
print("produce", i)
coroutine.yield(i)
i = i + 1
end
end
function consumer(co)
while true do
local status, value = coroutine.resume(co)
if status == false then
break
else
print("receive", value)
end
end
end
local co = coroutine.create(producer)
consumer(co)
总体流程与Python大同小异,就不详细说明了,下面是运行结果:
produce 0
receive 0
produce 1
receive 1
produce 2
receive 2
produce 3
receive 3
produce 4
receive 4
produce 5
receive 5
receive nil
golang
go routine没有yield和resume对应的api,它使用channel实现,channel基本使用如下:
// 创建一个无缓冲的channel
ch := make(chan int)
// 发送及接收数据操作
ch <- x // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch // a receive statement; result is discarded
生产者-消费者demo如下:
// 代码清单1-3
package main
import "fmt"
func main() {
ch := make(chan int)
done := make(chan struct{})
go producer(ch)
go consumer(ch, done)
<- done
fmt.Println("goroutine test exist.")
}
func producer(ch chan<- int) {
for i := 1; i <= 5; i++ {
ch <- i
fmt.Println("produce", i)
}
close(ch)
}
func consumer(ch <-chan int, done chan<- struct{}) {
for {
r := <- ch
if r == 0 {
fmt.Println("channel closed.")
break
}
fmt.Println("receive", r)
}
done <- struct{}{}
}
其中ch chan<- int表示只写channel,ch <-chan int表示只读channel。具体的执行流程与Python也差不多,就不详细分析,直接看下执行结果:
produce 1
receive 1
receive 2
produce 2
produce 3
receive 3
receive 4
produce 4
produce 5
receive 5
channel closed.
goroutine test exist.