初探Node.js Stream中Readable類的內部實現

寫在最前

本次試圖淺析探索Nodejs的Stream模塊中對於Readable類的一部分實現(可寫流也差不多)。其中會以可讀流兩種模式中的paused mode即暫停模式的表現形式來解讀源碼上的實現,爲什麼不分析flowing mode自然是因爲這個模式是我們常用的其原理相比暫停模式下相對簡單(其實是因爲筆者總是喜歡關注一些邊邊角角的東西,不按套路出牌=。=),同時核心方法都是一樣的,一通百通嘛,有興趣的童鞋可以自己看下完整源碼

歡迎關注我的博客,不定期更新中——

生產者消費者問題

首先先明確爲什麼Nodejs要實現一個stream,這就要清楚關於生產者消費者問題的概念。

生產者消費者問題(英語:Producer-consumer problem),也稱有限緩衝問題(英語:Bounded-buffer problem),是一個多線程同步問題的經典案例。該問題描述了共享固定大小緩衝區的兩個線程——即所謂的“生產者”和“消費者”——在實際運行時會發生的問題。生產者的主要作用是生成一定量的數據放到緩衝區中,然後重複此過程。與此同時,消費者也在緩衝區消耗這些數據。該問題的關鍵就是要保證生產者不會在緩衝區滿時加入數據,消費者也不會在緩衝區中空時消耗數據。

簡單來說就是內存問題。與前端不同,後端對於內存還是相當敏感的,比如讀取文件這種操作,如果文件很小就算了,但如果這個文件一個g呢?難道全讀出來?這肯定是不可取的。通過流的形式讀一部分寫一部分慢慢處理纔是一個可取的方式。PS:有關爲什麼使用stream歡迎大家百(谷)度(歌)一下。

實現一個可讀流

現在我們將自己實現一個可讀流,以此來方便觀察之後數據的流動過程:

const Readable = require('stream').Readable;
// 實現一個可讀流
class SubReadable extends Readable {
  constructor(dataSource, options) {
    super(options);
    this.dataSource = dataSource;
  }
  // 文檔提出必須通過_read方法調用push來實現對底層數據的讀取
  _read() {
    console.log('閾值規定大小:', arguments['0'] + ' bytes')
    const data = this.dataSource.makeData()
    let result = this.push(data)
    if(data) console.log('添加數據大小:', data.toString().length + ' bytes')
    console.log('已緩存數據大小: ', subReadable._readableState.length + ' bytes')
    console.log('超過閾值限制或數據推送完畢:', !result)
    console.log('====================================')
  }
}

// 模擬資源池
const dataSource = {
  data: new Array(1000000).fill('1'),
  // 每次讀取時推送一定量數據
  makeData() {
    if (!dataSource.data.length) return null;
    return dataSource.data.splice(dataSource.data.length - 5000).reduce((a,b) => a + '' + b)
  }
  //每次向緩存推5000字節數據
};

const subReadable = new SubReadable(dataSource);

至此subReadable便是我們實現的自定義可讀流。

Paused Mode 暫停模式都做了什麼?

先來看下整體的流程:
image.png
可讀流會通過_read()方式從資源讀取數據到緩存池,同時設置了一個閾值highWaterMark,標記數據到緩存池大小的一個上限,這個閾值是會浮動的,最小值也是默認值爲16384。當消費者監聽了readable事件之後,就可以顯式調用read()方法來讀取數據。

觸發暫停模式

通過註冊readable事件以此來觸發暫停模式:

subReadable.on('readable', () => {
    console.log('緩存剩餘數據大小: ', subReadable._readableState.length + ' byte')
    console.log('------------------------------------')
})

image.png
可以發現當註冊readable事件後可對流會從底層資源推送數據到緩存直到達到超過閾值或者底層數據全部加載完。

開始消費數據

調用read(n); n = 1000;

首先修改資源池大小data: new Array(10000).fill('1')(方便打印數據),執行read(1000)每次讀取1000字節資源讀取資源:

subReadable.on('readable', () => {
    let chunk = subReadable.read(1000)
    if(chunk) 
      console.log(`讀取 ${chunk.length} bytes數據`);
    console.log('緩存剩餘數據大小: ', subReadable._readableState.length + ' byte')
    console.log('------------------------------------')
})

image.png
結果執行了兩次讀取數據,同時如果每次讀取的字節少於緩存中的數據,則可讀流不會再從資源加載新的數據。

無參調用read()

subReadable.on('readable', () => {
    let chunk = subReadable.read()
    if(chunk) 
      console.log(`讀取 ${chunk.length} bytes數據`);
    console.log('緩存剩餘數據大小: ', subReadable._readableState.length + ' byte')
    console.log('------------------------------------')
})

image

直接調用read()後,會逐步讀取完全部資源,至於每次讀取多少下文會統一探討。

小結

以上我們依次嘗試了在實現可讀流後觸發暫停模式會發生的事情,接下來作者將會對以下幾個可能有疑問的點進行探究:
- 爲什麼自己實現的可讀流要實現_read()方法並在其中調用push()
- 觸發暫停模式後緩存池如何被蓄滿,以及爲何會直接執行一次回調
- 無參調用read()與傳入固定數據的區別

爲什麼自己實現的可讀流要實現_read()方法並在其中調用push()

Readable.prototype._read = function(n) {
  this.emit('error', new errors.Error('ERR_STREAM_READ_NOT_IMPLEMENTED'));
}; //只是定義接口
Readable.prototype.read = function(n) {
    ...
    var doRead = state.needReadable;
    if (doRead) {
        this._read(state.highWaterMark);
    }
}

當我們調用subReadable.read()便會執行到上面的代碼,可以發現,源碼中
對於_read()只是定義了一個接口,裏面並沒有具體實現,如果我們不自己定義那麼就會報錯。同時read()中會執行它通過它調用push()來從資源中讀取數據,並且傳入highWaterMark,這個值你可以用也可以不用因爲_read()是我們自己實現的。

Readable.prototype.push = function(chunk, encoding) {
  ...
  return readableAddChunk(this, chunk, encoding, false, skipChunkCheck);
};

從代碼中可以看出,將底層資源推送到緩存中的核心操作是通過push,通過語義化也可以看出push方法中最後會進行添加新數據的操作。由於之後方法中嵌套很多,不一一展示,直接來看最後調用的方法:

// readableAddChunk最後會調用addChunk
function addChunk(stream, state, chunk, addToFront) {
  ...
    state.buffer.push(chunk); //數據推送到buffer中
    if (state.needReadable)//判斷此屬性值來看是否觸發readable事件
      emitReadable(stream);
    maybeReadMore(stream, state);//可能會推送更多數據到緩存
}

我們可以看出,方法調用的最後確實執行了資源數據推送到緩存的操作。與此同時在會判斷needReadable屬性值來看是否觸發readable回調事件。而這也爲之後我們來分析爲什麼註冊了readable事件之後會執行一次回調埋下了伏筆。最後調用maybeReadMore()則是蓄滿緩存池的方法。

觸發暫停模式後緩存池如何被蓄滿

先來看下源碼裏是如何綁定的事件:

Readable.prototype.on = function(ev, fn) {
  if (ev === 'data') {
    ...
  } else if (ev === 'readable') {
    const state = this._readableState;
    state.needReadable = true;//設定屬性爲true,觸發readable回調
    ...
        process.nextTick(nReadingNextTick, this);
  }
};
function nReadingNextTick(self) {
  self.read(0);
}
//之後執行read(0) => _read() => push() => addChunk()
//        => maybeReadMore()

maybeReadMore()中當緩存池存儲大小小於閾值時則會一直調用read(0)不讀取數據,但是會一直push底層資源到緩存:

function maybeReadMore_(stream, state) {
...
  if (state.length < state.highWaterMark) {
    stream.read(0);
  }
}

綁定監聽事件後爲何會直接執行一次回調

上文提到過,綁定事件後會開始推送數據至緩存池,最後會執行到addChunk()方法,內部通過needReadable屬性來判斷是否觸發readable事件。當你第一次綁定事件時會執行state.needReadable = true;,從而在最後推送數據後會執行觸發readable的操作。

read()與傳入特定數值的區別

區別在執行read()方法的時候,會將參數n傳入到下面這個函數中由它來計算現在應該應該讀取多少數據:

function howMuchToRead(n, state) {
  if (n <= 0 || (state.length === 0 && state.ended))
    return 0;
  if (state.objectMode)
    return 1;
  if (n !== n) {
    // Only flow one buffer at a time
    if (state.flowing && state.length)
      return state.buffer.head.data.length;
    else
      return state.length;
  }
  // If we're asking for more than the current hwm, then raise the hwm.
  if (n > state.highWaterMark)
    state.highWaterMark = computeNewHighWaterMark(n);
  if (n <= state.length)
    return n;
  // Don't have enough
  if (!state.ended) { //傳輸沒有結束都是false
    state.needReadable = true;
    return 0;
  }
  return state.length;
}

當直接調用read(),n參數則爲NaN,當處於流動模式的時候n則爲buffer頭數據的長度,否則是整個緩存的數據長度。若爲read(n)傳入數字,大於當前的hwm時可以發現會重新計算一個hwm,與此同時如果已緩存的數據小於請求的數據量,那麼將設置state.needReadable = true;並返回0;

總結

第一次試圖梳理源碼的思路,一路寫下來發現有很多想說但是又不知道怎麼連貫的理清楚=。= 既然代碼細節也有些說不清,不過最後還是進行一個核心思路的提煉:

核心方法:

  • Readable.prototype.read()
  • Readable.prototype.push(); push中可能會執行emitReadable();

核心屬性:

  • this.needReadable:通過此屬性來決定是否觸發回調

核心思路:

  1. 推送數據至緩存與讀取緩存數據的操作均由read()控制(因爲read內部既實現了push也實現了howMuchToread(),不同的是前者爲read(0)即只推送不讀取;後者爲read()或read(n);
  2. 註冊readable事件後,執行read(0)資源就被push到緩存中,直到達到highWaterMark
  3. 期間會觸發回調函數,如果執行read()則相當於每次都會把緩存中的數據全部取出來,緩存中時刻沒有數據只能繼續從資源獲取直到數據全部取出並讀取完畢。若執行read(n)(n < highWaterMark),則只會取出2n的數據,同時緩存資源大於n時將會停止。因爲此時會認爲你每次只取n個數據,緩存中完全夠用,所以就不會再更新數據也就不會再觸發回調。

參考資料

最後

源碼的邊界情況比較多。作者如果哪裏說錯了請指正=。=

慣例po作者的博客,不定時更新中——
有問題歡迎在issues下交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章