我們的定時任務、非同步 MQ 的 jar 包程式等都會使用 System.in.read()
等阻塞程式,防止程式退出,在本地測試一直都沒有問題,直到有同學反饋,線上 Docker 環境中程式碼 System.in.read() 沒有阻塞,執行到了後面的程式,簡化過的程式碼如下所示。
public static void main(String[] args) throws IOException, InterruptedException {
System.out.println("enter main....");
// 啟動定時任務
startJobSchedule();
System.out.println("before system in read....");
System.in.read();
System.out.println("after system in read....");
}
複製程式碼
我瞄了一眼,覺得不可能,程式碼肯定會阻塞在 System.in.read()
,然後說如果輸出了 "after system in read....",我直播吃鞋。結果一試,確實 System.in.read();
退出了,執行了後續的語句,馬上鞋就端上來,嗯,真香。
通過閱讀這篇文章,你會了解到下面這些知識。
- 程式與檔案描述符 fd 的關係
- /dev/null 檔案的來龍去脈,讀取寫入的核心原始碼分析
- 重定向本質
- 管道概念初探
程式與檔案描述符 fd
接下來我們先來看看程式與檔案描述符 fd 之間的關係。一個程式啟動以後,除了會分配堆、棧空間以外,還會預設分配三個檔案描述符控制程式碼:0 號標準輸入(stdin)、1 號標準輸出(stdout)、2 號錯誤輸出(stderr),如下所示。
接下來了分析了一下開頭的案例,System.in.read() 實際上是從 fd 為 0 的 stdin 讀資料,我們將 System.in.read()
的返回值和讀到的內容列印出來,經過實驗,返回值為 -1,讀到了 EOF。這比較奇怪,為什麼去讀 stdin 會返回 EOF 呢?
接下來去看 fd 為 0 的 stdin 到底指向了什麼。在系統的 /proc/pid/fd 目錄儲存了程式所有開啟的檔案控制程式碼,使用 ls 檢視當前開啟的控制程式碼列表如下所示。
$ ls -l /proc/1/fd
total 0
lrwx------ 1 root root 64 4月 3 17:13 0 -> /dev/null
l-wx------ 1 root root 64 4月 3 17:13 1 -> pipe:[31508]
l-wx------ 1 root root 64 4月 3 17:13 2 -> pipe:[31509]
l-wx------ 1 root root 64 4月 3 17:13 3 -> /app/logs/gc.log
lr-x------ 1 root root 64 4月 3 17:13 4 -> /jdk8/jre/lib/rt.jar
lr-x------ 1 root root 64 4月 3 17:13 5 -> /app/system-in-read-1.0-SNAPSHOT.jar
複製程式碼
可以看到為 0 的 fd 指向了 /dev/null
。接下來看看 /dev/null
相關的知識。
/dev/null 檔案
/dev/null 檔案是什麼
/dev/null
是一個特殊的裝置檔案,所有接收到的資料都會被丟棄。有人把 /dev/null
比喻為 “黑洞”,比較形象恰當。
除了丟棄所有的寫入這個特性之外,從 /dev/null
讀資料會立即返回 EOF,這就是造成前面 System.in.read() 呼叫直接退出的原因。
使用 stat 檢視 /dev/null,輸出的結果如下。
$ stat /dev/null
File: ‘/dev/null’
Size: 0 Blocks: 0 IO Block: 4096 character special file
Device: 5h/5d Inode: 6069 Links: 1 Device type: 1,3
Access: (0666/crw-rw-rw-) Uid: ( 0/ root) Gid: ( 0/ root)
Context: system_u:object_r:null_device_t:s0
Access: 2020-03-27 19:27:37.857000000 +0800
Modify: 2020-03-27 19:27:37.857000000 +0800
Change: 2020-03-27 19:27:37.857000000 +0800
$ who -b
system boot 2020-03-27 19:27
複製程式碼
可以看到 /dev/null 檔案的大小為 0,建立、修改時間都與核心系統啟動時間一致。它並不是一個磁碟檔案,而是存在於記憶體中型別為 “character device file” 的檔案。
所有的往這個檔案的寫入的資料會被丟棄,write 呼叫會是始終返回成功,這個特殊的檔案不會被填滿,也不能更改它的檔案大小。
還有一個有趣的現象是使用 tail -f /dev/null 會永久阻塞,strace 命令輸出結果精簡如下所示。
$ strace tail -f /dev/null
open("/dev/null", O_RDONLY) = 3
read(3, "", 8192) = 0
inotify_init() = 4
inotify_add_watch(4, "/dev/null", IN_MODIFY|IN_ATTRIB|IN_DELETE_SELF|IN_MOVE_SELF) = 1
read(4,
複製程式碼
可以看到 tail -f 在執行過程中讀取 /dev/null 的 read 呼叫返回了 0,表明它讀取遇到了 EOF,隨後 tail 使用 inotify_init 系統呼叫建立了一個 inotify 例項,這個例項監聽了 /dev/null 檔案的 IN_MODIFY、IN_ATTRIB、IN_DELETE_SELF、IN_DELETE_SELF 事件。這四個事件的含義如下。
- IN_MODIFY:檔案被修改
- IN_ATTRIB:檔案後設資料修改
- IN_DELETE_SELF:監聽目錄/檔案被刪除
- IN_MOVE_SELF:監聽目錄/檔案被移動
隨後阻塞等待這些事件的發生,因為 /dev/null 不會發生這些事件,所以 tail 命令之後會一直阻塞。
從原始碼角度看 /dev/null
核心處理 /dev/null 的邏輯在 github.com/torvalds/li… ,往 /dev/null 寫入資料的程式碼在 write_null 函式,這個函式的原始碼如下所示。
static ssize_t write_null(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
return count;
}
複製程式碼
可以看到往 /dev/null 寫入資料,核心沒有做任何處理,只是返回了傳入的 count 值。
讀取的程式碼在 read_null 函式,這個函式的邏輯如下所示。
static ssize_t read_null(struct file *file, char __user *buf,
size_t count, loff_t *ppos)
{
return 0;
}
複製程式碼
可以看到,讀取 /dev/null 會立即返回 0,表示 EOF。
至此,/dev/null 相關知識就介紹到這裡。為什麼本機測試沒有出現問題?因為本機測試是用終端 terminal 去啟動 jar 包,這樣程式的 stdin 會被分配為鍵盤輸入,在不輸入字元的情況下,會始終阻塞。接下來我們來看看怎麼在本地復現這個問題。
檔案描述符與重定向
前面介紹的標準輸入、標準輸出、錯誤輸出在描述符中的位置不會變化,但是它們的指向是可以改變的,我們用到的重定向操作符 >
和 <
就是用來重定向資料流的。為了修改上面程式的標準輸入為 /dev/null
,只需要使用 <
重定向符即可。修改前面的程式碼,加上 sleep 不讓其退出。
public static void main(String[] args) throws IOException, InterruptedException {
System.out.println("enter main....");
byte[] buf = new byte[16];
System.out.println("before system in read....");
int length = System.in.read();
System.out.println("len: " + length + "\t" + new String(buf));
TimeUnit.DAYS.sleep(1);
}
複製程式碼
打包執行,輸出結果如下。
$ java -jar system-in-read-1.0-SNAPSHOT.jar < /dev/null
enter main....
before system in read....
len: -1
複製程式碼
可以看到出現了與線上 docker 環境一樣的現象,System.in.read() 沒有阻塞,返回了 -1。
檢視程式的 fd 列表如下所示:
$ ls -l /proc/482/fd
lr-x------. 1 ya ya 64 4月 3 20:00 0 -> /dev/null
lrwx------. 1 ya ya 64 4月 3 20:00 1 -> /dev/pts/6
lrwx------. 1 ya ya 64 4月 3 20:00 2 -> /dev/pts/6
lr-x------. 1 ya ya 64 4月 3 20:00 3 -> /usr/local/jdk/jre/lib/rt.jar
lr-x------. 1 ya ya 64 4月 3 20:00 4 -> /home/ya/system-in-read-1.0-SNAPSHOT.jar
複製程式碼
可以看到此時的標準輸入已經被替換為了 /dev/null
,System.in.read() 呼叫時讀取標準輸入會先來查這個檔案描述符列表,看 0 號描述符指向的是哪條資料流,再從這個資料流裡讀取資料。
上面的例子重定向了標準輸入,標準輸出和標準錯誤輸出也是可以用類似的方式重定向。
1>
或者>
重定向標準輸出2>
重定向標準錯誤輸出
或者可以組合使用:
java -jar system-in-read-1.0-SNAPSHOT.jar </dev/null > stdout.out 2> stderr.out
$ ls -l /proc/2629/fd
lr-x------. 1 ya ya 64 4月 3 20:35 0 -> /dev/null
l-wx------. 1 ya ya 64 4月 3 20:35 1 -> /home/ya/stdout.out
l-wx------. 1 ya ya 64 4月 3 20:35 2 -> /home/ya/stderr.out
複製程式碼
可以看到這次 fd 為 0、1、2 的檔案描述符都被替換了。
shell 指令碼中經常看到的 2>&1 是什麼意思
拆解來看,2>
表示重定向 stderr ,&1 表示 stdout,連起來的含義就是將標準錯誤輸出 stderr 改寫為標準輸出 stdout 相同的輸出方式。比如將標準輸出和標準錯誤輸出都重定向到檔案可以這麼寫。
cat foo.txt > output.txt 2>&1
複製程式碼
接下來繼續看檔案描述符與管道相關的概念。
管道
管道是一個單向的資料流,我們在命令列中經常會用到管道來連線兩條命令,以下面的命令為例。
nc -l 9090 | grep "hello" | wc -l
複製程式碼
執行上面的命令,實際上的執行過程如下
- 命令列建立的 zsh 程式
- zsh 程式啟動了 nc -l 9090 程式
- zsh 程式啟動了 grep 程式,同時將 nc 程式的標準輸出通過管道的方式連線到 grep 程式的標準輸入
- zsh 程式啟動了 wc 程式,同時將 grep 程式的標準輸出通過管道的方式連線到 wc 程式的標準輸入
他們的程式關係如下所示。
PID TTY STAT TIME COMMAND
23714 ? Ss 0:00 \_ sshd: ya [priv]
23717 ? S 0:00 | \_ sshd: ya@pts/5
23718 pts/5 Ss 0:00 | \_ -zsh
4812 pts/5 S+ 0:00 | \_ nc -l 9090
4813 pts/5 S+ 0:00 | \_ grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exc
4814 pts/5 S+ 0:00 | \_ wc -l
複製程式碼
檢視 nc 和 grep 兩個程式的檔案描述符列表如下。
$ ls -l /proc/pid_of_nc/fd
lrwx------. 1 ya ya 64 4月 3 21:22 0 -> /dev/pts/5
l-wx------. 1 ya ya 64 4月 3 21:22 1 -> pipe:[3852257]
lrwx------. 1 ya ya 64 4月 3 21:17 2 -> /dev/pts/5
$ ls -l /proc/pid_of_grep/fd
lr-x------. 1 ya ya 64 4月 3 21:22 0 -> pipe:[3852257]
l-wx------. 1 ya ya 64 4月 3 21:22 1 -> pipe:[3852259]
lrwx------. 1 ya ya 64 4月 3 21:17 2 -> /dev/pts/5
$ ls -l /proc/pid_of_wc/fd
lr-x------. 1 ya ya 64 4月 3 21:22 0 -> pipe:[3852259]
lrwx------. 1 ya ya 64 4月 3 21:22 1 -> /dev/pts/5
lrwx------. 1 ya ya 64 4月 3 21:17 2 -> /dev/pts/5
複製程式碼
關係如下圖所示。
在 linux 中,建立管道的函式是 pipe,常見的建立管道的方式如下所示。
int fd[2];
if (pipe(fd) < 0) {
printf("%s\n", "pipe error");
exit(1);
}
複製程式碼
pipe 函式建立了一個管道,同時返回了兩個檔案描述符,fd[0] 用來從管道讀資料,fd[1] 用來向管道寫資料,接下來我們來看一段程式碼,看下父子程式如何通過管道來進行通訊。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#define BUF_SIZE 20
int main() {
int fd[2];
if (pipe(fd) < 0) {
printf("%s\n", "pipe error");
exit(1);
}
int pid;
if ((pid = fork()) < 0) {
printf("%s\n", "fork error");
exit(1);
}
// child process
if (pid == 0) {
close(fd[0]); // 關閉子程式的讀
while (1) {
int n = write(fd[1], "hello from child\n", 18);
if (n < 0) {
printf("write eof\n");
exit(1);
}
sleep(1);
}
}
char buf[BUF_SIZE];
// parent process
if (pid > 0) {
close(fd[1]); // 關閉父程式的寫
while (1) {
int n = read(fd[0], buf, BUF_SIZE);
if (n <= 0) {
printf("read error\n");
exit(1);
}
printf("read from parent: %s", buf);
sleep(1);
}
}
return 0;
}
複製程式碼
執行上面的程式碼,就可以看到從子程式寫入的字串,在父程式中可以讀取並顯示在終端中了。
$ ./pipe_test
read from parent: hello from child
read from parent: hello from child
read from parent: hello from child
read from parent: hello from child
read from parent: hello from child
複製程式碼
docker 與 stdin
如果想讓 docker 程式的 stdin 變為鍵盤終端,可以用 -it 選項啟動 docker run。執行映象以後,重新檢視程式開啟的檔案描述符列表,可以看到 stdin、stdout、stderr 都已經發生了變化,如下所示。
$ docker exec -it 5fe22fbffe81 ls -l /proc/1/fd
total 0
lrwx------ 1 root root 64 4月 5 23:20 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月 5 23:20 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月 5 23:20 2 -> /dev/pts/0
複製程式碼
java 程式也阻塞在了 System.in.read() 呼叫上。
小結
這篇文章從一個小例子介紹了程式相關的三個基礎檔案描述符:stdin、stdout、stderr,以及這三個檔案描述符如何進行重定向。順帶介紹了一下管道相關的概念,好了,鞋吃飽了,睡覺。
有問題可以掃描下面的二維碼關注我的公眾號到聯絡我。