一文让你搞懂 Python 虚拟机执行字节码的奥秘

简介: 一文让你搞懂 Python 虚拟机执行字节码的奥秘


楔子



当解释器启动后,首先会进行运行时环境的初始化。注意这里的运行时环境,它和之前说的执行环境是不同的概念。运行时环境是一个全局的概念,而执行环境是一个栈帧。

关于运行时环境的初始化是一个很复杂的过程,涉及到 Python 进程、线程的创建,类型对象的完善等非常多的内容,我们暂时先不讨论。这里就假设初始化动作已经完成,我们已经站在了虚拟机的门槛外面,只需要轻轻推动第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。

在介绍字节码的时候我们说过,解释器可以看成是:编译器+虚拟机,编译器负责将源代码编译成 PyCodeObject 对象,而虚拟机则负责执行。整个过程如下:

所以我们的重点就是虚拟机是怎么执行 PyCodeObject 对象的?整个过程是什么,掌握了这些,你对虚拟机会有一个更深的理解。


虚拟机的运行框架



在介绍栈帧的时候我们说过,Python 是一门动态语言,一个变量指向什么对象需要在运行时才能确定,这些信息不可能静态存储在 PyCodeObject 对象中。

所以虚拟机在运行时会基于 PyCodeObject 对象动态创建出一个栈帧对象,然后在栈帧里面执行字节码。而创建栈帧,主要使用以下两个函数:

// 基于 PyCodeObject、全局名字空间、局部名字空间,创建栈帧
// 参数非常简单,所以它一般适用于模块这种参数不复杂的场景
// 我们说模块也会对应一个栈帧,并且它位于栈帧链的最顶层
PyObject *
PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);
// 它相比 PyEval_EvalCode 多了很多的参数
// 比如里面有位置参数以及个数,关键字参数以及个数
// 还有默认参数以及个数,闭包等等,显然它用于函数等复杂场景
PyObject *
PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals,
                  PyObject *const *args, int argcount,
                  PyObject *const *kws, int kwcount,
                  PyObject *const *defs, int defcount,
                  PyObject *kwdefs, PyObject *closure);

但这两个函数都属于高层的封装,它们最终都会调用 _PyEval_EvalCodeWithName 函数。这个函数内部的逻辑我们就不看了,只需要知道它在执行完毕之后栈帧就创建好了,而栈帧才是我们的重点,因为代码在执行期间所依赖的上下文信息全部由栈帧来维护。

一旦栈帧对象初始化完毕,那么就要进行处理了,处理的时候会调用以下两个函数。

PyObject *
PyEval_EvalFrame(PyFrameObject *f);
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag);

当然啦,上面这两个函数同样属于高层的封装,最终会调用 _PyEval_EvalFrameDefault 函数,虚拟机就是通过该函数来完成字节码的执行

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag);

到目前为止总共出现了 6 个函数,用一张图来描述一下它们的关系:

782f85abbff9667ecd55f6edf7e9f3a8.png

所以 _PyEval_EvalFrameDefault 函数是虚拟机运行的核心,并且代码量很大。

4065e1d0274c6146fd6c28b6713b06c2.png

可以看到这一个函数大概在 3100 行左右,不过也仅仅是代码量大而已,因为它的逻辑很好理解。

// 源代码位于 Python/ceval.c 中
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{  
    //......
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
    next_instr = first_instr;
    if (f->f_lasti >= 0) {
        assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0);
        next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1;
    }
    // 栈顶指针
    stack_pointer = f->f_stacktop;
    assert(stack_pointer != NULL);
    f->f_stacktop = NULL;       
    //......
}

该函数首先会初始化一些变量,PyCodeObject 对象包含的信息不用多说,还有一个重要的动作就是对指针 stack_pointer 进行初始化。stack_pointer 指向运行时栈的栈顶,关于运行时栈可能有人暂时还不理解它是做什么的,别急,马上你就知道它的作用了。

栈帧对象有两个重要字段是关于运行时栈的,f_stacktop 字段指向运行时栈的栈顶,f_valuestack 字段指向运行时栈的栈底。

所以对 stack_pointer 初始化的时候,将它的值初始化为 f->f_stacktop,让它指向运行时栈的栈顶。但操作运行时栈是通过 stack_pointer 操作的,随着元素的添加和删除,栈顶位置会变,所以后续它反过来还要再赋值给 f_stacktop。

然后栈帧中的 f_code 就是 PyCodeObject 对象,该对象的 co_code 字段则保存着字节码指令序列。而虚拟机执行字节码就是从头到尾遍历整个 co_code,对指令逐条执行的过程。

估计有人对栈帧和 PyCodeObject 对象的底层结构已经记不太清了,这里为了方便后续内容的理解,我们将它们的结构再展示一下。

30bc4a2d3c0df458201e7006e7559a95.png

所以字节码也叫作指令序列,它就是一个普普通通的 bytes 对象,对于 C 而言则是一个字符数组,一条指令就是一个字符、或者说一个整数。而在遍历的时候会使用以下两个变量:

  • first_instr:永远指向字节码指令序列的第一条字节码指令;
  • next_instr:永远指向下一条待执行的字节码指令;


当然别忘记栈帧的 f_lasti 成员,它记录了上一条已经执行过的字节码指令的偏移量。

多说一句,生成器之所以能够从中断的位置恢复执行,就是因为 f_lasti 记录了上一条执行的字节码指令的位置。

那么这个动作是如何一步步完成的呢?其实就是一个 for 循环加上一个巨大的 switch case 结构。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{   
    //......   
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
    //......
  
    // 死循环
    for (;;) {
        if (_Py_atomic_load_relaxed(eval_breaker)) {
        // 不断地读取 co->co_code 中下一条待执行的字节码指令
            opcode = _Py_OPCODE(*next_instr);
        // opcode 就是字节码指令序列中的每一条指令
        // 指令有哪些都定义在 Include/opcode.h 中
            if (opcode == SETUP_FINALLY ||
                opcode == SETUP_WITH ||
                opcode == BEFORE_ASYNC_WITH ||
                opcode == YIELD_FROM) {
                goto fast_next_opcode; 
            }
        fast_next_opcode:
            // ......
            // 判断该指令属于什么操作,然后执行相应的逻辑
            switch (opcode) {
                // 加载一个局部变量
                case TARGET(LOAD_FAST):
                    // ......
                    break;
                // 加载一个常量
                case TARGET(LOAD_CONST):
                    // ......
                    break;
                // ......
        }
    }
}

在这个执行架构中,对字节码的遍历是通过宏来实现的:

#define INSTR_OFFSET()  \
    (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr))
#define NEXTOPARG()  do { \
        _Py_CODEUNIT word = *next_instr; \
        opcode = _Py_OPCODE(word); \
        oparg = _Py_OPARG(word); \
        next_instr++; \
    } while (0)

首先每条字节码指令都会带有一个参数,co_code 中索引为 0 2 4 6 8... 的整数便是指令,索引为 1 3 5 7 9... 的整数便是参数。所以 co_code 里面并不全是字节码指令,每条指令后面都还跟着一个参数。因此 next_instr 每次向后移动两个字节,便可跳到下一条指令。

next_instr 和 first_instr 都是  _Py_CODEUNIT *  类型的变量,这个 _Py_CODEUNIT 是一个 uint16_t。所以只要执行 next_instr++,便可向后移动两字节,跳到下一条指令。

我们再看一下上面的宏,INSTR_OFFSET 计算的显然就是下一条待执行的指令和第一条指令之间的偏移量;然后是 NEXTOPARG,里面的变量 word 就是待执行的指令。

当然,由于 word 占两字节,所以也包括了参数。其中 word 的前 8 位是指令 opcode,后 8 位是参数 oparg。然后在解析出来指令以及参数之后,再执行 next_instr++,跳到下一条指令。

而接下来就要执行上面刚解析出来的字节码指令了,会利用 switch 语句对指令进行判断,根据判断的结果选择不同的 case 分支

97f977c8e641a08dd6c104e82cb7ae35.png

每一个 case 分支,对应一个字节码指令的实现,不同的指令执行不同的 case 分支。所以这个 switch case 语句非常的长,函数总共 3000 多行,这个 switch 就占了2400行。因为指令非常多,比如:LOAD_CONST, LOAD_NAME, YIELD_FROM等等,而每一个指令都要对应一个 case 分支

然后当匹配到的 case 分支执行完毕时,说明当前的这一条字节码指令就执行完毕了,那么虚拟机的执行流程会跳转到标签 fast_next_opcode 所在位置,或者 for 循环所在位置。但不管如何,虚拟机接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。

所以通过 for 循环一条一条遍历 co_code 中包含的所有字节码指令,然后交给内部的 switch 语句、选择不同的 case 分支进行执行,如此周而复始,最终完成了对整个 Python 程序的执行。

尽管目前只是简单的分析,但相信你也能大体地了解 Python 执行引擎的整体结构。说白了 Python 虚拟机就是将自己当成一个 CPU,在栈帧中一条条的执行指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它成员来维护。

因此在虚拟机的执行流程进入了那个巨大的 for 循环,并取出第一条字节码指令交给里面的 switch 语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的指令如同潮水般涌来,浩浩荡荡,横无际涯。


运行时栈的一些 API



这里先来简单介绍一下运行时栈,它是参数的容身之所,比如虚拟机在执行 a + b 的时候,知道这是一个加法操作。但在执行加法的时候,加号两边的值是多少,它要怎么获取呢?这时候就需要一个栈来专门保存相应的参数。

在执行加法之前,先将 a 和 b 压入栈中,然后执行加法的时候,再将 a 和 b 从栈里面弹出来即可。现在有一个印象,一会儿我们通过反编译查看字节码指令的时候,就一切都清晰了。

然后再来看看运行时栈相关的一些 API。

690d0293ba30c57a180949a73db937b1.png

API 非常多,但操作运行时栈都是通过操作 stack_pointer 实现的。假设运行时栈内部有三个元素,从栈底到栈顶分别是整数 1、2、3,那么运行时栈的结构就是下面这样。

0881b065e2752caf4990ee822836ff51.png

然后看一下这些和运行时栈相关的 API 都是干嘛的。

STACK_LEVEL():

#define STACK_LEVEL() \
     ((int)(stack_pointer - f->f_valuestack))

返回运行时栈的元素数量。

EMPTY():

#define EMPTY()      (STACK_LEVEL() == 0)

判断运行时栈是否为空。

TOP():

#define TOP()          (stack_pointer[-1])

查看当前运行时栈的栈顶元素。

SECOND():

#define SECOND()       (stack_pointer[-2])

查看从栈顶元素开始的第二个元素,所以随着元素不断添加,栈顶元素也在不断发生变化,而 stack_pointer 也在不断变化。

THIRD():

#define THIRD()        (stack_pointer[-3])

查看从栈顶元素开始的第三个元素。

FOURTH():

#define FOURTH()      (stack_pointer[-4])

查看从栈顶元素开始的第四个元素。

PEEK(n):

#define PEEK(n)       (stack_pointer[-(n)])

查看从栈顶元素开始的第 n 个元素。

SET_TOP(v):

#define SET_TOP(v)    (stack_pointer[-1] = (v))

将当前运行时栈的栈顶元素设置成 v,同理还有 SET_SECOND,SET_THIRD,SET_FOURTH,SET_VALUE。

PUSH(v):

往运行时栈中压入一个元素。

// 将 stack_pointer 设置成 v,然后再执行自增操作
#define BASIC_PUSH(v)   (*stack_pointer++ = (v))
// 也是调用了 BASIC_PUSH,但做了一层检测
// co_stacksize 表示运行时栈的大小,STACK_LEVEL() 不能超过它
#define PUSH(v)         { (void)(BASIC_PUSH(v), \
                          lltrace && prtrace(tstate, TOP(), "push")); \
                          assert(STACK_LEVEL() <= co->co_stacksize); }

假设当前运行时栈有 1、2、3 总共三个元素,我们往栈里面压入一个元素 4,那么运行时栈就会变成下面这个样子。

082c32200dc96f04089fa0dd84fc6c78.png

Python 的变量都是一个指针,所以 stack_pointer 是一个二级指针,它永远指向栈顶位置,只不过栈顶位置会变。

POP(v):

从运行时栈弹出一个元素,注意它和 TOP 的区别,TOP 是返回栈顶元素,但不弹出。

// 将 stack_pointer 先执行自减操作,然后解引用
#define BASIC_POP()    (*--stack_pointer)
#define POP()       ((void)(lltrace && prtrace(tstate, TOP(), "pop")), \
                     BASIC_POP())

假设当前运行时栈有 1、2、3 总共三个元素,我们弹出一个元素,那么运行时栈就会变成下面这个样子。

c927471b28ce394110c06b15497a5d88.png

stack_pointer 指向栈顶位置,所以它向栈底移动一个位置,就相当于元素被弹出了。


通过反编译查看字节码



我们写一段简单的代码,然后反编译,看看虚拟机是如何执行字节码的。

code = """\
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
"""
# 将上面的代码以模块的方式进行编译
co = compile(code, "...", "exec")
# 查看常量池
print(co.co_consts)  # (89, 99, 91, 3, None)
# 查看符号表
print(
    co.co_names
)  # ('chinese', 'math', 'english', 'avg')

在编译的时候,常量和符号(变量)都会被静态收集起来,然后我们反编译一下看看字节码,直接通过 dis.dis(co) 即可。结果如下:

1      0 LOAD_CONST               0 (89)
         2 STORE_NAME               0 (chinese)
  2      4 LOAD_CONST               1 (99)
         6 STORE_NAME               1 (math)
  3      8 LOAD_CONST               2 (91)
        10 STORE_NAME               2 (english)
  4     12 LOAD_NAME                0 (chinese)
        14 LOAD_NAME                1 (math)
        16 BINARY_ADD
        18 LOAD_NAME                2 (english)
        20 BINARY_ADD
        22 LOAD_CONST               3 (3)
        24 BINARY_TRUE_DIVIDE
        26 STORE_NAME               3 (avg)
        28 LOAD_CONST               4 (None)
        30 RETURN_VALUE

解释一下每一列的含义:

  • 第一列是源代码的行号
  • 第二列是指令的偏移量,或者说该指令在整个字节码指令序列中的索引。因为每条指令后面都跟着一个参数,所以偏移量是 0 2 4 6 8 ...;
  • 第三列是字节码指令,简称指令,它们在宏定义中代表整数;
  • 第四列是字节码指令参数,简称指令参数、或者参数,不同的指令参数的含义不同;
  • 第五列是 dis 模块给我们额外提供的信息,一会说;


我们从上到下依次解释每条指令都干了什么?

0 LOAD_CONST:表示加载一个常量(指针),并压入运行时栈。后面的指令参数 0 表示从常量池中加载索引为 0 的常量,至于 89 则表示加载的常量是 89。所以最后面的括号里面的内容实际上起到的是一个提示作用,告诉你加载的对象是什么。

2 STORE_NAME:表示将 LOAD_CONST 加载的常量用一个名字绑定起来,放在所在的名字空间中。后面的 0 (chinese) 则表示使用符号表中索引为 0 的名字(符号),且名字为 "chinese"。

所以像 chinese = 89 这种简单的赋值语句,会对应两条字节码指令。

然后 4 LOAD_CONST、6 STORE_NAME 和 8 LOAD_CONST、10 STORE_NAME 的作用显然和上面是一样的,都是加载一个常量,然后将某个符号和常量绑定起来,并放在名字空间中。

12 LOAD_NAME:加载一个变量,并压入运行时栈。而后面的 0 (chinese) 表示加载符号表中索引为 0 的变量的值,然后这个变量叫 chinese。14 LOAD_NAME 也是同理,将符号表中索引为 1 的变量的值压入运行时栈,并且变量叫 math。此时栈里面有两个元素,从栈底到栈顶分别是 chinese 和 math。

16 BINARY_ADD:将上面两个变量从运行时栈弹出,然后执行加法操作,并将结果压入运行时栈。

18 LOAD_NAME:将符号表中索引为 2 的变量 english 的值压入运行时栈,此时栈里面有两个元素,从栈底到栈顶分别是 chinese + math 的返回结果english

20 BINARY_ADD:将运行时栈里的两个元素弹出,然后执行加法操作,并将结果压入运行时栈。此时栈里面有一个元素,就是 chinese + math + english 的返回结果。

22 LOAD_CONST:将常量 3 压入运行时栈,此时栈里面有两个元素;

22 BINARY_TRUE_DIVIDE:将运行时栈里的两个元素弹出,然后执行除法操作,并将结果压入运行时栈,此时栈里面有一个元素;

24 STORE_NAME:将元素从运行时栈里面弹出,并用符号表中索引为 3 的变量 avg 和它绑定起来,然后放在名字空间中。

28 LOAD_CONST:将常量 None 压入运行时栈,然后通过 30 RETURN_VALUE 将其从栈中弹出,然后返回。

所以 Python 虚拟机就是把自己想象成一颗 CPU,在栈帧中一条条执行字节码指令,当指令执行完毕或执行出错时,停止执行。

我们通过几张图展示一下上面的过程,为了阅读方便,这里将相应的源代码再贴一份:

chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3

我们说模块也有自己的作用域,并且是全局作用域,所以虚拟机也会为它创建栈帧。而在代码还没有执行的时候,栈帧就已经创建好了,整个布局如下。

349a0e0894ced886c199e956f0a4b3bb.png

这里补充一个知识点,非常重要,首先我们看到栈帧里面有一个 f_localsplus 属性,它是一个数组。虽然声明的时候写着长度为 1,但实际使用时,长度不受限制,和 Go 语言不同,C 数组的长度不属于类型的一部分。

所以 f_localsplus 是一个动态内存,运行时栈所需要的空间就存储在里面。但这块内存并不光给运行时栈使用,它被分成了四块。

b5d4339d3a63d4d3c675a9aaddee0455.png

函数的局部变量是静态存储的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是开头的位置。在获取的时候直接基于索引操作即可,因此速度会更快。所以源码内部还有两个宏:

de433dae6834aac235ff6f1d6a1cb807.png

fastlocals 就是栈帧的 f_localsplus,而函数在编译的时候就知道某个局部变量在 f_localsplus 中的索引,所以通过 GETLOCAL 获取即可。同理 SETLOCAL 则是创建一个局部变量。

至于 cell 对象和 free 对象则是用来处理闭包的,而 f_localsplus 的最后一块内存则用于运行时栈。

所以 f_localsplus 是一个数组,它是一段连续内存,只不过从逻辑上讲,它被分成了四份,每一份用在不同的地方。但它们整体是连续的,都是数组的一部分。按照新一团团长丁伟的说法:彼此是鸡犬相闻,但又老死不相往来。

但我们当前是以模块的方式编译的,里面所有的变量都是全局变量,而且也不涉及闭包啥的,所以这里就把 f_localsplus 理解为运行时栈即可。

接下来就开始执行字节码了,next_instr 指向下一条待执行的字节码指令,显然初始状态下,下一条待执行的指令就是第一条指令。

于是虚拟机开始加载:0 LOAD_CONST,该指令表示将常量加载进运行时栈,而要加载的常量在常量池中的索引,由指令参数表示。

在源码中,指令对应的变量是 opcode,指令参数对应的变量是 oparg

// 代码位于 Python/ceval.c 中
case TARGET(LOAD_CONST): {
    // 调用元组的 GETITEM 方法
    // 从常量池中加载索引为 oparg 的对象(常量)
    // 当然啦,这里为了方便称其为对象,但其实是指向对象的指针
    PREDICTED(LOAD_CONST);
    PyObject *value = GETITEM(consts, oparg);
    // 增加引用计数
    Py_INCREF(value);
    // 压入运行时栈
    PUSH(value);
    FAST_DISPATCH();
}

该指令的参数为 0,所以会将常量池中索引为 0 的元素 89 压入运行时栈,执行完之后,栈帧的布局就变成了下面这样:

8932b468b9f1dcc5ca9cfdc107ee9de1.png

f_localsplus 下面的箭头方向,代表运行时栈从栈底到栈顶的方向。

接着虚拟机执行 2 STORE_NAME 指令,从符号表中获取索引为 0 的符号、即 chinese。然后将栈顶元素 89 弹出,再将符号 chinese 整数对象 89 绑定起来保存到 local 名字空间中。

case TARGET(STORE_NAME): {
    // 从符号表中加载索引为 oparg 的符号  
    // 符号本质上就是一个 PyUnicodeObject 对象
    // 这里就是字符串 "chinese"
    PyObject *name = GETITEM(names, oparg);
    // 从运行时栈的栈顶弹出元素
    // 显然是上一步压入的 89
    PyObject *v = POP();
    // 获取名字空间 namespace
    PyObject *ns = f->f_locals;
    int err;
    if (ns == NULL) {
        // 如果没有名字空间则报错,设置异常
        // 这个 tstate 是和线程密切相关的
        _PyErr_Format(tstate, PyExc_SystemError,
                      "no locals found when storing %R", name);
        Py_DECREF(v);
        goto error;
    }
    // 将符号和对象绑定起来放在 ns 中
    // 名字空间是一个字典,PyDict_CheckExact 则检测 ns 是否为字典
    if (PyDict_CheckExact(ns))
        // PyDict_CheckExact(ns) 类似于 type(ns) is dict
        // 除此之外,还有 PyDict_Check(ns)
        // 它类似于 isinstance(ns, dict),检测标准相对要宽松一些
        // 然后将键值对 "chinese": 89 设置到字典中
        err = PyDict_SetItem(ns, name, v);
    else
        // 走到这里说明 type(ns) 不是 dict,那么它应该继承 dict
        // 设置元素
        err = PyObject_SetItem(ns, name, v);
        
    // 对象的引用计数减 1,因为从运行时栈中弹出了
    Py_DECREF(v);
    // 如果 err != 0,证明设置元素出错了,跳转至 error 标签
    if (err != 0)
        goto error;
    DISPATCH();
}

执行完之后,栈帧的布局就变成了下面这样: 8932b468b9f1dcc5ca9cfdc107ee9de1.png

此时运行时栈为空,local 名字空间多了个键值对。

同理剩余的两个赋值语句也是类似的,只不过指令参数不同,比如 6 STORE_NAME 加载的是符号表中索引为 1 的符号,8 STORE_NAME 加载的是符号表中索引为 2 的符号,分别是 math 和 english。

b331e67fe1fc6bd71ab45e1a5564cec6.png

然后 12 LOAD_NAME14 LOAD_NAME 负责将符号表中索引为 0 和 1 的变量的值压入运行时栈:

case TARGET(LOAD_NAME): {
    // 从符号表 co_names 中加载索引为 oparg 的变量
    // 但是注意:全局变量是通过字典存储的
    // 所以这里的 name 只是一个字符串罢了,比如 "chinese"
    // 然后还要再根据这个字符串从字典里面查找对应的 value
    PyObject *name = GETITEM(names, oparg);
    // 对于模块来说,f->f_locals 和 f->f_globals 指向同一个字典
    PyObject *locals = f->f_locals;
    PyObject *v;
    // ....
    if (PyDict_CheckExact(locals)) {
        // 根据 name 获取 value
        // 所以 print(chinese) 本质上就是下面这样
        // print(locals["chinese"])
        v = PyDict_GetItemWithError(locals, name);
        if (v != NULL) {
            Py_INCREF(v);
        }
        else if (_PyErr_Occurred(tstate)) {
            goto error;
        }
    }
    else {
        // ...
    }
    // ...
    // 将符号表中索引为 oparg 的变量的值
    // 压入运行时栈
    PUSH(v);
    DISPATCH();
}

上面两条指令执行完之后,栈帧的布局就变成了下面这样:

253a0fc722b5e3abfeb6d3626f16568b.png

接下来执行 16 BINARY_ADD,它会将栈里的两个元素弹出,然后执行加法操作,最后再将结果入栈。

当然上面这种说法是为了方便理解,其实虚拟机真正执行的时候,只会弹出一个元素,而另一个元素只是使用 TOP() 进行查看,但不弹出。将结果计算完毕之后,再将栈顶元素替换掉。


所以本质上,和弹出两个元素、再将计算结果入栈是一样的。

case TARGET(BINARY_ADD): {
    // 从栈顶弹出元素,这里是 99(变量 math),
    PyObject *right = POP();
    // math 弹出之后,chinese 就成为了新的栈顶元素
    // 这里的 TOP() 则是获取栈顶元素 89(变量 chinese)
    PyObject *left = TOP();
    // 用于保存两者的和
    PyObject *sum;
    
    // 如果是字符串,执行专门的函数
    if (PyUnicode_CheckExact(left) &&
             PyUnicode_CheckExact(right)) {
        sum = unicode_concatenate(tstate, left, right, f, next_instr);
    }
    // 否则通过泛型 API PyNumber_Add 进行计算
    else {
        sum = PyNumber_Add(left, right);
        Py_DECREF(left);
    }
    // 从栈里面弹出,所以减少引用计数
    Py_DECREF(right);
    // 将栈顶元素替换成 sum
    SET_TOP(sum);
    if (sum == NULL)
        goto error;
    DISPATCH();
}

BINARY_ADD 指令执行完之后,栈帧的布局就变成了下面这样:

7ee5183984dcf8b6cdfa0bccaec18314.png

然后 18 LOAD_NAME 负责将符号表中索引为 2 的变量 english 的值压入运行时栈;而指令 20 BINARY_ADD 则是继续执行加法操作,并将结果设置在栈顶;然后 22 LOAD_CONST 将常量 3 再压入运行时栈。

这三条指令执行之后,运行时栈变化如下:

96541f51088ff7c6254b0a2795686062.png

接着是 24 BINARY_TRUE_DIVIDE,它的逻辑和 BINARY_ADD 类似,只不过一个执行除法,一个执行加法。

case TARGET(BINARY_TRUE_DIVIDE): {
    // 从栈顶弹出元素,显然是 3
    PyObject *divisor = POP();
    // 查看栈顶元素,此时栈顶元素变成了 279
    PyObject *dividend = TOP();
    // 调用 PyNumber_TrueDivide,执行 279 / 3
    PyObject *quotient = PyNumber_TrueDivide(dividend, divisor);
    // 从栈里面弹出,减少引用计数
    Py_DECREF(dividend);
    Py_DECREF(divisor);
    // 将栈顶元素替换为 279 / 3 的计算结果
    SET_TOP(quotient);
    if (quotient == NULL)
        goto error;
    DISPATCH();
}

24 BINARY_TRUE_DIVIDE 执行完之后,运行时栈如下:

eda7d6e3100ffe6f0073e2fa4d589677.png

然后 26 STORE_NAME 将栈顶元素 93.0 弹出,并将符号表中索引为 3 的变量 avg 和它绑定起来,放到名字空间中。因此最终栈帧关系图如下:

9611d7b6e5863f66f3febb89259b258e.png

以上就是虚拟机对这几行代码的执行流程,整个过程就像 CPU 执行指令一样。

我们再用 Python 代码描述一遍上面的逻辑:

# LOAD_CONST 将 89 压入栈中
# STORE_NAME 将 89 从栈中弹出
# 并将符号 "chinese" 和 89 绑定起来,放在名字空间中
chinese = 89
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89}
math = 99
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99}
english = 91
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99, 'english': 91}
avg = (chinese + math + english) / 3
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99, 'english': 91, 'avg': 93.0}

现在你是不是对虚拟机执行字节码有一个更深的了解了呢?当然字节码指令有很多,不止我们上面看到的那几个。你可以随便写一些代码,然后分析一下它的字节码指令是什么样的。

指令都定义在 Include/opcode.h 中


变量赋值时用到的指令



这里我们来介绍几个在变量赋值的时候,所用到的指令。因为出现频率极高,所以有必要单独说一下。

64c8dcc62639981d9c308b613e32ca69.png

下面来实际操作一波,看看这些指令:

0e0d51a1ee6fc7bf141b5e8c51630958.png

0 LOAD_CONST:加载字符串常量 "female";

2 STORE_FAST:在局部作用域中定义一个局部变量 gender,和字符串对象 "female" 建立映射关系,本质上就是让变量 gender 保存这个字符串对象的地址;

4 LOAD_GLOBAL:在局部作用域中加载一个内置变量 print;

6 LOAD_FAST:在局部作用域中加载一个局部变量 gender;

14 LOAD_GLOBAL:在局部作用域中加载一个全局变量 name;

7f25a23136324cea181891ef43247ee9.png

0 LOAD_CONST:加载字符串常量 "古明地恋";

2 STORE_GLOBAL:在局部作用域中定义一个被 global 关键字声明的全局变量;

12ae92c602ef71443027b53fffd7f208.png

0 LOAD_CONST:加载字符串常量 "古明地觉";

2 STORE_NAME:在全局作用域中定义一个全局变量 name,并和上面的字符串对象进行绑定;

4 LOAD_NAME:在全局作用域中加载一个内置变量 print;

6 LOAD_NAME:在全局作用域中加载一个全局变量 name;

以上我们就通过代码实际演示了这些指令的作用,它们和常量、变量的加载,以及变量的定义密切相关,可以说常见的不能再常见了。你写的任何代码在反编译之后都少不了它们的身影,因此有必要提前解释一下。

不管加载的是常量、还是变量,得到的永远是指向对象的指针。


变量赋值的具体细节



这里再通过变量赋值感受一下字节码的执行过程,首先关于变量赋值,你平时是怎么做的呢?

446a2866da0e7d8df325e5440f2e8c3d.png

这些赋值语句背后的原理是什么呢?我们通过字节码来逐一回答。

1)a, b = b, a 的背后原理是什么?

想要知道背后的原理,查看它的字节码是我们最好的选择。

0 LOAD_NAME                0 (b)
  2 LOAD_NAME                1 (a)
  4 ROT_TWO
  6 STORE_NAME               1 (a)
  8 STORE_NAME               0 (b)

里面关键的就是 ROT_TWO 指令,虽然我们还没看这个指令,但也能猜出来它负责交换栈里面的两个元素。假设 a 和 b 的值分别为 22、33,整个过程如下:

5ca81b9abf2e6804291170254233f591.png

来看一下 ROT_TWO 指令。

case TARGET(ROT_TWO): {
    // 获取栈顶元素,由于 b 先入栈、a 后入栈
    // 再加上栈是先入后出,所以这里获取的栈顶元素就是 a
    PyObject *top = TOP();
    // 运行时栈的第二个元素就是 b
    // TOP 是查看栈顶元素、SECOND 是查看栈的第二个元素
    // 并且这两个宏只是获取,不会将元素从栈中弹出
    PyObject *second = SECOND();
    // 将栈顶元素设置为 second,这里显然就是变量 b
    // 将栈的第二个元素设置为 top,这里显然就是变量 a
    SET_TOP(second);
    SET_SECOND(top);
    FAST_DISPATCH();
}

因此执行完 ROT_TWO 指令之后,栈顶元素就是 b,栈的第二个元素就是 a。然后后面的两个 STORE_NAME 会将栈里面的元素 b、a 依次弹出,赋值给 a、b,从而完成变量交换。

2)a, b, c = c, b, a 的背后原理是什么?

老规矩,还是查看字节码,因为一切真相都隐藏在字节码当中。

0 LOAD_NAME                0 (c)
  2 LOAD_NAME                1 (b)
  4 LOAD_NAME                0 (a)
  6 ROT_THREE
  8 ROT_TWO
 10 STORE_NAME               2 (a)
 12 STORE_NAME               1 (b)
 14 STORE_NAME               0 (c)

整个过程和 a, b = b, a 是相似的,首先 LOAD_NAME 将变量 c、b、a 依次压入栈中。由于栈先入后出的特性,此时栈的三个元素按照顺序(从栈顶到栈底)分别是 a、b、c。

然后是 ROT_THREE 和 ROT_TWO,毫无疑问,这两个指令执行完之后,会将栈的三个元素调换顺序,也就是将 a、b、c 变成 c、b、a。

最后 STORE_NAME 将栈的三个元素 c、b、a 依次弹出,分别赋值给 a、b、c,从而完成变量的交换。

因此核心就在 ROT_THREE 和 ROT_TWO 上面,由于后者上面已经说过了,所以我们看一下 ROT_THREE。

case TARGET(ROT_THREE): {
    PyObject *top = TOP();
    PyObject *second = SECOND();
    PyObject *third = THIRD();
    SET_TOP(second);
    SET_SECOND(third);
    SET_THIRD(top);
    FAST_DISPATCH();
}

栈顶元素是 top、栈的第二个元素是 second、栈的第三个元素是 third,然后将栈顶元素设置为 second、栈的第二个元素设置为 third、栈的第三个元素设置为 top。

所以栈里面的 a、b、c 在经过 ROT_THREE 之后就变成了 b、c、a,显然这还不是正确的结果。于是继续执行 ROT_TWO,将栈的前两个元素进行交换,执行完之后就变成了 c、b、a。

假设 a、b、c 的值分别为 "a"、"b"、"c",整个过程如下:

a444c3ca4029b4fad2a6f8a23a18e7c0.png

3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么区别呢?

我们还是看一下字节码。

0 LOAD_NAME                0 (d)
  2 LOAD_NAME                1 (c)
  4 LOAD_NAME                2 (b)
  6 LOAD_NAME                3 (a)
  8 BUILD_TUPLE              4
 10 UNPACK_SEQUENCE          4
 12 STORE_NAME               3 (a)
 14 STORE_NAME               2 (b)
 16 STORE_NAME               1 (c)
 18 STORE_NAME               0 (d)

依旧是将等号右边的变量,按照从左往右的顺序,依次压入栈中,但此时没有直接将栈里面的元素做交换,而是构建一个元组。因为往栈里面压入了四个元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示构建长度为 4 的元组。

case TARGET(BUILD_TUPLE): {
    PyObject *tup = PyTuple_New(oparg);
    if (tup == NULL)
        goto error;
    // 元素从栈顶到栈底依次是 a、b、c、d
    // 所以元素弹出也是这个顺序
    // 但是注意循环,元素是从后往前设置的
    // 所以 item[3], item[2], item[1], item[0] = a, b, c, d
    while (--oparg >= 0) {
        PyObject *item = POP();
        PyTuple_SET_ITEM(tup, oparg, item);
    }
    // 将元组 item 压入栈中,元组为 (d, c, b, a)
    PUSH(tup);
    DISPATCH();
}

此时栈里面只有一个元素,指向一个元组。接下来是 UNPACK_SEQUENCE,负责对序列进行解包,它的指令参数也是 4,表示要解包的序列的长度为 4,我们来看看它的逻辑。

case TARGET(UNPACK_SEQUENCE): {
    PREDICTED(UNPACK_SEQUENCE);
    // seq:从栈里面弹出的元组 (d, c, b, a)
    // item:用于遍历元素
    // items:指向一个 PyObject * 类型的数组
    PyObject *seq = POP(), *item, **items;
    if (PyTuple_CheckExact(seq) &&
        PyTuple_GET_SIZE(seq) == oparg) {
        // 获取元组内部的 ob_item 成员
        // 元素就存储在它指向的数组中
        items = ((PyTupleObject *)seq)->ob_item;
        // 遍历内部的每一个元素,并依次压入栈中
        // 由于是从后往前遍历的,所以遍历的元素依次是 a b c d
        // 但在压入栈中之后,元素从栈顶到栈底就变成了 d c b a
        while (oparg--) {
            item = items[oparg];
            Py_INCREF(item);
            PUSH(item);
        }
    } else if (PyList_CheckExact(seq) &&
               PyList_GET_SIZE(seq) == oparg) {
        // 该指令同样适用于列表,逻辑一样
        items = ((PyListObject *)seq)->ob_item;
        while (oparg--) {
            item = items[oparg];
            Py_INCREF(item);
            PUSH(item);
        }
    } 
    // ...
    Py_DECREF(seq);
    DISPATCH();
}

最后 STORE_NAME 将 d c b a 依次弹出,赋值给变量 a b c d,从而完成变量交换。所以当交换的变量多了之后,不会直接在运行时栈里面操作,而是将栈里面的元素挨个弹出,构建元组;然后再按照指定顺序,将元组里面的元素重新压到栈里面。

假设变量 a b c d 的值分别为 1 2 3 4,我们画图来描述一下整个过程。

9047ade4da311b3ca7cc05bb06b39378.png

不管是哪一种做法,Python在进行变量交换时所做的事情是不变的,核心分为三步走。首先将等号右边的变量,按照从左往右的顺序,依次压入栈中;然后对运行时栈里面元素的顺序进行调整;最后再将运行时栈里面的元素挨个弹出,还是按照从左往右的顺序,再依次赋值给等号左边的变量。

只不过当变量不多时,调整元素位置会直接基于栈进行操作;而当达到四个时,则需要额外借助于元组。

然后多元赋值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字节码。

0 LOAD_CONST               0 ((1, 2, 3))
  2 UNPACK_SEQUENCE          3
  4 STORE_NAME               0 (a)
  6 STORE_NAME               1 (b)
  8 STORE_NAME               2 (c)

元组直接作为一个常量被加载进来了,然后解包,再依次赋值。

4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有区别吗?

答案是没有区别,两者在反编译之后对应的字节码指令只有一处不同。

0 LOAD_NAME                0 (d)
  2 LOAD_NAME                1 (c)
  4 LOAD_NAME                2 (b)
  6 LOAD_NAME                3 (a)
  8 BUILD_LIST               4
 10 UNPACK_SEQUENCE          4
 12 STORE_NAME               3 (a)
 14 STORE_NAME               2 (b)
 16 STORE_NAME               1 (c)
 18 STORE_NAME               0 (d)

前者是 BUILD_TUPLE,现在变成了 BUILD_LIST,其它部分一模一样,所以两者的效果是相同的。当然啦,由于元组的构建比列表快一些,因此还是推荐第一种写法。

5)a = b = c = 123 背后的原理是什么?

如果变量 a、b、c 指向的值相同,比如都是 123,那么便可以通过这种方式进行链式赋值。那么它背后是怎么做的呢?

0 LOAD_CONST               0 (123)
  2 DUP_TOP
  4 STORE_NAME               0 (a)
  6 DUP_TOP
  8 STORE_NAME               1 (b)
 10 STORE_NAME               2 (c)

出现了一个新的字节码指令 DUP_TOP,只要搞清楚它的作用,事情就简单了。

case TARGET(DUP_TOP): {
    // 获取栈顶元素,注意是获取、不是弹出
    // TOP:查看元素,POP:弹出元素
    PyObject *top = TOP();
    // 增加指向对象的引用计数
    Py_INCREF(top);
    // 压入栈中
    PUSH(top);
    FAST_DISPATCH();
}

所以 DUP_TOP 干的事情就是将栈顶元素拷贝一份,再重新压到栈里面。另外不管链式赋值语句中有多少个变量,模式都是一样的.

我们以 a = b = c = d = e = 123 为例:

0 LOAD_CONST               0 (123)
   2 DUP_TOP
   4 STORE_NAME               0 (a)
   6 DUP_TOP
   8 STORE_NAME               1 (b)
  10 DUP_TOP
  12 STORE_NAME               2 (c)
  14 DUP_TOP
  16 STORE_NAME               3 (d)
  18 STORE_NAME               4 (e)

将常量压入运行时栈,然后拷贝一份,赋值给 a;再拷贝一份,赋值给 b;再拷贝一份,赋值给 c;再拷贝一份,赋值给 d;最后自身赋值给 e。

以上就是链式赋值的秘密,其实没有什么好神奇的,就是将栈顶元素进行拷贝,再依次赋值。

但是这背后有一个坑,就是给变量赋的值不能是可变对象,否则容易造成 BUG。

a = b = c = {}
a["ping"] = "pong"
print(a)  # {'ping': 'pong'}
print(b)  # {'ping': 'pong'}
print(c)  # {'ping': 'pong'}

虽然 Python 一切皆对象,但对象都是通过指针来间接操作的。所以 DUP_TOP 是将字典的地址拷贝一份,而字典只有一个,因此最终 a、b、c 会指向同一个字典。

6)a is b 和 a == b 的区别是什么?

is 用于判断两个变量是不是引用同一个对象,也就是保存的对象的地址是否相等;而 == 则是判断两个变量引用的对象是否相等,等价于 a.__eq__(b)

Python 的变量在 C 看来只是一个指针,因此两个变量是否指向同一个对象,等价于 C 中的两个指针存储的地址是否相等;


而 Python 的 ==,则需要调用 PyObject_RichCompare,来比较它们指向的对象所维护的值是否相等。

这两个语句的字节码指令是一样的,唯一的区别就是指令 COMPARE_OP 的参数不同。

// a is b 
  0 LOAD_NAME                0 (a)
  2 LOAD_NAME                1 (b)
  4 COMPARE_OP               8 (is)
  6 POP_TOP
  
  // a == b
  0 LOAD_NAME                0 (a)
  2 LOAD_NAME                1 (b)
  4 COMPARE_OP               2 (==)
  6 POP_TOP

我们看到指令参数一个是 8、一个是 2,然后是 COMPARE_OP 指令的背后逻辑:

case TARGET(COMPARE_OP): {
    // 弹出栈顶元素,这里是 b
    PyObject *right = POP();
    // 显然 left 就是 a
    // b 被弹出之后,它成为新的栈顶
    PyObject *left = TOP();
    // 进行比较,比较结果为 res
    PyObject *res = cmp_outcome(tstate, oparg, left, right);
    // 减少 left 和 right 引用计数
    Py_DECREF(left);
    Py_DECREF(right);
    // 将栈顶元素替换为 res
    SET_TOP(res);
    if (res == NULL)
        goto error;
    // 指令预测,暂时不用管
    PREDICT(POP_JUMP_IF_FALSE);
    PREDICT(POP_JUMP_IF_TRUE);
    // 相当于 continue
    DISPATCH();
}

所以逻辑很简单,核心就在 cmp_outcome 函数中。

static PyObject *
cmp_outcome(int op, PyObject *v, PyObject *w)
{  
    int res = 0;
    // op 就是 COMPARE_OP 指令的参数
    switch (op) {
    // PyCmp_IS 是一个枚举变量,等于 8
    // 定义在 Include/opcode.h 中
    case PyCmp_IS:
        // is 操作符,在 C 的层面直接一个 == 判断即可
        res = (v == w);
        break;
    // ...
    default:
        // 而 PyObject_RichCompare 是一个函数调用
        // 比较对象维护的值是否相等
        return PyObject_RichCompare(v, w, op);
    }
    v = res ? Py_True : Py_False;
    Py_INCREF(v);
    return v;
}

我们实际举个栗子:

a = 3.14
b = float("3.14")
print(a is b)  # False
print(a == b)  # True

a 和 b 都是 3.14,两者是相等的,但不是同一个对象。

反过来也是如此,如果 a is b 成立,那么 a == b 也不一定成立。可能有人好奇,a is b 成立说明 a 和 b 指向的是同一个对象,那么 a == b 表示该对象和自己进行比较,结果应该始终是相等的呀,为啥也不一定成立呢?以下面两种情况为例:

class Girl:
    def __eq__(self, other):
        return False
g = Girl()
print(g is g)  # True
print(g == g)  # False

__eq__ 返回 False,此时虽然是同一个对象,但是两者不相等。

import math
import numpy as np
a = float("nan")
b = math.nan
c = np.nan
print(a is a, a == a)  # True False
print(b is b, b == b)  # True False
print(c is c, c == c)  # True False

nan 是一个特殊的浮点数,意思是 not a number(不是一个数字),用于表示空值。而 nan 和所有数字的比较结果均为 False,即使是和它自身比较。


但需要注意的是,在使用 == 进行比较的时候虽然是不相等的,但如果放到容器里面就不一定了。举个例子:

import numpy as np
lst = [np.nan, np.nan, np.nan]
print(lst[0] == np.nan)  # False
print(lst[1] == np.nan)  # False
print(lst[2] == np.nan)  # False
# lst 里面的三个元素和 np.nan 均不相等
# 但是 np.nan 位于列表中,并且数量是 3
print(np.nan in lst)  # True
print(lst.count(np.nan))  # 3

出现以上结果的原因就在于,元素被放到了容器里,而容器的一些 API 在比较元素时会先判定它们存储的对象的地址是否相同,即:是否指向了同一个对象。如果是,直接认为相等;否则,再去比较对象维护的值是否相等。

可以理解为先进行 is 判断,如果结果为 True,直接判定两者相等;如果 is 操作的结果不为 True,再去进行 == 判断。

因此 np.nan in lst 的结果为 True,lst.count(np.nan) 的结果是 3,因为它们会先比较对象的地址。地址相同,则直接认为对象相等。

在用 pandas 做数据处理的时候,nan 是一个非常容易坑的地方。

提到 is 和 ==,那么问题来了,在和 True、False、None 比较时,是用 is 还是用 == 呢?

由于 True、False、None 它们不仅是关键字,而且也被看做是一个常量,最重要的是它们都是单例的,所以我们应该用 is 判断。


另外 is 在底层只需要一个 == 即可完成;但 Python 的 ==,在底层则需要调用 PyObject_RichCompare 函数。因此 is 在速度上也更有优势,== 操作肯定比函数调用要快。



小结



以上我们就研究了虚拟机是如何执行字节码的,相信你对 Python 虚拟机也有了更深的了解。说白了虚拟机就是把自己当成一颗 CPU,在栈帧中不停地执行字节码指令。


而执行逻辑就是 _PyEval_EvalFrameDefault 里面的那个大大的 for 循环,for 循环里面有一个巨型 switch,case 了所有的分支,不同的分支执行不同的逻辑。

相关文章
|
1月前
|
存储 缓存 Java
深度解密 Python 虚拟机的执行环境:栈帧对象
深度解密 Python 虚拟机的执行环境:栈帧对象
59 13
|
2月前
|
存储 自然语言处理 编译器
深度解密 Python 的字节码
深度解密 Python 的字节码
65 8
|
1月前
|
存储 自然语言处理 编译器
Python 源文件编译之后会得到什么,它的结构是怎样的?和字节码又有什么联系?
Python 源文件编译之后会得到什么,它的结构是怎样的?和字节码又有什么联系?
41 0
|
1月前
|
存储 Java 开发者
用一篇文章告诉你如何篡改 Python 虚拟机
用一篇文章告诉你如何篡改 Python 虚拟机
11 0
|
3月前
|
自然语言处理 编译器 开发者
|
5月前
|
安全 Java Python
GIL是Python解释器的锁,确保单个进程中字节码执行的串行化,以保护内存管理,但限制了多线程并行性。
【6月更文挑战第20天】GIL是Python解释器的锁,确保单个进程中字节码执行的串行化,以保护内存管理,但限制了多线程并行性。线程池通过预创建线程池来管理资源,减少线程创建销毁开销,提高效率。示例展示了如何使用Python实现一个简单的线程池,用于执行多个耗时任务。
42 6
|
6月前
|
算法 Python
深入理解Python虚拟机:super超级魔法的背后原理
深入理解Python虚拟机:super超级魔法的背后原理
|
NoSQL Redis 芯片
无意苦争春,一任群芳妒!M1 Mac book(Apple Silicon)能否支撑全栈工程师的日常?(Python3/Ruby/PHP/Mysql/Redis/NPM/虚拟机/Docker)
就像大航海时代里突然诞生的航空母舰一样,苹果把玩着手心里远超时代的M1芯片,微笑着对Intel说:“不好意思,虽然你也玩桌面芯片,但是,从今天开始,游戏就已经结束了,X86?还是省省吧。
无意苦争春,一任群芳妒!M1 Mac book(Apple Silicon)能否支撑全栈工程师的日常?(Python3/Ruby/PHP/Mysql/Redis/NPM/虚拟机/Docker)
|
机器学习/深度学习 IDE 前端开发
新手入门指南之玩转蓝桥云课(线上运行虚拟机,c++,Java,Javaweb,python环境,以及如何成功利用命令行运行这些环境)(2)
新手入门指南之玩转蓝桥云课(线上运行虚拟机,c++,Java,Javaweb,python环境,以及如何成功利用命令行运行这些环境)(2)
1554 0
新手入门指南之玩转蓝桥云课(线上运行虚拟机,c++,Java,Javaweb,python环境,以及如何成功利用命令行运行这些环境)(2)
|
机器学习/深度学习 Ubuntu Java
新手入门指南之玩转蓝桥云课(线上运行虚拟机,c++,Java,Javaweb,python环境,以及如何成功利用命令行运行这些环境)(1)
新手入门指南之玩转蓝桥云课(线上运行虚拟机,c++,Java,Javaweb,python环境,以及如何成功利用命令行运行这些环境)(1)
1757 0
新手入门指南之玩转蓝桥云课(线上运行虚拟机,c++,Java,Javaweb,python环境,以及如何成功利用命令行运行这些环境)(1)

热门文章

最新文章