Python 为什么这么慢?

Python 在近几年变得异常流行,Python 语言学习成本低,写出来很像伪代码(甚至很像英语),可读性高,等等有很多显而易见的有点。被 DevOps, Data Science, Web Development 各种场景所青睐。但是这些美誉里面从来都没有速度。相比于其他语言,无论是 JIT 的,还是 AOT 的,Python 几乎总是最慢的。导致 Python 的性能问题的有很多方面,本文尝试谈论一下这个话题。

TL;DR

  1. Python 有 GIL
  2. Python 是一种“解释型”语言
  3. Python 是动态类型的语言

GIL

现代计算机处理器一般都会有多核,甚至有些服务器有多个处理器。所以操作系统抽象出 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 呢?Pypy 有 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。静态类型不仅对编译器来说更友好,对程序员来说程序也更好维护。个人认为,未来是属于静态类型的。

 

阅读资料:

  1. Python 官方 wiki
  2. Removing Python’s GIL: The Gilectomy
  3. David Beazley 有关 GIL 的 Slides:http://www.dabeaz.com/GIL/ ,视频(比较糊,毕竟2010年的)
  4. Gilectomy的最新动态
  5. https://hackernoon.com/why-is-python-so-slow-e5074b6fe55b
  6. https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/
  7. https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/
 

连接池中的连接失效的几种处理方案

在分布式系统中,用连接池缓存住连接,来节省连接反复销毁和创建的成本,是一种很常见的做法。但是在高可用的分布式系统中,”切换”是一个非常普遍的操作,切换就会造成连接池失效的问题。

TCP 虽说是”有连接的”,但这个连接实际上是一个虚拟连接。客户端用一段内存保存连接的五元组(源端口,源IP,目的端口,目的IP,协议),服务端也保存一个这样的五元组,双方就认为有这么一个连接了,可以通过这个连接收发。

但是假如客户端保存有这个五元组,服务器因为某种原因将这个五元组删掉了,就会造成客户端单方面认为有这么一个连接,而服务器不承认(反过来也有这个问题,但是服务器丢失连接的情况更加普遍也更加严重)。当客户端想使用这个连接向服务器发送请求的时候,会被服务器拒绝。

保持一个连接池的做法会使这个问题更加严重,因为连接用完之后不会马上销毁,等待下一次使用,这段时间天知道会发生什么。比如说应用连接数据库这种情况,一般会有连接池保持和数据库的连接。假如 DB 重启了、DB 端因为超时或者其他什么原因把连接关闭了(即把内存的五元组删掉)、DB 出现故障,从一个IP迁移到另一个IP了,都会造成客户端连接池中的连接不可用,这个时候客户端通过这些”坏连接”向DB发送请求,就会失败。(多久时间会返回失败也是一个问题,这受 socketTimeout 参数和 Linux 系统参数影响,具体可见这篇 Datebase timeouts.)

除了DB之外,像 Nginx 保持向后端应用的连接等负载均衡设备通常也有这种问题,只不过有的组件自身选好了方案处理掉了,有的是暴露出来选项让你自己设置。本文讨论处理这种问题的几种思路。

一、失败了就是败了,重建就是

这是成本最低的方式,连接池的实现一般都会包括如果连接不可用,就删除并重建。影响是会失败一些请求(数量=连接池的数量)。

当然有些时候我们是可以预测需要切换DB的,有条件的话可以将业务切换走,然后再切换DB。业务回切之前先对应用发送一波压测流量,用假的业务流量刷掉不用的连接。

二、主动保持

之前在博客中介绍过 TCP Keepalive,可以正好解决这种场景。就是设置一个定时器,定时检查连接是否还可用。

对于 TCP 来说,由于这是一个基于流的协议,所以可以发送一段长度为0的片段来检验是否能收到 ACK。对于 MySQL 来说,要看驱动程序的实现,比如如果实现了 ping 函数可以使用 ping;如果没有 ping 可以使用 SELECT 1。对于 redis 来说也有 ping 命令。Jboss 提供的 background-validation 参数,就是这个原理。

一般来说这个方案是复杂度低,并且比较有效的。缺点是当分布式的连接池巨大的时候,比如上千台机器的话,每台 DB 要承受测活的连接数是 机器数 * 连接池大小,资源消耗是很大的。

三、使用较小的 idle timeout 和 小连接池

连接池一般都提供设置两个参数:

  1. idle timeout:当 idle 时间超过之后,会主动回收;
  2. min connections:连接池最小要保持多少连接;

通过设置 idle timeout,可以保证没有连接是开头说的那种很长时间没用过的连接,因为都会回收掉了。

但是这里会存在一个问题:min connections 要保证连接池有多少个连接,假如设置的是10,平时只用5的话,那么因为 idle timeout 参数会回收5个连接,导致连接池会新建5个新连接。过一段时间,因为有一些用不到又会被回收,就造成了在反复创建连接。

假如采取另一种策略:回收 idle timeout 的连接但是保证连接数要大于 min connections,假如连接数不大于 min connections,那么即使 idle timeout 也不回收。这样可以解决上面的问题,但是会造成无法保证 connection pool 里面的连接都是新鲜的,回到我们最初的问题了。

所以这种方案其实又要做一个 trade off,如果能保证连接需求的比较稳定,需要的连接数总是大于 min connections 又不至于大太多,倒是可以用在这种方案。

四、每次使用之前检查一次

顾名思义,每次使用连接之前先检查一下这个连接是否可用。成本太高一般不用,对客户端来说会增加延迟,对DB来说会增加压力。

实现的话,可以看下 DBCP 的 TestOnBorrow

五、HAProxy

HAProxy 并不能说是一个思路,只是一个现成的工具,它是一个正向代理,实现了连接的测活和切换。HAProxy 的原理我还没研究,但是应该不出这几种思路。甚至是可以让你选的,毕竟它配置项目那么多。

HAProxy 怎么用呢?考虑有多个应用连接多个数据库的场景,从原来的直接连接变成通过 HAProxy 连接。当需要切换的时候,应用不需要感知 DB IP 的变化,而是 HAProxy 更改后端 DB 的 IP,从而对应用透明了,应用只需要一个 DB 集群的 URL 就可以了。

App 直接连接 DB

App 通过 HAProxy 连接 DB

 

另一方面,也节省了 DB 的连接数。

 

程序员应该知道的时间概念

很多人对“速度”没有什么概念,同机房内 RTT (Round Trip Time)大约是多少?如果将一个应用内的函数的调用拆成两个应用 RPC 调用,将增加多少延迟?打印日志有多快,打印日志的多少会增加多少延迟?

之前看过 Jeff Dan(忘记在哪里看到的了)写一个叫 Latency Numbers Every Programmer Should Know 的东西,我费了半天劲,终于找到了。决定把它贴在这里,传播一下。原文如下,这是从这个 gist 看到的。

有意思的事实是,CPU 的 Cache 到内存的操作都是纳秒级别的,同机房的 RTT 和 SSD 的读写速度在一个量级,机械硬盘比 SSD 慢20倍,比内存慢80倍。

以上的数据是2012年的,技术在发展,这个网页也可以拖动滚动条对不同年代的时间做可视化。2005年之前,CPU和内存的速度在飞速发展,但是2005年之后基本停滞了,之后是网络带宽、硬盘(SSD)快速发展。

https://people.eecs.berkeley.edu/~rcs/research/interactive_latency.html

这个网站可以看各个城市的延迟:https://wondernetwork.com/pings/

哦对了,说到 ping,我清明节的时候写了一个小工具 pingtop,可以同时 ping 多个 server 并在终端展示出来,喜欢的同学请点个赞❤️。

最后贴一张有人画的形象化的版本。

其他相关的阅读:

  • Jeff Dan 的一个分享 http://videolectures.net/wsdm09_dean_cblirs/
  • Teach Yourself Programming in Ten Years (我搜这个时间表的时候搜到的,这篇文章值得一看  ——为什么大家都这么着急呢?)
 

用 ssh 传输文件

今天收到了研发同学的一个工单,内容大体是:需要将文件拷贝到服务器上去,请求帮助安装 scp 程序。

我尝试了对那台服务器执行 scp 命令,结果是 -bash: scp: command not found,但是 ssh 是正常可以用的。这种情况下应该是 openssh-clients 被删掉了。

但是!scp 命令是基于 ssh 协议的,既然可以用 ssh ,还要什么 scp 呢!

我们可以直接用 ssh 就可以传输文件,学会了之后,发现它比 scp 还好用,scp 的 path 写起来比较蛋疼。

首先很多人忽略的一个事实是,ssh 可以直接输入命令对远程主机执行,比如 ssh [email protected] "cat access.log" ,就可以直接 cat 出远程文件的 log 内容。ssh 会将远程命令的 stdout 和本地的 stdout 连接起来。可以用这样的命令来看实时的日志: ssh [email protected] "tail -f access.log"。这样就可以在本地执行一条命令就可以了,方便脚本化或记录到 本地 history。

既然 ssh 可以将 stdout 连接起来,那么自然也可以将 stdin 连接起来!比如用这个命令将 ssh key 拷贝到服务器上去。

将一个文件传输到服务器:

上面这个命令的原理就是,将文件内容输入到 stdout 中,用管道和 ssh 连接起来,然后这个 stdout 就成了远程命令的 stdin。

要拷贝整个文件夹呢?没有问题:

如果是像日志这种压缩性能很高的文件,可以考虑压缩之后再传输,远程那边从 stdin 解压缩。而且直接将输入和输出通过管道连接起来,压缩中间生成的文件丝毫不会占用空间呢!

如果要指定远程的目标文件夹,可以使用 tar 的 C 参数来指定,比如远程解压缩到 /tmp 下面:

要注意像图片、视频这种二进制文件,本身就是经过压缩之后的了,如果再使用 tar z 来压缩一遍的话,不会节省多少传输体积,反而会白白耗费 CPU。

实用技巧:如果每天备份 MySQL 到另一台机器,但是不占用本机空间?Crontab 的脚本这么写:

假如一台机器A在一个网络环境,另一个机器B在另一个网络环境,他们之间不互通。但是你的电脑(或堡垒机)能同时用 ssh 登陆两台机器,那么怎么把 Server A 的文件拷贝到  Server B?

用两个 ssh!

理解 ssh 能连接 stdin 和 stdout 了,就有无限的可能了!而且你可以将脚本都放在本地,不用还得本地放一些,远程的机器放一些通过 ssh 来执行。

哦对了,ssh 是一个加密的协议,所以在传输的过程中会看到 CPU 使用上涨,因为这是在加密和(远程服务器)解密。用的时候需要考虑到这个。scp 命令是基于 ssh 的,所以会有一样的问题。

nc 基于 tcp 明文传输的,如果不需要加密,传输内容比较多,可以考虑用这个。

在 Server 端执行 nc 监听端口,将输入到 nc 的内容输出到一个文件中。

然后在 Client 端将要发送的文件输入到服务器的这个端口中:

 

这个独门绝技是 @mrluanma 教我的。

 

DNS 解析的原理

提起 DNS,大家都知道是将域名映射成 IP 的一种协议。但是我花了很长时间才真正理解这个“映射”的过程。网上有很多介绍 DNS 的资料,但是遗漏了很多细节,比如都跨过了哪一些缓存,谁去真正发出查询的 packet。我们电脑要配置 DNS 服务器,如果你有网站的话,你会发现你的网站也有一个配置项叫 DNS 解析服务器,这两种服务器是一样的吗?还有一些图和资料是错误的,比如有些图画的是根域名服务器指向权威域名服务器,给人造成一种错觉是根服务器去查询了权威服务器。

本文试图用简单并且准确的图片和文字解释 DNS 查询的过程,以及一些我曾经有的困惑。如果有何 RFC 冲突的地方那肯定是我错了,请指正。

首先介绍一个 DNS,说白了它就是一个名字到 IP 的映射,因为像 64.202.189.170 这样的 IP 太难记了,所以就有了 DNS 这样的东西,好比是一个电话本,我们打电话(访问网页)的时候,就查一下这个名字(网络地址)对应什么样的 IP,然后拨出去。

如果你有很多联系人的话,查号码的时候肯定不会一个一个看,一般会在手机上下拉到这个字母姓氏的地方,然后再定位到这个人的名字。DNS 也是一样,全球已经有上千万个域名了,并且还在实时的变动和更新,所以DNS就采取了一种“分层”的查询。要查询 www.kawabangga.com 这个域名对应的 IP,实际上查询的是 www.kawabangga.com. 最后这个 . 就是 root nameserver,平时我们都省略了,其实这个才是一切开始的地方。

Recursive resolver(下面会讲到这是啥)只知道 . 的地址(一共13个IP),这就够了。当它查询一个域名的时候,它就去 root nameserver 查询,大体的步骤如下:

  1. . root nameserver 发出查询请求,root nameserver 说,“我也不知道 www.kawabangga.com. 这个域名的地址,但是我知道 .com nameserver 的地址。Root Name Server 是怎么知道所有顶级域名的地址呢?因为它就是干这个的,Root Name Server 维护了所有顶级域名对应的地址,这个文件可以在 https://www.internic.net/domain/root.zone 这里下载。现在是 2019 年3月,这个文件是 2万多行,一共2.15M 。
  2. .com Name Server 这种域名是最顶层的域名,所以叫做顶级域名,Top Level Domain Name Server,TLD Name Server。因为……它在最顶层。.com 域名服务商也就叫做顶级域名服务商,其他有名的顶级域名还有 .org .gov .cn 等。TLD nameserver 这里其实也不知道 www.kawabangga.com. 的 IP 地址,但是它知道 kawabangga.com. 的 nameserver 的地址。
  3. kawabangga.com. 的 Name Server 也叫做 Authoritative Name Server,通常翻译成“权威域名服务器”,但是我更喜欢叫他“被授权的域名服务器”,为什么呢?因为……它是被 .com 域名服务器授权的。上一步,TLDName Server 怎么知道 kawabnagga.com. 的Name Server 在哪里的呢?因为你注册一个域名的时候,本质上是花钱购买 xxx.com. 这个权威域名服务器的管理权益,你把钱给域名注册商,域名注册商告诉 .com. TLD Name Server 说,hey 以后把 xxx.com. 这个域名交给 xxx 域名服务器来解析就好啦!于是你就获得了这个域名的解析权利。kawabangga.com. 的 Authoritative Name Server 地址是 ns1.myhostadmin.net. 当 Recursive resolver 过来查询 www.kawabangga.com. 的地址的时候,ns1.myhostadmin.net. 会告诉它对应的IP。到这里,一次查询就宣告结束了。

这三种 Name Server 的职责都很明确:Root Name Server 负责维护全球顶级域名的地址;TLD Name Server 负责维护每一个购买了域名的人提交的他们的 Name Server 地址;Authoritative Name Server 由域名的持有人自己想怎么解析就怎么解析。但是一般的机构和个人都不会自己假设域名服务器,因为可能子域名的需求不多,比如本博客,就3个子域名而已,所以会选择使用一个公共的 DNS 解析服务,比如 DNSPod。域名注册商现在一般也都提供域名解析服务,CDN 像 CloudFlare 也会有域名解析的服务。像大型网站自己假设解析服务器,一般会使用 BIND 这个很流行的软件。当然也有自己研发 DNS 解析服务的。

下面我们再解释一下上面提到的 Recursive Resolver,有的地方叫 Resolving Name Server,意思就是解析 DNS 的 Server。以上的查询工作都由它来完成。

为什么呢?首先 DNS 设计的第一目的就是要快,这是非常基础的设施,所以必须要非常快。但是再快,全世界这么大的量也扛不住啊,并发高?加缓存!DNS 是层层缓存的。缓存有一个经典的问题就是,贵的缓存比较快。这里的贵不一定是价格贵。你在浏览器访问 www.kawabangga.com 的时候,至少经过了浏览器对 DNS 的缓存、电脑对 DNS 的缓存,路由器对 DNS 的缓存…… 浏览器的DNS缓存肯定最快,但是无法跟其他应用共享,如果每个应用都缓存的话,占用的空间就太大了。路由器上面的缓存可以给多台电脑共享,一般又是在本地,所以理所应当的做了最多的缓存。

如果在终端使用 dig 命令,会发现DNS的结果都是路由器给出的。

在这里,路由器就是开放了53端口,来提供DNS查询服务。它就是 Resolving Name Server,每次查询请求发过来,它检查自己的内存是否有答案,如果没有,就执行上述的查询过程。所以当我们执行 DNS 查询的时候,抓包会发现发出了一个包到路由器53端口,收到了一个回到,查询就结束了,还奇怪怎么和书上写的查询过程不一样呢?其实完整的查询过程是在 Resolving Name Server 上面做的。

当然,一个  Resolving Name Server 并不复杂。如果我们在执行 dig 命令的时候,带上 +trace 标志,dig 就会作为 Resolving Name Server 的角色从 . 开始查询直到查询到最后的答案。

CloudFlare 提供了一个很有名的 DNS 服务,1.1.1.1,这其实也是 Resolving Name Server,dig 命令可以使用 @1.1.1.1 的方式来指定。系统也可以设置 DNS 解析地址(现在你明白这个设置是什么意思了吧)。基本上需要网络的设备都可以设置这个DNS服务地址,比如 PS4,电视盒子。很多玩家知道设置成韩国的DNS服务地址玩游戏有时候会更快一些,原理就是这样,DNS是一个基础的服务,你离Resolving Name Server更近了,解析的也就更快了。Resolving Name Server 缓存的命中率高,也就更快,否则的话Resolving Name Server执行起来查询的过程也是耗费时间的。

 

 

参考资料

  1. How the Domain Name System (DNS) Works
  2. https://miek.nl/2013/november/10/why-13-dns-root-servers/
  3. DNS explained