Shell脚本进阶:掌握异步多进程的艺术



前言

  在现代技术领域,深入理解操作系统的 内核机制 对于每一个技术人来说都是至关重要的。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 内核 特性 ,编写出更加高效、健壮的脚本,以应对各种 技术挑战


  文章到这里就结束了,希望我的分享能为你的技术之旅增添一抹亮色。如果你喜欢这篇文章,请点赞收藏支持我,给予我前行的动力!🚀



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值