原来Bash的并行计算是这样的

今天有个批量下载图片的任务,不打算用python或者其他高级语言实现,决定用bash尝试下。

接下来我们一起了解下bash的并发任务是如何实现的?先上一版简单粗暴的代码:

#!/bin/bash

todo () { sleep 3;echo "$1 OK"; }

# --- 串行
for x in `seq 5`;do
    echo $x;
    todo $x;
done

# --- 并行
for x in `seq 5`;do
{
    echo $x;
    todo $x;
}&
done
wait
# 是的,你没看错,并行相较于串行之多了几个符号 `{ }&` 和 `wait`。
# 其中,wait函数,该函数将等待后台所有子进程结束。正是因为有了此函数,
# 才能保证commands2在所有commands1并行子任务执行完后再执行。

具体执行效果如下:

计算效果

注意⚠️:这个并行计算会傻瓜式的扩容,假设这样的任务我们安排100000个同时计算,再牛的服务器终究也会打趴下。运行之前确认你有足够的内存、cpu或者带宽资源,不建议用到复杂的场景,

那么我们有没有办法去实现像其它语言那种优雅的并发呢?答案肯定是有的,这里我们就要发挥下Linux系统的优势了。

++首先我们了解下管道(pipe)++

无名管道:熟悉Linux命令的同学一定用过,比如ps aux|grep nginx|awk '{print $2}',其中|就是管道连接符号,它将前一个命令的结果输出到后一个进程中,作为两个进程的数据通道,不过他是无名的。但是这里要注意下:|对错误信息信息没有直接处理能力。

有名管道:使用mkfifo命令创建的管道即为有名管道,例如,mkfifo pipefile, pipefile即为有名管道。

$ mkfifo -m 644 pipe1 # 创建管道, `-m MODE`设置新创建的FIFO文件的许可权位的值,MODE变量与为`chmod`命令定义的方式操作数相同。
$ ls -al > pipe1      # 终端1: 向管道 pipe1 输入内容,这个时候终端会阻塞
$ cat < pipe1         # 终端2: 读取管道 pipe1 内容,这个终端1才会退出,终端2获取管道中数据并输出。
$ ls -al pipe1
prw-r--r-- 1 root root 0 Apr 29 22:47 pipe1

++Linux文件描述符++

Linux中读写文件都会通过文件描述符来完成,而不是通过文件名来完成的,所以只要打开文件,Linux内核就会为程序分配一个文件描述符。例如cat access.log会打开access.log文件并分配一个文件描述符,cat命令退出后,打开的文件就会被关闭,所分配的文件描述符也会释放。

linux启动后,会默认打开3个文件描述符:标准输入fd=0标准输出fd=1标准错误fd=2,在/proc/self/fd可以找到。用户可以自定义文件描述符范围是:3 ~ NUM,这个最大数字,跟用户的:ulimit –n 定义数字有关系,不能超过最大值,即 NUM = ulimit -n-1。

$ ls -al /proc/self/fd
total 0
dr-x------ 2 root root  0 Apr 29 23:13 .
dr-xr-xr-x 9 root root  0 Apr 29 23:13 ..
lrwx------ 1 root root 64 Apr 29 23:13 0 -> /dev/pts/0
lrwx------ 1 root root 64 Apr 29 23:13 1 -> /dev/pts/0
lrwx------ 1 root root 64 Apr 29 23:13 2 -> /dev/pts/0
lr-x------ 1 root root 64 Apr 29 23:13 3 -> /proc/1216486/fd
  • 基础重定向操作

对于基本的重定向操作应该都很熟悉,比如:

$ echo hello world >/tmp/access.log
$ cat </tmp/access.log
# 在命令行中指定的重定向目标都只在该命令中有效,命令退出后,重定向行为就消失了。

$ exec >/tmp/access.log
# exec是shell内置命令,会设置当前bash进程的标准输出fd=1的数据流目标为/tmp/access.log。
$ echo helloworld
$ ls
$ exec </tmp/a.log

# 恢复bash的标准输出
$ exec >$(tty)
$ ls
pipe1  tmp.txt   # 结果即可输出终端
  • 打开文件

其实,每个重定向操作也是打开文件的操作,同时会分配文件描述符。比如echo hello > /tmp/access.log命令,Shell会打开access.log文件并将fd=1关联到该文件,当echo进程运行时会继承Shell的fd=1以及它关联的access.log属性,于是echo的标准输出就会输出到access.log中。但是在命令中打开的文件(包括重定向)都是临时的,命令退出完后文件就会关闭,所分配的文件描述符也会释放。

在Shell中,可以手动打开文件:

exec N<> FILENAME     # 表示打开文件描述符N的写/读

整数N建议在[3,9]范围内,超出9的文件描述符有可能已经被bash内部使用了。表示在当前Shell进程内打开文件FILENAME并分配文件描述符N,只要不手动关闭文件,只要当前Shell进程不退出,那么打开的FILENAME就一直处于打开状态。

例如:

$ exec 3<> /tmp/access.log
$ lsof -n | grep /tmp/access.log | grep -v grep
bash  1216519  root  3u  REG  252,1  0  1190008  /tmp/access.log

# 这里我们顺带验证下用户自定义文件描述符的范围
$ ulimit -n
65535
$ exec 65535<> /tmp/max.log
-bash: 65535: Bad file descriptor
$ exec 65534<> /tmp/max.log
$ ls /proc/self/fd
0  1  2  3  4  65534
  • 关闭文件

在编写脚本的时候,不再使用的文件就要关闭,以便释放文件描述符,防止程序一直占用文件,导致无法释放文件占用的磁盘空间。如果大量文件描述符不关闭,还可能达到打开文件的上限,使得程序报错。

关闭文件描述符的方式:

exec N>&-   # 或者 exec N<&-   表示关闭文件描述符N的写/读

这表示关闭文件描述符N。如果是用exec N<>FILENAME打开的文件,则上面两种方式都能关闭N。

exec 3<&-   # 或者 exec 3>&-
$ lsof -n | grep /tmp/access.log | grep -v grep

关于文件描述符的其他有用的操作,比如:文件描述符的复制移动自动分配文件描述符号read从文件描述符读取数据等等这些操作这里我们先不做赘述了,具体的可以参见本文文末的参考文档。

好了,我们回归正题,接着我们开始的话题,如何编写我们优雅的并行shell任务。利用管道文件和文件描述符来控制进程并发,直接上脚本:

#!/bin/bash
#
# trap是捕获中断命令。接受信号2 `ctrl+C`做的操作,
# 表示在脚本运行过程中,如果接收到`Ctrl+C`中断命令,
# 则关闭文件描述符1000的读写,并正常退出。
trap "exec 1000>&-;exec 1000<&-;exit 0" 2

FIFO_FILE=$$.fifo     # $表示当前执行文件的PID
mkfifo $FIFO_FILE     # 创建管道文件
exec 1000<>$FIFO_FILE # 将管道文件和文件操作符绑定
rm -rf $FIFO_FILE     # 删除管道文件

# task
todo () { sleep 3;echo "$1 OK"; }

# 并行数目
PROCESS_NUM=20
# 对文件操作符进行写入操作,通过for循环写入空行,
# 空行数目为定义的后台线程数量
for _ in `seq $PROCESS_NUM`;do
    echo>&1000
done

for x in `seq 999`;do
    # 从文件描述符读入空行。
    # read -u 后面跟fd,从文件描述符中读入,该文件描述符可以是exec新开启的。
    read -u1000
    {
        todo $x   # 要执行的命令
        # 上一条命令执行完毕后,在文件描述符中写入空行
        echo >&1000
    } &           # &表示进程放到linux后台执行
done

wait              # wait指令等待所有后台进程执行结束
exec 1000>&-      # 表示关闭文件描述符1000的写

好了,今天就到这里,其实Linux还有很多优秀的设计理念,这里只是个开胃菜。

参考文档: