大家好,我是猫哥。今天分享一篇文章,讨论了拖慢 Python 整体性能的三大原因。在开始正文之前,需要说明一下(免得有人误以为 Python 慢就不值得使用):性能很关键,但并不总是决定因素,语言的选择是系统性的问题,需要多方考虑。
本文讨论的三个原因,解释了 Python 性能的天花板在哪里,认识这几个方面,就有助于认识这门语言的特点,同时也是在寻找突破口:在什么层面上应该(should)、可以(can)、怎么(how)做出优化?
我前不久刚翻译的一篇文章《GIL 已经被杀死了么?》,据它介绍,GIL 不用太久将成为历史,这真是个好消息。
作者:laixintao | 转载:Python猫
(本文经原作者授权转载,不得二次转载)
原文: https://www.kawabangga.com/posts/2979
Python 在近几年变得异常流行,Python 语言学习成本低,写出来很像伪代码(甚至很像英语),可读性高,等等有很多显而易见的优点。被 DevOps, Data Science, Web Development 各种场景所青睐。但是这些美誉里面从来都没有速度。相比于其他语言,无论是 JIT 的,还是 AOT 的,Python 几乎总是最慢的。导致 Python 的性能问题的有很多方面,本文尝试谈论一下这个话题。
现代计算机处理器一般都会有多核,甚至有些服务器有多个处理器。所以操作系统抽象出 Thread,可以在一个进程中 spawn 出多个 Thread,让这些 Thread 在多个核上面同时运行,发挥处理器的最大效率。(在 top 命令里面可以看到系统中的 threads 数量)
所以很显然,在编程时使用 Thread 来并行化运行可以提升速度。
但是 Python (有时候)不行。
Python 是不需要你手动管理内存的(C 语言就需要手动 malloc/free),它自带垃圾回收程序。意思是你可以随意申请、设置变量,Python 解释器会自动判断这个变量什么时候会用不到了(比如函数退出了,函数内部变量就不用到了),然后自动释放这部分内存。实现垃圾回收机制有很多种方法,Python 选择的是引用计数+分代回收。引用计数为主。原理是每一个对象都记住有多少其他对象引用了自己,当没有人引用自己的时候,就是垃圾了。
但是在多线程情况下,大家一起运行,引用计数多个线程一起操作,怎么保证不会发生线程不安全的事情呢?很显然多个线程操作同一个对象需要加锁。
这就是 GIL,只不过这个锁的粒度太大了,整个 Python 解释器全局只有一个 Thread 可以运行。详见 dabeaz 博客。
绿色表示正在运行的线程,一次只能有一个。
因为其他语言没有 GIL,所以很多人对 GIL 误解。比如:
“Python 一次只能运行一个线程,所以我写多线程程序是不需要加锁的。” 这是不对的,“一次只能运行一个线程”指的是 Python 解释器一次只能运行一个线程的字节码(Python 代码会编译成字节码给Python虚拟机运行),是 opcode 层面的。一行 Python 代码,比如 a += 1
,实际上会编译出多条 opcode:先 load 参数 a,然后 a + 1,然后保存回参数 a。假如 load 完成还没计算,这时候线程切换了,其他线程修改了 a 的值,然后切换回来继续执行计算和存储 a,那么就会造成线程不安全。所以多线程同时操作一个变量的时候,依然需要加锁。
“Python 一次只能运行一个线程,所以 Python 的多线程是没有意义的。” 这么说也不完全对。假如你要用多线程利用多核的性能,那 Python 确实不行。但是假如 CPU 并不是瓶颈,网络是瓶颈,多线程依然是有用的。通常的编程模式是一个线程处理一个网络连接,虽然只有一个线程在运行,但其他线程都在等待网络连接,也不算“闲着”。简单说,CPU 密集型的任务,Python 的多线程确实没啥用(甚至因为多线程切换的开销还会比单线程慢),IO 密集型的任务,Python 的多线程依然可以加速。
这么说可能比较好理解:无论你的电脑的 CPU 有多少核,对 Python 来说,它只用 1 个核。
其他的 Python Runtime 呢?Pypi 有 GIL,但是可以比 CPython 快 3x。Jython 是基于 JVM 的,JVM 没有 GIL,所以 Jython 依然 JVM 的内存分配,它也没有 GIL。
其他语言呢?刚刚说了 JVM,Java 也是用的引用计数,但是它的的 GC 是 multithread-aware 的,实现上更复杂一些(有朋友跟我说 Java 已经不是引用计数了,这个地方请读者注意,附一个参考资料)。JavaScript 是单线程异步编程的模式,所以它没有这个问题。
像 C/C++/Rust 这些语言直接编译成机器码运行,是编译型语言;Python 的运行过程是虚拟机读入 Python 代码(文本),词法分析,编译成虚拟机认识的 opcode,然后虚拟机解释 opcode 执行。但这其实不是最主要的原因,Python import 之后会缓存编译后的 opcode,(pyc
文件或者 __pycache__
文件夹)。所以读入、词法分析和编译并没有占用太多的时间。
那么真正的慢的是哪一步分呢?就是后面的虚拟机解释 opcode 执行的部分。前期的编译是将 Python 代码编译成解释器可以理解的中间代码,解释器再将中间代码翻译成 CPU 可以理解的指令。相比于 AOT(提前编译型语言,比如C)直接编译成机器码,肯定是慢的。
但是为什么 Java 不慢呢?
因为 Java 有 JIT。即时编译技术将代码分成 frames,AOT 编译器负责在运行时将中间代码翻译成 CPU 可以理解的代码。这一部分跟 Python 的解释器没有太大的区别,依然是翻译中间代码、执行。真正快的地方是,JIT 可以在运行时做优化,比如虚拟机发现一段代码在频繁执行(大多数情况下我们的程序都在反复执行一段代码),就会开始优化,将这段代码用更改的版本替换掉。这是仅有虚拟机语言才有的优势,因为要收集运行时信息。像 gcc 这种 AOT编译器,只能基于静态分析做一些分析。
为什么 Python 没有 JIT 呢?
第一是 JIT 开发成本比较高,非常复杂。C# 也有很好的 JIT,因为微软有钱。
第二是 JIT 启动速度慢,Java 和 C# 虚拟机启动很多。CPython 也很慢,Pypy 有 JIT,它比 CPython 还要慢 2x – 3x。长期运行的程序来说,启动慢一些没有什么,毕竟运行时间长了之后代码会变快,收益更高。但是 CPython 是通用目的的虚拟机,像命令行程序来说,启动速度慢体验就差很多了。
第三是 Java 和 C# 是静态类型的虚拟机,编译器可以做一些假设。(这么说不知道对不对,因为 Lua 也有很好的 JIT)
静态类型的语言比如 C,Java,Go,需要在声明变量的时候带上类型。而 Python 就不用,Python 帮你决定一个变量是什么类型,并且可以随意改变。
动态类型为什么慢呢?每次检查类型和改变类型开销太大;如此动态的类型,难以优化。
动态类型带来好处是,写起来非常简单,符合直觉(维护就是另一回事了);可以在运行时修改对象的行为,Monkey Patch 非常简单。
近几年的语言都是静态类型的,比如 Go,Rust。静态类型不仅对编译器来说更友好,对程序员来说程序也更好维护。个人认为,未来是属于静态类型的。
阅读资料: