讓 Node.js 變“懶”的 COW 技術
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 變“懶”了,但性能卻更高了。