当前位置: 首页 > 文档资料 > larva 中文文档 >

协程

优质
小牛编辑
119浏览
2023-12-01

就一个简单实现的语言来说,如果有并发需求,像之前说的直接使用宿主环境的线程,加上必要的调度控制即可实现需求,但如果要求比较高,触发到上篇讲的线程和单线程异步的相关缺陷,一个较普遍的解决办法是采用用户态并发,即对于os内核来说,进程只有一个或少数几个线程,而对于源语言来说,接口和使用线程别无二致,由虚拟机实现对这些“线程”的调度,虚拟机的实现则可以一定程度简化、优化调度算法和内存占用,从而达到高并发高效率的目的。这个过程中一般使用到协程技术

协程这个概念是1963年提出来的,最早的出发点并不是针对并发(那时候的os应该还没有线程),而是另一种编程模式,相对子例程而言。子例程通俗说就是函数,现在我们写程序,已经习惯了函数的调用是在栈中,有调用者和被调用者的区别,还有递归调用,而协程模型中,各协程虽然看上去是一个函数,但彼此之间是对等的,没有谁调用谁的关系;子例程的调用由于是一个栈结构,则需要严格按照后进先出的方式来调用和返回,而协程的生命周期则是自由的,按照实际需要来结束(返回)

如果查阅一些资料,可能会看到说另一个区别是,协程是一个多入口多出口的子例程,但这个入口和出口跟一般子例程还是有区别,一般子例程是“调用”和“返回”,而一个协程的出入更合适的说法是“切换”,举个简单的生产者-消费者的例子(代码摘自wiki):

var q := new queue   
生产者协程:   
loop   
    while q is not full   
        create some new items   
        add the items to q   
    yield to consume   
消费者协程:   
loop   
    while q is not empty   
        remove some items from q   
        use the items   
    yield to produce

这里的yield表示切换协程,生产者和消费者都是一个无限loop,生产者往队列q里面尽可能增加新任务,然后切换到消费者执行,消费者处理完q里面所有任务后,切换回生产者执行,可以拿这个例子和函数调用、线程并发比较下:

如果采用函数调用的方式,上述代码直接改yield切换为调用是行不通的,因为函数调用必须有调用者和被调用者,如果produce里面调用了consume,consume里面又去调用produce,则无限递归,因此必须考虑实际情况,consume作为produce的子例程,produce的yield改为call,consume的yield改为return,这样形成一个从属关系即可,毕竟是要先produce才能consume。函数调用和协程的另一个区别是,虽然每次函数调用都有自己的局部环境,但随着return切换,局部环境会销毁,而协程的局部环境是跟协程的生命周期走的,实际上上面这段代码在一个不支持协程的语言里面实现的时候,相当于建立两个协程的数据环境,yield则是直接goto到对应代码执行

协程和线程很类似,事实上很多时候协程被认为是用户态调度的线程,这个说法勉强也算对,但由于协程出现早,如果咬文嚼字的话不应该用线程来描述协程,应该说线程是协程的替代品。考虑上面的例子,如果用线程实现就非常简单了,比如yield改为sleep一段时间,主动放弃cpu,os会帮我们在两个线程切换,或者如果q是一个内核对象,则对q的出入操作可以由os来阻塞调度,这样代码就能进一步简化了

从这个角度说,协程在程序中的实现跟线程在os中的实现是很类似的,一个协程包含一个自己的数据环境(对应线程的栈环境),执行代码(对应线程的入口函数),声明周期(线程入口函数的调用和返回)。线程调度依赖os,协程则可以自己调度,但是,要实现自己调度需要一个非常自由的goto(线程调度实际也是切换上下文环境后直接jmp),而在基于函数调用模式的语言中实现协程,还是跟线程的os一样使用一个中央调度器。或许唯一的区别是,线程的标准调度是被硬件强行中断的,而协程是自己交出控制权

和函数,线程类比,则一个协程的“多入口多出口”也能很好的理解,比如如下协程代码:

func f():   
    for i from 1 to 10:   
        return i

如果f是个函数,则上面这段代码就相当于return 1,但如果是协程,则依次调用f的时候,会返回1到10,也就是说每次return会返回给调用者当前的i,同时当前执行状态也被保留,下次调用时从上次的状态继续,显然这是一个状态机,因此协程可以用一个对象实现(python代码):

class F:   
    def __init__(self):   
        self.i = 1   
    def __call__(self):   
        if self.i > 10:   
            raise CrEnded()   
        ret = self.i   
        self.i += 1   
        return ret   
f = F()   
while True:   
    try:   
        print f()   
    except CrEnded:   
        break

f是一个状态机,重载它的()运算(call),就能像上面一样反复调用了,协程最终结束后,再调用会抛出CrEnded异常,当然也可以有其他类型的表示结束的方法。python中的协程(generator生成器)实际就是类似这样实现的

而如果用线程来实现这个例子,由于线程是os调度的,要想f受控运行,需要通过通讯来控制:

func f():   
    for i from 1 to 10:   
        recv_from_queue()   
        send_to_queue(i)

f在一个线程执行,通过一个queue和外界通讯,每recv到一个请求,就将i send回去。换句话说,线程的多入口多出口是通过通讯和阻塞实现的

协程通过自己主动yield放弃执行来调度,一个协程本质就是一个自动机,虽然对一个自动机而言,整个流程是比较清晰的,但是如果业务比较复杂,手工写自动机也是比较繁琐的,比如说,我们要从若干数据库和表中分别读取一个用户的各种信息,组成一个总信息结构,每个信息的读取可能阻塞的,则需要多次yield,用协程:

user = User(id)   
trans = get_info_a(user.id) //提交info a的请求   
yield trans   
user.info_a = trans.result   
... //其他info

这里为了写清楚一些,get_info_a提交请求后返回一个事务对象trans,然后将trans在切换时以“返回值”的形式返回,当结果回来时,外部控制代码填写trans.result,然后继续执行。当然完全不必这么麻烦,python直接用这种语法:

user.info_a = yield get_info_a(user.id)

提交请求并yield返回trans,yield本身是个表达式,其值为继续执行时传入的参数

这样一来,就可以在协程很方便的写同步代码了,但是,外部代码依然要自己实现为异步的,仅仅是协程的自动机代码写起来紧凑而已,不至于像单线程异步那样凌乱。而更严重的问题是,如果我要实现函数调用,比如一个函数a调用b,a的其他地方和b都可能阻塞,那么改成协程的话,就不得不建立a和b两个协程,它俩还是对等的,这样写代码还是比较麻烦,至少不符合现在流行的线程+函数的习惯了