Skip to content

Python 3.14 新特性

10 月 7 日 Python 3.14 正式发布,该版本又是一个有着巨大提升的版本,可以说是继 Python 3.12 和 Python 3.13 之后的又一个史诗级版本,话不多说,此次更新最主要内容如下:

  • Python 开始彻底多线程特性,移除了 GIL 的限制,在一个 Python 进程中可以启动多个 Interpreter,Interpreter 之间的线程没有 GIL,真正实现了多线程并发执行。Python 标准库中提供了明确的支持。
  • 增加了一个新的 interpreter ,主要使用 clang-19 提供 tail-call 的能力,能够提升整个解释器的执行效率。
  • 新增一个外部调试接口,可以直接通过进程号对 Python 程序进行调试。
  • 延迟解析类型注解。
  • Asyncio 支持通过进程号的方式了解 asyncio 的运行情况,展示 asyncio 中的任务依赖树,提供了类似 pstree 的功能。

Free-threaded Python

在 Python 3.14 版本中,彻底移除了 GIL(全局解释器锁)的限制,Python 解释器现在支持多线程并发执行。在这个版本中 Free-threaded Python 的性能已经有了很大的提升。PEP 703 中提到的实现目前已经实现完成,实验测试 No-GIL Python 在但线程场景下只比 With-GIL 的 Python 慢 5%-10% 具体取决与使用 Python 的操作系统和编译器。

单进程多解释器

Tail-Call 优化解释器

该解释器主要是使用 clang-19 提供的一种编译调用规约(Calling Convention)来进行解释器本身的优化,在 pyperformance benchmark 上能够有 3%-5% 的性能提升。所谓调用规约就是在汇编语言层面确定调用者和被调用者之间的参数是如何的传递的一种约定。

以 System V AMD64 ABI 调用规约(主要使用在 Linux 、FreeBSD 等操作系统)为例子,下表表示各个通用寄存器的保存和恢复约定。

寄存器类型寄存器名称说明
Caller Saved%rax返回值寄存器,调用者负责保存
%rcx第 4 个整数参数,调用者负责保存
%rdx第 3 个整数参数,调用者负责保存
%rsi第 2 个整数参数,调用者负责保存
%rdi第 1 个整数参数,调用者负责保存
%r8第 5 个整数参数,调用者负责保存
%r9第 6 个整数参数,调用者负责保存
%r10临时寄存器,调用者负责保存
%r11临时寄存器,调用者负责保存
Callee Saved%rbx被调用者必须保存和恢复
%rbp栈帧指针,被调用者必须保存和恢复
%r12被调用者必须保存和恢复
%r13被调用者必须保存和恢复
%r14被调用者必须保存和恢复
%r15被调用者必须保存和恢复
特殊%rsp栈指针,由被调用者维护

在函数调用过程中,Caller Saved 寄存器的值可能被被调用函数修改,因此调用者在调用前需要保存这些寄存器的值。而 Callee Saved 寄存器必须由被调用函数负责保存和恢复,保证调用前后值不变,比如一个被调用的函数在编译的时候如果需要使用寄存器 %r12,那么他就需要在函数返回之间将这个寄存器恢复成函数刚调用时的值。

Callee Saved 寄存器示例

以下是一个简单的 C 语言例子 demo.c,展示被调用函数必须保存和恢复 Callee Saved 寄存器:

c
#include <stdio.h>

int callee_function(int a, int b) {

    int register c = a + b  + 100;

    int register result = c * 2 + b * 4 + a * 8;

    return result;
}

int main() {
    int x = 42;

    int result = callee_function(10, 20);
    printf("x = %d, result = %d\n", x, result);
    return 0;
}
bash
gcc -c demo.c

上面的例子编译之后得到的汇编结果如下

asm
0000000000000000 <callee_function>:
   0:   f3 0f 1e fa             endbr64
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   53                      push   %rbx
   9:   89 7d f4                mov    %edi,-0xc(%rbp)
   c:   89 75 f0                mov    %esi,-0x10(%rbp)
   f:   8b 55 f4                mov    -0xc(%rbp),%edx
  12:   8b 45 f0                mov    -0x10(%rbp),%eax
  15:   01 d0                   add    %edx,%eax
  17:   8d 58 64                lea    0x64(%rax),%ebx
  1a:   8b 45 f0                mov    -0x10(%rbp),%eax
  1d:   01 c0                   add    %eax,%eax
  1f:   8d 14 03                lea    (%rbx,%rax,1),%edx
  22:   8b 45 f4                mov    -0xc(%rbp),%eax
  25:   c1 e0 02                shl    $0x2,%eax
  28:   01 d0                   add    %edx,%eax
  2a:   8d 1c 00                lea    (%rax,%rax,1),%ebx
  2d:   89 d8                   mov    %ebx,%eax
  2f:   5b                      pop    %rbx
  30:   5d                      pop    %rbp
  31:   c3                      retq

在上面的汇编程序中使用到了 rbxebx 这两个寄存器,而这个寄存器是一个 Callee Saved 寄存器(被调用者保存的寄存器),因此在函数开始时会将 rbx 的值保存到栈中(push %rbx),在函数返回之前再将其恢复(pop %rbx),这就是调用规约的基本的工作原理。

如果在函数 callee_function 中没有保存和恢复 rbx 寄存器,而在调用它的函数中使用到了该寄存器,那么程序的行为将是未定义的,可能会导致程序崩溃或者结果错误,因为编译器在按照调用规约编译的时候默认 rbx 寄存器的值是不会被 Callee 修改的。

Clang-19 preserve_none

在 clang-19 中提供了一种新的调用规约 preserve_none,该调用规约规定了所有的寄存器都是 Caller Saved 寄存器,这样被调用函数就不需要保存和恢复任何寄存器,这个调用规约在尾调用或者尾递归的时候非常好用,因为在尾调用的时候,调用者函数的栈帧已经不需要了,Caller 已经不在需要任何寄存器的值了,只需要等待函数返回时将 rax 寄存器(在 AMD64 Linux 中返回值都是保存在 rax 寄存器)的值返回即可。

我们现在修改一下前面的例子,然后增加一些属性来使用 preserve_none 调用规约和 musttail 属性来强制尾调用优化:

c
#include <stdio.h>

__attribute__((preserve_none))
int callee_function(int a, int b) {
    int register c = a + b + 100;
    int register result = c * 2 + b * 4 + a * 8;
    return result;
}

__attribute__((preserve_none))
int caller_function(int x) {
    __attribute__((musttail))
    return callee_function(x, x + 1);
}

int main() {
    int result = caller_function(5);
    printf("Result: %d\n", result);
    return 0;
}

编译之后得到的 caller_function 的汇编代码如下:

asm
0000000000000000 <callee_function>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   8b 45 fc                mov    -0x4(%rbp),%eax
   a:   83 c0 64                add    $0x64,%eax
   d:   89 45 f8                mov    %eax,-0x8(%rbp)
  10:   8b 45 f8                mov    -0x8(%rbp),%eax
  13:   d1 e0                   shl    %eax
  15:   8b 4d fc                mov    -0x4(%rbp),%ecx
  18:   c1 e1 03                shl    $0x3,%ecx
  1b:   01 c8                   add    %ecx,%eax
  1d:   89 45 f4                mov    %eax,-0xc(%rbp)
  20:   8b 45 f4                mov    -0xc(%rbp),%eax
  23:   5d                      pop    %rbp
  24:   c3                      retq

此时我们可以看到 callee_function 函数中没有任何寄存器的保存和恢复代码,包括寄存器 rbx ,因为所有的寄存器都是 Caller Saved 寄存器,因此被调用函数不需要保存任何寄存器的值。

虚拟机解释器类型

c
void small_switch(int x) {
    switch(x) {
        case 1: printf("One\n"); break;
        case 2: printf("Two\n"); break;
        case 3: printf("Three\n"); break;
        default: printf("Other\n"); break;
    }
}

外部调试接口

在 Python 3.14 版本中,新增了一个外部调试接口,可以通过进程号直接对正在运行的 Python 程序进行调试。这个接口允许开发者在不修改代码的情况下,动态地检查和修改程序的状态,极大地方便了调试过程。

转载请联系作者