在容器打印日誌到控制檯阻塞的排障的時候看到一個觀點:
把日誌打印到控制檯要比打印到文件慢,而且是非常慢。
log4j2和logback的兩個issue官方也提到了這一點(見LOG4J2-2239、LOGBACK-1422)。
那麼爲何輸出到控制檯慢?有何辦法加速呢?問題要從三個角度來分別回答:
- linux的
stdout
角度 - Java程序角度
- docker容器角度
stdout
角度
寫到控制檯其實就是寫到stdout
,更嚴格的說應該是fd/1
。Linux操作系統將fd/0
、fd/1
和fd/2
分別對應stdin
、stdout
和stdout
。
那麼問題就變成爲何寫到stdout
慢,有何優化辦法?
造成stdout
慢的原因有兩個:
- 你使用的終端會拖累
stdout
的輸出效率 -
stdout
的緩衝機制
在SO的這個問題中:Why is printing to stdout so slow? Can it be sped up?,這回答提到打印到stdout慢是因爲終端的關係,換一個快速的終端就能提升。這解釋了第一個原因。
stdout
本身的緩衝機制是怎樣的?Stdout Buffering介紹了glibc對於stdout緩衝的做法:
- 當
stdout
指向的是終端的時候,那麼它的緩衝行爲是line-buffered
,意思是如果緩衝滿了或者遇到了newline字符,那麼就flush。 - 當
stdout
沒有指向終端的時候,那麼它的緩衝行爲是fully-buffered
,意思是隻有當緩衝滿了的時候,纔會flush。
其中緩衝區大小是4k。下面是一個總結的表格“
GNU libc (glibc) uses the following rules for buffering”:
Stream | Type | Behavior |
---|---|---|
stdin | input | line-buffered |
stdout (TTY) | output | line-buffered |
stdout (not a TTY) | output | fully-buffered |
stderr | output | unbuffered |
那也就是說當stdout
指向一個終端的時候,它採用的是line-buffered
策略,而終端的處理速度直接影響到了性能。
同時也給了我們另一個思路,不將stdout
指向終端,那麼就能夠用到fully-buffered
,比起line-buffered
能夠帶來更大提速效果(想想極端情況下每行只有一個字符)。
我寫了一段小代碼來做測試(gist)。先試一下stdout
指向終端的情況:
$ javac ConsolePrint.java
$ java ConsolePrint 100000
...
lines: 100,000
System.out.println: 1,270 ms
file: 72 ms
/dev/stdout: 1,153 ms
代碼測試了三種用法:
-
System.out.println
指的是使用System.out.println
所花費的時間 -
file
指的是用4k BufferedOutputStream 寫到一個文件所花費的時間 -
/dev/stdout
則是同樣適用4k BufferedOutputStream 直接寫到/dev/stdout
所花費的時間
發現寫到文件花費速度最快,用System.out.println
和寫到/dev/stdout
所花時間在一個數量級上。
如果我們將輸出重定向到文件:
$ java ConsolePrint 100000 > a
$ tail -n 5 a
...
System.out.println: 920 ms
file: 76 ms
/dev/stdout: 31 ms
則會發現/dev/stdout
速度提升到file
一個檔次,而System.out.println
並沒有提升多少。之前不是說stdout
不指向終端能夠帶來性能提升嗎,爲何System.out.println
沒有變化呢?這就要Java對於System.out
的實現說起了。
Java程序角度
下面是System
的源碼:
public final static PrintStream out = null;
...
private static void initializeSystemClass() {
FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
}
...
private static native void setOut0(PrintStream out);
...
private static PrintStream newPrintStream(FileOutputStream fos, String enc) {
...
return new PrintStream(new BufferedOutputStream(fos, 128), true);
}
可以看到System.out
是PrintStream
類型,下面是PrintStream
的源碼:
private void write(String s) {
try {
synchronized (this) {
ensureOpen();
textOut.write(s);
textOut.flushBuffer();
charOut.flushBuffer();
if (autoFlush && (s.indexOf('\n') >= 0))
out.flush();
}
} catch (InterruptedIOException x) {
Thread.currentThread().interrupt();
} catch (IOException x) {
trouble = true;
}
}
可以看到:
-
System.out
使用的緩衝大小僅爲128字節。大部分情況下夠用。 -
System.out
開啓了autoFlush,即每次write都會立即flush。這保證了輸出的及時性。 -
PrintStream
的所有方法加了同步塊。這避免了多線程打印內容重疊的問題。 -
PrintStream
如果遇到了newline符,也會立即flush(相當於line-buffered
)。同樣保證了輸出的及時性。
這解釋了爲何System.out
慢的原因,同時也告訴了我們就算把System.out
包到BufferedOutputStream裏也不會有性能提升。
Docker容器角度
那麼把測試代碼放到Docker容器內運行會怎樣呢?把gist裏的Dockerfile和ConsolePrint.java放到同一個目錄裏然後這樣運行:
$ docker build -t console-print .
$ docker run -d --name console-print console-print 100000
$ docker logs --tail 5 console-print
...
lines: 100,000
System.out.println: 2,563 ms
file: 27 ms
/dev/stdout: 2,685 ms
可以發現System.out.println
和/dev/stdout
的速度又變回一樣慢了。因此可以懷疑stdout
使用的是line-buffered
模式。
爲何容器內的stdout
不使用fully-buffered
模式呢?下面是我的兩個猜測:
- 不論你是
docker run -t
分配tty
啓動,還是docker run -d
不非配tty啓動,docker都會給容器內的stdout
分配一個tty
。 - 因爲docker的logging driver都是以“行”爲單位收集日誌的,那麼這個
tty
必須是line-buffered
。
雖然System.out.println
很慢,但是其吞吐量也能夠達到~40,000 lines/sec,對於大多數程序來說這不會造成瓶頸。