Python的Sync与Async执行速度的快慢

本文总阅读量

前记

Python新的版本中支持了async/await语法, 很多文章都在说这种语法的实现代码会变得很快, 但是这种快是有场景限制的。这篇文章将尝试简单的解释为何Async的代码在某些场景比Sync的代码快。

1.一个简单的例子

首先先从一个例子了解两种调用方法的差别, 为了能清晰的看出他们的运行时长差别, 都让他们重复运行10000次, 具体代码如下:

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
import asyncio
import time


n_call = 10000


# sync的调用时长
def demo(n: int) -> int:
return n ** n

s_time = time.time()
for i in range(n_call):
demo(i)
print(time.time() - s_time)

# async的调用时长
async def sub_demo(n: int) -> int:
return n ** n

async def async_main() -> None:
for i in range(n_call):
await sub_demo(i)

loop = asyncio.get_event_loop()
s_time = time.time()
loop.run_until_complete(async_main())
print(time.time() - s_time)

# 输出
# 5.310615682601929
# 5.614157438278198

可以看得出来, sync的语法大家都是很熟悉, 而async的语法比较不一样, 函数需要使用async def开头, 同时调用async def函数需要使用await语法, 运行的时候需要先获取线程的事件循环, 然后在通过事件循环来运行async_main函数来达到一样的效果, 但是从运行结果的输出可以看得出, sync的语法在这个场景中比async的语法速度快了一些些(由于Python的GIL原因, 这里无法使用多核的性能, 只能以单核来跑)。

造成这样的原因是同样由同一个线程执行的情况下(cpu单核心),async的调用还需要经过一些事件循环的额外调用, 这会产生一些小开销, 从而运行时间会比sync的慢, 同时这是一个纯cpu运算的示例, 而async的的优势在于网络io运算, 在这个场景无法发挥优势, 但会在高并发场景则会大放光彩, 造成这样的原因则是因为async是以协程运行的, sync是以线程运行的。

NOTE: 目前所说的async语法都是支持网络io, 而文件系统的异步io还不是非常的完善, 所以文件系统的异步读写是通过封装交给多线程去处理, 而不是协程。
具体可见: https://github.com/python/asyncio/wiki/ThirdParty#filesystem

2.一个io的例子

为了了解async在io场景下的运行优势, 先假定有一个io场景–Web后台服务通常需要处理许多请求, 所有请求都是从不同的客户端发出的, 示例如图:
io场景

在这种场景下, 客户端请求都是在短时间内发出的。 而服务端为了能够在短时间内处理大量的请求, 防止处理延迟, 都会以某种方式来支持并发或者并行。

NOTE: 并发,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
并行是计算机系统中能同时执行两个或多个处理的一种计算方法。

对于sync语法来说, 这个Web后台可以通过进程, 线程或者两者结合来实现, 他们的提供并发/并行的能力会局限于woker的数量, 比如当有5个客户端同时请求而服务端只有4个worker时, 有一个请求会进入阻塞等待阶段, 直到运行的4个worker有一个被处理完毕。 为了让服务器能提供更好的服务, 我们都会提供足够多的worker, 同时由于进程具有良好的隔离性且比较每起一个进程都会占用一份独立的资源, 所以都是以几个进程+大量线程的形式来提供服务。

NOTE: 进程是最小的资源分配单位, 过多的进程会占用很多系统资源, 一般的后台服务启用的进程数量不会很多, 同时线程是最小的调度单位, 所以以下的调度我都以线程来描述。

但是这种方式是很耗系统的资源的(相对于协程来说), 因为线程的运行都是靠cpu来执行的, 而cpu是有限的, 同一时刻只能支持固定的几个worker运行, 其他线程则得等待被调度, 这样就意味着每个线程都只能工作一个时间分片, 之后就会被调度系统控制进入阻塞或者就绪阶段, 让位给其他线程, 直到下次获取时间分片时才可以继续运行。 为了能模拟出同一时刻内, 多个线程同时运行, 且防止其他线程饿死的情况, 线程每次获得的运行时间很短, 线程间的调度切换很频繁, 当启用更多的进程和更多的线程时, 调度就会更加的频繁。

不过调度线程的开销还不算大, 比较大的开销是调度线程而产生的下文切换和竞争条件(具体可以参考《计算机导论》中进程调度相关的资料, 我这里只是简单说明), cpu在执行代码时,它需要把数据加载到cpu的缓存中去的再运行, 当cpu运行的线程在这个时间分片内执行完成时, 该线程的最新运行数据就会保存起来, 然后cpu会去加载准备被调度的线程的数据, 并运行。 虽然这部分暂存数据是保存在比内存更快, 比内存更靠近cpu的寄存器上, 但是寄存器的访问速度也没有cpu缓存的访问速度快, 所以cpu在切换运行的线程时, 都会花上一部分时间用来装载数据上还有装载缓存时的竞争问题。

对比线程的调度产生的上下文切换与抢占式, async语法实现的协程是非抢占式的, 协程的调度是依赖于一个循环来控制, 这个循环是一个非常常高效的任务管理器和调度器, 由于调度的是一段代码的实现逻辑, 所以cpu的执行代码并不用切换, 也就没有上下文切换的开销, 同时, 也不用考虑装载缓存的竞争问题。
还是以上面那个图为例子, 在服务开始启动时, 会先启动一个事件循环, 当收到请求时, 它会创建一个任务来处理客户端发送过来的请求, 这个任务会从事件循环获取到了执行权,独占整个线程资源并一直执行, 直到遇到需要等待外部事件, 比如等待数据库返回数据的事件, 这时任务会告诉事件循环自己在等待这个事件, 然后交出执行权, 事件循环就会把执行权传递给最需要运行的任务。 当刚才交出执行权的任务在后续收到数据库事件响应时, 事件循环会把它安排到就绪列表的第一个(不同的事件循环实现可能不一样)并在下一次切换执行权时, 把执行权返回给他, 让他继续执行, 直到遇到下一个等待事件。

这种切换协程的方式称为协作式多任务处理, 由于只会在单个进程或者单个线程中运行, 切换协程时上下文是不用改变的, cpu不用重新读写缓存, 所以会节省一些开销。 从上面可以看出协作式切换执行权是基于协程自己主动让出的, 而线程是抢占式的, 线程在没遇到io事件时, 也可能从运行状态转为就绪状态, 直到再次被调用, 这样会多出很多调度带来的开销, 而协程是会一直运行, 直到遇到让步事件才切换, 所以协程调度的次数会比线程少很多。 同时可以看出协程的何时调度是由开发者指定(比如上面所说的等等数据库返回事件), 而且是非抢占式的, 这就意味着某个协程在运行时, 其他协程是没办法运行的, 只能等到运行的协程交出执行权, 所以开发者要确保不能让任务在cpu上停留太长时间,否则剩余的任务就会饿死。

3.总结

在io场景下, io的开销比cpu执行代码逻辑外的开销大很多, 从这里也可以换个想法思考, 在遇到io的开销时, 代码逻辑需要进行等待, 而cpu是空闲的, 于是就通过协程/线程的方式对于cpu的多路复用, 压榨cpu。
假设sync语法和async语法执行的代码逻辑是一样的, 那么他们执行速度快慢的对比可以转换为协程与多进程/线程的开销对比, 也就是协程事件循环调度开销与多进程/线程的调度的开销逻辑对比, 而事件循环调度的开销是基本不变(或者变化不大),多进程/线程的开销除了比事件循环调度的开销大外,还会随着worker的量变多而变多, 当并发量高到一定程度时, 多进程/多线程的开销会大于协程切换的开销, 这时async语法的执行速度就会快于sync语法。
所以在普通场景下, sync语法的执行速度会快于async语法的执行速度, 但在io计算大于cpu计算且高并发场景下时, async语法的执行速度会比sync语法速度还快。

查看评论