先前的章節撰寫猜數字程式的時候,我們有先用到Promise物件、asyncawait關鍵字,如果您不太會JavaScript的話,應該會對這些東西非常不熟悉。這個章節將會開始介紹這些東西的用法與演進關係。



首先,也許您已經聽過JavaScript是單執行緒的程式語言,即便我們使用async關鍵字來實作非同步函數,JavaScript也還是只會用單執行緒來執行。

舉例來說:

async function infiniteLoop() {
    while (1) {
    }
}

infiniteLoop();

console.log("Hello!");

以上程式,我們實作了一個infiniteLoop非同步函數,它的主體就是一個無窮迴圈。然後在程式第6行去呼叫這個infiniteLoop函數,並在其之後嘗試輸出Hello!文字。

然而當我們實際運行程式就會發現,Hello!文字永遠不會出現!這是因為呼叫非同步函數並不等於讓JavaScript開啟一個新的執行緒來執行它,在JavaScript中從頭到尾都會使用同一個執行緒來執行程式。

那麼所謂的「非同步」函數到底是什麼意思,它就跟沒有async關鍵字的「同步」函數一樣不是嗎?要解釋這個就得從回呼(callback)函數開始講起,同時也要對JavaScript的「事件循環」(Event Loop)機制有粗略地了解。

事件循環(Event Loop)

事件循環是一個使JavaScript得以實現非阻塞I/O操作的機制。JavaScript會將計時器、I/O操作交由系統核心(system kernel)來處理,使JavaScript唯一的執行緒空閒下來得以執行其它的JavaScript程式。

由於大部分的核心是多執行緒的,所以它可以在背景同時處理多個不同的操作。當核心將一個操作處理完之後,就會替JavaScript產生事件,JavaScript則會依照特定的順序去「輪詢」要執行的「事件」,一個事件的函數執行完之後,才會去執行下一個事件的函數,這樣的輪詢就是「事件循環」。

不同JavaScript執行環境對於事件循環會有不一樣的實作方式。

以Node.js來說,它在啟動的時候,會初始化事件循環,在初始化的過程中會處理使用者提供的JavaScript程式。執行這份JavaScript程式的過程中可能會產生一些要交由系統核心來處理的操作。

在這份JavaScript程式執行完後,Node.js的行程並不會立刻結束執行,而是會進入事件循環,開始輪詢系統核心送來的事件。

事件循環大致上可以分為六個階段,每個階段都有一個先進先出(FIFO)的佇列,來負責儲存不同階段中各自的事件。當一個階段的佇列中的事件全被處理完或者處理完一定數量的事件之後,事件循環就會進入下一個階段。

以下分別介紹這六個階段。

第一階段:計時器(timer)

JavaScript內建的setTimeoutsetInterval函數可以用來產生計時器相關的事件。

舉例來說:

setTimeout(() => {
    console.log("Hello");
}, 1000);

當JavaScript執行完以上這個程式敘述時,setTimeout函數會產生一個計時器事件,存到計時器階段的佇列中。而傳進setTimeout函數第一個參數的匿名函數即為該事件要執行的函數,第二個參數則為這個計時器事件的延期時間(此處為1000毫秒,即1秒)。當事件循環在輪詢的時候,會去看這個計時器事件是否已經到期,到期的話就會立刻執行它,並將它從佇列中移出。

setInterval函數的用法和setTimeout一樣,如下:

setInterval(() => {
    console.log("Hello");
}, 1000);

當JavaScript執行完以上這個程式敘述時,setInterval函數會產生一個計時器事件,存到計時器階段的佇列中。而傳進setInterval函數第一個參數的匿名函數即為該事件要執行的函數,第二個參數則為這個計時器事件的延期時間(此處為1000毫秒,即1秒)。當事件循環在輪詢的時候,會去看這個計時器事件是否已經到期,到期的話就會立刻執行它,並再重新觸發一次這個計時器事件。邏輯有點像是以下這樣:

const f = () => {
    console.log("Hello");

    setTimeout(f, 1000);
};

setTimeout(f, 1000);

雖然setTimeoutsetInterval函數看起來是在控制JavaScript在幾毫秒之後要去執行某個函數,但JavaScript其實並沒有辦法保證這些函數一定會剛好在指定的時間之後被執行。

當佇列中的計時器事件都被檢查或是處理過了,就會進入下一個階段。

第二階段:pending callbacks

Node.js提供的檔案系統或是網路等I/O相關的功能,許多是非同步的API。為了能夠讓JavaScript知道這些非同步的API的運行狀況和結果,我們可以向Node.js內部註冊回呼函數(callback),作為事件發生時要執行的函數。所謂的「回呼函數」就是專門提供給特定程式在運行時能夠傳入參數並呼叫的函數。

許多非同步的API只會有一種事件,即「執行完畢」的事件。例如刪除檔案的API,在嘗試刪除檔案後,就會觸發該API「執行完畢」的事件,將執行結果(包含錯誤訊息)作為回呼函數的參數並執行它。舉例來說,以下程式可以刪除一個檔案,並在主控台上輸出Hello文字:

import fs = require("node:fs");

fs.unlink(path, (err) => {
    if (err) {
        console.error(err);
    }
});

console.log("Hello");

利用fs模組提供的unlink函數,可以將第一個參數傳入的檔案路徑對應的檔案刪除。由於刪除檔案的動作可能會因為程式沒有修改檔案系統的權限或是檔案根本就不存在而操作失敗,所以它可能會回傳一個錯誤訊息。以上程式,JavaScript執行緒在執行fs模組的unlink函數之後,刪除檔案的工作會交由Node.js內部和作業系統來處理,JavaScript不需等到檔案刪除的動作做完,即可先繼續執行之後的程式(也就是在主控台上輸出Hello文字)。

刪除檔案的結果,會由Node.js內部的程式來監聽,一旦刪除檔案的動作執行完畢,這個「執行完畢」的事件就會被觸發,事件的函數就是傳進unlink函數的第二個參數的回呼函數。事件被觸發後,該事件就會被儲存在這個階段的佇列中。

第三階段:idle, prepare

Node.js內部所使用的階段。

第四階段:poll

這個階段有兩個主要的功能:

  1. 檢查其它階段的佇列,看要讓Node.js在這個階段待多久。
  2. 處理在poll佇列中的事件。

進入這個階段時若沒有計時器的事件,如果這個階段的佇列中有要處理的事件,Node.js就會依序將它們處理完,或者直到一定數量(數量有多少是系統相依的)的事件被處理完就會直接進入下一次的事件循環迭代;如果poll佇列中沒有要處理的事件,且下一個階段──check階段──有要處理的事件的話,事件循環就會進入下一個階段;如果poll佇列中沒有要處理的事件,且check階段的佇列也沒有事件要處理的話,事件循環就會停在這個階段,會立即對進入這個階段的事件做出反應。

進入這個階段時若有計時器的事件,在處理完這個階段的佇列中的所有事件後,會去檢查計時器佇列的是否有到期的事件,如果有的話,事件循環就會回到第一階段。

有些非同步的API會有多種事件,而且可能會重複被觸發。例如讀取檔案的串流API,在向Node.js內部註冊data事件的回呼函數之後,Node.js內部就會開始讀取檔案,檔案內容並不會一次全被讀進記憶體中,而是會每次在讀取一個片段之後,去觸發data事件,將該段讀取到的資料代進回呼函數,然後再繼續讀取檔案。舉例來說:

import fs = require("node:fs");

const file = fs.createReadStream("/dev/random");

file.on("data", (chunk) => {
    console.log(chunk);
});

console.log("Hello");

fs模組提供的createReadStream函數可以用來建立一個檔案讀取串流物件,其提供的on方法可以用來註冊要監聽的事件,以及事件發生時要執行的回呼函數,透過這樣的方式監聽到的事件就會儲存到這個階段的佇列中。/dev/random是Unix-like系統下提供的檔案,可以用來產生亂數資料。

以上程式,會先在主控台輸出Hello文字,並在每次Unix-like系統產生一段亂數資料後,去觸發data事件,將隨機的資料輸出到主控台上。

第五階段:check

setImmediate函數所產生的事件可以保證在前一個階段,也就是poll階段的處理告一段落之後被處理。換句話說,我們可以把比較不重要的工作交由setImmediate函數來執行。

需要注意到的地方是,雖然理論上位於第五階段才被處理的immediate事件一定會比位於第一階段的計時器事件還要來得晚,但實際執行以下程式,會發現timeout文字和immediate文字的輸出順序並不固定:

setTimeout(() => {
    console.log("timeout");
}, 0);

setImmediate(() => {
    console.log("immediate");
});

這是因為0秒計時器事件當初的建立時間必須要至少與當前時間相差1毫秒,才可以被判定為到期。如果CPU執行得很快,在第一次事件循環迭代的時候,因計時器事件還沒有到期,就會先處理immediate事件。如果JavaScript執行緒已經進入事件循環,才去呼叫setImmediate函數和0秒的setTimeout函數的話,那麼setImmediate函數一定會比0秒的setTimeout函數還要早被執行,除非setImmediate函數是在接下來要介紹的第六階段時才被呼叫。

第六階段:close callbacks

事件循環的第四階段,也就是poll階段,並不會處理I/O關閉的事件。正常的關閉事件是由等等會提到的process.nextTick函數來觸發,而像是Socket相關的「突發」關閉事件則會在這個階段才會被處理。

例如:

import net = require("node:net");

const client = new net.Socket();

client.connect(443, "magiclen.org", function () {
    console.log("Connected");
    client.destroy();
});

client.on("close", function () {
    console.log("The close callback is fired.");
});

以上程式,引用了Node.js提供的net模組,來建立一個Socket客戶端與本站連線。Socket連線建立後,當JavaScript執行緒運行到事件循環的第二階段時,就會輸出Connected文字到主控台上,並且呼叫Socket客戶端物件的destroy函數使這個Socket連線突然被關閉,來觸發close事件,將這個事件儲存到這個階段的佇列中。當JavaScript執行緒運行到事件循環的第六階段時,就會處理這個階段的佇列中的所有事件,此時會向主控台輸出The close callback is fired.文字。

當這個階段的佇列空了,JavaScript執行緒就會進入下一個事件循環的迭代。

微任務(Micro Task)和process.nextTick函數

JavaScript的執行緒在進入一個事件循環階段時,如果該階段的佇列是空的,或者是每執行完一個事件函數之後,就會去檢查當前有沒有正在等待中的「微任務」,如果有的話就會先將它們依序執行完。(實際情況會比這邊敘述的流程複雜許多,我們只需理解成這樣就好了。微任務的產生也可以被視為是一個事件的產生。)我們之前用過的Promise物件,就是用來產生微任務的物件。

在這裡,我們還要介紹Node.js提供的另外一個東西,那就是process.nextTick函數。這個函數的用法如下:

process.nextTick(() => {
    console.log("Hello");
});

process.nextTick函數也可以用來新增特殊任務,這類任務的執行時機類似JavaScript引擎的微任務,但它們會在微任務之前執行。

以上程式,會建立一個要用來輸出Hello文字的任務。這個任務的函數(即傳進process.nextTick函數的第一個參數的函數)會在JavaScript執行緒進入事件循環的時候被執行,然後就會輸出Hello文字。

回呼(callback)函數

藉由JavaScript的事件循環機制,以及JavaScript的執行環境內部提供的額外執行緒,使我們可以在單執行緒的JavaScript程式中使用JavaScript環境提供的非同步功能。透過回呼函數,我們可以控制JavaScript在發生某個事件的時候去執行某個特定的函數。

舉例來說,如果我們要讀取一個檔案,計算其MD5校驗碼(checksum)並同時將其寫入到另一個檔案,比較完整的程式可以這樣寫:

import fs = require("node:fs");
import crypto = require("node:crypto");

const INPUT_PATH = "input";
const OUTPUT_PATH = "output";

const crypt = crypto.createHash("md5");

const reader = fs.createReadStream(INPUT_PATH);
const writer = fs.createWriteStream(OUTPUT_PATH);

reader.on("data", (chunk) => {
    crypt.update(chunk);
    writer.write(chunk);
});

reader.on("error", (err) => {
    if (err) {
        console.error(err);
    }
});

reader.on("close", () => {
    writer.close();
    console.log(`The MD5 checksum is ${crypt.digest().toString("hex")}`);
});

writer.on("close", () => {
    console.log("Done!");
});

writer.on("error", (err) => {
    if (err) {
        console.error(err);
    }
});

以上程式,利用Node.js內建的crypto模組來計算MD5雜湊值,並在檔案讀取完畢時,將雜湊值(雜湊值會用Node.js內建的Buffer物件來表示)轉成16進制字串。fs模組的createWriteStream函數可以建立一個寫入串流物件。

對於用onaddEventListener(用於網頁DOM)等方法來監聽某特定名稱的事件,回呼函數相當好用。但如果是要呼叫只會有一種「執行完畢」事件的API,用回呼函數的方式寫起來就會很可怕了。

好比我們想要取得一個檔案的資訊(檔案大小等),並創立一個目錄,將這個檔案複製進新的目錄下,然後再把原本的檔案刪除。程式撰寫如下:

import fs = require("node:fs");
import path = require("node:path");

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

fs.stat(INPUT_PATH, (err, stats) => {
    if (err) {
        console.error(err);
        return;
    }

    console.log(stats);

    fs.mkdir(OUTPUT_FOLDER, { recursive: true }, (err) => {
        if (err) {
            console.error(err);
            return;
        }

        const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);

        fs.copyFile(INPUT_PATH, outputPath, (err) => {
            if (err) {
                console.error(err);
                return;
            }

            fs.unlink(INPUT_PATH, (err) => {
                if (err) {
                    console.error(err);
                }

                console.log("Done!");
            });
        });
    });
});

以上程式,應用了Node.js內建的path模組提供的join函數來安全地串接路徑。

有沒有開始覺得回呼函數程式寫到後來會愈寫愈多層,且愈看愈噁心了?

還好,Node.js也有提供同步的API,可以有效改善上面這樣難以閱讀的程式碼。套用如下:

import fs = require("node:fs");
import path = require("node:path");

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

try {
    const stats = fs.statSync(INPUT_PATH);

    console.log(stats);

    fs.mkdirSync(OUTPUT_FOLDER, { recursive: true });

    const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);

    fs.copyFileSync(INPUT_PATH, outputPath);

    fs.unlinkSync(INPUT_PATH);

    console.log("Done!");
} catch (err) {
    console.error(err);
}

只不過用了同步API,就代表JavaScript的執行緒在執行這些程式的時候,完全無法利用事件循環機制去執行其它地方的程式碼。如果我們的JavaScript程式只是指令工具或是自動排程用途倒還好,但如果是要拿來跑Web伺服器等服務,或是要用在網頁瀏覽器上,那可就不妙了。在處理一項工作的時候下一個工作會完全進不來,服務或是網頁就會像是「凍結」了一樣。

所以儘管會讓我們的JavaScript變得更加複雜,我們在該用非同步API的時候還是得去用它。還好隨著JavaScript版本的演進,我們也能寫出「看起來同步」的程式語法來使用非同步API。之後的章節會介紹JavaScript後來發展出來的Promise和「async/await」語法。

下一章:用Promise把非同步程式寫成同步的樣子