分享一些今天做 Python 异步编程性能测试时的小趣闻。

2020-03-09 22:19:09 +08:00
 LeeReamond

如题,众做周知 python 的(几乎)唯一痛点在于其孱弱的性能,所以写 py 脚本的人都会对代码性能优化非常敏感。这个贴算是分享一些今天测试下的小趣闻吧。

众所周知异步编程框架在一个事件循环下执行,我们普遍被教导如果用异步实现两个并行“线程”,相较于申请系统级线程,它有两头个优势 1、它并不需要在用户态与内核态之间切,理论性能更高 2、它的并行任务在同线程下执行,需要使用锁的情况很少 3、用户可以自由设置并行任务在何时跳出(将程序控制权限交还给事件循环),控制粒度更细致。

今天突发奇想想测试一下所谓的不需要进入内核态的线程切换,相比于传统模式到底有多少优势,简单一测,惊掉了下吧

以下是测试代码:

from collections import deque
from threading import Thread , Lock
import time , asyncio

def thread_speed_test():

    def add1():
        nonlocal count
        for i in range(single_test_num):
            mutex.acquire()
            count += 1
            mutex.release()

    mutex = Lock()
    count = 0
    thread_list = list()
    for i in range(thread_num):
        thread_list.append(Thread(target = add1))

    st_time = time.time()
    for thr in thread_list:
        thr.start()

    for thr in thread_list:
        thr.join()

    ed_time = time.time()
    print(count)
    print(f'threading finished in {round(ed_time - st_time,4)}s ,speed {round(single_test_num * thread_num / (ed_time - st_time),4)}q/s')

def asyncio_speed_test():

    count = 0

    @asyncio.coroutine
    def switch():
        yield

    async def add1():
        nonlocal count
        for i in range(single_test_num):
            count += 1
            await switch()

    async def main():
        
        tasks = asyncio.gather(     *(add1() for i in range(thread_num))
                        )
        st_time = time.time()
        await tasks
        ed_time = time.time()
        print(count)
        print(f'asyncio   finished in {round(ed_time - st_time,4)}s ,speed {round(single_test_num * thread_num / (ed_time - st_time),4)}q/s')

    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

if __name__ == "__main__":
    single_test_num = 1000000
    thread_num = 2
    thread_speed_test()
    asyncio_speed_test()

cpython3.7 运行结果如下:

    2000000
    threading finished in 0.9332s ,speed 2143159.1985q/s

    2000000
    asyncio   finished in 16.044s ,speed 124657.3379q/s

简单来说就是做单纯的任务切换,让线程(或协程)交替运行,业务代码是将变量 count 每次加 1,加满 200 万次为止。 令人惊掉下巴的一点在于,根据测试结果,使用双线程交替执行代码的速度 [远高于] 使用协程安排代码,即便在额外增加线程锁的加锁与开锁的开销以后,其效率仍超出协程事件循环约 20 倍,说好的更快呢?

当然这个(也许)可以理解,原因大概在于系统角度看来线程切换在时间上的粒度较大,并不如 asyncio 是真正的两个协程交替,每次各自+1,直到加满 200 万次。线程的情况是,很可能在一次切换之间 forloop 已经运行出去几百上千次了,实际切换并不足两百万次。

实际表现就是,如果增大线程(或协程)数量,两个模型的效率一个会越来越快,一个会越来越慢 下面是一组逐渐增加线程数的测试:

# asyncio #
thread_num        numbers of switching in 1sec     average time of a single switch(ns)
         2                              122296                                    8176
        32                              243502                                    4106
       128                              252571                                    3959
       512                              253258                                    3948 
      4096                              239334                                    4178

# threading #
thread_num        numbers of switching in 1sec     average time of a single switch(ns)
         2                             2278386                                     438
         4                              737829                                    1350
         8                              393786                                    2539
        16                              367123                                    2720
        32                              369260                                    2708
        64                              381061                                    2624
       512                              381403                                    2622

由此可见,随着线程逐渐增多,threading 的平均切换时间稳定在约 2600 纳秒,而 asyncio 稳定在 4000 纳秒左右。(但是线程还是比协程快啊 kora !)。虽然我们可以推测出可能导致这种情况的原因,并不足以推翻线程切换比协程切换更快的结论,但是这也确实地导向了一个疑问,即绝大多数情况下使用异步安排任务是否真的有意义呢?

=============================================================================

目前为止事情还处在可以理解的范畴内,而接下来的情况就有点匪夷所思了。

根据在 stackoverflow 上的老哥评论,该老哥没太搞清楚状况,他说代码中的await switch()是干什么用的,可不可以注释掉,我注释掉以后提示在 0.1 秒内跑完了 200 万次,即每秒 2000 万次循环。

这个老哥很显然没有搞清楚状况,因为 switch()是一个生成器的封装,加入这行代码的意图在于将进程控制权交还给事件循环,如果注释掉的话代码就变成了普通的同步执行,事件循环会依次执行次数为一百万次的 forloop,直到指定个数为止。

这个可以理解,对于性能比较敏感的人而言,无法理解的一点是,cpython 解释器(比如我在用的 3.7 版,也是 asyncio api 稳定后的第一版),即使在同步的情况下,写一个简单的 forloop 并计算其运行时间,每秒也只能跑一千万轮左右。而这个老哥把它放到异步框架里跑,居然速度比同步运行提升了一倍。

大概只能用匪夷所思来形容了。诸位有兴趣可以自己测测。

1217 次点击
所在节点    问与答
1 条回复
crella
2020-03-10 12:08:06 +08:00
帮顶

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/651347

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX