前言
最近开发的公众号后台需要有一个比较费时的检索操作,向某api接口请求数据,但接口返回时间不定,公众号又规定必须在5秒之内返回,不然会重发三次请求,还是无响应则显示公众号故障。由于公众号没有认证,无法主动给用户发送消息,所以我想了两天,想了几种解决方案:
1.直接使用requests的timeout,超过4秒的请求直接返回无结果,但是这对用户的体验很不友好。
2.超时之后返回“正在请求”,并继续将结果缓存到redis中,等下次用户请求相同数据时,直接从redis中返回,无需后端再向接口请求。
基于第二种方法,第一时间我想到的是异步编程,使用异步的http客户端请求接口,超时则返回,但请求依旧在进行,直到将结果缓存到redis中。
但是结果却出乎我的意料,在async函数1中await另一个async函数2,如果1已经返回,则2是强制被取消,原因大概是已经退出了事件循环,导致无法继续运行。
虽然最后没有用异步编程解决办法,但是还是重学一下python中的异步编程,梳理一下知识。
概念
异步编程中有许多概念,例如同步,异步,阻塞和非阻塞。我从网上找了许多解释,并加入了自己的理解。
同步与异步
同步与异步最大的差别在与返回的结果是否立即返回,他们的关注点是消息的通信机制。举个例子:
假如函数1调用函数2,如果采用同步方式,则1要等待2返回结果后,才能返回。
而如果采用异步方式,则1无需关注2返回结果,继续运行,直到2返回后通过例如回调函数,通知等方式处理结果或通知调用者1完成了本次调用。
简单一点说,就是同步是调用者主动等待被调用者的结果,而异步则是被调用者主动通知调用者结果。
阻塞与非阻塞
这两个的关注点在于程序在等待调用结果时所在的状态。
阻塞调用是指在结果返回前,当前线程将被挂起。而调用线程只有在得到结果后才返回。
非阻塞调用是不能立即得到结果之前,该调用不会阻塞当前线程。还是举个简单例子:
如果是阻塞调用,则调用者会将自己挂起,不做其他事,直到得到调用结果。
而如果是非阻塞调用,则调用者可以做其他事情,但每隔一段时间就会查看是否得到调用结果。
所以同步通信可以阻塞也可以非阻塞。在编程中我们常见的逻辑基本上是同步阻塞的。这样逻辑较为清楚。
实践
asyncio
python之父用了好几年的时间才将asyncio库编写完成,可见异步编程是值得这么做的。python中可以实现多线程和多进程,很多人认为有这两个就没有特别必要实现异步,但是多线程存在着许多问题,例如竞争,锁等问题,导致处理十分麻烦。基于这点python3.4加入了asyncio模块,加入了异步编程,并在3.7中加入了async与await关键字。
这个异步模块特点在于只有一个线程,这一点与JavaScript相同。由于只有一个线程,所以asyncio 是”多任务合作”模式(cooperative multitasking),即允许任务交出执行权给其他任务,等待其他任务完成后回收执行权。其实就是分享运行时间。
这样虽然没有合理使用多线程多进程来充分利用CPU,但代码逻辑清晰,符合编程思维。
asyncio在单线程中启动一个事件循环(event loop)时刻监听进入循环的事件,处理,重复过程,直到异步任务结束,而事件循环与JavaScript的模型相同。
协程
协程又叫微线程,Coroutine,协程的作用是可以随时中断去执行其他操作,虽然像是多线程,但协程只有一个线程在运行。其优点有:
- 执行效率极高,因为子程序切换(函数)不是线程切换,由程序自身控制,没有切换线程的开销。所以与多线程相比,线程的数量越多,协程性能的优势越明显。
- 不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在控制共享资源时也不需要加锁,因此执行效率高很多。
一个简单的demo(来自官方文档):
1 | import asyncio |
这个例子中,先打印了hello隔了一秒钟后打印world。
在asyncio中,提供了三种方式运行协程
- asyncio.run() 函数用来运行最高层级的入口点
- 等待一个协程,也就是await关键字。
- asyncio.create_task() 函数用来并发运行作为 asyncio 任务的多个协程。
Future 对象
Future较为少用,因为这个比较底层,表示的是一个异步操作的结果。在Java中也有Future,在python的官方文档中讲的也不是很多,这里不过多讲述。
任务
如上面第三个方式,任务是Future的子类,用来的是并发执行协程
1 | import asyncio |
这段代码等待的时间并不是3秒,而是2秒,因为task1和task2是并发执行。
在文档中还提到另一个并发执行任务的函数,asyncio.gather,那么它与create_task又有什么区别?
create_task将协程交给事件循环,返回task,并且可以通过cancel()取消任务。
而asyncio.gather用于你需要获得协程的结果,它返回的是各自的结果的列表。如果其中一个协程发生异常,则引发异常。
同步与异步对比
这里要说的一点是time.sleep与asyncio.sleep的差别,time.sleep是阻塞的,而asyncio.sleep是非阻塞的。在异步方法中使用time.sleep会使整个线程阻塞。asyncio.sleep返回的是一个future对象,可等待。
1 | #同步示例 |
1 | #异步示例 |
这里要说的是,如果asyncio.sleep前加入await,则异步函数hello会等待sleep结束才继续输出,不加await会报warning,但是不影响运行。
异步http
基于以上的代码可以知道,异步操作其实是就是将耗时操作异步化,使得代码运行时间缩短。
在爬虫中如果需要大量的请求,并且要缩短时间,通常使用异步的http客户端进行爬取。由于requests库是同步的,在请求的时候会阻塞,所以requests不支持异步操作,通常我们会使用grequests和aiohttp等库,这里我们使用aiohttp。
1 | import asyncio |
response.read()是一个耗时的io操作,前面加上await关键字等待响应。
多链接同步请求与异步请求对比
1 | import requests,time,asyncio |
可以发现使用异步操作爬取页面速度非常之快。
使用同一个ClientSession
而aiohttp文档中建议使用单个ClientSession,而不是每次连接都重新创建一个对象。
1 | import requests,time,asyncio |
这次我们使用了同一个ClientSession(),在测试了30,100,500,1000个请求后发现,使用同一个session速度略快一些,大概是两倍左右。
后记
学了几天基础的python异步编程,对异步编程又有了新的理解,学习了异步爬虫的基本原理,曾经使用过的scrapy框架,现在能够理解它对异步的支持。
虽然没有解决开发上的问题,但还是收获很多。