Python 协程:yield,greenlet,gevent,

2020-04-19  本文已影响0人  moon_light_
进程

进程是资源分配的最小单位,拥有独立的内存空间,有寄存器信息、堆、栈、数据段、代码段、虚拟内存、文件句柄、IO 状态、信号信息等等内容,不同进程的切换开销比较大,同时进程比较独立稳定,通常不受其他进程影响

进程间的通信有管道(Pipe)、消息队列(Message Queue)、信号量(Semaphore)、共享内存(Shared Memory)、套接字(Socket)等等

线程

线程是系统调度的最小单位,只需要保存自己的栈、寄存器信息等少量内容,一个进程至少要有一个线程,不同线程的切换开销比进程切换要小很多,但线程不够独立稳定,容易受进程和其他线程的影响

由于不同线程都是共享同一段内存,线程间通信直接使用共享内存,就是使用全局定义的变量即可,另外不同的线程间通常还需要通过锁实现同步、互斥等功能

协程

进程和线程都是操作系统调度的,虽然线程切换开销比进程要小,但如果是频繁切换,依然会严重影响性能

操作系统通常在三种情况下会进行切换

  1. 程序运行时间比较长
  2. 有更高优先级的程序抢占
  3. 程序发生了阻塞

在很多网络应用中,会同时接受大量请求,这些请求计算量很小,主要的时间是耗在 IO 上了,并且最主要是网络的 IO 时间,导致了频繁的 IO 阻塞和线程切换,严重影响性能

协程就是为了解决以 IO 为主要开销的程序,在高并发场景下的性能问题

在一个线程内可以运行多个协程,当一个协程调用了需要 IO 阻塞的命令时,会使用异步 IO 的方式,避免触发操作系统进行切换,然后继续执行另一个协程,由于是在同一个线程内实现,切换开销非常小,性能会有很大提升

注意协程在 IO 并发量很大的情况下作用才比较明显,因为只有这种情况下才能保证随时有异步 IO 准备就绪可以执行的,如果 IO 量很小,比如 10 分钟才有一条请求,那做了异步操作后,还是得等待这个异步 IO 就绪,照样会导致线程切换

注意协程只在一种情况下会切换:IO 调用

这个功能需要由程序框架实现,对操作系统是透明的,对应用程序也是透明的,这样既避免了以 IO 为主要开销的程序在高并发时频繁地触发多线程的切换,又不增加应用程序开发的工作量

在 Go 语言中,这个功能是原生的,Go 语言本身就实现了这个功能,在语法层面上就支持
在 Python 语言中,这个功能由 gevent 包提供支持

下面主要讲 Python 的协程

yield

yield 是为了生成器使用的,比如下面的代码

def f(max): 
    n = 1
    while n <= max: 
        yield n*n
        n = n + 1
        
for i in f(5):
    print(i)

如果不使用 yield,那么函数 f 就需要返回一个 list,如果 max 非常大,那么就需要创建一个很大的内存在放这个 list,而在使用了 yield 后,函数被当成迭代器,f(5) 返回的是一个迭代器,for 语句每次取值的时候触发迭代器,迭代器执行到 yield 命令时返回 n*n 并停止执行,直到 for 下一次取值,迭代器再从 n = n + 1 继续执行,这样无论 max 多大,内存的使用都是恒定的

再举一个例子

def f():
    n = 1
    print("f function with yield inside")
    while True:
        msg = yield n
        print("msg: ", msg)
        n = n + 1

iter = f()
print("before invoke next")
print("receive: ", next(iter))
print("after invoke next")
print("receive: ", next(iter))

返回的是

before invoke next
f function with yield inside
('receive: ', 1)
after invoke next
('msg: ', None)
('receive: ', 2)

可以看到调用 iter = f() 的时候没有打印任何信息出来,即 f() 函数其实没有被执行,而是返回了一个迭代器,当执行 next(iter) 函数时 (next 是 python 内置函数),f() 函数才被执行,并且这里只执行到 yield n 就停止继续执行并将 n 作为结果返回 (这里连 msg 的赋值都没执行,后面会进一步讲到),等下一个 next 函数时,会从 msg 的赋值开始继续执行,直到再次遇见 yield,如果迭代器已经执行完,那么 next 函数会报 StopIteration 异常

继续下一个例子

def f():
    n = 1
    print("f function with yield inside")
    while True:
        msg = yield n
        print("msg: ", msg)
        n = n + 1

iter = f()
print("before invoke next")
print("receive: ", next(iter))
print("after invoke next")
print("receive: ", iter.send("from outside"))

这里把第二个 next 换成调用迭代器的 send 函数
返回的是

before invoke next
f function with yield inside
('receive: ', 1)
after invoke next
('msg: ', 'from outside')
('receive: ', 2)

和上一个例子的唯一区别就是打印的 msg 不是 None 而是 send 函数的参数,send 函数和 next 一样会触发迭代器继续执行,但同时会将参数作为 yield 语句的结果赋值给 msg

下面用 yield 模拟协程

def f_0():
    n = 5
    while n >= 0:
        print('[f_0] ' + str(n))
        yield
        n = n - 1

def f_1():
    m = 3
    while m >= 0:
        print('[f_1] ' + str(m))
        yield
        m = m - 1

iter_list = [f_0(), f_1()]
while True:
    for it in iter_list:
        try:
            next(it)
        except:
            iter_list.remove(it)

        if len(iter_list) == 0:
            break

返回结果为

[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0

可以看到实现了两个函数不断切换的功能,但代码写起来麻烦点

greenlet

greenlet 是底层实现了原生协程的 C 扩展库

from greenlet import greenlet

def f_0():
    n = 5
    while n >= 0:
        print('[f_0] ' + str(n))
        parent_greenlet.switch()
        n = n - 1

def f_1():
    m = 3
    while m >= 0:
        print('[f_1] ' + str(m))
        parent_greenlet.switch()
        m = m - 1

def parent():
    while True:
        for task in greenlet_list:
            task.switch()
            if task.dead:
                greenlet_list.remove(task)
        if len(greenlet_list) == 0:
            break

parent_greenlet = greenlet(parent)
greenlet_list = [greenlet(f_0, parent_greenlet), greenlet(f_1, parent_greenlet)]
parent_greenlet.switch()

返回

[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0

switch 也可以传值,根据程序运行情况会传给函数参数,或是传给 switch 的返回

def test1(x, y):
    z = gr2.switch(x+y)
    print(z)

def test2(u):
    print(u)
    gr1.switch(42)
    print "end"

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch("hello", " world")

返回

hello world
42

可以看到没有打印出 end,因为没指定 parent,默认有一个结束就返回 main,另一个就不会执行了,如果有指定 parent,则结束后会返回 parent

gevent

greenlet 写起来也比较复杂,并且 greenlet 只实现了协程,却没有实现捕获 IO 操作并进行切换的功能,实际上一般的计算并不需要协程的切换,性能没什么影响,只有在高并发 IO 操作时能切换程序,其性能才会有较大提升

gevent 基于 greenlet,使用了包括 linux 的 epoll 事件监听机制在内的许多优化措施,以提升高并发 IO 的性能,比如当一个 greenlet 程序需要做网络 IO 操作时,就将其注册为异步监听,并切换到其他 greenlet 程序,等 IO 完成,在适当的时候会再切回来继续执行,这样当 IO 很高时,可以让程序一直在运行,而不是把时间耗在 IO 等待上,同时又能避免线程的切换开销

import gevent

def f_0(param):
    n = param
    while n >= 0:
        print('[f_0] ' + str(n))
        gevent.sleep(0.1)
        n = n - 1

def f_1(param):
    m = param
    while m >= 0:
        print('[f_1] ' + str(m))
        gevent.sleep(0.1)
        m = m - 1

g1 = gevent.spawn(f_0, 5)
g2 = gevent.spawn(f_1, 3)
gevent.joinall([g1, g2])

返回

[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0

可以看到代码很简洁清楚,和正常程序相比,就是用 gevent.sleep() 替换了 time.sleep() 是 gevent 能在需要阻塞的地方做协程的切换

实际上还可以更简单

import time
import gevent

from gevent import monkey

monkey.patch_all()

def f_0(param):
    n = param
    while n >= 0:
        print('[f_0] ' + str(n))
        time.sleep(0.1)
        n = n - 1

def f_1(param):
    m = param
    while m >= 0:
        print('[f_1] ' + str(m))
        time.sleep(0.1)
        m = m - 1

g1 = gevent.spawn(f_0, 5)
g2 = gevent.spawn(f_1, 3)
gevent.joinall([g1, g2])

通过 monkey.patch_all() 打补丁,可以拦截到大量 IO 操作,比如 time sleep,http request 等,对其做异步执行,并切换协程,这种做法的最大的好处是原函数不用修改就能直接使用,对程序开发人员而言,协程就是透明的,不用特意修改代码,交给 gevent 打理就可以

asyncio

Python 3.6 中正式引入了 asyncio 库作为 python 标准库

最主要是 async 和 await 关键字

async 用来声明一个函数为异步函数,可以被挂起

await 用来用来声明程序被挂起,await 后面只能跟异步程序或有 __await__ 属性的对象

import asyncio
import aiohttp

async def f_0(param):
    n = param
    while n >= 0:
        print('[f_0] ' + str(n))
        await asyncio.sleep(0.1)
        n = n - 1

async def f_1(param):
    m = param
    while m >= 0:
        print('[f_1] ' + str(m))
        await asyncio.sleep(0.1)
        m = m - 1

loop = asyncio.get_event_loop()

tasks = [
    f_0(5),
    f_1(3)
]

loop.run_until_complete(asyncio.wait(tasks))
loop.close()

返回

[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0

另一个例子

import asyncio
import aiohttp

async def request(session, url):
    async with session.get(url) as response:
        return await response.read()

async def fetch(url):
    await asyncio.sleep(1)
    async with aiohttp.ClientSession() as session:
        html = await request(session, url)
        print(html)

url_list = [
    "http://www.qq.com",
    "http://www.jianshu.com",
    "http://www.cnblogs.com"
]

tasks = [fetch(url) for url in url_list]

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

可以看到需要加上 async 表示支持异步调用,并且要用 await 指定被挂起的地方
如果 await 指定的代码无法被挂起的话,是会出错的
并且需要使用特定的异步方法,或是类

相比较而言 gevent 则可以做到对程序透明
一个正常的同步程序,不需要任何修改就可以通过 gevent 实现异步

但 gevent 是借助三方包,asyncio 则是 python 标准库,在语法层面提供支持


上一篇下一篇

猜你喜欢

热点阅读