偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

深入源碼,進(jìn)一步考察字節(jié)碼的執(zhí)行流程

開(kāi)發(fā) 前端
我們就深入源碼,考察了虛擬機(jī)執(zhí)行字節(jié)碼的流程,幀評(píng)估函數(shù)雖然很長(zhǎng),也有那么一些復(fù)雜,但是核心邏輯不難理解。就是把自己當(dāng)成一顆 CPU,在棧幀中執(zhí)行指令。?

源碼解析字節(jié)碼的執(zhí)行過(guò)程

之前說(shuō)了,虛擬機(jī)就是把自己當(dāng)成一個(gè) CPU,在棧幀中執(zhí)行字節(jié)碼。面對(duì)不同的字節(jié)碼指令,執(zhí)行不同的處理邏輯。

具體實(shí)現(xiàn)由 Python/ceval.c 中的 _PyEval_EvalFrameDefault 函數(shù)負(fù)責(zé),該函數(shù)比較長(zhǎng),并且存在很多的宏,我們會(huì)進(jìn)行適當(dāng)?shù)暮?jiǎn)化。

圖片圖片

_PyEval_EvalFrameDefault 函數(shù)的上方定義了一個(gè)宏,這里需要解釋一下。對(duì)于 CPython 而言,一個(gè) Python 函數(shù)調(diào)用在底層會(huì)涉及多個(gè) C 函數(shù)調(diào)用,而 C 函數(shù)在調(diào)用時(shí)顯然也要?jiǎng)?chuàng)建棧幀。

所以整個(gè)過(guò)程存在兩個(gè)調(diào)用棧,一個(gè)是 Python 調(diào)用棧,另一個(gè)是 C 調(diào)用棧。而調(diào)用一個(gè) Python 函數(shù),底層的 C 調(diào)用棧會(huì)消耗 3 個(gè)單元。因此不難發(fā)現(xiàn),隨著 Python 調(diào)用棧在增長(zhǎng)的同時(shí),C 調(diào)用棧也在增長(zhǎng)。

圖片圖片

為此 CPython 引入了一個(gè)優(yōu)化,對(duì)于遞歸函數(shù)而言,當(dāng)執(zhí)行新的遞歸調(diào)用時(shí),C 調(diào)用棧不會(huì)再增長(zhǎng)。換句話(huà)說(shuō),不會(huì)再次調(diào)用 _PyEval_EvalFrameDefault 函數(shù),而是通過(guò)改變上下文,在同一個(gè) _PyEval_EvalFrameDefault 里面重新開(kāi)始。

好啦,下面我們就來(lái)分析一下這個(gè)長(zhǎng)得跟巨無(wú)霸一樣的函數(shù),當(dāng)然在 3.12 里面它已經(jīng)不像之前那么大了,因?yàn)橛幸徊糠执a被拆分出來(lái)了。

// Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{   
    // 檢測(cè)線(xiàn)程狀態(tài)對(duì)象是否不為 NULL
    // 如果為 NULL,說(shuō)明 GIL 被釋放,而這是不允許的
    // 字節(jié)碼指令必須在當(dāng)前線(xiàn)程持有 GIL 的情況下執(zhí)行
    _Py_EnsureTstateNotNULL(tstate);
    // _PyEval_EvalFrameDefault 的調(diào)用次數(shù)加 1
    CALL_STAT_INC(pyeval_calls);

// 如果使用 "計(jì)算跳轉(zhuǎn)",導(dǎo)入靜態(tài)跳轉(zhuǎn)表
#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif

    // ...
}

由于該函數(shù)的代碼量較大,并且很多內(nèi)容都需要花費(fèi)一定筆墨去解釋?zhuān)詾榱烁忧逦覀兎侄沃v解。先看上面這段代碼,里面出現(xiàn)了計(jì)算跳轉(zhuǎn),需要解釋一下它是什么意思。

_PyEval_EvalFrameDefault(后續(xù)簡(jiǎn)稱(chēng)為幀評(píng)估函數(shù))雖然很復(fù)雜,但它核心不難理解,就是循環(huán)遍歷字節(jié)碼指令集,處理每一條指令。而當(dāng)一條指令執(zhí)行完畢時(shí),虛擬機(jī)會(huì)有以下三種動(dòng)作之一:

  • 停止循環(huán)、退出幀評(píng)估函數(shù),當(dāng)執(zhí)行的指令為 RETURN_VALUE、YIELD_VALUE 等。
  • 指令執(zhí)行過(guò)程中出錯(cuò)了,比如執(zhí)行 GET_ITER 指令,但對(duì)象不具備可迭代的性質(zhì)。執(zhí)行出錯(cuò)也要退出幀評(píng)估函數(shù),然后執(zhí)行異常處理邏輯(或者直接拋出異常)。
  • 進(jìn)入下一輪循環(huán),執(zhí)行下一條指令。

前面兩種動(dòng)作沒(méi)啥好說(shuō)的,關(guān)鍵是第三種,如何執(zhí)行下一條指令。首先虛擬機(jī)內(nèi)部有一個(gè)巨型的 switch 語(yǔ)句,偽代碼如下:

for (;;) {
    // 循環(huán)遍歷指令集,獲取指令和指令參數(shù)
    uint8_t opcode = ...;  // 指令
    int oparg = ...;  // 指令參數(shù)
    // 執(zhí)行對(duì)應(yīng)的處理邏輯
    switch (opcode) {
        case LOAD_CONST:
            處理邏輯;
        case LOAD_FAST:
            處理邏輯;
        case LOAD_FAST:
            處理邏輯;
        case BUILD_LIST: 
            處理邏輯;
        case DICT_UPDATE: 
            處理邏輯;
        // ...
    }
}

一個(gè) case 分支,對(duì)應(yīng)一個(gè)字節(jié)碼指令的實(shí)現(xiàn),由于指令非常多,所以這個(gè) switch 語(yǔ)句也非常龐大。然后遍歷出的指令,會(huì)進(jìn)入這個(gè) switch 語(yǔ)句進(jìn)行匹配,執(zhí)行相應(yīng)的處理邏輯。

所以循環(huán)遍歷 co_code 得到字節(jié)碼指令,然后交給內(nèi)部的 switch 語(yǔ)句、執(zhí)行匹配到的 case 分支,如此周而復(fù)始,最終完成了對(duì)整個(gè) Python 程序的執(zhí)行。

其實(shí)到這里,你應(yīng)該已經(jīng)了解了幀評(píng)估函數(shù)的整體結(jié)構(gòu)。說(shuō)白了就是將自己當(dāng)成一個(gè) CPU,在棧幀中執(zhí)行一條條指令,而執(zhí)行過(guò)程中所依賴(lài)的常量、變量等,則由棧幀的其它字段來(lái)維護(hù)。因此在虛擬機(jī)的執(zhí)行流程進(jìn)入了那個(gè)巨大的 for 循環(huán),并取出第一條字節(jié)碼指令交給里面的 switch 語(yǔ)句之后,第一張多米諾骨牌就已經(jīng)被推倒,命運(yùn)不可阻擋的降臨了。一條接一條的指令如同潮水般涌來(lái),浩浩蕩蕩,橫無(wú)際涯。

雖然在概念上很好理解,但很多細(xì)節(jié)被忽略掉了,本篇文章就將它們深挖出來(lái)。還是之前的問(wèn)題,當(dāng)一個(gè)指令執(zhí)行完畢時(shí),怎么執(zhí)行下一條指令。

估計(jì)有人對(duì)這個(gè)問(wèn)題感到奇怪,在 case 分支的內(nèi)部加一行 continue 進(jìn)行下一輪循環(huán)不就行了嗎?沒(méi)錯(cuò),這種做法是行得通的,但存在性能問(wèn)題。因?yàn)?continue 會(huì)跳轉(zhuǎn)到 for 循環(huán)所在位置,所以遍歷出下一條指令之后,會(huì)再次進(jìn)入 switch 語(yǔ)句進(jìn)行匹配。盡管邏輯上是正確的,但 switch 里面有數(shù)百個(gè) case 分支,如果每來(lái)一個(gè)指令,都要順序匹配一遍的話(huà),那么效率必然不高。

而事實(shí)上整個(gè)字節(jié)碼指令集是已知的,所以不管執(zhí)行哪個(gè)指令,我們都可以提前得知它的下一個(gè)指令,只需將指針向后偏移兩個(gè)字節(jié)即可。

圖片圖片

那么問(wèn)題來(lái)了,既然知道下一條要執(zhí)行的指令是什么,那么在當(dāng)前指令執(zhí)行完畢時(shí),可不可以直接跳轉(zhuǎn)到下一條指令對(duì)應(yīng)的 case 分支中呢?

答案是可以的,這個(gè)過(guò)程就叫做計(jì)算跳轉(zhuǎn),通過(guò)標(biāo)簽作為值即可實(shí)現(xiàn)。關(guān)于什么是標(biāo)簽作為值,我們用一段 C 代碼解釋一下。

#include <stdio.h>

void label_as_value(int jump) {
    int num = 4;
    void *label;
    switch (num) {
        case 1:
            printf("%d\n", 1);
            break;
        // 在 case 2 分支里面定義了一個(gè)標(biāo)簽叫 two
        case 2: two: {
            printf("%d\n", 2);
            break;
        }
        // 在 case 3 分支里面定義了一個(gè)標(biāo)簽叫 three
        case 3: three: {
            printf("%d\n", 3);
            break;
        }
        case 4:
            printf("%d\n", 4);
            // 如果參數(shù) jump 等于 2,保存 two 標(biāo)簽的地址
            // 如果參數(shù) jump 等于 3,保存 three 標(biāo)簽的地址
            if (jump == 2) label = &&two;
            else if (jump == 3) label = &&three;
            // 跳轉(zhuǎn)到指定標(biāo)簽
            goto *label;
        default:
            break;
    }
}

int main() {
    label_as_value(2);
    // 4
    // 2
    label_as_value(3);
    // 4
    // 3
}

由于變量 num 等于 4,所以會(huì)進(jìn)入 case 4 分支,在里面有一個(gè) goto *label。如果你對(duì) C 不是特別熟悉的話(huà),估計(jì)會(huì)有些奇怪,覺(jué)得不應(yīng)該是 goto label 嗎?如果是 goto label,那么需要顯式地定義一個(gè)名為 label 的標(biāo)簽,但這里并沒(méi)有。

我們的目的是跳轉(zhuǎn)到 two 標(biāo)簽或 three 標(biāo)簽,具體跳轉(zhuǎn)到哪一個(gè),則由參數(shù)控制。因此可以使用 && 運(yùn)算符,這是 GCC 的一個(gè)擴(kuò)展特性,叫做標(biāo)簽作為值,它允許我們獲取標(biāo)簽的地址作為一個(gè)值。

所以在開(kāi)頭聲明了一個(gè) void *label,然后讓 label 保存標(biāo)簽地址,再通過(guò) goto *label 跳轉(zhuǎn)到指定標(biāo)簽。由于 *label 代表哪個(gè)標(biāo)簽是在運(yùn)行時(shí)經(jīng)過(guò)計(jì)算才能知曉,因此稱(chēng)為計(jì)算跳轉(zhuǎn)(在運(yùn)行時(shí)動(dòng)態(tài)決定跳轉(zhuǎn)目標(biāo))。

注意:goto *&&標(biāo)簽名 屬于高級(jí)的、非標(biāo)準(zhǔn)的 C 語(yǔ)言用法。

回到源碼中來(lái),在 Python/generated_cases.c.h 文件里面,我們可以看到標(biāo)簽的定義。

圖片圖片

這個(gè)文件里面定義的便是每個(gè)指令的處理邏輯,總共 4800 行,之前內(nèi)嵌在幀評(píng)估函數(shù)里面,現(xiàn)在單獨(dú)拆出來(lái)了。執(zhí)行幀評(píng)估函數(shù)時(shí),直接 #include 進(jìn)來(lái)即可。

圖片圖片

另外我們看到每個(gè)指令都調(diào)用了 TARGET,顯然這是一個(gè)宏,看一下它長(zhǎng)什么樣子。

// Python/ceval_macros.h
#define INSTRUCTION_START(op) (frame->prev_instr = next_instr++)

#if USE_COMPUTED_GOTOS
#  define TARGET(op) TARGET_##op: INSTRUCTION_START(op);
#  define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
#  define TARGET(op) case op: TARGET_##op: INSTRUCTION_START(op);
#  define DISPATCH_GOTO() goto dispatch_opcode
#endif

如果將宏展開(kāi)的話(huà),那么幀評(píng)估函數(shù)里面的 switch 語(yǔ)句等價(jià)于如下。

圖片圖片

如果不使用計(jì)算跳轉(zhuǎn),那么展開(kāi)之后就是一個(gè) switch 語(yǔ)句,里面是每個(gè)指令的處理邏輯。而在邏輯的最后,會(huì)調(diào)用一個(gè) DISPATCHER() 或 DISPATCH_GOTO()。

// Python/ceval_macros.h

// 會(huì)依次調(diào)用 3 個(gè)宏,中間第二個(gè)宏可以忽略掉
#define DISPATCH() \
    { \
        NEXTOPARG(); \
        PRE_DISPATCH_GOTO(); \
        DISPATCH_GOTO(); \
    }

/*
typedef union {
    uint16_t cache;
    struct {
        uint8_t code;
        uint8_t arg;
    } op;
} _Py_CODEUNIT;
*/
// next_instr 指向下一條待執(zhí)行的指令,prev_instr 指向最近一條執(zhí)行完畢的指令
// 所以 prev_instr 的下一條指令就是 next_instr
// 由于在處理指令時(shí),先執(zhí)行了 frame->prev_instr = next_instr++;
// 所以調(diào)用 NEXTOPARG() 時(shí),next_instr 已經(jīng)指向了新的待執(zhí)行的字節(jié)碼指令
// 這里獲取指令和指令參數(shù)
#define NEXTOPARG()  do { \
        _Py_CODEUNIT word = *next_instr; \
        opcode = word.op.code; \
        oparg = word.op.arg; \
    } while (0)

// 跳轉(zhuǎn)
#if USE_COMPUTED_GOTOS
#  define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
#  define DISPATCH_GOTO() goto dispatch_opcode
#endif

因此每個(gè)指令在執(zhí)行完畢后,會(huì)調(diào)用 DISPATCHER_GOTO(),如果沒(méi)使用計(jì)算跳轉(zhuǎn),那么會(huì)直接 goto 到 dispatch_opcode 標(biāo)簽,然后進(jìn)入 switch。

所以在 CPython 3.12 版本中,switch 外層的 for 循環(huán)已經(jīng)沒(méi)有了,使用的是 goto,在獲取下一條待執(zhí)行的指令和參數(shù)之后,直接 goto 到 switch 語(yǔ)句所在的標(biāo)簽(dispatch_opcode)。當(dāng)然這和 for 循環(huán)本質(zhì)上沒(méi)太大差異,因?yàn)樵讷@取到新的指令時(shí),都要重新走一遍 switch。

以上是不使用計(jì)算跳轉(zhuǎn),如果使用計(jì)算跳轉(zhuǎn),從圖中可以看到是沒(méi)有 switch 語(yǔ)句的,當(dāng)然 TARGET 展開(kāi)之后也不會(huì)出現(xiàn) case 分支。以 TARGET(LOAD_FAST) 為例:

  • 不使用計(jì)算跳轉(zhuǎn),展開(kāi)之后是 case LOAD_FAST: TARGET_LOAD_FAST:
  • 使用計(jì)算跳轉(zhuǎn),展開(kāi)之后是 TARGET_LOAD_FAST:

可以看到使用計(jì)算跳轉(zhuǎn)之后,留下的只是一堆標(biāo)簽,當(dāng)然此時(shí)有沒(méi)有 switch 已經(jīng)無(wú)所謂了,重點(diǎn)是 DISPATCHER_GOTO() 之后可以直接跳轉(zhuǎn)到指定位置,不需要挨個(gè)比較了。而根據(jù)宏定義,最后會(huì) goto 到 *opcode_targets[opcode],這個(gè) opcode_targets 便是一開(kāi)始導(dǎo)入的靜態(tài)跳轉(zhuǎn)表。

它定義在 Python/opcode_targets.h 中,我們看一下。

每個(gè)指令的處理邏輯會(huì)對(duì)應(yīng)一個(gè)標(biāo)簽,這些標(biāo)簽的地址全部保存在了數(shù)組中,執(zhí)行幀評(píng)估函數(shù)時(shí)導(dǎo)入進(jìn)來(lái)即可。這里可能有人會(huì)問(wèn),導(dǎo)入數(shù)組時(shí),它里面的標(biāo)簽都還沒(méi)有定義吧。確實(shí)如此,不過(guò)沒(méi)關(guān)系,對(duì)于 C 來(lái)說(shuō),標(biāo)簽只要定義了,那么它在函數(shù)的任何一個(gè)位置都可以使用。

變量 opcode 就是指令(一個(gè) uint8 整數(shù)),而最后跳轉(zhuǎn)到了 *opcode_targets[opcode],那么我們有理由相信,指令和 opcode_targets 數(shù)組的索引之間存在某種關(guān)聯(lián)。

這種關(guān)聯(lián)也很好想,opcode_targets[opcode] 指向的標(biāo)簽,其內(nèi)部的邏輯就是用來(lái)處理 opcode 指令的,我們來(lái)驗(yàn)證一下。

圖片圖片

LOAD_CONST 的值是 100,那么 opcode_targets[100] 一定是 &&TARGET_LOAD_CONST。

圖片圖片

結(jié)果沒(méi)有問(wèn)題,其它指令也是一樣的,通過(guò)計(jì)算跳轉(zhuǎn),可以直接 goto 到指定的標(biāo)簽。

好,我們總結(jié)一下,早期的幀評(píng)估函數(shù)內(nèi)部有一個(gè)巨型的 switch,每來(lái)一個(gè)指令都要順序匹配數(shù)百個(gè) case 分支,找到符合條件的那一個(gè)。盡管這些 case 分支內(nèi)部也定義了標(biāo)簽,但對(duì)實(shí)現(xiàn)精確跳轉(zhuǎn)沒(méi)太大幫助。

而在 3.12 里面引入了計(jì)算跳轉(zhuǎn),此時(shí) switch 已經(jīng)沒(méi)有了,只剩下了標(biāo)簽。當(dāng)然有沒(méi)有 switch 都無(wú)所謂,重點(diǎn)是每個(gè)標(biāo)簽的地址被保存在了數(shù)組 opcode_targets 中,并且標(biāo)簽在數(shù)組中的索引和對(duì)應(yīng)的指令是相等的。

比如下一條要執(zhí)行的指令是 YIELD_VALUE,它等于 150,那么 opcode_targets[150] 就等于 &&TARGET_YIELD_VALUE,指向的標(biāo)簽內(nèi)部便是 YIELD_VALUE 的處理邏輯,至于其它指令也是同理。

因此讀取完下一條指令之后,就不用像之前一樣跳轉(zhuǎn)到開(kāi)頭重新走一遍 switch 了。而是將指令作為索引,從 opcode_targets 拿到標(biāo)簽地址直接跳轉(zhuǎn)即可,并且跳轉(zhuǎn)后的標(biāo)簽內(nèi)部的邏輯就是用來(lái)處理該指令的。

所以底層為每個(gè)指令的處理邏輯都定義了一個(gè)標(biāo)簽,而標(biāo)簽的地址在數(shù)組中的索引,和處理的指令是相等的。

不過(guò)要想實(shí)現(xiàn)計(jì)算跳轉(zhuǎn),需要 GCC 支持標(biāo)簽作為值這一特性,即 goto *標(biāo)簽地址,至于標(biāo)簽地址是哪一個(gè)標(biāo)簽的地址,則在運(yùn)行時(shí)動(dòng)態(tài)計(jì)算得出。比如 opcode_targets[opcode] 指向哪個(gè)標(biāo)簽無(wú)從得知,這取決于 opcode 的值。

goto 標(biāo)簽:靜態(tài)跳轉(zhuǎn),標(biāo)簽需要顯式地定義好,跳轉(zhuǎn)位置在編譯期間便已經(jīng)固定。

goto *標(biāo)簽地址:動(dòng)態(tài)跳轉(zhuǎn)(計(jì)算跳轉(zhuǎn)),跳轉(zhuǎn)位置不固定,可以是已有標(biāo)簽中的任意一個(gè)。至于具體是哪一個(gè),需要在運(yùn)行時(shí)經(jīng)過(guò)計(jì)算才能確定。

關(guān)于計(jì)算跳轉(zhuǎn)我們就解釋完了,當(dāng)然還解釋了很多其它內(nèi)容,下面繼續(xù)看源碼。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{
    _Py_EnsureTstateNotNULL(tstate);
    CALL_STAT_INC(pyeval_calls);

#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif

    uint8_t opcode;  // 指令
    int oparg;       // 指令參數(shù)
    
    /*
    typedef struct _PyCFrame {
        struct _PyInterpreterFrame *current_frame;
        struct _PyCFrame *previous;
    } _PyCFrame;

    結(jié)構(gòu)體位于 Include/cpython/pystate.h 中
    */
    // _PyCFrame 相當(dāng)于對(duì) _PyInterpreterFrame 做了一層封裝
    // 為了方便描述,我們也稱(chēng) _PyCFrame 實(shí)例為棧楨
    // cframe 會(huì)在 C 語(yǔ)言的調(diào)用棧中傳遞,從而提高性能
    _PyCFrame cframe;
    // 入口幀
    _PyInterpreterFrame  entry_frame;
    PyObject *kwnames = NULL;

    // tstate 表示線(xiàn)程狀態(tài)對(duì)象,tstate->cframe 指向正在執(zhí)行的棧楨
    // 那么顯然要將 tstate->cframe 更新為 &cframe
    // 不過(guò)更新之前,要先將目前的 tstate->cframe 保存起來(lái)
    // 因?yàn)檫M(jìn)入到幀評(píng)估函數(shù)中,說(shuō)明開(kāi)啟了新的幀,也意味著它成為了上一個(gè)幀
    _PyCFrame *prev_cframe = tstate->cframe;
    cframe.previous = prev_cframe;  // 通過(guò) previous 保存上一個(gè)幀
    tstate->cframe = &cframe;       // 指向當(dāng)前幀

    // 對(duì)入口幀內(nèi)部的屬性初始化
    entry_frame.f_code = tstate->interp->interpreter_trampoline;
    entry_frame.prev_instr =
        _PyCode_CODE(tstate->interp->interpreter_trampoline);
    entry_frame.stacktop = 0;
    entry_frame.owner = FRAME_OWNED_BY_CSTACK;
    entry_frame.return_offset = 0;
    /* Push frame */
    entry_frame.previous = prev_cframe->current_frame;
    // frame 是 _PyEval_Vector 內(nèi)部創(chuàng)建的棧楨,也就是當(dāng)前正在執(zhí)行的幀
    // _PyEval_Vector 創(chuàng)建完棧楨后,會(huì)將它作為參數(shù),調(diào)用幀評(píng)估函數(shù)
    // 而 frame->previous 同樣指向上一個(gè) _PyInterpreterFrame
    // 這里將它賦值為 &entry_frame,即入口幀
    frame->previous = &entry_frame;
    // cframe 和 frame 都可以理解為正在執(zhí)行的棧楨
    // 但前者對(duì)后者做了一層封裝,這里將 cframe.current_frame 賦值為 frame 
    cframe.current_frame = frame;

    // 判斷還能否安全地進(jìn)行遞歸調(diào)用
    // 如果不能,就減少遞歸計(jì)數(shù)并跳轉(zhuǎn)到退出邏輯
    tstate->c_recursion_remaining -= (PY_EVAL_C_STACK_UNITS - 1);
    if (_Py_EnterRecursiveCallTstate(tstate, "")) {
        tstate->c_recursion_remaining--;
        tstate->py_recursion_remaining--;
        goto exit_unwind;
    }

    // next_instr 剛才說(shuō)過(guò)的,它指向下一條待執(zhí)行的指令
    // 通過(guò)不斷執(zhí)行 frame->prev_instr = next_instr++
    // 最終完成整個(gè)字節(jié)碼指令集的遍歷
    _Py_CODEUNIT *next_instr;
    // stack_pointer 指向運(yùn)行時(shí)棧的棧頂
    // 元素的入棧和出棧,都是通過(guò)操作 stack_pointer 實(shí)現(xiàn)的
    PyObject **stack_pointer;

// 一個(gè)宏,負(fù)責(zé)初始化 next_instr 和 stack_pointer
// 但里面有一個(gè) _PyInterpreterFrame_LASTI 函數(shù)需要說(shuō)一下
// 我們說(shuō) prev_instr 指向上一條、或者最近一條執(zhí)行完畢的字節(jié)碼指令
// 那么 prev_instr 的偏移量是多少呢?這個(gè)還是很簡(jiǎn)單的
// 用 frame->prev_instr - _PyCode_CODE(f_code) 即可
// 而這個(gè)偏移量在以前的版本中,會(huì)有一個(gè)專(zhuān)門(mén)的棧楨字段(f_lasti)保存
#define SET_LOCALS_FROM_FRAME() \
    assert(_PyInterpreterFrame_LASTI(frame) >= -1); \
    next_instr = frame->prev_instr + 1; \
    stack_pointer = _PyFrame_GetStackPointer(frame);

// 開(kāi)始在棧楨中執(zhí)行字節(jié)碼了,但還要做一下檢測(cè)
// 判斷函數(shù)的調(diào)用層級(jí)是否超過(guò)了最大深度(默認(rèn) 1000)
start_frame:
    if (_Py_EnterRecursivePy(tstate)) {
        goto exit_unwind;
    }

// 調(diào)用宏 SET_LOCALS_FROM_FRAME,初始化 next_instr 和 stack_pointer
resume_frame:
    SET_LOCALS_FROM_FRAME();

// ...
}

這部分代碼主要是負(fù)責(zé)初始化一些屬性,其中最重要的兩個(gè)屬性是 next_instr 和 stack_pointer,字節(jié)碼的遍歷由 next_instr 負(fù)責(zé),運(yùn)行時(shí)棧的操作由 stack_pointer 負(fù)責(zé)。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{
    // ...  
    // ...

    // 開(kāi)始進(jìn)行調(diào)度,準(zhǔn)備執(zhí)行字節(jié)碼  
    DISPATCH();
handle_eval_breaker:
    if (_Py_HandlePending(tstate) != 0) {
        goto error;
    }
    DISPATCH();

    {
    /* Start instructions */
    // 如果不使用計(jì)算跳轉(zhuǎn),那么這里就是帶標(biāo)簽的 switch 語(yǔ)句
    // 如果使用計(jì)算跳轉(zhuǎn),那么這兩行無(wú)效
#if !USE_COMPUTED_GOTOS
    dispatch_opcode:
        switch (opcode)
#endif
        {

// 導(dǎo)入 generated_cases.c.h,還記得這個(gè)文件嗎?里面包含了指令的處理邏輯
// 如果不使用計(jì)算跳轉(zhuǎn),那么宏 TARGET 展開(kāi)之后會(huì)生成一個(gè)帶標(biāo)簽的 case 語(yǔ)句 
// 如果使用計(jì)算跳轉(zhuǎn),那么宏 TARGET 展開(kāi)之后就只有一個(gè)標(biāo)簽
#include "generated_cases.c.h"
        
        // ...

        } /* End instructions */
        Py_UNREACHABLE();
    }
    // ...
}

這一部分是虛擬機(jī)開(kāi)始執(zhí)行字節(jié)碼,如果程序沒(méi)有出現(xiàn)錯(cuò)誤,那么會(huì)將所有字節(jié)碼指令執(zhí)行完畢。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, 
                         _PyInterpreterFrame *frame, 
                         int throwflag)
{
    // ...
    {
        // ...

// 如果執(zhí)行時(shí)出現(xiàn) UnboundLocalError,會(huì)跳轉(zhuǎn)到此標(biāo)簽
unbound_local_error:
        {
            format_exc_check_arg(tstate, PyExc_UnboundLocalError,
                UNBOUNDLOCAL_ERROR_MSG,
                PyTuple_GetItem(frame->f_code->co_localsplusnames, oparg)
            );
            goto error;
        }

// 如果字節(jié)碼執(zhí)行時(shí)報(bào)錯(cuò)了,但運(yùn)行時(shí)棧里面還有元素
// 那么要將運(yùn)行時(shí)棧里的元素彈出
// 當(dāng)運(yùn)行時(shí)棧里包含 4 個(gè)元素時(shí),跳轉(zhuǎn)到此標(biāo)簽
pop_4_error:
    STACK_SHRINK(1);
// 當(dāng)運(yùn)行時(shí)棧里包含 3 個(gè)元素時(shí),跳轉(zhuǎn)到此標(biāo)簽
pop_3_error:
    STACK_SHRINK(1);
// 當(dāng)運(yùn)行時(shí)棧里包含 2 個(gè)元素時(shí),跳轉(zhuǎn)到此標(biāo)簽
pop_2_error:
    STACK_SHRINK(1);
// 當(dāng)運(yùn)行時(shí)棧里包含 1 個(gè)元素時(shí),跳轉(zhuǎn)到此標(biāo)簽
pop_1_error:
    STACK_SHRINK(1);
error:
        kwnames = NULL;

        assert(_PyErr_Occurred(tstate));
        assert(frame != &entry_frame);
        // 報(bào)錯(cuò)時(shí),要生成 traceback
        // 關(guān)于 traceback,等介紹異常捕獲的時(shí)候再說(shuō)
        if (!_PyFrame_IsIncomplete(frame)) {
            PyFrameObject *f = _PyFrame_GetFrameObject(frame);
            if (f != NULL) {
                PyTraceBack_Here(f);
            }
        }
        monitor_raise(tstate, frame, next_instr-1);
exception_unwind:
        // ...
        // 后續(xù)介紹
    }

// ...
}

以上就是幀評(píng)估函數(shù)的源碼邏輯,因?yàn)樯婕暗胶竺娴膬?nèi)容,所以我們省略掉了一部分。

通過(guò)反編譯查看字節(jié)碼

源碼部分我們就看完了,可能有小伙伴會(huì)覺(jué)得有些枯燥吧,但我相信認(rèn)真讀完一定會(huì)有很大收獲。下面我們實(shí)際操作一波,通過(guò)反編譯一段簡(jiǎn)單的代碼,來(lái)觀察虛擬機(jī)執(zhí)行字節(jié)碼的整個(gè)過(guò)程。

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

# 將上面的代碼以模塊的方式進(jìn)行編譯
code_obj = compile(code_string, "...", "exec")
# 查看常量池
print(code_obj.co_consts)  # (89, 99, 91, 3, None)
# 查看符號(hào)表
print(
    code_obj.co_names
)  # ('chinese', 'math', 'english', 'avg')

在編譯的時(shí)候,常量和符號(hào)(變量)都會(huì)被靜態(tài)收集起來(lái),然后我們反編譯一下看看字節(jié)碼,直接通過(guò) dis.dis(code_obj) 即可。結(jié)果如下:

0           0 RESUME                   0

  2           2 LOAD_CONST               0 (89)
              4 STORE_NAME               0 (chinese)

  3           6 LOAD_CONST               1 (99)
              8 STORE_NAME               1 (math)

  4          10 LOAD_CONST               2 (91)
             12 STORE_NAME               2 (english)

  5          14 LOAD_NAME                0 (chinese)
             16 LOAD_NAME                1 (math)
             18 BINARY_OP                0 (+)
             22 LOAD_NAME                2 (english)
             24 BINARY_OP                0 (+)
             28 LOAD_CONST               3 (3)
             30 BINARY_OP               11 (/)
             34 STORE_NAME               3 (avg)
             36 RETURN_CONST             4 (None)

我們從上到下依次解釋每條指令都干了什么?

2 LOAD_CONST:表示加載一個(gè)常量(地址),并壓入運(yùn)行時(shí)棧。后面的指令參數(shù) 0 表示從常量池中加載索引為 0 的常量。

4 STORE_NAME:表示將 LOAD_CONST 加載的常量用一個(gè)名字綁定起來(lái),放入所在的名字空間中。后面的 0 (chinese) 表示使用符號(hào)表中索引為 0 的名字(符號(hào)),且名字為 "chinese"。

所以像 chinese = 89 這種簡(jiǎn)單的賦值語(yǔ)句,會(huì)對(duì)應(yīng)兩條字節(jié)碼指令。

然后 6 LOAD_CONST、8 STORE_NAME、10 LOAD_CONST、12 STORE_NAME 的作用顯然和上面是一樣的,都是加載一個(gè)常量,然后和指定的符號(hào)綁定起來(lái),并放入名字空間中。

14 LOAD_NAME:加載一個(gè)變量,并壓入運(yùn)行時(shí)棧。后面的 0 (chinese) 表示加載符號(hào)表中索引為 0 的變量的值,然后這個(gè)變量叫 chinese。

16 LOAD_NAME:同理,將符號(hào)表中索引為 1 的變量的值壓入運(yùn)行時(shí)棧,并且變量叫 math。此時(shí)棧里面有兩個(gè)元素,從棧底到棧頂分別是 chinese 和 math。

18 BINARY_OP:將上面兩個(gè)變量從運(yùn)行時(shí)棧彈出,然后執(zhí)行加法操作,并將結(jié)果壓入運(yùn)行時(shí)棧。

22 LOAD_NAME:將符號(hào)表中索引為 2 的變量 english 的值壓入運(yùn)行時(shí)棧,此時(shí)棧里面有兩個(gè)元素,從棧底到棧頂分別是 chinese + math 的返回結(jié)果和 english。

24 BINARY_OP:將運(yùn)行時(shí)棧里的兩個(gè)元素彈出,然后執(zhí)行加法操作,并將結(jié)果壓入運(yùn)行時(shí)棧。此時(shí)棧里面有一個(gè)元素,就是 chinese + math + english 的運(yùn)算結(jié)果。

28 LOAD_CONST:將常量 3 壓入運(yùn)行時(shí)棧,此時(shí)棧里面有兩個(gè)元素。

30 BINARY_OP:將運(yùn)行時(shí)棧里的兩個(gè)元素彈出,然后執(zhí)行除法操作,并將結(jié)果壓入運(yùn)行時(shí)棧,此時(shí)棧里面有一個(gè)元素。

34 STORE_NAME:將元素從運(yùn)行時(shí)棧里面彈出,并用符號(hào)表中索引為 3 的符號(hào) avg 和它綁定起來(lái),然后放在名字空間中。

36 RETURN_CONST:將常量 None 壓入運(yùn)行時(shí)棧,然后彈出并返回。

所以 Python 虛擬機(jī)就是把自己想象成一顆 CPU,在棧幀中一條條執(zhí)行字節(jié)碼指令,當(dāng)指令執(zhí)行完畢或執(zhí)行出錯(cuò)時(shí),停止執(zhí)行。

我們通過(guò)幾張圖展示一下上面的過(guò)程,為了閱讀方便,這里將相應(yīng)的源代碼再貼一份:

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

上面的代碼位于最外層的模塊中,由于模塊也有自己的作用域,并且是全局作用域,所以虛擬機(jī)也會(huì)為它創(chuàng)建棧幀。而在代碼還沒(méi)有執(zhí)行的時(shí)候,棧幀就已經(jīng)創(chuàng)建好了,整個(gè)布局如下。

f_localsplus 下面的箭頭方向,代表運(yùn)行時(shí)棧從棧底到棧頂?shù)姆较颉?/p>

這里再?gòu)?qiáng)調(diào)一個(gè)重要的知識(shí)點(diǎn),我們看到棧幀里面有一個(gè) f_localsplus 屬性,它是一個(gè)數(shù)組。雖然聲明的時(shí)候?qū)懼L(zhǎng)度為 1,但實(shí)際使用時(shí),長(zhǎng)度不受限制,和 Go 語(yǔ)言不同,C 數(shù)組的長(zhǎng)度不屬于類(lèi)型的一部分。

所以 f_localsplus 是一個(gè)動(dòng)態(tài)內(nèi)存,運(yùn)行時(shí)棧所需要的空間就存儲(chǔ)在里面,但這塊內(nèi)存并不光給運(yùn)行時(shí)棧使用,它被分成了四塊。

函數(shù)的局部變量是靜態(tài)存儲(chǔ)的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是開(kāi)頭的位置。在獲取的時(shí)候直接基于索引操作即可,因此速度會(huì)更快。所以源碼內(nèi)部還有兩個(gè)宏:

圖片圖片

函數(shù)在編譯的時(shí)候就知道每個(gè)局部變量在 f_localsplus 中的索引,所以直接通過(guò)索引操作即可,關(guān)于局部變量的具體操作細(xì)節(jié),后續(xù)再聊。

然后 cell 變量和 free 變量是用來(lái)處理閉包的,而 f_localsplus 的最后一塊內(nèi)存則用于運(yùn)行時(shí)棧。

所以 f_localsplus 是一個(gè)數(shù)組,它是一段連續(xù)內(nèi)存,只不過(guò)從邏輯上講,它被分成了四份,每一份用在不同的地方。但它們整體是連續(xù)的,都是數(shù)組的一部分。但當(dāng)前示例中的代碼是以模塊的方式編譯的,里面所有的變量都是全局變量,而且也不涉及閉包啥的,所以這里就把 f_localsplus 理解為運(yùn)行時(shí)棧即可。

接下來(lái)就開(kāi)始執(zhí)行字節(jié)碼了,虛擬機(jī)會(huì)執(zhí)行:2 LOAD_CONST,該指令表示將常量加載進(jìn)運(yùn)行時(shí)棧,而要加載的常量在常量池中的索引,由指令參數(shù)表示。

在源碼中,指令對(duì)應(yīng)的變量是 opcode,指令參數(shù)對(duì)應(yīng)的變量是 oparg

// Python/generated_cases.c.h
TARGET(LOAD_CONST) {
    PREDICTED(LOAD_CONST);
    PyObject *value;
    #line 204 "Python/bytecodes.c"
    // 從常量池中加載索引為 oparg 的常量
    value = GETITEM(frame->f_code->co_consts, oparg);
    // 增加引用計(jì)數(shù)
    Py_INCREF(value);
    #line 114 "Python/generated_cases.c.h"
    // stack_pointer++,為入棧的元素留出一個(gè)空間
    // 我們上一篇專(zhuān)門(mén)介紹了這些運(yùn)行時(shí)棧的 API
    STACK_GROW(1);
    // 將棧頂元素設(shè)置為 value
    stack_pointer[-1] = value;
    // STACK_GROW(1) 和 stack_pointer[-1] = value 組合起來(lái)等價(jià)于 PUSH(value)
    // 調(diào)用 DISPATCHER,讀取下一條指令,并跳轉(zhuǎn)到指定位置進(jìn)行處理
    DISPATCH();
}


// Python/ceval_macros.h
static inline PyObject *
GETITEM(PyObject *v, Py_ssize_t i) {
    // 從元組 v 中獲取索引為 i 的元素
    assert(PyTuple_Check(v));
    assert(i >= 0);
    assert(i < PyTuple_GET_SIZE(v));
    return PyTuple_GET_ITEM(v, i);
}

該指令的參數(shù)為 0,所以會(huì)將常量池中索引為 0 的元素 89 壓入運(yùn)行時(shí)棧,執(zhí)行完之后,棧幀的布局就變成了下面這樣:

圖片圖片

接著虛擬機(jī)執(zhí)行 4 STORE_NAME 指令,從符號(hào)表中獲取索引為 0 的符號(hào)、即 chinese。然后將棧頂元素 89 彈出,再將符號(hào) chinese 和整數(shù)對(duì)象 89 綁定起來(lái)保存到 local 名字空間中。

TARGET(STORE_NAME) {
    // 獲取運(yùn)行時(shí)棧的棧頂元素,顯然是上一步壓入的 89
    PyObject *v = stack_pointer[-1];
    #line 1013 "Python/bytecodes.c"
    // 從符號(hào)表中加載索引為 oparg 的符號(hào)  
    // 符號(hào)本質(zhì)上就是一個(gè) PyUnicodeObject 對(duì)象
    // 這里就是字符串 "chinese"
    PyObject *name = GETITEM(frame->f_code->co_names, oparg);
    // #define LOCALS() frame->f_locals
    // 獲取名字空間(namespace)
    PyObject *ns = LOCALS();
    int err;
    if (ns == NULL) {
        // 如果沒(méi)有名字空間則報(bào)錯(cuò),設(shè)置異常
        _PyErr_Format(tstate, PyExc_SystemError,
                      "no locals found when storing %R", name);
    #line 1405 "Python/generated_cases.c.h"
        Py_DECREF(v);
    #line 1020 "Python/bytecodes.c"
        if (true) goto pop_1_error;
    }
    // 將符號(hào)和對(duì)象綁定起來(lái)放在名字空間 ns 中
    // 名字空間是一個(gè)字典,PyDict_CheckExact 負(fù)責(zé)檢測(cè) ns 是否為字典
    if (PyDict_CheckExact(ns))
        err = PyDict_SetItem(ns, name, v);
    else
        err = PyObject_SetItem(ns, name, v);
    #line 1414 "Python/generated_cases.c.h"
    // 對(duì)象的引用計(jì)數(shù)減 1
    Py_DECREF(v);
    #line 1027 "Python/bytecodes.c"
    if (err) goto pop_1_error;
    #line 1418 "Python/generated_cases.c.h"
    // 棧收縮,v = stack_pointer[-1] 和 STACK_SHRINK(1)
    // 兩者組合起來(lái)就等價(jià)于 v = POP(),即彈出運(yùn)行時(shí)棧的棧頂元素
    STACK_SHRINK(1);
    DISPATCH();
}

執(zhí)行完之后,棧幀的布局就變成了下面這樣:

圖片圖片

此時(shí)運(yùn)行時(shí)棧為空,local 名字空間多了個(gè)鍵值對(duì)。

同理剩余的兩個(gè)賦值語(yǔ)句也是類(lèi)似的,只不過(guò)指令參數(shù)不同,比如 8 STORE_NAME 加載的是符號(hào)表中索引為 1 的符號(hào),12 STORE_NAME 加載的是符號(hào)表中索引為 2 的符號(hào),分別是 math 和 english。執(zhí)行完之后,棧楨的布局如下:

圖片圖片

然后 14 LOAD_NAME 和 16 LOAD_NAME 負(fù)責(zé)將符號(hào)表中索引為 0 和 1 的變量的值壓入運(yùn)行時(shí)棧:

TARGET(LOAD_NAME) {
    PyObject *v;
    #line 1236 "Python/bytecodes.c"
    // 獲取全局名字空間,對(duì)于模塊來(lái)說(shuō)
    // f->f_locals 和 f->f_globals 指向同一個(gè)字典
    PyObject *mod_or_class_dict = LOCALS();
    if (mod_or_class_dict == NULL) {
        _PyErr_SetString(tstate, PyExc_SystemError,
                         "no locals found");
        if (true) goto error;
    }
    // 從符號(hào)表 co_names 中加載索引為 oparg 的符號(hào)
    // 但是注意:全局變量是通過(guò)字典存儲(chǔ)的
    // 所以這里的 name 只是一個(gè)字符串罷了,比如 "chinese"
    // 然后還要再根據(jù)這個(gè)字符串從字典里面查找對(duì)應(yīng)的 value
    PyObject *name = GETITEM(frame->f_code->co_names, oparg);
    // 根據(jù) name 從字典中獲取 value
    if (PyDict_CheckExact(mod_or_class_dict)) {
        v = PyDict_GetItemWithError(mod_or_class_dict, name);
        if (v != NULL) {
            Py_INCREF(v);
        }
        else if (_PyErr_Occurred(tstate)) {
            goto error;
        }
    }
    
    // ...

    #line 1763 "Python/generated_cases.c.h"
    // 將變量的值壓入運(yùn)行時(shí)棧
    STACK_GROW(1);
    stack_pointer[-1] = v;
    DISPATCH();
}

上面兩條指令執(zhí)行完之后,棧幀的布局就變成了下面這樣:

圖片圖片

接下來(lái)執(zhí)行 18 BINARY_OP,它會(huì)將棧里的兩個(gè)元素彈出,然后執(zhí)行加法操作,最后再將結(jié)果入棧。

TARGET(BINARY_OP) {
    PREDICTED(BINARY_OP);
    // 獲取棧里的兩個(gè)元素
    PyObject *rhs = stack_pointer[-1];
    PyObject *lhs = stack_pointer[-2];
    // 計(jì)算結(jié)果
    PyObject *res;
    // ...
    // binary_ops[oparg] 會(huì)拿到對(duì)應(yīng)的函數(shù)指針(這里是加法)
    // 然后傳入 lhs 和 rhs 進(jìn)行計(jì)算
    res = binary_ops[oparg](lhs, rhs);
    #line 4663 "Python/generated_cases.c.h"
    // 減少引用計(jì)數(shù)
    Py_DECREF(lhs);
    Py_DECREF(rhs);
    #line 3384 "Python/bytecodes.c"
    if (res == NULL) goto pop_2_error;
    #line 4668 "Python/generated_cases.c.h"
    // 棧里面有兩個(gè)元素,應(yīng)該將它們彈出,然后再將 res 入棧
    // 但事實(shí)上只需彈出一個(gè),然后再用 res 將棧頂元素替換掉即可
    STACK_SHRINK(1);
    stack_pointer[-1] = res;
    next_instr += 1;
    DISPATCH();
}

BINARY_OP 指令執(zhí)行完之后,棧幀的布局就變成了下面這樣:

圖片圖片

然后 22 LOAD_NAME 負(fù)責(zé)將符號(hào)表中索引為 2 的變量 english 的值壓入運(yùn)行時(shí)棧;而指令 24 BINARY_OP 則是繼續(xù)執(zhí)行加法操作,并將結(jié)果設(shè)置在棧頂;然后 28 LOAD_CONST 將常量 3 再壓入運(yùn)行時(shí)棧;接著再執(zhí)行 30 BINARY_OP,此時(shí)對(duì)應(yīng)的是除法操作。

這四條指令執(zhí)行時(shí),運(yùn)行時(shí)棧變化如下:

圖片圖片

此時(shí)運(yùn)行時(shí)棧里面只剩下一個(gè)元素 93.0,然后 34 STORE_NAME 將棧頂元素 93.0 彈出,并將符號(hào)表中索引為 3 的符號(hào) avg 和它綁定起來(lái),放到名字空間中。因此最終棧幀關(guān)系圖如下:

圖片圖片

以上就是虛擬機(jī)對(duì)這幾行代碼的執(zhí)行流程,整個(gè)過(guò)程就像 CPU 執(zhí)行指令一樣。

我們?cè)儆?Python 代碼描述一遍上面的邏輯:

# LOAD_CONST 將 89 壓入棧中
# STORE_NAME 將 89 從棧中彈出
# 并將符號(hào) "chinese" 和 89 綁定起來(lái),放在名字空間中
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}

現(xiàn)在你是不是對(duì)虛擬機(jī)執(zhí)行字節(jié)碼有一個(gè)更深的了解了呢?當(dāng)然字節(jié)碼指令有很多,不止我們上面看到的那幾個(gè)。你可以隨便寫(xiě)一些代碼,然后分析一下它的字節(jié)碼指令是什么樣的。

小結(jié)

到此,我們就深入源碼,考察了虛擬機(jī)執(zhí)行字節(jié)碼的流程,幀評(píng)估函數(shù)雖然很長(zhǎng),也有那么一些復(fù)雜,但是核心邏輯不難理解。就是把自己當(dāng)成一顆 CPU,在棧幀中執(zhí)行指令。

責(zé)任編輯:武曉燕 來(lái)源: 古明地覺(jué)的編程教室
相關(guān)推薦

2011-07-27 12:58:43

Android MarAndroid應(yīng)用商店

2014-11-28 13:37:30

DCN無(wú)線(xiàn)

2019-03-22 10:20:39

加速Windows 10啟動(dòng)

2023-09-01 18:20:43

Chrome代碼測(cè)試版

2020-12-10 20:00:04

數(shù)字貨幣比特幣區(qū)塊鏈

2011-07-29 15:02:22

LifeSize視頻協(xié)作

2014-01-08 10:22:28

思科Videoscape

2015-10-19 14:57:51

2009-11-30 18:35:05

BizSparkDreamSparkWebSiteSpar

2009-08-26 14:48:05

C#委托與事件

2010-03-15 09:40:19

Windows 8研發(fā)

2024-05-10 15:09:34

2009-12-28 10:08:07

OracleSQLDevelope開(kāi)發(fā)框架

2012-04-30 21:35:08

Windows Pho

2020-09-22 10:49:12

大數(shù)據(jù)旅游技術(shù)

2009-03-31 11:12:59

萬(wàn)兆以太網(wǎng)

2013-06-17 11:53:49

思科云服務(wù)思科

2021-04-05 18:06:36

谷歌安卓Google Play

2009-12-13 15:23:36

2010-04-14 16:11:09

Oracle服務(wù)器
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)