前言

Shell一行行地执行实在是太影响效率.本文将带你提升你的Shell执行效率,采用伪”多线程”的方式启动多个后端进程,最大程度利用cpu性能.

本文代码关键点有一定的注释,方便读者理解并灵活使用.本文示例及部分内容摘录自互联网,由本人整理成以下内容.遵守CC BY-NC-SA 4.0协议.


先问你一个问题.

多线程有什么用?

这里引用知乎pansz用户的回答

  1. 单进程单线程:一个人在一个桌子上吃菜
  2. 单进程多线程:多个人在同一个桌子上一起吃菜
  3. 多进程单线程:多个人每个人在自己的桌子上吃菜

多线程的问题是多个人同时吃一道菜的时候容易发生争抢
例如两个人同时夹一个菜,一个人刚伸出筷子,结果伸到的时候已经被夹走菜了…
此时就必须等一个人夹一口之后,在还给另外一个人夹菜,也就是说资源共享就会发生冲突争抢。

  1. 对于 Windows 系统来说,【开桌子】的开销很大,因此 Windows 鼓励大家在一个桌子上吃菜。因此 Windows 多线程学习重点是要大量面对资源争抢与同步方面的问题。

  2. 对于 Linux 系统来说,【开桌子】的开销很小,因此 Linux 鼓励大家尽量每个人都开自己的桌子吃菜。这带来新的问题是:坐在两张不同的桌子上,说话不方便。因此,Linux 下的学习重点大家要学习进程间通讯的方法。

开桌子的意思是指创建进程,开销这里主要指的是时间开销.

注意,线程和进程是不同的!

多线程就像火车的多个车厢,而进程则是火车。

在我们实现多线程之前,先了解2个概念.
有名管道FIFO和File Descriptor (FD)

有名管道FIFO

管道文件有两种,一个是有名管道,一个是匿名管道。

“FIFO”则是有名管道(有名字的)。
它的特性是:如果一个进程打开FIFO文件进行写操作,而另一个进程对之进行读操作,数据就可以如同在shell或者其它地方常见的的匿名管道一样流线执行。

利用有名管道FIFO的上述特性就可以实现一个队列控制了。

你可以这样想:一个女士公共厕所总共就10个蹲位,这个蹲位就是队列长度。女厕所门口放着10把钥匙,要想上厕所必须拿一把钥匙,上完厕所后归还钥匙,下一个人就可以拿钥匙进去上厕所了。好,现在同时来了1000位美女要上厕所,那前10个人抢到钥匙进去上厕所了,后面的990人就需要等一个人出来归还钥匙后才可以拿到钥匙进去上厕所,这样10把钥匙就实现了控制1000人上厕所的任务(os中称之为信号量)。

mkfifo命令可用于创建fifo:

1
mkfifo $tmp_fifofile      # 新建一个fifo类型的文件

管道具有存一个读一个,读完一个就少一个,没有则阻塞,放回的可以重复取的特点。这正是队列特性,但问题是如果往管道文件里面放入一段内容,没人取则会阻塞,这样你永远也没办法往管道里面同时放入10段内容(相当于10把钥匙),解决这个问题的关键就是文件描述符(File Descriptor,FD)了。

File Descriptor (FD)

Linux shell中的File Descriptor (FD),可以理解为一个指向文件的指针。

默认有三个FD:0,1,2。Shell中还允许有3到9的FD,默认都没有打开,可以认为指向null。使用如下命令可查看FD:

1
ls /proc/self/fd

image-20220511132755462

在macOS上使用命令 ls /dev/fd/

image-20220511132955394

利用重定向>&可以为一个FD赋值,使其指向一个非null的文件,其实就是打开一个FD:

1
2
3
6>&1
# 可以理解为将FD6指针指向FD1指针指向的文件
# 这样,FD6和FD1就同时指向同一个文件

将FD6指针置为空值null,可关闭FD6:

1
6>&-

一个重定向只在当前命令中有效。通过exec可以使IO重定向在当前shell中长期有效:

1
2
3
4
# 打开FD6
exec 6>&1
# 关闭FD6
exec 6>&-

再回到我们刚才的1000位美女要去厕所,解决一个管道文件不能放10把“钥匙”的问题:

先利用exec 6<>/tmp/fd1 创建文件描述符6关联管道文件。
这时,6这个文件描述符就拥有了管道的所有特性(存一个读一个,读完一个就少一个,没有则阻塞,放回的可以重复取)。除此之外,它还拥有一个管道不具有的特性:无限存不阻塞,无限取不阻塞,且不用关心管道内是否为空、是否有内容写入引用文件描述符。
&6可以执行n次echo >&6 往管道里放入n把钥匙。

接下来就是怎么使用FIFO和FD实现shell”多线程”了~

shell”多线程”示例脚本

需求:并发处理1000个命令,如何用shell实现?

方案1.挨个挨个处理

这个是最容易想到的,用for循环1000次即可。

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

date # 脚本开始时间

for ((i=1;i<=1000;i++))
do
sleep 1 #sleep 1用来模仿执行一条命令需要花费的时间(可以用真实命令来代替)
echo 'success'$i;
done

date # 脚本结束时间

一个for循环1000次相当于需要处理1000个文件,循环体用sleep 1代表运行一条命令需要的时间,用success$i来标示每条任务。

这样写的问题是,1000条命令都是顺序执行的,假如每条命令的运行时间是1秒,那么1000条命令的运行时间则为1000秒,效率相当低,不满足我们并发处理1000个命令的需求。而且,假如在顺序执行到第900个文件时,发现该文件有问题,那么到这时所需要的时间就是900s!

所以,问题的关键点是:如何实现并发?

方案2.使用’&’+wait 实现“多线程”

我们通过后台运行(&),wait等待所有子后台进程结束实现”多线程”。

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

date # 脚本开始时间

for ((i=1;i<=1000;i++))
do
{
sleep 1 #sleep 1用以模仿执行一条命令需要花费的时间(可以用真实命令来代替)
echo 'success'$i;
}& #用{}把循环体括起来,后加一个&符号,代表每次循环都把命令放入后台运行
#一旦放入后台,就意味着{}里面的命令交给操作系统的一个线程处理了
#循环了1000次,就有1000个&将任务放入后台,操作系统会并发1000个线程来处理
done
wait #wait命令表示。等待上面的命令(放入后台的任务)都执行完毕了再往下执行

date # 脚本结束时间

shell中实现并发,就是把循环体的命令用&符号放入后台运行,1000个任务就会并发1000个线程,运行时间2s左右,比起方案一的1000s,已经非常快了。

但问题是,’&’+wait 这种方法对线程并发数不可控。如果有很多文件,系统会随着高并发压力的不断攀升,处理速度变得越来越慢。

打个简单的比方,方案1是有1000块砖,你每次搬一块,虽然慢但是搬得动;方案2是有1000块砖,一次搬1000块,搬到后面是不是会越来越吃力?而下面要说的方案3,则是设置每次搬的数量,比如5块,提高效率又不会伤身体。

方案3.使用FIFO实现“多进程”

先新建一个FIFO,写入一些字符。一个进程开始前会先从这个FIFO中读走一个字符,执行完之后再写回一个字符。如果FIFO中没有字符,该线程就会等待,fifo就成了一个锁。

下面是设置5个线程的例子:

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
#!/bin/bash
#将1000个bam文件进行转bed

start_time=`date +%s` #定义脚本运行的开始时间

tmp_fifofile="/tmp/$$.fifo"
mkfifo $tmp_fifofile # 新建一个FIFO类型的文件
exec 6<>$tmp_fifofile # 将FD6指向FIFO类型
rm $tmp_fifofile #删也可以,

thread_num=5 # 定义最大线程数

#根据线程总数量设置令牌个数
#实际上就是在fd6中放置了$thread_num个回车符
for ((i=0;i<${thread_num};i++));do
echo
done >&6

for i in data/*.bam # 找到data文件夹下所有bam格式的文件
do
# 一个read -u6命令执行一次,就从FD6中减去一个回车符,然后向下执行
# 当FD6中没有回车符时,就停止,从而实现线程数量控制
read -u6
{
echo "great" # 可以用实际命令代替
echo >&6 # 当进程结束以后,再向FD6中加上一个回车符,即补上了read -u6减去的那个
} &
done

wait # 要有wait,等待所有线程结束

stop_time=`date +%s` # 定义脚本运行的结束时间
echo "TIME:`expr $stop_time - $start_time`" # 输出脚本运行时间

exec 6>&- # 关闭FD6
echo "over" # 表示脚本运行结束

注意:实际运用中,要先测试代码是否正确,然后再进行多线程。

参考:

1

2

本来想写点 xargs 的,但unix上的命令和linux参数不一样,然后感觉精力有限,就不写了.欢迎读者自己去了解Linux xargs命令详解shell高效处理文本(1):xargs并行处理