说到 Linux 系统文件描述符,可能最先想到的就是 012, 分别是标准输入、标准输出、以及标准错误输出。其实这些只是我们在 SHELL中常用到的,而其实文件描述符不只是这样(e.g. xxxxx 2>&1)。下面我将具体介绍下文件描述符以及它的应用。

Linux文件类型

Linux 系统中包括七种文件类型,分别是普通文件、目录文件、块设备文件、字符设备文件(如:键盘)、符号连接文件、套接字文件、以及管道文件。

文件属性 文件类型 说明
- 普通文件
d 目录文件
b 块文件,如硬盘
c 字符设备文件 如键盘支持以 character 为单位进行线性访问
l 链接文件(包括软连接、硬链接) 硬链接就是同一个文件使用了多个别名,他们有共同的 inode
s 套接字文件 用于进程间通信
p 管道文件 命名管道和无名管道,进程间通信方式之一,管道通常用于从一个进程读取数据直接发送给第二个进程处理的场合。可以构建生产消费模型,多个进程写入,被其他的进程消费

查看文件类型的方式:

1
2
3
file xxxx
stat xxxx
ls -l

Linux 中的文件描述符 File Descriptor

上面一节介绍的7种文件类型,当它们被打开时,系统都会创建一个非负整数来代表打开的文件,这个整数就是文件描述符。它的存在可以使系统高效得操作文件。所有执行 I/O 操作的系统调用都通过文件描述符来实现。对于每个进程,进程内部都会维护一个类似数组的结构,里面存放该进程打开的文件对应的文件描述符,以及该文件的指针(可以这么理解,但系统中的实现会更复杂,系统维护了一个共通的打开文件表,用来详细记录文件的信息,包括 inode指针、文件偏移量、访问标识等)。每个进程的文件描述符都是从 0开始,即标准输入、标准输出(1)、错误输出(2),这之后每打开一个文件,文件描述符就加 1。所以,不同进程间的文件描述符是互不相干扰的,可以不同的文件描述符指向同一个文件,也可以相同的文件描述符指向不同的文件。

查看当前进程的文件描述符

1
2
3
4
5
6
ll /proc/$$/fd  # $$表示当前进程,fd文件夹下的数字就是该进程的文件描述符了
lrwx------ 1 root root 64 4月   5 00:14 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:15 255 -> /dev/pts/0
# /dev/pts/0 是虚拟终端控制台

查看某个进程打开文件的文件描述符

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#我们使用vim打开一个文件
vim /etc/hosts
#查看vim对应的pid
pidof vim #得到pid是29718
#查看
ll /proc/29718/fd
lrwx------ 1 root root 64 4月   5 00:20 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:20 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:16 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:20 4 -> /etc/.hosts.swp #这个就是我们打开的文件

我们知道 /proc 目录是存在于内存中的,它记录的是系统运行过程中的各种有用信息。所以,在 Linux 系统中,如果我们误操作,不小心删除了一个文件,只要打开那个文件的进程还没有退出,那么,我们就可以通过找到该进程中打开文件的文件描述符的方式,把我们误删除的文件恢复回来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
echo "test file descriptor" > test.txt

ll /proc/$$/fd
lrwx------ 1 root root 64 4月   5 00:14 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:15 255 -> /dev/pts/0

exec 4<> test.txt #打开了一个文件描述符

ll /proc/$$/fd
lrwx------ 1 root root 64 4月   5 00:14 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:15 255 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:37 4 -> /root/practice/test.txt  

echo "modified" >> /proc/$$/fd/4 #追加内容,修改了文件描述符指向的文件
cat test.txt
test file descriptor
modified

rm -f test.txt #删除文件,描述符并没有关闭,还在
ll /proc/$$/fd
lrwx------ 1 root root 64 4月   5 00:14 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:15 255 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:37 4 -> /root/practice/test.txt (deleted)

#恢复文件
cp /proc/$$/fd/4 test_recovery.txt
cat test_recovery.txt
test file descriptor
modified

#当然,完成了使命的文件描述符,可以被释放掉了
ll /proc/$$/fd
lrwx------ 1 root root 64 4月   5 00:14 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:15 255 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:37 4 -> /root/practice/test.txt (deleted)

exec 4<&- #释放文件描述符
ll /proc/$$/fd
lrwx------ 1 root root 64 4月   5 00:14 0 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 1 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:14 2 -> /dev/pts/0
lrwx------ 1 root root 64 4月   5 00:15 255 -> /dev/pts/0

文件描述符的应用

命名管道的使用

管道分为两种,一种是我们经常使用的无名管道(|),我们大部分在写 SHELL 时使用的就是它。它能解决在亲子进程、兄弟进程之间的通信问题;而另一种就是有名管道(named pipe),即 FIFO。从名字可以看出它设计的数据结构是先进先出,先写入的数据先被读出,它可以满足不同进程间的通信问题。为什么?因为它是文件,只要进程可以访问到该管道文件的目录,就都可以从该管道文件中读取、写入数据,从而实现了通信的目的。下面是一个小例子,

开启一个控制台A

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/bin/bash

tmp_fifo=/tmp/test.fifo
[ -p $tmp_fifo ]||mkfifo $tmp_fifo
exec 88<> $tmp_fifo
count=0
while :
do
                echo "added $count" >& 88 #这里用>&而不是>>
                echo "added $count"
                let count++
                sleep 2
done
exec 88<&-

开启另一个控制台B

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/bash

tmp_fifo=/tmp/test.fifo
exec 66<> $tem_fifo 

while :
do
        read line
        echo "$line"
        sleep 1
done <& 66

exec 66<&-

分别在两个客户端执行脚本,可以看到控制台B依次读到了控制台A写入的数据。这种方式就非常适合生产消费模型,可以根据该特性,设计出很多高效的通讯方案。

利用有名管道控制多进程并发数

管道在并发控制方面也有作用,比如我们管理着大量服务器,需要定期去判断哪些服务器掉线了,如果脚本如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/bash

for i in {1..254}
do
        {
                ip=192.168.1.${i}
                ping -c1 -W1 $ip &>/dev/null
                if [ $? -eq 0 ];then
                        echo "${ip} is up."
                else
                        echo "${ip} is down."
                fi

        }&

done

wait
echo "all finish..."

如果定时执行这样的并发操作,势必会对机器的性能带来一定的损耗,如果机器正在处理别的重要任务,势必是会带来影响的。因此就需要引入一种简单的并发控制机制,有名管道的特性正好可以用来做并发控制,降低同时并发进程的数量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/usr/bin/bash

count=5                    #并发数
tmp_fifofile=/tmp/$$.fifo  #管道文件路径

mkfifo ${tmp_fifofile}
exec 88<> ${tmp_fifofile}  #打开管道
rm -f ${tmp_fifofile}      #因为文件描述符已经打开,内存中已存在,可以删除原文件

for i in `seq ${count}`    #向管道中放$count个回车
do
        echo >& 88
done

for i in {1..254}
do
        read -u 88        #如果从管道中读到了东西就继续 
        {
                ip=192.168.1.${i}
                ping -c1 -W1 $ip &>/dev/null
                if [ $? -eq 0 ];then
                        echo "${ip} is up."
                else
                        echo "${ip} is down."
                fi
                echo >& 88  #如果执行完毕,就向管道中放回一个东西,此处是回车

        }&

done

wait
exec 88<&-                  #释放文件描述符
echo "all finish..."

这样在管道中始终存在固定数量个东西,供循环使用,就保证了最多同时并发固定数目的进程。

对于其他各种编程语言来说,各自都有进程间通信的解决方案(当然也可以使用有名管道),而对于 SHELL 脚本来说,简单的使用有名管道,不失为一种简单有效的处理方式。