python - 协程异步IO(asyncio)

什么是协程,为什么要使用协程?

由于GIL的存在,导致Python多线程性能甚至比单线程更糟。

GIL: 全局解释器锁(英语:Global Interpreter Lock,缩写GIL),是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行。即便在多核心处理器上,使用 GIL 的解释器也只允许同一时间执行一个线程。

于是出现了协程(Coroutine)这么个东西。

协程: 协程,又称微线程,纤程,英文名Coroutine。协程的作用, 是在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A(可以自由切换)。但这一过程并不是函数调用(没有调用语句),这一整个过程看似像多线程,然而协程只有一个线程执行.

协程有以下2个优势:

  • 协程的数量理论上可以是无限个,而且没有线程之间的切换动作,所以执行效率极高。对于IO密集型任务非常适用,如果是cpu密集型,推荐多进程+协程的方式。
  • 协程不需要“锁”机制,即不需要lock和release过程,因为所有的协程都在一个线程中。
  • 相对于线程,协程更容易调试debug,因为所有的代码是顺序执行的。

什么是同步IO和异步IO,它们之间有什么区别?

答:举个现实例子,假设你需要打开4个不同的网站,但每个网站都比较卡。IO过程就相当于你打开网站的过程,CPU就是你的点击动作。你的点击动作很快,但是网站打开很慢。同步IO是指你每点击一个网址,都等待该网站彻底显示,才会去点击下一个网址。异步IO是指你点击完一个网址,不等对方服务器返回结果,立马新开浏览器窗口去打开另外一个网址,以此类推,最后同时等待4个网站彻底打开。很明显异步IO的效率更高。

异步IO:就是发起一个IO操作(如:网络请求,文件读写等),这些操作一般是比较耗时的,不用等待它结束,可以继续做其他事情,结束时会发来通知。

在Python3.4之前,官方没有对协程的支持,存在一些三方库的实现,比如gevent和Tornado。3.4之后就内置了asyncio标准库,官方真正实现了协程这一特性。

而Python对协程的支持,是通过Generator实现的,协程是遵循某些规则的生成器。因此,我们在了解协程之前,我们先要学习生成器。

参考另一篇文章:协程和⽣成器的要用法

Python中的协程和⽣成器很相似但又稍有不同。主要区别在于:

  • ⽣成器是数据的⽣产者
  • 协程则是数据的消费者

异步IO(asyncio)

异步IO的asyncio库使用事件循环驱动的协程实现并发。用户可主动控制程序,在认为耗时IO处添加await(yield from)。在asyncio库中,协程使用@asyncio.coroutine装饰,使用yield from来驱动,在python3.5中作了如下更改:

@asyncio.coroutine -> async
yield from -> await

  • async关键字将一个函数声明为协程函数,函数执行时返回一个协程对象。
  • await关键字将暂停协程函数的执行,等待异步IO返回结果。

asyncio中几个重要概念

1.事件循环

管理所有的事件,在整个程序运行过程中不断循环执行并追踪事件发生的顺序将它们放在队列中,空闲时调用相应的事件处理者来处理这些事件。

EventLoop是一个程序结构,用于等待和发送消息和事件。简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程")。

2.Future

Future对象表示尚未完成的计算,还未完成的结果

3.Task

是Future的子类,作用是在运行某个任务的同时可以并发的运行多个任务。

asyncio.Task用于实现协作式多任务的库,且Task对象不能用户手动实例化,通过下面2个函数创建:

asyncio.async()
loop.create_task() 或 asyncio.ensure_future()

最简单的异步IO示例

  • run_until_complete():

阻塞调用,直到协程运行结束才返回。参数是future,传入协程对象时内部会自动变为future

  • asyncio.sleep():

模拟IO操作,这样的休眠不会阻塞事件循环,前面加上await后会把控制权交给主事件循环,在休眠(IO操作)结束后恢复这个协程。

提示:若在协程中需要有延时操作,应该使用 await asyncio.sleep(),而不是使用time.sleep(),因为使用time.sleep()后会释放GIL,阻塞整个主线程,从而阻塞整个事件循环。

import asyncio

async def coroutine_example():
    await asyncio.sleep(2)
    print('Coroutine ID: asyncio')

coro = coroutine_example()
loop = asyncio.get_event_loop()
loop.run_until_complete(coro)
loop.close()

上面输出:会暂停2秒,等待 asyncio.sleep(1) 返回后打印

创建Task

loop.create_task():

接收一个协程,返回一个asyncio.Task的实例,也是asyncio.Future的实例,毕竟Task是Future的子类。返回值可直接传入run_until_complete(),返回的Task对象可以看到协程的运行情况

import asyncio

async def coroutine_example():
    await asyncio.sleep(2)
    print('Coroutine ID: asyncio')

coro = coroutine_example()
loop = asyncio.get_event_loop()
task = loop.create_task(coro)
print('运行情况:', task)

loop.run_until_complete(task)
print('再看下运行情况:', task)
loop.close()

输出结果:

从下图可看到,当task为finished状态时,有个result()的方法,我们可以通过这个方法来获取协程的返回值:

 

获取协程返回值

  • 第1种方案:通过task.result()

可通过调用 task.result() 方法来获取协程的返回值,但是只有运行完毕后才能获取,若没有运行完毕,result()方法不会阻塞去等待结果,而是抛出 asyncio.InvalidStateError 错误

import asyncio

async def coroutine_example():
    await asyncio.sleep(2)
    print('Coroutine ID: asyncio')

coro = coroutine_example()
loop = asyncio.get_event_loop()
task = loop.create_task(coro)
print('运行情况:', task)

try:
    print('返回值:', task.result())
except asyncio.InvalidStateError:
    print('task状态未完成,捕获了 InvalidStateError 异常')

loop.run_until_complete(task)
print('再看下运行情况:', task)
loop.close()

运行结果可以看到:只有task状态运行完成时才能捕获返回值

  • 第2种方案:通过add_done_callback()回调
import asyncio

async def coroutine_example():
    await asyncio.sleep(2)
    return 'Coroutine ID: asyncio'

def my_callback(future):
    print('返回值:', future.result())

coro = coroutine_example()
loop = asyncio.get_event_loop()
task = loop.create_task(coro)
task.add_done_callback(my_callback)
loop.run_until_complete(task)
loop.close()

控制任务

通过asyncio.wait()可以控制多任务
asyncio.wait()是一个协程,不会阻塞,立即返回,返回的是协程对象。传入的参数是future或协程构成的可迭代对象。最后将返回值传给run_until_complete()加入事件循环

  • 最简单控制多任务

下面代码asyncio.wait()中,参数传入的是由协程构成的可迭代对象

import asyncio

async def coroutine_example(name):
    print('正在执行name:', name)
    await asyncio.sleep(1)
    print('执行完毕name:', name)

loop = asyncio.get_event_loop()

tasks = [coroutine_example('Coroutine' + str(i)) for i in range(3)]
wait_coro = asyncio.wait(tasks)
loop.run_until_complete(wait_coro)
loop.close()

输出结果:

  • 多任务中获取返回值

方案1:需要通过loop.create_task()创建task对象,以便后面来获取返回值

下面代码asyncio.wait()中,参数传入的是由future(task)对象构成的可迭代对象

import asyncio

async def coroutine_example(name):
    print('正在执行name:', name)
    await asyncio.sleep(1)
    print('执行完毕name:', name)
    return '返回值:' + name

loop = asyncio.get_event_loop()

tasks = [loop.create_task(coroutine_example('Coroutine' + str(i))) for i in range(3)]
wait_coro = asyncio.wait(tasks)
loop.run_until_complete(wait_coro)

for task in tasks:
    print(task.result())

loop.close()

输出结果: 

方案2:通过回调add_done_callback()来获取返回值

import asyncio

def my_callback(future):
    print('返回值:', future.result())

async def coroutine_example(name):
    print('正在执行name:', name)
    await asyncio.sleep(1)
    print('执行完毕name:', name)
    return '返回值:' + name

loop = asyncio.get_event_loop()

tasks = []
for i in range(3):
    task = loop.create_task(coroutine_example('Coroutine' + str(i)))
    task.add_done_callback(my_callback)
    tasks.append(task)

wait_coro = asyncio.wait(tasks)
loop.run_until_complete(wait_coro)

loop.close()

输出结果:

动态添加协程

方案是创建一个线程,使事件循环在线程内永久运行

相关函数介绍:

loop.call_soon_threadsafe() :与 call_soon()类似,等待此函数返回后马上调用回调函数,返回值是一个 asyncio.Handle 对象,此对象内只有一个方法为 cancel()方法,用来取消回调函数。
loop.call_soon() : 与call_soon_threadsafe()类似,call_soon_threadsafe() 是线程安全的
loop.call_later():延迟多少秒后执行回调函数
loop.call_at():在指定时间执行回调函数,这里的时间统一使用 loop.time() 来替代 time.sleep()
asyncio.run_coroutine_threadsafe(): 动态的加入协程,参数为一个回调函数和一个loop对象,返回值为future对象,通过future.result()获取回调函数返回值

  • 动态添加协程同步方式

通过调用 call_soon_threadsafe()函数,传入一个回调函数callback和一个位置参数

注意:同步方式,回调函数 thread_example()为普通函数

import asyncio
from threading import Thread

def start_thread_loop(loop):
    asyncio.set_event_loop(loop)
    loop.run_forever()

def thread_example(name):
    print('正在执行name:', name)
    return '返回结果:' + name

new_loop = asyncio.new_event_loop()
t = Thread(target=start_thread_loop, args=(new_loop,))
t.start()

handle = new_loop.call_soon_threadsafe(thread_example, 'Coroutine1')
handle.cancel()
new_loop.call_soon_threadsafe(thread_example, 'Coroutine2')
print('主线程不会阻塞')
new_loop.call_soon_threadsafe(thread_example, 'Coroutine3')
print('继续运行中...')

输出结果:

  • 动态添加协程异步方式

通过调用 asyncio.run_coroutine_threadsafe()函数,传入一个回调函数callback和一个loop对象

注意:异步方式,回调函数 thread_example()为协程

import asyncio
from threading import Thread

def start_thread_loop(loop):
    asyncio.set_event_loop(loop)
    loop.run_forever()

async def thread_example(name):
    print('正在执行name:', name)
    await asyncio.sleep(1)
    return '返回结果:' + name

new_loop = asyncio.new_event_loop()
t = Thread(target=start_thread_loop, args=(new_loop,))
t.start()

future = asyncio.run_coroutine_threadsafe(thread_example('Coroutine1'), new_loop)
print(future.result())
asyncio.run_coroutine_threadsafe(thread_example('Coroutine2'), new_loop)
print('主线程不会阻塞')
asyncio.run_coroutine_threadsafe(thread_example('Coroutine3'), new_loop)
print('继续运行中...')

输出结果:

从上面2个例子中,当主线程运行完成后,由于子线程还没有退出,故主线程还没退出,等待子线程退出中。若要主线程退出时子线程也退出,可以设置子线程为守护线程 t.setDaemon(True)

协程中生产-消费模型设计

通过上面的动态添加协程的思想,我们可以设计一个生产-消费的模型,至于中间件(管道)是什么无所谓,下面以内置队列和redis队列来举例说明。

提示:若想主线程退出时,子线程也随之退出,需要将子线程设置为守护线程,函数 setDaemon(True)

内置双向队列模型

使用内置双向队列deque

import asyncio
from threading import Thread
from collections import deque
import random
import time

def start_thread_loop(loop):
    asyncio.set_event_loop(loop)
    loop.run_forever()

def consumer():
    while True:
        if dq:
            msg = dq.pop()
            if msg:
                asyncio.run_coroutine_threadsafe(thread_example('Coroutine' + msg), new_loop)


async def thread_example(name):
    print('正在执行name:', name)
    await asyncio.sleep(2)
    return '返回结果:' + name


dq = deque()

new_loop = asyncio.new_event_loop()
loop_thread = Thread(target=start_thread_loop, args=(new_loop,))
loop_thread.setDaemon(True)
loop_thread.start()

consumer_thread = Thread(target=consumer)
consumer_thread.setDaemon(True)
consumer_thread.start()

while True:
    i = random.randint(1, 10)
    dq.appendleft(str(i))
    time.sleep(2)

输出结果:

 

redis队列模型

下面代码的主线程和双向队列的主线程有些不同,只是换了一种写法而已,代码如下

生产者代码:

import redis

conn_pool = redis.ConnectionPool(host='127.0.0.1')
redis_conn = redis.Redis(connection_pool=conn_pool)

redis_conn.lpush('coro_test', '1')
redis_conn.lpush('coro_test', '2')
redis_conn.lpush('coro_test', '3')
redis_conn.lpush('coro_test', '4')

消费者代码:

import asyncio
from threading import Thread
import redis

def get_redis():
    conn_pool = redis.ConnectionPool(host= '127.0.0.1')
    return redis.Redis(connection_pool= conn_pool)

def start_thread_loop(loop):
    asyncio.set_event_loop(loop)
    loop.run_forever()

async def thread_example(name):
    print('正在执行name:', name)
    await asyncio.sleep(2)
    return '返回结果:' + name

redis_conn = get_redis()

new_loop = asyncio.new_event_loop()
loop_thread = Thread(target= start_thread_loop, args=(new_loop,))
loop_thread.setDaemon(True)
loop_thread.start()

#循环接收redis消息并动态加入协程
while True:
    msg = redis_conn.rpop('coro_test')
    if msg:
        asyncio.run_coroutine_threadsafe(thread_example('Coroutine' + bytes.decode(msg, 'utf-8')), new_loop)

输出结果:

asyncio在aiohttp中的应用

异步IO特别适合爬虫的工作,因为爬虫中所有的请求都属于IO密集型任务,想得到比较好的爬虫效率,使用协程很重要。关于Http异步请求,建议使用aiohttp库,一个异步的HTTP客户端/服务器框架。下面只是简单对客户端做个介绍以及一个经常遇到的异常情况,更多用法可以参考其官方文档。

aiohttp客户端最简单的例子

import asyncio
import aiohttp

async def get_http(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as res:
            global count
            count += 1
            print(count, res.status)

def main():
    count = 0
    loop = asyncio.get_event_loop()
    url = 'https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&ch=&tn=baiduerr&bar=&wd={0}'
    tasks = [get_http(url.format(i)) for i in range(10)]
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()
if __name__ == '__main__':
    main()

输出结果: 

aiohttp并发量太大的异常解决方案

在使用aiohttp客户端进行大量并发请求时,程序会抛出 ValueError: too many file descriptors in select() 的错误。

说明:测试机器为windows系统

异常代码示例:

同上,只是url请求量从10变成了600:

 tasks = [get_http(url.format(i)) for i in range(600)]

原因分析:使用aiohttp时,python内部会使用select(),操作系统对文件描述符最大数量有限制,linux为1024个,windows为509个。

解决方案:

最常见的解决方案是:限制并发数量(一般500),若并发的量不大可不作限制。其他方案这里不做介绍,如windows下使用loop = asyncio.ProactorEventLoop() 以及使用回调方式等

限制并发数量方法

提示:此方法也可用来作为异步爬虫的限速方法(反反爬)

使用semaphore = asyncio.Semaphore(500) 以及在协程中使用 async with semaphore: 操作

具体代码如下:

import asyncio
import aiohttp

async def get_http(url):
    async with semaphore:
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as res:
                global count
                count += 1
                print(count, res.status)

if __name__ == '__main__':
    count = 0
    semaphore = asyncio.Semaphore(500)
    loop = asyncio.get_event_loop()
    url = 'https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&ch=&tn=baiduerr&bar=&wd={0}'
    tasks = [get_http(url.format(i)) for i in range(600)]
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()

参考:

  1. https://zhuanlan.zhihu.com/p/68043798
  2. https://zhuanlan.zhihu.com/p/24118476
  3. https://zhuanlan.zhihu.com/p/59621713

 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值