讓 Node.js 變“懶”的 COW 技術

語言: CN / TW / HK

COW 不是奶牛,是 Copy-On-Write 的縮寫,這是一種是複製但也不完全是複製的技術。

一般來說複製就是創建出完全相同的兩份,兩份是獨立的:

但是,有的時候複製這件事沒多大必要,完全可以複用之前的,這時候可以只是引用之前的那份,在寫內容的時候才去複製對應的一部分內容。這樣如果內容用於讀的話,就免去了複製,而如果需要寫,才會真正複製部分內容來做修改。

這就叫做“寫時複製”,也就是 Copy-On-Write。

原理很簡單,但是在作業系統的記憶體管理和檔案系統中卻很常見,Node.js 裡面也因為這種技術變“懶”了。

本文我們來探究下 Copy-On-Write 在 Node.js 的程序建立和檔案複製的應用:

檔案複製

檔案複製這件事最常見的思路就是完全寫一份相同的檔案內容到另一個位置,但是這樣有兩個問題:

  • 完全寫一份相同的內容,如果同樣的檔案複製了幾百次,那麼也建立相同的內容幾百次麼?太浪費硬碟空間了

  • 如果寫到一半斷電了怎麼辦?覆蓋的內容如何恢復?

怎麼辦呢?這時候作業系統設計者就想到了 COW 技術。

用 COW 技術實現檔案複製以後完美解決了上面兩個問題:

  • 複製只是新增一個引用到之前的內容,如果不修改並不會真正複製,只有到第一次修改內容的時候才去真正複製對應的資料塊,這樣就避免了大量硬碟空間的浪費。

  • 寫檔案時會先在另一個空閒磁碟塊做修改,等修改完之後才會複製到目標位置,這樣就不會有斷電無法回滾的問題

在 Node.js 的 fs.copyFile 的 api 就可以使用 Copy-On-Write 模式:

預設情況下,copyFile 會寫入目標檔案,覆蓋原內容

const fsPromises = require('fs').promises;

(async function() {
  try {
    await fsPromises.copyFile('source.txt', 'destination.txt');
  } catch(e) {
    console.log(e.message);
  }
})();

但是可以通過第三個引數指定複製的策略:

const fs = require('fs');
const fsPromises = fs.promises;
const { COPYFILE_EXCL, COPYFILE_FICLONE, COPYFILE_FICLONE_FORCE} = fs.constants;

(async function() {
  try {
    await fsPromises.copyFile('source.txt', 'destination.txt', COPYFILE_FICLONE);
  } catch(e) {
    console.log(e.message);
  }
})();

支援的 flag 有 3 個:

  • COPYFILE_EXCL: 如果目標檔案已存在,會報錯(預設是覆蓋)

  • COPYFILE_FICLONE: 以 copy-on-write 模式複製,如果作業系統不支援就轉為真正的複製(預設是直接複製)

  • COPYFILE_FICLONE_FORCE:以 copy-on-write 模式複製,如果作業系統不支援就報錯

這3個常量分別是 1,2,4,可以通過按位或把它們合併之後傳入:

const flags = COPYFILE_FICLONE | COPYFILE_EXCL;
fsPromises.copyFile('source.txt', 'destination.txt', flags);

Node.js 支援作業系統的 copy-on-write 技術,在一些場景下可以提升效能,建議使用 COPYFILE_FICLONE 的方式,會比預設的方式好一些。

程序建立

fork 是常見的建立程序的方式,而它的實現就是一種 copy-on-write 技術。

我們知道,程序在記憶體中分為程式碼段、資料段、堆疊段這 3 部分:

  • 程式碼段:存放要執行的程式碼

  • 資料段:存放一些全域性資料

  • 堆疊段:存放執行的狀態

如果基於該程序建立一個新的程序,那麼要複製這 3 部分記憶體。而如果這三部分記憶體是一樣的內容,那就浪費了記憶體空間。

所以 fork 並不會真正的複製記憶體,而是建立一個新的程序,引用父程序的記憶體,當做資料的修改的時候,才會真正複製該部分的記憶體。

這也是為什麼把程序建立叫做 fork,也就是分叉,因為不完全是獨立的,只是某部分做了分叉,成了兩份,但是大部分還是一樣的。

但如果要執行的程式碼不一樣怎麼辦呢,這時候就要用 exec 了,它會建立新的程式碼段、資料段、堆疊段、執行新的程式碼。

Node.js 裡面同樣可以用 fork 和 exec 的 api:

fork:

const cluster = require('cluster');

if (cluster.isMaster) {
  console.log('I am master');
  cluster.fork();
  cluster.fork();
} else if (cluster.isWorker) {
  console.log(`I am worker #${cluster.worker.id}`);
}

exec:

const { exec } = require('child_process');
exec('my.bat', (err, stdout, stderr) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(stdout);
});

fork 是 linux 程序建立的基礎,由此可見 copy-on-write 技術多麼重要了。

總結

複製同樣的內容多份無疑比較浪費空間,所以作業系統在做檔案複製、程序建立時的記憶體複製的時候都採用了 Copy-On-Write 技術,只有真正修改的時候才會去做複製。

Node.js 支援了 fs.copyFile 的 flags 的設定,可以指定 COPYFILE_FICLONE 來使用 Copy-On-Write 的方式做檔案複製,也建議大家使用這種方式來節省硬碟空間,提高檔案複製的效能。

程序的 fork 也是 Copy-On-Write 的實現,並不會直接複製程序的程式碼段、資料段、堆疊段到新的內容,而是引用之前的,只有在修改的時候才會做真正的記憶體複製。

除此以外,Copy-On-Write 在 Immutable 的實現,在分散式的讀寫分離等領域都有很多應用。

COW 讓 Node.js 變“懶”了,但效能卻更高了。