学习了生成器以后(额…如果你还没学习,请速速去学😝),我们就可以开始学习什么是协程了。要分清二者的联系去差别😯 —— 松言松语

协程

什么是协程

协程(Coroutine,又名微线程)和多线程一样,都是用来执行某一种计算(任务)的,所以协程是一个消费者,它接受生产者发送的消息并处理。

进程/线程/协程

在python中,需要注意python多线程和其他编程语言多线程的差异。

Python的多线程

Python的解释器有一个历史遗留问题:GIL锁,即Global Interpreter Lock。

  • 每个进程有有一个独立的GIL锁。
  • Python每个线程执行前,都要先获得所在进程的GIL锁,执行100条字节码(python2.x采用ticks计数器,python3.0+改用计时器)后,解释器自动释放GIL锁,未持有GIL锁的线程都处于等待状态。
  • 一个进程里的不同线程在同一个时间内只会有一个线程在执行,所以只用到一个CPU核心,所以只是并发而无法并行。
  • 若要充分利用多个CPU,充分挖掘CPU的计算能力,仅仅用多线程是行不通的,还得加上多进程。

Java的多线程则不同,Java的多线程是能够用到多个核心的。

堆、栈的对比
内存占用 调度
进程 总之就是大 独立 独立 操作系统调度
线程 4M+ 共享 独立 操作系统调度
协程 4k+ 共享 独立 代码块中显示调度

协程适合的多任务场景

通常有两种类型的多任务场景:CPU密集型、IO密集型。

CPU密集型

要充分利用多核CPU,充分挖掘CPU的计算能力进行大量计算

瓶颈主要在于CPU的核心数、CPU计算能力、代码效率上,而不在于IO操作;切换任务的开销反而会影响CPU的使用效率。

python是脚本语言,代码效率不算高,所以CPU密集型任务并不太适合用python来处理。

如果要充分利用CPU多个核心,python中可以利用多进程来实现。

常见的场景有:加解密、视频解码等。

IO密集型

花费的时间主要在各种IO操作的阻塞等待上,CPU消耗不高。

常见的场景有:读取文件、网络请求、数据库操作等;

IO密集型的应对方案通常通过任务切换(线程切换、协程切换)来解决:

场景 多线程 协程
IO密集,适量任务 适合。
1. 线程(假设是a)调用IO操作,a进入阻塞状态;
2.自动切换到其他线程处理任务,不浪费CPU;
3. 待数据返回后,a等待时间片(什么时候执行由系统决定)继续执行;
适合。
1. 配合异步IO使用,防止单线程阻塞,其他协程一起受影响;
2. 代码层直接可以决定时间分配;
IO密集,大量任务 不适合。
1. 线程开销大(一个线程4M+内存);
2. 线程间切换时间较长;
适合。
1. 协程运行在线程上,内存小;
2. 切换时间短;
3. 配合异步IO使用,以防止单线程阻塞;

python中的协程

在python中,协程和生成器长得差不多。直接来看示例:

def consumer():
    result = ''
    print('开始啦')
    while True:
        yield result

上面定义了一个consumer,乍一看他就是一个生成器。如果你什么都不做,那事实上它的确就只是一个生成器。只不过这个生成器没什么用处(它只是一直在返回空字符串result

现在加上以下代码执行看看:

cc = consumer()
next(cc)

发现只打印了一个“开始啦”,嗯,这的确就是一个没什么用的生成器(当然啦,有没有用取决于里面的代码逻辑)。

但是如果你要向他发消息,那就可以把它变成一个协程。

cc = consumer()
cc.send(None)

这里先临时解释一下,cc.send(None)意思是启动一个协程。

发现和生成器的执行结果是一样,也是打印了一个“开始啦”。看来所谓生成器协程,其实只是同一种形式的两种不同用法罢了,只不过是看里面的代码怎么写:

  • 生成器是用来生成数据的;
  • 协程是用来消费数据的;

下面我们来构造一个稍微像样的协程:把上面的consumer函数稍微改造一下,然后再多定义一个生产者,用来给consumer发消息。

# 这是一个协程,协程是一个消费者
def consumer():
    result = ''
    while True:
        # 启动协程后,代码进入循环体,将result返回给生产者,并在yield关键字处等待消息
        data_to_consume = yield result
        print(f"[消费者]:消费 {data_to_consume} ing")
        result = ("生产数据 " + str(data_to_consume) + " 消费完成")


# 这是一个生产者,向协程发送消息
def produce(c):
    # 启动协程
    c.send(None)
    n = 2
    while n >= 0:
        print(f"[生产者]:生产 {n} 完成")
        # 向协程发送消息
        result = c.send(n)
        print(f"[生产者]:消费者返回 ———— {result}")
        n -= 1
    # 关闭协程
    c.close()


produce(consumer())

执行结果为

[生产者]:生产 2 完成
[消费者]:消费 2 ing
[生产者]:消费者返回 ———— 生产数据 2 消费完成
[生产者]:生产 1 完成
[消费者]:消费 1 ing
[生产者]:消费者返回 ———— 生产数据 1 消费完成
[生产者]:生产 0 完成
[消费者]:消费 0 ing
[生产者]:消费者返回 ———— 生产数据 0 消费完成

在一个线程中来回控制不同任务的执行。这就是协程。

启动协程的两种方式

上面我们提到了用send(None)来启动协程,其实还有其他方式。

启动协程有两种方式,他们的效果是一样的。假设c为协程返回的迭代对象

  • c.send(None)
  • next(c)

上述两种方式都可以启动协程。

def consumer():
    result = ''
    print("启动")
    while True:
        # 启动协程后,代码进入循环体,将result返回给生产者,并在yield关键字处等待消息
        data_to_consume = yield result
        if not data_to_consume:
              return
        print(f"[消费者]:消费 {data_to_consume} ing")
        result = "生产数据 " + str(data_to_consume) + " 消费完成"

c = consumer()
next(c)

启动协程后,代码会进入协程中的循环体,并且在yield的位置中等待数据的传入。

上述代码调用 c.send(None)next(c) 的时候,都会打印“启动”。

协程与生成器的对比

项目 生成器 协程
是否能接收外部传入的数据 否。 是。用send(数据)。
是否能对外生成/返回数据 是。用yield。 是。用yield。
是否需要启动/关闭 否。 是。需要用send(None)或者next()来启动,用close()来关闭。
切换控制权方式/迭代方式 调用next() 或者__next()__。 调用send()。

2.11 异步IO

CPU的处理速度远远高于网络IO和磁盘IO,所以“卡顿”的感觉通常是来自各类IO操作。

  • CPU执行的时候,遇到IO操作,主线程等待IO操作完成以后才继续往下执行,这叫做同步IO。

  • CPU执行的时候,遇到IO操作,主线程不等待IO操作完成,继续往下执行,等IO处理完了,再通知CPU来处理(执行预先注册的回调函数),这叫做异步IO。

利用多线程?

多线程是处理同步IO的常用操作:把同步IO丢到一个独立的子线程去处理,主线程继续往下执行,子线程处理完了再把结果通知给主线程,或者子线程直接执行预先注册好的回调函数。但因为线程比较耗资源以及线程切换的效率问题,所以不宜开过多的线程。

协程不够用?

既然多线程耗资源,也听说协程可以替代多线程,既然已经有了协程,难道就可以为所欲为了吗?

No,别忘了协程是在线程之上的。如果协程调用了耗时的IO操作,其所在线程也一样会阻塞。

要想做到异步IO,需要一种新的机制来支持。在python2.x中,则通常使用Gevent。python3.4+内置了一个asyncio库,他们都提供了一种叫做eventloop的机制,来将同步转为异步。

关于python协程的发展可以参考👇这篇文章

[谈谈Python协程技术的演进](https://segmentfault.com/a/1190000012291369)

asyncio

协程如果单独使用,那并没有什么优势,本质上还是一个单线程,但是如果与异步结合,它的真正优势就能够发挥出来了

asycnio中,有两个关键字

  • async,用于将一个方法标注为一个协程;
  • await,表示等待操作返回;

简单的示例如下所示:

# since python3.4

import asyncio


async def task(n):
    print(f"start a task({n})......")

    await asyncio.sleep(1)

    print(f"back from task({n})......")


loop = asyncio.get_event_loop()

loop.run_until_complete(asyncio.wait([task(1), task(2)]))

loop.close()

上述代码中,asynctask标注为协程。

  1. 通过asyncio拿到一个eventloop循环队列,依次放入两个协程;

  2. 队列执行task(1)的时候,执行到 await asyncio.sleep(1)处,协程task(1)中断等待于此,eventloop继续执行循环队列中的其他下协程:task(2)

  3. task(1)task(2)await返回时,再分别继续往下执行代码;

输出结果为:

start a task(2)......
start a task(1)......
back from task(2)......
back from task(1)......

大致如此,这里不对asycnio库及其方法做更多介绍。