Node中console.log的同步實現

pagecao發表於2019-03-03

console.log相信使用過js的朋友都不會陌生,對於我這種前端轉過來的node開發者,用起這個函式更是毫不手軟,使用它把需要的資訊列印到標準輸出,覺得就是1+1=2那麼正常,但是有天在網上看到一個問題console.log到底是非同步還是同步?我覺得很詫異,這還是個問題麼?當然是同步啦。但是問題的答案出乎我的意料,上面告訴我是要分情況的,根據process.stdout的情況可能會出現非同步的情況。我當時眉頭一皺,才發現問題確實不是我想的那麼簡單,於是在Node的文件中發現了這一段提示:

Writes may be synchronous depending on what the stream is connected to and whether the system is Windows or POSIX:
	Files: synchronous on Windows and POSIX
	TTYs (Terminals): asynchronous on Windows, synchronous on POSIX
	Pipes (and sockets): synchronous on Windows, asynchronous on POSIX
複製程式碼

當我發現自己對這個知識存在盲區後,趕緊深入核心去看看到底是啥情況,我選擇了最常用的POSIX上的TTYs來深入理解。

從console.log出發

首先我在node原始檔中從lib/console.js找到了console.log的程式碼:

Console.prototype.log = function log(...args) {
  write(this._ignoreErrors,
        this._stdout,
        util.format.apply(null, args),
        this._stdoutErrorHandler,
        this[kGroupIndent]);
};
複製程式碼

這個中間包含了一些格式化字串之類的東西,不過其核心還是很明顯的就是write函式中的

stream.write(string, errorhandler);
複製程式碼

而stream就是this._stdout,從程式碼:

module.exports = new Console(process.stdout, process.stderr);
module.exports.Console = Console;
複製程式碼

中我們又可以知道,這個this._stout就是process.stdout,那上面的問題也解釋的通了,所以console.log到底是同步輸出還是非同步輸出還真得看情況了。

process.stdout的實現

現在我們將目光轉向process.stdout,對於這個屬性的定義在lib/internal/process/stdiso.js中,通過分析該檔案,我們可以發現stdout的stream是這樣定義的:

const tty_wrap = process.binding(`tty_wrap`);
switch (tty_wrap.guessHandleType(fd)) {
	case `TTY`:
		var tty = require(`tty`);
		stream = new tty.WriteStream(fd);
		stream._type = `tty`;
	break;

	case `FILE`:
		var fs = require(`internal/fs`);
		stream = new fs.SyncWriteStream(fd, { autoClose: false });
		stream._type = `fs`;
	break;

	case `PIPE`:
	case `TCP`:
		var net = require(`net`);
		stream = new net.Socket({
			fd: fd,
			readable: false,
			writable: true
		});
		stream._type = `pipe`;
	break;

	default:
	// Probably an error on in uv_guess_handle()
	throw new errors.Error(`ERR_UNKNOWN_STREAM_TYPE`);
}

// For supporting legacy API we put the FD here.
stream.fd = fd;

stream._isStdio = true;
複製程式碼

從上面就可以看出文件中的提示,file狀態使用了fs.SyncWriteStream自然是同步的,而PIPE是用net.Socket實現的,在Posix標準的機器上自然是非同步的。而讓我最困惑的是TTY的實現方式,其中的tty.WriteStream在lib/tty.js中是這樣實現的:

function WriteStream(fd) {
	...

	net.Socket.call(this, {
		handle: new TTY(fd, false),
		readable: false,
		writable: true
	});

	this._handle.setBlocking(true);

	...
}
inherits(WriteStream, net.Socket);
複製程式碼

可以看到TTY時的stream是繼承net.Socket的,連new的時候建構函式都是直接呼叫它的建構函式,只是handle是TTY物件的。剛剛才說過net.Socket不應該是非同步的嗎?到這兒來咋就成非同步的了呢?這個時候我就產生了一絲不解,想知道它是如何使TTY方式下的stdout變成同步的。於是翻起了原始碼,既然tty的stream是繼承net.Socket所以,而net.Socket物件是一個標準的node流物件,他直接繼承自stream.Duplex這個雙全工的流物件,所以我們可以直接到lib/_stream_writable.js中找到方法Writable.prototype.write,通過分析它的程式碼我們可以知道實際上呼叫的是Socket.prototype._writeGeneric函式,而在這個函式中會根據不同的字元型別選擇呼叫不同的stream方法:

switch (encoding) {
	case `latin1`:
	case `binary`:
		return handle.writeLatin1String(req, data);

	case `buffer`:
		return handle.writeBuffer(req, data);

	case `utf8`:
	case `utf-8`:
		return handle.writeUtf8String(req, data);

	case `ascii`:
		return handle.writeAsciiString(req, data);

	case `ucs2`:
	case `ucs-2`:
	case `utf16le`:
	case `utf-16le`:
		return handle.writeUcs2String(req, data);

	default:
		return handle.writeBuffer(req, Buffer.from(data, encoding));
}	
複製程式碼

而這個例子中的handle為TTY的例項,TTY是通過process.bingding得到的,所以這些方法是NODE_BUILTIN_MODULE的方法。上面的這些方法都是呼叫src/stream_base.cc中的模板函式

template <enum encoding enc>
int StreamBase::WriteString(const FunctionCallbackInfo<Value>& args)
複製程式碼

從這個函式中我們可以看到,雖然編碼不同會造成在生成stack_storage值時所用的處理方式不同,但是最後都是通過

err = DoWrite(
    req_wrap,
    &buf,
    1,
    reinterpret_cast<uv_stream_t*>(send_handle));
複製程式碼

操作來完成寫操作的,DoWrite是個純虛擬函式,這個函式的實際定義實在TTYWrap的基類,streamBase的派生類中定義的,在檔案src/stream_wrap.cc中定義,在其中使用了libuv的方法uv_write來執行io真正的寫操作。緊接著我又把目光轉向了檔案deps/uv/src/unix/stream.c中的這個方法,其中呼叫了uv_write2,這是libuv中一個很經典的非同步方法,但是也有特例從其中執行寫操作的實際函式uv__write中我們可以看到,如果當前的這個流設定了UV_STREAM_BLOCKING標記,則會一直同步寫完,並不會出現非同步操作。那我們的TTY是在哪兒設定的這個標記?我們可以回到lib/tty.js中的這句話:

net.Socket.call(this, {
	handle: new TTY(fd, false),
	readable: false,
	writable: true
});
複製程式碼

這裡TTY物件剛剛我們說了是node的內部物件,所以這裡實際會呼叫的是src/tty_wrap.cc中的void TTYWrap::New(const FunctionCallbackInfo<Value>& args)函式,其中通過:

TTYWrap* wrap = new TTYWrap(env, args.This(), fd, args[1]->IsTrue(), &err);
複製程式碼

生成TTYWrap的例項,而TTYWrap物件的建構函式中通過:

uv_tty_init(env->event_loop(), &handle_, fd, readable);
複製程式碼

初始化tty的libuv stream handle,從uv_tty_init的程式碼中可以知道,當readable引數為false時就會給handle設定UV_STREAM_BLOCKING標記,而readable引數是通過new TTY(fd, false)第二個引數傳入的,剛好是false,所以process.stdout自然是同步的咯。

總結

以前一直覺得自己對node已經很熟悉了,發現是net.Socket的流操作時,雖然有點困惑,但也是覺得可能handle是TTY的例項,寫操作會不一樣,但是在原始碼中一步步探索,最後發現還是通過libuv的uv_write2的時候,變得異常困惑,因為之前一直覺得它就是通過非同步來完成寫操作的,而忽略了設定UV_STREAM_BLOCKING的情況,最後是在通過在uv_tty_init和其他的流初始化中比較,發現了tty中出現了設定UV_STREAM_BLOCKING的情況,再回過頭去找,才發現了設定該標誌的寫操作是同步的情況。通過這件事還是明白了,很多東西不能想當然,得自己多探索瞭解才能在技術上面沉澱的更多,希望我的這篇文章也同時能幫助到大家。

相關文章