Zer0e's Blog

浅谈python异步编程

字数统计: 2.5k阅读时长: 9 min
2020/03/11 Share

前言

最近开发的公众号后台需要有一个比较费时的检索操作,向某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
2
3
4
5
6
import asyncio
async def func1():
print("hello")
await asyncio.sleep(1)
print("world")
asyncio.run(func1())

这个例子中,先打印了hello隔了一秒钟后打印world。
在asyncio中,提供了三种方式运行协程

  1. asyncio.run() 函数用来运行最高层级的入口点
  2. 等待一个协程,也就是await关键字。
  3. asyncio.create_task() 函数用来并发运行作为 asyncio 任务的多个协程。

Future 对象

Future较为少用,因为这个比较底层,表示的是一个异步操作的结果。在Java中也有Future,在python的官方文档中讲的也不是很多,这里不过多讲述。

任务

如上面第三个方式,任务是Future的子类,用来的是并发执行协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import asyncio
import time

async def func1(delay):
await asyncio.sleep(delay)
print(delay)

async def main():
task1 = asyncio.create_task(
func1(1))

task2 = asyncio.create_task(
func1(2))

print(f"started at {time.strftime('%X')}")
await task1
await task2
print(f"finished at {time.strftime('%X')}")
asyncio.run(main())

这段代码等待的时间并不是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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#同步示例
import time

def hello():
time.sleep(1)

def main():
for i in range(2):
hello()
print('Hello World:%s' % time.time())
if __name__ == '__main__':
main()

#Hello World:1583993483.900457
#Hello World:1583993484.9013405
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#异步示例
import time

async def hello():
asyncio.sleep(1)
print('Hello World:%s' % time.time())

async def main():
for i in range(2):
await hello()

if __name__ == '__main__':
asyncio.run(main())

#Hello World:1583994087.0926125
#Hello World:1583994087.0926125

这里要说的是,如果asyncio.sleep前加入await,则异步函数hello会等待sleep结束才继续输出,不加await会报warning,但是不影响运行。

异步http

基于以上的代码可以知道,异步操作其实是就是将耗时操作异步化,使得代码运行时间缩短。
在爬虫中如果需要大量的请求,并且要缩短时间,通常使用异步的http客户端进行爬取。由于requests库是同步的,在请求的时候会阻塞,所以requests不支持异步操作,通常我们会使用grequests和aiohttp等库,这里我们使用aiohttp。

1
2
3
4
5
6
7
8
9
10
11
12
import asyncio
from aiohttp import ClientSession

url = "https://www.example.com/"
async def hello():
async with ClientSession() as session:
async with session.get(url) as response:
response = await response.read()
print(response)

if __name__ == '__main__':
asyncio.run(hello())

response.read()是一个耗时的io操作,前面加上await关键字等待响应。

多链接同步请求与异步请求对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import requests,time,asyncio
from aiohttp import ClientSession

url = "http://www.qq.com"
async def hello():
async with ClientSession() as session:
async with session.get(url) as response:
response = await response.read()


def hello2():
res = []
for i in range(30):
r = requests.get(url)
response = r.text


async def main():
tasks = []
for i in range(30):
task = asyncio.create_task(hello())
tasks.append(task)
await asyncio.wait(tasks)


if __name__ == '__main__':
print("start time at {}".format(time.strftime("%X")))
hello2()
print("end time at {}".format(time.strftime("%X")))

print("start time at {}".format(time.strftime("%X")))
asyncio.run(main())
print("end time at {}".format(time.strftime("%X")))
#start time at 16:59:05
#end time at 16:59:12
#start time at 16:59:12
#end time at 16:59:12

可以发现使用异步操作爬取页面速度非常之快。

使用同一个ClientSession

而aiohttp文档中建议使用单个ClientSession,而不是每次连接都重新创建一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import requests,time,asyncio
from aiohttp import ClientSession
url = "http://www.qq.com"
async def hello():
async with ClientSession() as session:
async with session.get(url) as response:
response = await response.read()

async def main():
tasks = []
for i in range(50):
task = asyncio.create_task(hello())
tasks.append(task)
await asyncio.wait(tasks)



async def hello1(session):
async with session.get(url) as response:
response = await response.read()


async def main2():
async with ClientSession() as session:
tasks = []
for i in range(50):
task = asyncio.create_task(hello1(session))
tasks.append(task)
await asyncio.wait(tasks)



if __name__ == '__main__':

start = time.time()
asyncio.run(main())
print(time.time() - start)

start = time.time()
asyncio.run(main2())
print(time.time() - start)

这次我们使用了同一个ClientSession(),在测试了30,100,500,1000个请求后发现,使用同一个session速度略快一些,大概是两倍左右。

后记

学了几天基础的python异步编程,对异步编程又有了新的理解,学习了异步爬虫的基本原理,曾经使用过的scrapy框架,现在能够理解它对异步的支持。
虽然没有解决开发上的问题,但还是收获很多。

CATALOG
  1. 1. 前言
  2. 2. 概念
    1. 2.1. 同步与异步
    2. 2.2. 阻塞与非阻塞
  3. 3. 实践
    1. 3.1. asyncio
    2. 3.2. 协程
    3. 3.3. Future 对象
    4. 3.4. 任务
    5. 3.5. 同步与异步对比
    6. 3.6. 异步http
    7. 3.7. 多链接同步请求与异步请求对比
    8. 3.8. 使用同一个ClientSession
  4. 4. 后记