非同步知多少

不吃貓的魚發表於2017-06-21

前言

非同步相關的概念可以參考淺出js非同步事件。Javascript單執行緒的機制帶來的好處就是在程式碼執行時可以確保程式碼訪問的變數不會受到其它執行緒的干擾。試想如果當你遍歷一個陣列的時候,另外一個執行緒修改了這個陣列,那就亂了套了。setTimeout/setInterval, 瀏覽器端的ajax, Node裡的IO等的運用都是建立在正確的理解非同步(e.g. Event loop, Event queue)的基礎上。

非同步迴圈

假設我有一個含檔名的陣列,我想依次讀取檔案直到第一次成功讀取某檔案,返回檔案內容。也就是如果含檔名的陣列是['a.txt', 'b.txt'],那就先讀a.txt,如果成功返回a.txt內容。讀取失敗的話讀b.txt。依此類推。讀檔案的話Node分別提供了同步方法readFileSync跟非同步方法readFile

假設我們有2個檔案:a.txt(檔案內容也為a.txt)跟b.txt(檔案內容也為b.txt)。

同步的寫法比較簡單:

let fs = require('fs'),
    path = require('path');

function readOneSync(files) {
    for(let i = 0, len = files.length; i < len; i++) {
        try {
            return fs.readFileSync(path.join(__dirname, files[i]), 'utf8');
        } catch(e) {
            //ignore
        }
    }
    throw new Error('all fail');
}

console.log(readOneSync(['a.txt', 'b.txt'])); //a.txt
console.log(readOneSync(['filenotexist', 'b.txt'])); //b.txt複製程式碼

同步寫法最大的問題就是會阻塞事件佇列裡的其它事件處理。假設讀取的檔案非常大耗時久,會導致app在此期間無響應。非同步IO的話可以有效避免這個問題。但是需要在回撥裡處理呼叫的順序(i.e. 在上一個檔案讀取的回撥裡進行是否讀取下一個檔案的判斷和操作)。

let fs = require('fs'),
    path = require('path');

function readOne(files, cb) {
    function next(index) {
        let fileName = files[index];
        fs.readFile(path.join(__dirname, fileName), 'utf8', (err, data) => {
            if(err) {
                return next(index + 1);
            } else {
                return cb(data);
            }
        });
    }
    next(0);
}

readOne(['a.txt', 'b.txt'], console.log); //a.txt
readOne(['filenotexist', 'b.txt'], console.log); //b.txt複製程式碼

非同步的寫法需要傳一個回撥函式(i.e. cb)用來對返回結果進行操作。同時定義了一個方法next用來在讀取檔案失敗時遞迴呼叫自己(i.e. next)讀取下一個檔案。

同時發起多個非同步請求

假設現在我有一個含檔名的陣列,我想同時非同步讀取這些檔案。全部讀取成功時呼叫成功回撥。任意一個失敗的話呼叫失敗回撥。

let fs = require('fs'),
    path = require('path');

function readAllV1(files, onsuccess, onfail) {
    let result = [];
    files.forEach(file => {
        fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => {
            if(err) {
                onfail(err);
            } else {
                result.push(data);
                if(result.length === files.length) {
                    onsuccess(result);
                }
            }
        });
    });
}

readAllV1(['a.txt', 'b.txt'], console.log, console.log); //結果不確定性複製程式碼

這裡有個問題。因為讀取檔案的操作是同時非同步觸發的,取決於檔案的讀取時間,早讀完的檔案的handler會被先放入事件佇列裡。這會導致最後result陣列裡的內容跟files的檔名並非對應的。舉個例子, 假設files是['a.txt', 'b.txt'], a.txt是100M, b.txt是10kb, 2個同時非同步讀取,因為b.txt比較小所以先讀完了,這時候b.txt對應的readFile裡的回撥在事件佇列裡的順序會先於a.txt的。當讀取b.txt的回撥執行時,result.push(data)會把b.txt的內容先塞入result中。最後返回的result就會是[${b.txt的檔案內容}, ${a.txt的檔案內容}]。當對返回的結果有順序要求的時候,我們可以簡單的修改下:

let fs = require('fs'),
    path = require('path');

function readAllV2(files, onsuccess, onfail) {
    let result = [];
    files.forEach((file, index) => {
        fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => {
            if(err) {
                onfail(err);
            } else {
                result[index] = data;
                if(result.length === files.length) {
                    onsuccess(result);
                }
            }
        });
    });
}

readAllV2(['a.txt', 'b.txt'], console.log, console.log); //結果不確定性複製程式碼

看起來好像是木有問題了。但是!

let arr = [];
arr[1] = 'a';
console.log(arr.length); //2複製程式碼

按照readAllV2的實現,假設在a.txt還未讀完的時候,b.txt先讀完了,我們設了result[1] = data。這時候if(result.length === files.length)是true的,直接就呼叫了成功回撥。。所以我們不能依賴於result.length來做檢查。

let fs = require('fs'),
    path = require('path');

function readAllV3(files, onsuccess, onfail) {
    let result = [], counter = 0;
    files.forEach((file, index) => {
        fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => {
            if(err) {
                onfail(err);
            } else {
                result[index] = data;
                counter++;
                if(counter === files.length) {
                    onsuccess(result);
                }
            }
        });
    });
}

readAllV3(['a.txt', 'b.txt'], console.log, console.log); //[ 'a.txt', 'b.txt' ]複製程式碼

如果對Promise比較熟悉的話,Promise裡有個Promise.all實現的就是這個效果。

同步跟非同步回撥函式不要混用,儘量保持介面的一致性

假設我們實現一個帶快取的讀取檔案方法。當快取裡沒有的時候我們去非同步讀取檔案,有的話直接從快取裡面取。

 let fs = require('fs'),
    path = require('path'),
    cache = {};

function readWithCacheV1(file, onsuccess, onfail) {
    if(cache[file]) {
        onsuccess(cache[file]);
    } else {
       fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => {
           if(err) {
               onfail(err);
           } else {
               cache[file] = data;
               onsuccess(data);
           }
       });
    }
}複製程式碼

具體看下上面的實現:

  • 當快取裡有資料時,是同步進行呼叫了成功回撥onsuccess。
cache['a.txt'] = 'hello'; //mock一下快取裡的資料
readWithCacheV1('a.txt', console.log);//同步呼叫,要等呼叫完後才進入下一個statement
console.log('after you');

//輸出結果:
hello
after you複製程式碼
  • 當快取沒有資料時,是非同步呼叫。
readWithCacheV1('a.txt', console.log);//快取沒資料。非同步呼叫
console.log('after you');

//輸出結果:
after you
hello複製程式碼

這就造成了不一致性, 程式的執行順序不可預測容易導致bug車禍現場。要保持一致性的話可以統一採取非同步呼叫的形式,用setTimeout包裝下。

 let fs = require('fs'),
    path = require('path'),
    cache = {};

function readWithCacheV2(file, onsuccess, onfail) {
    if(cache[file]) {
        setTimeout(onsuccess.bind(null, cache[file]),0);
    } else {
       fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => {
           if(err) {
               onfail(err);
           } else {
               cache[file] = data;
               onsuccess(data);
           }
       });
    }
}複製程式碼

重新跑下有快取跟沒有快取2種情況:

  • 當快取裡有資料時,通過setTimeout非同步呼叫
    ```javascript
    cache['a.txt'] = 'hello';
    readWithCacheV2('a.txt', console.log);
    console.log('after you');

//輸出結果:
after you
hello


* 當快取沒有資料時,

```javascript
readWithCacheV2('a.txt', console.log);
console.log('after you');

//輸出結果:
after you
hello複製程式碼

Reference

Code

Notice

  • 如果您覺得該Repo讓您有所收穫,請「Star 」支援樓主。
  • 如果您想持續關注樓主的最新系列文章,請「Watch」訂閱

相關文章