比Mojo慢68000倍,Python性能差的锅该给GIL吗?

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第1张图片# 关注并星标腾讯云开发者

# 每周1 | 鹅厂工程师带你审判技术

# 第3期 | 李志瑞:天使还是魔鬼?聊聊 Python GIL

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第2张图片

9 月 7 日,新兴编程语言 Mojo 正式发布。Mojo 的最初设计目标是比 Python 快 35000 倍,近期该团队表示,因为结合了动态与静态语言的优点,Mojo 一举将性能提升到了 Python 的 68000 倍。腾讯工程师此前也曾试用 Python 并做了相关评测,参考:《放弃Python拥抱Mojo?鹅厂工程师真实使用感受》

这不是第一个号称比 Python 更快的编程语言,相信也不会是最后一个。那么问题来了,为什么是个编程语言就比 Python 快呢?Python 在高性能、多线程方面为什么这么为人诟病?本文将以 Python PEP 703 草案的相关内容为核心,分析个中原因。


在学习 Python 的时候,相信大家应该都会了解到类似「Python 的多线程是伪多线程」、「Python 并不能通线程发挥多核 CPU 性能」这样的说法,导致 Python 这些问题的原因就是 Python 的 GIL,即全局解释器锁(global interpreter lock)。

这里需要明确的一点是,从 Python 语言标准的角度看,GIL 并不是必须的,但 Python 的默认实现是 CPython,这是我们去官网下载 Python 时获得的默认实现,也是绝大多数 Python 用户使用的实现。是 CPython 虚拟机使用了 GIL 而非 Python 语言要求要有 GIL。除了 CPython 之外,社区也有各种不同的 Python 实现,例如 JVM 上的 Jython,.Net 上的 IronPython 等,它们都没有 GIL。也就是说,如果你喜欢,可以用任意语言在任意平台上自己实现一个符合标准并且不带 GIL 的 Python。但由于最广为使用的 Python 实现是 CPython,因此 GIL 造成的局限性仍然在很多语境下被认为是 Python 的局限性。

ad09bd5e42679f6bbc5a754e6639301c.png

要理解 GIL 对 Python 的影响,我们首先要先明白 GIL 到底是什么以及它是如何工作的。

简单来说,GIL 就是一个 Python 虚拟机中的全局锁,它使得同一时刻只有一个线程能够获得 Python 虚拟机的控制权,防止多个线程同时执行 Python 字节码。在 Python 中,每个线程在执行 Python 字节码的时候都需要持有 GIL,这意味着,多个线程的 Python 字节码解释事实上会被 GIL 强制变为串行执行。那么,这个切换过程是如何发生的呢?事实上,GIL 的实现也随着 Python 的发展发生过明显的变化。在 Python 3.2 之前,Python 虚拟机主要是基于 tick 数来控制 GIL 切换,默认情况下,一个线程在执行 100 条字节码后就会释放 GIL,其他线程就可以去抢占 GIL,如下图所示:

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第3张图片

除了 tick 之外,一些阻塞操作,例如 send、recv、sleep 也会触发 GIL 的主动释放,例如 timemodule.c 中的 pysleep 代码:

static int pysleep(_PyTime_t timeout) {
    ...
        int ret;
        Py_BEGIN_ALLOW_THREADS
        ret = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &timeout_abs, NULL);
        err = ret;
        Py_END_ALLOW_THREADS
    ...
}

这里将实际的 sleep 调用使用 Py_BEGIN_ALLOW_THREADS 和 Py_END_AL‍LOW_THREADS 包裹了起来,这两个宏是 Python 扩展模块开发中会常用到的一个模式,用来显式释放 GIL,允许其他线程继续执行,执行完毕后,再获取 GIL 继续后面的逻辑。

上面这个基于 tick 的 GIL 调度实现容易导致在其他核心上执行的线程频繁尝试抢占 GIL, 造成 CPU 的空转。另外,由于正在工作的线程达到约定 tick 数时会先释放锁,然后立刻再去抢锁,因此这里很容易出现该线程重复抢到锁使得其他线程饥饿的情况,显然这个调度算法并不合理。关于这一点,David Beazley 在 PyCon 2010 作过一个关于 GIL 的分享,可以参考其中的数据。

在 Python 3.2 后,GIL 持有的调度改为基于时间片。等待 GIL 的线程在等待超时后,将一个名为 gil_drop_request 原子全局变量的值设置为 1,通过这个方式来通知当前的工作线程释放 GIL。而当前工作线程会去检查这个值,并在释放 GIL 后,通过条件变量通知等待中的线程 GIL 已经被释放,这既避免了等待中的线程频繁去尝试抢锁,也避免了该线程重复获得锁引发的其他线程饥饿问题:

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第4张图片

我们可以很清晰地从 ceval.c 代码中看到这个逻辑。Python 虚拟机在一个巨大的 eval 循环中持续执行 Python 字节码:

PyObject* _PyEval_EvalFrameDefault(...) {
    // 计算主循环
main_loop:
    for (;;) {
        if (_Py_atomic_load_relaxed(eval_breaker)) {
            opcode = _Py_OPCODE(*next_instr);
            // 对于一些特定的字节码不做任何检查就继续执行
            if (...) {
                goto fast_next_opcode;
            }


            // 检查是否需要处理正在等待的任务
            if (eval_frame_handle_pending(tstate) != 0) {
                goto error;
            }
        }
    }
}

在 eval_frame_handle_pending 中,Python 虚拟机会检查 gil_drop_request 的值,并根据需要释放 GIL:

static int eval_frame_handle_pending(PyThreadState *tstate) {
    _PyRuntimeState * const runtime = &_PyRuntime;
    struct _ceval_runtime_state *ceval = &runtime->ceval;
    ...
    // 检查 gil_drop_request 是否被设为 1
    if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) {
        // 释放 GIL
        drop_gil(ceval, ceval2, tstate);
        // 此时其他线程就可以开始执行了


        // 重新获取 GIL
        take_gil(tstate);
    }
    ...
}

默认情况下,Python 每 5ms 会在线程间切换一次,我们可以通过 getswitchinterval 来获取当前的切换间隔时间,通过 setswitchinterval 来设置间隔时间(单位为秒):

>>> import sys
>>> sys.getswitchinterval()
0.005
>>> sys.setswitchinterval(1)
>>> sys.getswitchinterval()
1.0

be49ea47c0fdf3d1d058278e98b2300a.png

了解 GIL 是如何工作的之后,我们来看一下 GIL 如何影响我们现有的程序实现方式。

对于 Python 程序来说,既然 GIL 使单个虚拟机无法并行执行 Python 程序,那么在需要充分利用多核 CPU 的场景中,最常见的做法就是启动多个 Python 进程,这也是 Python 服务器的常见实现方式,例如:

from multiprocessing import Process, Pipe


def f(conn):
    conn.send([42, None, 'hello'])
    conn.close()


if __name__ == '__main__':
    parent_conn, child_conn = Pipe()
    p = Process(target=f, args=(child_conn,))
    p.start()
    print(parent_conn.recv())   # prints "[42, None, 'hello']"
    p.join()

此时,由于每个进程是独立的 Python 虚拟机,因此 GIL 也是相互独立的,互不影响,以此实现了并行计算。但这里存在一个问题,由于进程间无法简单共享对象,因此进程间通信需要进行对象的序列化和反序列化操作,这造成了明显的计算开销。另外,实现这样的逻辑也不可避免地增加了程序的复杂性。

对于 Python 的扩展模块开发者而言,情况会稍微好一些。一方面,由于 GIL 的存在,扩展模块的开发者可以简单地直接操作数据而不用担心并发导致的数据出错,这降低了心智负担。另一方面,如果希望实现并行化计算,开发者可以在扩展中启动多个系统线程,此时,这些线程的执行是不受 GIL 限制的。另外,就像之前我们看到的那样,扩展的实现甚至可以在执行复杂计算任务的时候将 GIL 释放掉,允许 Python 虚拟机继续执行其他逻辑,等待繁重的计算任务结束后或者 IO 数据加载好后再重新获取 GIL 再将数据返回,这是许多科学计算库常用的解决方案。但这里还是存在一个问题,如果这个扩展模块与 Python 虚拟机无关的单次操作时间没有那么长,而是需要不断去操作 Python 虚拟机内部的数据,那么即便开发者注意去释放了 GIL,在程序执行到需要操作 Python 虚拟机内部数据的逻辑时,它还是需要频繁去获取 GIL,这又使得 GIL 对性能的影响变得无法忽略了。

很显然,这个 GIL 还真是为开发者造成了不少麻烦。看到这里,你也许想问,既然这个 GIL 有这么多问题,Python 的创始人 Guido van Rossum 为什么当初要这样设计呢?

d93352b77de88804b33786ea711ca7a6.png

当我们考虑一个技术选择是否合理的时候,不能只以当下的目光去审视过去,而需要回到当时的场景中去考虑问题。从 Python 的大事件时间轴中可以看到,我们正在谈论的对象是一个从 1989 年就开始设计的语言,到 1991 年的时候就出现了第一个 release 版本(0.9.0),到 2000 年的时候,Python 2 就发布了。而世界上第一个多核 CPU 是 2001 年的 IBM Power4,也就是说,在 Python 最初被设计时,并没有多核 CPU 这种东西,后来即便有了,也并不普及。因此 CPython 在实现的时候并没有作过并发和并行计算这方面的考虑。

不过在 Python 面世后的几年内,当时一些操作系统就开始逐步提供多线程能力了。此时,Python 自然也想要支持多线程,但由于 Python 虚拟机的实现一开始并没有并发安全的考虑,因此要实现完美的多线程支持需要重构整个虚拟机,这不仅是工作量大的问题,而且短时间内很难保证软件质量。因此 Python 采用了一个取巧的方法,就是加入 GIL。有了 GIL 后,Python 就能为用户提供了一个类似操作系统多线程的能力,如果用户使用的硬件是单核 CPU,那这个多线程表现得和操作系统的多线程差不多,多个线程之间可以切换,分时复用 CPU 资源,共同完成工作,看起来就像是它们在多核 CPU 上执行一样。这对于当时的用户群来说确实完全够用了,因为当时大多数用户使用的都是单核 CPU。而且,GIL 的出现使得一些线程不安全的 C 模块能很容易地被暴露给 CPython 调用而不用担心各种隐蔽的问题,这样的表现对于一个胶水语言来说实在是无可挑剔。尤其在那个 C 语言具有极强统治力的时代里,这样简单易用的优势在很大程度上促进了 Python 的流行。从这个角度来说,GIL 对于 Python 的发展可谓是功不可没。

当然,当时决策的历史局限性会在时间往前推进的过程中越发明显,尤其是到了摩尔定律终结的时候:

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第5张图片

我们知道,CPU 的单核计算能力并没有如摩尔定律预言的那样无限提高,CPU 的性能逐渐显露出瓶颈,除了针对特殊用途定制特定芯片之外,加强计算能力的渠道就逐步从依赖提高单核计算能力的提升转变为将芯片做小并增加核心数量。硬件的风向转变影响了软件开发,开发者们希望 Python 也能提供充分利用多核硬件来实现并行计算的能力。因此 GIL 带来的约束就使它变得更加臭名昭著了,它也就从促进 Python 流行的天使慢慢变为限制 Python 发展的魔鬼。

话虽如此,但 GIL 也并不是想去掉就能去掉。Guido van Rossum 曾在博客《It isn't Easy to Remove the GIL》中正面回应过为什么不从 CPython 中去掉 GIL,这里主要提到了两个问题:

  • 很难做到在移除 GIL 后依然让 Python 虚拟机保持移除前的单核性能

  • 移除 GIL 会令 Python 扩展模块的开发变复杂,因为所有的扩展模块都需要考虑被多线程调用的情况了

另外,在 Python 社区中,CPython 的维护者对于去掉 GIL 的提案还有一些明确的约束:

  • 简单:不能让 CPython 变得不可维护

  • 并发:能确实提高多线程性能

  • 速度:不能降低单线程性能

  • 特性:必须完整实现目前 CPython 的所有特性,包括 __del__ 和弱引用

  • API 兼容:和目前的 CPython 扩展所使用的宏源码兼容

  • ……

可以看出,移除 GIL 的提案其实非常难以获得通过。一件显而易见的事情是,针对多线程的架构在单线程下非常难以达到和针对单线程的架构同样的执行效率,因此这对于实现者来说本身就具有非常大的挑战。一个典型例子是 CPython 的 GC 实现,目前 CPython GC 的方案是以引用计数为主,辅以标记清除来处理循环引用的情况。每个 Python 对象在 CPython 虚拟机内大致表示为这样的形式:

struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    PyTypeObject *ob_type;
};

可以看到,这个引用计数 ob_refcnt 就是一个裸的整型值。由于 GIL 的存在,虚拟机在和扩展模块在操作对象的引用计数时并不需要额外加锁。这不仅使得 Python 在单线程下在处理引用计数的增减非常高效,而且从根源上避免了死锁。如果想要去掉 GIL,那就必须考虑到对引用计数的并发访问,在这种情况下,无论是细粒度的锁还是对引用计数进行原子操作,都会或多或少造成一定的性能开销。事实上,早在 1999 年,Greg Stein 就实现了一个 CPython 的分支,用细粒度的锁替换了 GIL,结果使得单核执行效率减半了,这个修改自然也就不了了之。

另外,我们从上面的描述中也能看出,在经历了从 Python 2 到 Python 3 的剧痛之后,整个 Python 社区都很明确地知道了永远不要低估用户进行版本迁移的成本这件事,因此修改的兼容性被放在了非常重要的位置。由于 GIL 存在已久,不仅是 CPython 虚拟机本身,许多扩展模块都已经非常依赖于 GIL,因此去除 GIL 很有可能对这些模块产生影响。因此可以预见的是这个升级的过程可能并不会那么顺利,别看现在大家对 GIL 抱怨不断,等真能去除 GIL 的时候,有多少用户真去升级现有版本呢,又有多少现有的扩展模块去适配新的规范呢?这些都很难说。Python 社区自然也非常清楚这一点,因此 Sam Gross 在 PEP 703 中提出的建议是为 CPython 增加一个编译参数 --disable-gil,以便让有需要的用户逐步迁移。这当然是一个稳健的做法,不过这个过渡时间有多久,就还真是一个未知数了,需要实践之后才能知道结果。

ffa4e9c6e3df1e8f0a416531f6a4e5a8.png

Python GIL 并不像很多人想象的那样是一个愚蠢的决策,在当时的时代背景下,它很大程度促进了 Python 的发展和生态的繁荣。只是随着时代的变迁,完成历史使命的它也到了逐渐退出历史舞台的时候了。虽然这个过程无法一蹴而就,但目前渐进的改变更能保障兼容性,让用户有一个平滑过渡的机会。然而编程语言届从来都没有风平浪静的一天,想要挑战 Python 地位的竞争者不断出现,例如前文提到的 Mojo 就号称兼容 Python 并巨幅提升性能,至于 Python 的未来会如何,还请拭目以待。

-End-

原创作者|李志瑞

你是否支持 Python 中移除 GIL,为什么?欢迎再评论区分享。我们将选取1则最有意义的评论,送出腾讯云开发者-便携通勤袋1个(见下图)。9月18日中午12点开奖。

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第6张图片

欢迎加入腾讯云开发者社群,社群专享券、大咖交流圈、第一手活动通知、限量鹅厂周边等你来~

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第7张图片

(长按图片立即扫码)

e4b27bf45b8b21f52d5e1c546ecd0755.png

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第8张图片

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第9张图片

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第10张图片

比Mojo慢68000倍,Python性能差的锅该给GIL吗?_第11张图片

关注并星标腾讯云开发者

每周1看鹅厂程序员聊热门技术

你可能感兴趣的:(mojo,python,开发语言)