先前的章節撰寫猜數字程式的時候,我們有先用到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擁有「非阻塞」(non-blocking)的操作,JavaScript的執行緒執行完JavaScript程式之後並不會立刻停止,而是會依照特定的順序去「輪詢」要執行的「事件」,一個事件的函數執行完之後,才會去執行下一個事件的函數,這樣的輪詢稱為「事件循環」。所謂的「事件」,即為發生了某件特定的事之後要執行的動作,這件要執行的動作是由函數來承載。

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

大致上來說,JavaScript執行環境會建立數種儲存空間,來負責儲存不同類型的事件。當一個事件發生時,它並不會「立刻」被JavaScript的執行緒處理,而是會根據該事件的類型,被JavaScript執行環境送進相對應的記憶體空間中「排隊」(queuing)。正在進行「事件循環」的JavaScript執行緒就會在特定的「階段」去分別檢查這些儲存空間中的事件,看是不是要將該事件拿出來,並執行該事件的函數。當所有事件都被處理完,且也已無會產生新事件的物件存在的話,這個JavaScript程式才會真正停止運行。

以Node.js來說明,其JavaScript執行緒在事件循環中,事件的處理可大致分為以下四個階段。

第一階段:計時器(timer)

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

舉例來說:

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

當JavaScript執行完以上這個程式敘述時,setTimeout函數會產生一個計時器事件,存到負責儲存計時器相關事件的儲存空間(雖然這個儲存空間實際上不是佇列,但為了方便說明,以下將其稱為計時器事件佇列)中,而傳進setTimeout函數第一個參數的匿名函數即為該事件要執行的函數,第二個參數則為這個計時器事件的延期時間(此處為1000毫秒,即1秒),所以這個計時器事件會有一個到期的時間點。當JavaScript執行緒進入計時器階段時,會記下進入此階段時的時間,然後會逐一檢查計時器事件佇列中的事件,如果發現此時檢查的計時器事件的到期時間超過進入計時器階段時記下的時間,就會把這個事件從計時器事件佇列中移出,然後去執行該事件的函數;如果還沒有到期,就會把該事件的到期時間改為進入計時器階段時記下的時間再加1(毫秒)。一旦計時器事件佇列中的事件都還沒有到期,或者計時器事件佇列已經空了,就會進入下一個階段。

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

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

當JavaScript執行完以上這個程式敘述時,setInterval函數會產生一個計時器事件,存到計時器事件佇列中,而傳進setInterval函數第一個參數的匿名函數即為該事件要執行的函數,第二個參數則為這個計時器事件的延期時間(此處為1秒),所以這個計時器事件就會有一個到期的時間點。當JavaScript執行緒進入計時器階段時,會記下進入此階段時的時間,然後會逐一檢查計時器事件佇列中的事件,如果發現此時檢查的計時器事件的到期時間超過進入計時器階段時記下的時間,就會把這個事件從計時器事件佇列中移出,然後去執行該事件的函數,接著再重新觸發一次這個計時器事件。邏輯有點像是以下這樣:

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

    setTimeout(f, 1000);
};

setTimeout(f, 1000);

如果計時器事件還沒有到期,就會把該事件的到期時間改為進入計時器階段時記下的時間再加1。一旦計時器事件佇列中的事件都還沒有到期,或者計時器事件佇列已經空了,就會進入下一個階段。

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

第二階段:I/O(pending callbacks, idle, prepare, poll)

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

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

import fs = require('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函數的第二個參數的回呼函數。事件被觸發後,該事件就會被儲存在負責儲存I/O相關事件的佇列(以下稱為I/O事件佇列)中。

當JavaScript執行緒進入事件循環的I/O階段時,就會去I/O事件佇列中取出裡面的事件,並執行其對應的函數。當I/O事件佇列中的事件都被處理過了,就會進入下一個階段。(有些事件會被延後到下一次事件循環迭代的I/O階段才會被處理。)

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

import fs = require('fs');

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

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

console.log('Hello');

fs模組提供的createReadStream函數可以用來建立一個檔案讀取串流物件,其提供的on方法可以用來註冊要監聽的事件,以及事件發生時要執行的回呼函數。/dev/random是類Unix系統下提供的檔案,可以用來產生亂數資料。

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

第三階段:immediate(check)

這個階段會去處理JavaScript內建的setImmediate函數所產生的事件,事件會儲存在immediate事件佇列中。當immediate事件佇列空了,JavaScript執行緒就會進入下一個階段。

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

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

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

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

例如:

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

    setImmediate(() => {
        console.log('immediate');
    });
}, 0);

以上程式,immediate文字永遠在timeout文字前被輸出。

第四階段:關閉回呼(close callbacks)

事件循環的第二階段,也就是I/O階段,並不會處理所有I/O關閉的事件,像是Socket相關的「突發」關閉事件就會在這個階段才會被處理。

例如:

import net = require('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文字。

順帶一提,Socket I/O的「正常」關閉事件會用process.nextTick函數來產生,所以I/O關閉事件不一定只會在第二階段或是第四階段才被處理。

回呼(callback)函數

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

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

import fs = require('fs');
import crypto = require('crypto');

const INPUT_PATH = 'input';
const OUTPUT_PATH = 'output';

let crypt = crypto.createHash('md5');

let reader = fs.createReadStream(INPUT_PATH);
let 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('fs');
import path = require('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;
        }

        let 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('fs');
import path = require('path');

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

try {

    let stats = fs.statSync(INPUT_PATH);

    console.log(stats);

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

    let 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把非同步程式寫成同步的樣子