异步并发

温故知新

在 《Python 基础 · 常用模块》中,我们已经介绍过 Gevent 了,在介绍它的同时,也解释了同步、异步的基本概念。
同步(Synchronous) 并非指 同步到云端 这种,而是按次序执行、等待,就像只有一个工作窗口异步 (Asynchronous) 则像是有很多个工作窗口,可以同时接待很多事务。
Synchronous 一般也会简写为 sync,而 Asynchronous 则是 async。
并发 是指同时执行多个任务,所以,一般它是异步的。至于细节上的区分,倒不用太关注,知道这么一回事就可以了。

模拟现场

我们再反过来看之前的 simple_dns_server.py,代码中,dns_handler 的运行是 同步 的。
首先, while True 内的每次循环,都会等待 53 端口过来的数据,没有的话,就会 wait,可以认为这是一个 block (锁住)。当数据过来时,也就是 client 端发 DNS 查询的请求过来了,会调用 dns_handler,假设 dns_handler 还没有执行完,又有新的查询请求过来,这个时候要等待前面一个 dns_handler 先执行完成。
这样的逻辑,就叫做同步
但因为 dns_handler 的处理速度很快,所以,你感觉不到 dns_handler 会 block 住整个程序的运行。那我们做一个小小的调整,就是在 dns_handler 函数的最后,增加一行 time.sleep(5),表示 dns_handler 给 client 返回 response 之后,还会休眠 5 秒,如此,这个函数至少会 block 住整个程序 5 秒的时间。
然后,重新 sudo python simple_dns_server.py,再开两个命令行窗口,分别去查询 dig google.com @127.0.0.1,你会发现明显有一个后执行的命令行窗口,会处于卡住的状态。

Gevent 异步

引入 Gevent 很简单,对 simple_dns_server.py 而言,做两个小的改动:

  1. 在代码文件的正文首行增加 from gevent.monkey import patch_all; patch_all()
  2. dns_handler 的调用改为 spawn(dns_handler, udp_sock, message, address)

第一个改动,是让 Gevent 的异步逻辑,进行全局的 patch,具体请重新翻阅 《Monkey Patch (猴子补丁)》;第二个改动,就是将同步执行的 dns_handler 改为异步执行的逻辑,不管 dns_handler 内部 sleep 多长时间,在 while True 的主循环逻辑内,由 spawn 去调用,都是瞬间完成的。

引入 Pool

在 Gevent 的异步逻辑中,我们用 spawn 来执行,理想地来说,它会无限异步,来多少个任务,就全部分发出去,如果并发请求过来非常多,这容易让当前服务器的硬件资源急速地耗尽。
一般异步逻辑中,都会有个 Pool 的概念,词如其意,就是一个 池子,但池子的大小是有限定的。比如下面的代码中,我们给出一个 500 并发的 pool,如果同时并发超过 500,那就要先等待。

from gevent.pool import Pool
pool = Pool(500)
while True:
    msg, addr = udp_sock.recvfrom(8192)
    if pool.full():
        logging.info('pool is full ...')
        pool.wait_available()
    pool.spawn(dns_handler, udp_sock, msg, addr)

因为 DNS 的解析返回结果还是很快的,不论你内部的逻辑有多复杂,给出 response 的速度肯定不会太慢,这个时候的 Pool 的价值或许并不明显。
但是对于异步编程而言,Pool 的作用很大,让我们可以限定当前异步的规模。比如一个函数必然会消耗很大的资源 (CPU、内存、网络),那么就需要限定异步的规模。
除了限定规模之外,Pool 也更容易管理,把很多异步的 job 扔到 Pool 中,如果有什么问题,大不了把整个 Pool 给 kill 掉,而不用逐次去处理每个 job。

补充说明

因为 simple_dns_server.pywhile True 天然会让程序一直保持运行,所以 Gevent 可以在程序的整个生命周期内发挥作用。
如果在其它地方尝试 Gevent 进行并发时,你可能会发现,程序直接退出了,毫无错误提示,直接退出了。真是窘迫,会有这样的局面……

我们在上文介绍 spawn 时说过,它的执行速度很快,相当于工作任务被放在背后执行了,但告诉主程序不用等我了,你继续
如果你的代码文件中,没有一直保持运行,对于主程序而言,所有 spawn 处理过的函数,都已经完成了执行,那整个程序自然就退出了。
当然,我们也不能所有地方都写成 while True,这是死循环,滥用的话,既不符合逻辑,代码也不好看。我们要确保所有 spawn 的函数调用成功后,主程序才能退出,而且这个时间要刚刚好才可以。

在 Gevent 中有一个 joinall 的函数,就是等待所有任务都完成了,它才会继续。如果主程序中,joinall() 是最后一行,那么等所有 spawn 过的函数执行完了,自然退出程序了,刚刚好。 Pool 上也有 join 这个命令,效果就是等整个 pool 都执行完了。
注: 不用把 join 翻译为 加入,不然会很难理解代码层面上的含义,简单地把它理解成 wait 吧。

from gevent import joinall

理解了吗

异步很重要,因为真实的产品场景中,它是必然存在的。
比如我们要处理一个 Web 页面的访问统计,那么在页面 response 返回给浏览器的同时,就应该异步地统计数据,免得这个行为 block 住了 response 的返回。
比如我们要处理很多条数据,one by one 执行下来,数量不多倒没有什么,但是数量一多,让程序跑起来后,我们来来回回泡一天的咖啡都不一定能完成,而且 CPU 的利用率还奇低无比。这个时候就是异步大显身手的时候。

理解异步并不难,如果仍然懵懂,建议本文再阅读一遍、尝试一次。重点在于:未处理过的、多个dns_handler 在同一个循环逻辑中,天然是同步的,一定会等待前一个执行完,才有后一个。