技术干货,第一时间推送
我们的定时任务、异步 MQ 的 jar 包程序等都会使用 System.in.read() 等阻塞程序,防止程序退出,在本地测试一直都没有问题,直到有同学反馈,线上 Docker 环境中代码 System.in.read() 没有阻塞,执行到了后面的程序,简化过的代码如下所示。
publicstaticvoidmain(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/fdtotal 0lrwx------ 1 root root 64 4月 3 17:13 0 -> /dev/nulll-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.loglr-x------ 1 root root 64 4月 3 17:13 4 -> /jdk8/jre/lib/rt.jarlr-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 fileDevice: 5h/5d Inode: 6069 Links: 1 Device type: 1,3Access: (0666/crw-rw-rw-) Uid: ( 0/ root) Gid: ( 0/ root)Context: system_u:object_r:null_device_t:s0Access: 2020-03-27 19:27:37.857000000 +0800Modify: 2020-03-27 19:27:37.857000000 +0800Change: 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/nullopen("/dev/null", O_RDONLY) = 3read(3, "", 8192) = 0inotify_init() = 4inotify_add_watch(4, "/dev/null", IN_MODIFY|IN_ATTRIB|IN_DELETE_SELF|IN_MOVE_SELF) = 1read(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 的逻辑在 https://github.com/torvalds/linux/blob/master/drivers/char/mem.c ,往 /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/nullenter main....before system in read....len: -1可以看到出现了与线上 docker 环境一样的现象,System.in.read() 没有阻塞,返回了 -1。
查看进程的 fd 列表如下所示:
$ ls -l /proc/482/fdlr-x------. 1 ya ya 64 4月 3 20:00 0 -> /dev/nulllrwx------. 1 ya ya 64 4月 3 20:00 1 -> /dev/pts/6lrwx------. 1 ya ya 64 4月 3 20:00 2 -> /dev/pts/6lr-x------. 1 ya ya 64 4月 3 20:00 3 -> /usr/local/jdk/jre/lib/rt.jarlr-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/fdlr-x------. 1 ya ya 64 4月 3 20:35 0 -> /dev/nulll-wx------. 1 ya ya 64 4月 3 20:35 1 -> /home/ya/stdout.outl-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 COMMAND23714 ? Ss 0:00 \_ sshd: ya [priv]23717 ? S 0:00 | \_ sshd: ya@pts/523718 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/fdlrwx------. 1 ya ya 64 4月 3 21:22 0 -> /dev/pts/5l-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/fdlr-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/fdlr-x------. 1 ya ya 64 4月 3 21:22 0 -> pipe:[3852259]lrwx------. 1 ya ya 64 4月 3 21:22 1 -> /dev/pts/5lrwx------. 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 20int 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_testread from parent: hello from childread from parent: hello from childread from parent: hello from childread from parent: hello from childread from parent: hello from childdocker 与 stdin
如果想让 docker 进程的 stdin 变为键盘终端,可以用 -it 选项启动 docker run。运行镜像以后,重新查看进程打开的文件描述符列表,可以看到 stdin、stdout、stderr 都已经发生了变化,如下所示。
$ docker exec -it 5fe22fbffe81 ls -l /proc/1/fdtotal 0lrwx------ 1 root root 64 4月 5 23:20 0 -> /dev/pts/0lrwx------ 1 root root 64 4月 5 23:20 1 -> /dev/pts/0lrwx------ 1 root root 64 4月 5 23:20 2 -> /dev/pts/0java 进程也阻塞在了 System.in.read() 调用上。
小结
这篇文章从一个小例子介绍了进程相关的三个基础文件描述符:stdin、stdout、stderr,以及这三个文件描述符如何进行重定向。顺带介绍了一下管道相关的概念,好了,鞋吃饱了,睡觉。
-解读源码-
知其然并知其所以然
举报/反馈

程序员面试经验分享

41获赞 132粉丝
给您最专业最快速的提升通道!
关注
0
0
收藏
分享