前言
在现代技术领域,深入理解操作系统的 内核机制
对于每一个技术人来说都是至关重要的。Linux 操作系统以其卓越的性能和灵活性,成为了许多技术专家的首选平台。本文将深入探讨 Linux 中进程间通信的一种高效机制 —— FIFO 命名管道,以及如何结合文件描述符实现高效的 异步多进程
脚本的制作。对于希望提升 脚本编写能力
、优化系统资源利用的技术工作者而言,本文将提供 细致
的洞见和实例理解。
大家好,我是技术界的小萌新,今天要和大家分享一些干货。在阅读之前请先点赞👍,给我一点鼓励吧!这对我来说很重要 (*^▽^*)
1、FIFO 命名管道的背景
FIFO
(First In, First Out,先进先出)命名管道是一种特殊的文件类型,它允许进程以一种特定的方式进行通信。在 Unix 和类 Unix 操作系统中,FIFO 是一种进程间通信( IPC
)机制,它允许数据在两个或多个进程之间单向流动,类似于 消息队列
。FIFO 的概念最早由 Unix 的创始人之一的 Ken Thompson 和 Dennis Ritchie 在1970年代初期引入, 用于解决早期Unix系统中进程间通信的问题。
相比较于我们熟知的匿名管道 |
只能在两个进程间进行数据传输与通信,FIFO 不仅都能做到,甚至还能进行不同计算机,多个进程之间的数据传输与通信。而且不像匿名管道只存在于创建它的进程与子进程之间,不具有 持久性
,FIFO 在进程结束后依然可以长久存在,等待下次被调用。FIFO 还拥有其他特性,如下:
- FIFO 的工作原理是基于
半双工机制
,这规定了在同一时间数据流只能沿着一个方向进行传输 (读或写)。如果需要实现全双工通信(两个进程之间可以互相发送和接收数据),那么你需要创建两个命名管道。 - FIFO 不支持复杂的数据结构,只支持
字节流
。 - FIFO 可以通过
mkFIFO
命令在文件系统中创建。创建后,一个或多个进程可以打开 FIFO 进行读写操作。写入 FIFO 的数据会被缓冲,(也就是说会被存入到内核空间),直到有进程读取。即对 FIFO 命名管道的写入或读取动作通常会被堵塞
,除非 FIFO 两端同时有读与写的进程,或者用代码方式实现非阻塞模式。 - FIFO 文件具有文件权限,可以控制哪些用户或进程可以读写,通信安全性更好,通过
mkfifo -m 0644 /root/myfifo
即可做到。
我们创建一个 FIFO 管道来直观感受其特性:
可以观察到 FIFO 管道的 写阻塞
,当向 FIFO 管道中写入字节流时,命令行不可用,窗口被阻塞,直到另一个进程读取了这个数据,并且其中的数据流向是单向的,当向 FIFO 管道中读取字节流时,也会 读阻塞
,直到另一个进程向其中输入了数据时,才会完成字节流的一套 “先入先出” 的 FIFO 流程。 明白了这个阻塞的特性我们再接着往下看。
2、文件描述符的介绍
在Linux中,文件描述符 ( File Descriptor
) 是内核为了高效的管理已经被打开的文件所创建的 索引
,它是一个 非负整数
,用于指代每一个被进程打开的文件,所有执行 I/O 操作
的系统调用都是通过文件描述符完成的。它是一个抽象的指示符,指向内核中的 一个
特定对象,这个对象可以是文件、管道、设备、套接字等。每个进程启动时,都会打开一些我们熟知的标准文件描述符:
- 标准输入(stdin) :文件描述符为
0
。 - 标准输出(stdout) :文件描述符为
1
。 - 标准错误(stderr) :文件描述符为
2
。
文件描述符具有以下特性:
- 唯一性:每个打开的文件或 I/O 通道在进程中都有一个
唯一
的文件描述符。一个文件描述符只能绑定一个文件,但一个文件可以被多个文件描述符连接。 - 资源管理:文件描述符用于管理进程的 I/O 资源,包括打开、读写、关闭等操作。
- 缓冲:许多文件描述符操作会用到缓冲区,例如标准 I/O 库提供了缓冲机制。
- 继承性:在创建子进程时,子进程会
继承
父进程的文件描述符,同时子进程结束时也会清理
其拥有的文件描述符。 - 限制:系统对文件描述符的数量有限制,可以通过
ulimit -n
命令,可以查看当前用户的限制。 - 文件描述符定位文件方式:文件描述符指向了内核中维护的该文件的 "
文件表条目
",其中包括了索引节点
(inode) 条目与它指向文件数据块的位置信息
,所以 fd 才可以而重定向输入输出数据。 - 用于恢复数据:如果把文件删除,建立同名文件,会发现 fd 没有再次重定向,因为同名文件已经被分配了新的 inode,这个文件描述符还能继续进行
上一个
的读写操作,甚至还能通过 fd 恢复已经被删除的文件,只要打开该文件的进程没有消亡,那么 fd 就不会被回收
,这是 fd 的冷门用法。
其中我们需要用到的 fd 特性是 绑定
文件并进行 读写操作
,那么让我们通过以下例子理解 fd 是如何绑定到文件,并进行读写操作的。
首先介绍一下 exec 命令,exec 主要用于执行一个新程序替换当前 shell,并允许在这个过程中重新分配 fd ,来改变数据在文件中的流向,从而帮助更有效地管理系统资源。
exec [command] [重定向操作]
文件描述符重定向:
0< 或 <:将标准输入重定向。
1> 或 >:将标准输出重定向。
2>:将标准错误重定向。
&>:将标准输出和标准错误合并重定向。
fd<>
:这个符号在重定向操作中用来表示双向绑定
,即同时设置输入和输出的重定向。任何对文件描述符 5 的读写操作都会直接针对文件 file 进行,而不是针对控制台或其他默认的输入/输出设备。
关闭文件描述符:
fd>&-
:关闭文件描述符。
>& fd
:>& 用于将前面的标准输出重定向到后一个 fd 的标准输入,> 与 & 中间 不能有空格。
注意:
- 使用 exec 命令执行的程序会替换当前 shell 进程。这意味着新程序将接管当前 shell 的进程 ID、内存空间等资源。
- exec 命令执行后,不会返回原来的 shell 环境,因为它替换了当前进程,shell 早已被关闭。
以下我们通过一系列样例来展示 exec 命令通过 fd 改变文件数据流向的特性:
#/bin/bash
exec 1> myfile 2>&1
# exec 表示将 bash 的标准输出重定向给 myfile,将标准错误重定向给文件描述符 1,即其绑定的 myfile。
# exec 后无命令,则改变当前 shell 的 fd ,无法使用命令行,所以用子 bash 执行脚本。
ls -l /proc/$$/fd
# $$ 为当前脚本的 PID 号,fd 为其储存的所有文件描述符
执行以上脚本,可以看到 exec 命令把替换后 shell 的标准输出与标准错误的文件描述符都重定向给了 myfile 文件,所以 ls 命令执行后本来应该显示在 shell 界面的标准输出的字节流已经通过文件描述符 1 重定向给了 myfile 文件。
[root@ECS-TEST ~] bash test.sh
[root@ECS-TEST ~] cat myfile
total 0
lrwx------ 1 root root 64 Jul 14 17:15 0 -> /dev/pts/1
l-wx------ 1 root root 64 Jul 14 17:15 1 -> /root/myfile
l-wx------ 1 root root 64 Jul 14 17:15 2 -> /root/myfile
lr-x------ 1 root root 64 Jul 14 17:15 255 -> /root/test.sh
接下来演示通过自定义文件描述符重定向文件的标准输入输出,即通过 直接
对 fd 进行读写操作,达到 间接
对文件进行读写操作的目的。
[root@ECS-TEST ~] exec 6<> myfile
# 把文件描述符 6 绑定给了 myfile
[root@ECS-TEST ~] ll /proc/$$/fd
total 0
lrwx------ 1 root root 64 Jul 14 18:50 0 -> /dev/pts/1
lrwx------ 1 root root 64 Jul 14 18:50 1 -> /dev/pts/1
lrwx------ 1 root root 64 Jul 14 18:50 2 -> /dev/pts/1
lrwx------ 1 root root 64 Jul 14 18:51 255 -> /dev/pts/1
lrwx------ 1 root root 64 Jul 14 18:50 6 -> /root/myfile
# 6 -> /root/myfile,fd 6 相当于指向 myfile 的硬链接,当移动 myfile 时,会发现 fd 6 指向的文件路径不会随着它的移动而改变。也验证了上述 fd 的特性,指向了文件的 inode,根据其中的指针来重定向输入输出到文件数据块,这跟硬连接的行为无异。
# 结果如此:lrwx------ 1 root root 64 Jul 14 18:50 6 -> /myfile。
[root@ECS-TEST ~] echo 1 >&6
[root@ECS-TEST ~] echo 2 >&6
[root@ECS-TEST ~] echo 3 >&6
[root@ECS-TEST ~] tac <&6
3
2
1
# 可以发现通过 >& 可以指定操作 fd 进行写入工作,有人可能没有 tac 这个命令,它如字面意思同是以相反顺序读取文件内容,跟 cat 相反。
# 这时候有人可能会问,为什么不用 cat 读取 fd 呢?因为读取不到,要讲清楚这个问题会延伸出很多知识,但这部分不是本篇的重点,限于篇幅,会单开一篇来深入阐述文件描述符的底层逻辑,有兴趣的可以关注我的下一篇文章。
通过上述演示可以发现通过 fd 间接实现了文件的输入输出,验证了这个 fd 的特性就可以用于接下来的与 FIFO 管道的配合了。
3、FIFO 命名管道与文件描述符的配合
想要在 Shell 中掌握异步多进程的艺术,需要多个 Linux 特性之间的配合才能实现,如果在其他编程语言中,可能就有强大的库可以直接进行多进程的异步调用,虽然在 Linux 中也可以使用一些命令如 xargs,parallel 来实现,但是直接利用 Linux 内核特性进行 Shell 脚本编程,能够享受到 Shell 精简
与 高效
的魅力,这可以让我们在对 Linux 内核 深入学习
的路上走得更远。
在以上漫长的介绍中,我们获悉了 FIFO 具有 读写阻塞
的性质,文件描述符可以用来间接修改文件,那么他们如何配合呢?
其实 FIFO 在 Linux 文件系统中就是以管道文件的形式持久化存在的,所以通过文件描述符指向 FIFO 管道文件,可以更 灵活
的重定向数据流给 FIFO,来利用它的读写阻塞机制, 控制进程数
,这里的文件描述符提供了一个更为强大和灵活的接口,使得我们能够更有效地编写高效、易于管理的脚本。
接下来演示一个例子,来结合 fd 模拟一下两个不同主机利用 FIFO 进行基础的双向通信,看一下 fd 与 FIFO 是如何配合的。
主机 A 的通信脚本:
#!/bin/bash
for pipe in "pipeA-tx" "pipeA-rx"; do
if [ ! -p "$pipe" ]; then
mkfifo "$pipe"
fi
done
# ! -p 检测到 FIFO 管道不存在则为真,创建 FIFO 管道
exec 6<>pipeA-tx
exec 8<>pipeA-rx
# 用文件描述符绑定对 FIFO 管道的读写
function mess_tx() {
touch ".label_tx"
read -u6 mess
echo "$mess" | ssh hostB "cat >/root/pipeB-rx"
rm -rf .label_tx
}
# read -u 指定读取文件描述符的信息,先运行 read 命令会读阻塞
# 通过文件描述符 6 读取 hostB 的发送消息,ssh 传送给 hostA 的 FIFO 接收管道文件,完成消息的送达
# 通过 .label_tx 文件作为进程存活证明的标签,方便进程结束后再进行 if 判断调用
function recov_rx() {
touch ".label_rx"
read -u8 recov
echo "HostB received: $recov"
rm -rf .label_rx
}
# 一旦读取到对方主机传送过来到文件描述符 8 的信息后,就输出到标准输出,完成消息的读取
# 注意两主机间需要设置秘钥登录,无输入密码操作
mess_tx &
recov_rx &
while sleep 1;
do
if [ ! -f ".label_tx" ] ; then mess_tx & fi
if [ ! -f ".label_rx" ] ; then recov_rx & fi
done &
# 如果标签文件不存在,则说明进程已消亡,重启消息发送/接收进程,在后台每隔 1s 检测一次。
# 注意 if 命令写成一行时,有 & 符号则不能在之后继续加 ; 会报错
主机 B 的通信脚本:
#!/bin/bash
for pipe in "pipeB-tx" "pipeB-rx"; do
if [ ! -p "$pipe" ]; then
mkfifo "$pipe"
fi
done
# **` -p 检测到 FIFO 管道不存在则为真,创建 FIFO 管道
exec 6<>pipeB-tx
exec 8<>pipeB-rx
# 用文件描述符绑定对 FIFO 管道的读写
function mess_tx() {
touch ".label_tx"
read -u6 mess
echo "$mess" | ssh hostA "cat >/root/pipeA-rx"
rm -rf .label_tx
}
# 通过文件描述符 6 读取 hostB 的发送消息,ssh 传送给 hostA 的 FIFO 接收管道文件,完成消息的送达
# 通过 .label_tx 文件作为进程存活证明的标签,方便进程结束后再进行 if 判断调用
function recov_rx {
touch ".label_rx"
read -u8 recov
echo "HostB received: $recov"
rm -rf .label_rx
}
# 一旦读取到对方主机传送过来到文件描述符 8 的信息后,就输出到标准输出,完成消息的读取
# 注意两主机间需要设置秘钥登录,无输入密码操作
mess_tx &
recov_rx &
while sleep 1;
do
if [ ! -f ".label_tx" ] ; then mess_tx & fi
if [ ! -f ".label_rx" ] ; then recov_rx & fi
done &
# 如果标签文件不存在,则说明进程已消亡,重启消息发送/接收进程,在后台每隔 1s 检测一次。
运行脚本:
在两个主机上分别运行这些脚本, 并分别向用于传输信息的 FIFO 管道发送信息即可传达,是不是很有趣?
# 在主机 A 上
bash hostA.sh
echo "Hello, hostB!" > pipeA-tx
# 在主机 B 上
bash hostB.sh
echo "Hello, hostA!" > pipeB-tx
现在,你可以在任一主机上通过文件描述符间接读写 FIFO 管道来发送,接受消息,消息将通过 SSH 链路
发送到另一主机,并被另一主机的脚本接收。
4、Shell 实现多进程异步运行
有这样一种场景,数据库服务器经过长时间的业务运行会逐渐积累大量数据,而数据备份是随时需要进行的,当数据量较大,比如几十G甚至更多,在进行备份时如果启动更多进程就可以提升效率,如果使用 Shell 脚本时通过 & 把进程全部放入后台,没有控制进程数,那么在短时间内,这些子进程会 耗费
大量的系统资源,造成卡顿,甚至死机,所以需要进行进程数的 控制
,为了更好的利用 CPU 资源,提高任务脚本执行的 效率
。
利用 FIFO 管道会 读写阻塞
的特性,我们可以先向 FIFO 管道中写入信息,这样每一个阻塞的管道可以用来控制一个进程的通信,从而达到 间接控制
进程数量的目的,只有当读取到一个 FIFO 管道的信息后,才允许进程运行,运行完毕后再写入 FIFO 管道一行信息,用来开启下一个进程,这样不停地进出 FIFO 管道的流程的消息就控制了进程数目,相当于已阻塞的管道数目。
上述说明了一个 FIFO 管道间接控制一个进程的原理,看起来不太需要与 fd 绑定就可以进行异步多进程的运行,但是其实利用 fd 来间接控制 FIFO 管道的读写有许多优点,如下所列:
- 减少系统调用开销:FIFO 在第一次使用时被打开,并绑定到一个文件描述符上。后续的读写操作都通过这个文件描述符来进行,而不需要重复打开和关闭 FIFO,直到文件描述符被关闭, FIFO 才会被关闭系统调用,这减少了对 FIFO 的打开与关闭操作,降低了系统
调用开销
。 - 上下文切换:如果使用多进程模型,每次进程间的切换都可能涉及上下文切换 (即进程由于读写阻塞等待而被 CPU 挂起,再到另一核心运行的过程) 的开销。利用 fd 绑定可以减少这种切换,因为进程可以共享同一个 FIFO 描述符,通过 fd 减少了读写操作。
- 同步机制:直接使用 FIFO 可能需要额外的同步机制来避免竞争条件,这可能影响性能。fd 绑定可以简化同步机制的实现。
- 错误处理:直接操作 FIFO 时,每个进程都需要处理打开 FIFO 时可能发生的错误。fd 绑定可以集中错误处理,简化代码。
- 并发管理:通过fd绑定,可以更容易地在多个进程间
共享 FIFO
,简化并发控制
。
接下来是一个用 ping 命令 检查
整个网段内主机网络状态的例子,来简单实现一下 多进程异步
运行,如果我们有很多主机每天需要检测网络连通性,这就很有帮助。
#!/bin/bash
# 创建一个命名管道,用于进程间的通信,由于每个进程的 PID 号($$)是唯一的,所以使用$$
# 作为命名管道的名称可以避免命名冲突,使用$$作为文件名是创建临时文件的典型用法。
mkfifo $$.fifo
# 打开 fd 8,用于读写命名管道
exec 8<>$$.fifo
# 脚本执行完毕后删除命名管道,是为了清理临时文件,
# 避免脚本执行完毕后留下不需要的文件。
rm -rf $$.fifo
# 定义hostping函数,用于ping指定的主机
# 参数$1: 主机的编号
function hostping() {
# 使用ping命令检查主机是否可达,-c指定发送的ICMP报文请求次数,-i指定间隔,-w指定超时时间
# -w 为所有数据包超时时间的总和,-W 指定每个数据包的超时时间
ping -c 3 -i 0.2 -w 1 192.168.1.$1 &> /dev/null
# 检查ping命令的返回值,0表示成功,非0表示失败
if [ $? -eq 0 ] ;then
# 如果主机可达,输出ok,使用ANSI转义序列设置文本颜色为红色
# 存储结果到临时文件中
echo -e "\033[31m192.168.1.$1 \t ok\033[0m" >> tmp
else
# 如果主机不可达,输出no,使用ANSI转义序列设置文本颜色为绿色
echo -e "\033[32m192.168.1.$1 \t no\033[0m"
fi
}
# 定义thread函数,用于在管道中写入指定数量的信号
# 参数$1: 要写入的信号数量
function thread() {
for i in $(seq 1 $1)
do
# 向管道中写入一个信号,通知reader进程可以开始读取
echo >& 8
done
}
# 调用thread函数,指定要发送的信号数量为参数$2
thread $2
# 循环读取管道中的信号,并执行hostping函数
for i in $(seq 1 $1)
do
{
# 从管道中读取一个信号
read -u8
# 调用hostping函数,参数为当前循环的计数器i
hostping $i
# 向管道中写入一个信号,通知writer进程可以继续发送
echo >& 8
}&
done
# 等待所有子进程执行完毕
wait
# 关闭文件描述符8
exec 8>&-
# 输出结果
cat tmp | sort -n -k1 && rm -rf tmp
可以看到上面的演示动图,脚本的效果很好,右边监视的进程树显示了 ping 的子进程一直被控制在所 指定
的 25个,同时整个局域网网段内被检查的 主机网络状态
也很好的 展示
了出来,希望这能够帮助到大家领略到 Shell 脚本异步多进程的艺术,从而理解到更多 Linux 的 Shell 脚本知识
,提高日常学习工作中的效率 (*^▽^*)
。
总结
最后我们来总结一下,通过本文的深入分析,我们可以看到 FIFO 命名管道和文件描述符在 Linux 系统中的 重要性
和实用性。 FIFO 不仅为进程间通信提供了一种高效的方式,而且通过与文件描述符的结合使用,我们能够实现更为精细的 进程控制
和 资源管理
。
无论是在处理大规模 数据备份
,还是进行网络状态 监控
的场景下,利用 FIFO 和文件描述符的特性,可以显著提高任务执行的 效率
和系统的 响应能力
。希望本文的分享能够帮助技术同仁们在面对复杂系统任务时,能够运用这些 Linux 内核 特性
,编写出更加高效、健壮的脚本,以应对各种 技术挑战
。
文章到这里就结束了,希望我的分享能为你的技术之旅增添一抹亮色。如果你喜欢这篇文章,请点赞收藏支持我,给予我前行的动力!🚀