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
的情況,再回過頭去找,才發現了設定該標誌的寫操作是同步的情況。通過這件事還是明白了,很多東西不能想當然,得自己多探索瞭解才能在技術上面沉澱的更多,希望我的這篇文章也同時能幫助到大家。