V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX 提问指南
LeeReamond
V2EX  ›  问与答

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

  •  
  •   LeeReamond · 2020-03-09 22:19:09 +08:00 · 1214 次点击
    这是一个创建于 1576 天前的主题,其中的信息可能已经有所发展或是发生改变。

    如题,众做周知 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 并计算其运行时间,每秒也只能跑一千万轮左右。而这个老哥把它放到异步框架里跑,居然速度比同步运行提升了一倍。

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

    crella
        1
    crella  
       2020-03-10 12:08:06 +08:00 via Android
    帮顶
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1029 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 20:01 · PVG 04:01 · LAX 13:01 · JFK 16:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.