關於 /dev/null 差點直播吃鞋的一個小問題

挖坑的張師傅發表於2020-04-07

我們的定時任務、非同步 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(); 退出了,執行了後續的語句,馬上鞋就端上來,嗯,真香。

關於 /dev/null 差點直播吃鞋的一個小問題

通過閱讀這篇文章,你會了解到下面這些知識。

  • 程式與檔案描述符 fd 的關係
  • /dev/null 檔案的來龍去脈,讀取寫入的核心原始碼分析
  • 重定向本質
  • 管道概念初探

程式與檔案描述符 fd

接下來我們先來看看程式與檔案描述符 fd 之間的關係。一個程式啟動以後,除了會分配堆、棧空間以外,還會預設分配三個檔案描述符控制程式碼:0 號標準輸入(stdin)、1 號標準輸出(stdout)、2 號錯誤輸出(stderr),如下所示。

關於 /dev/null 差點直播吃鞋的一個小問題

接下來了分析了一下開頭的案例,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 差點直播吃鞋的一個小問題

除了丟棄所有的寫入這個特性之外,從 /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 號描述符指向的是哪條資料流,再從這個資料流裡讀取資料。

關於 /dev/null 差點直播吃鞋的一個小問題

上面的例子重定向了標準輸入,標準輸出和標準錯誤輸出也是可以用類似的方式重定向。

  • 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
複製程式碼

關係如下圖所示。

關於 /dev/null 差點直播吃鞋的一個小問題

在 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,以及這三個檔案描述符如何進行重定向。順帶介紹了一下管道相關的概念,好了,鞋吃飽了,睡覺。

有問題可以掃描下面的二維碼關注我的公眾號到聯絡我。

關於 /dev/null 差點直播吃鞋的一個小問題

相關文章