Zhupiter 追梦不怠

Linux Shell 多线程工作

这段时间一直在atlas的grid上交作业,每当作业跑完后都得下载非常多的文件,之后还要将这些文件解压,预处理才能使用。虽然编写了shell脚本来处理节省了大量工作量,但文件数太多,单线程跑要花非常多的时间,经常这么一套下来都要花大概一天的时间。以预处理的脚本为例,它是这样子的。

单线程循环执行脚本

#!/bin/bash
path1="../"
files1=$(ls ${path1})

make clean
make

for file in $files1
do
  ./selector_ana background OS $path1 $file
done
echo "all done"

一次两次这么干还可以忍受,但程序出了几次bug,中途又更新了下框架,多几趟简直要把人折磨疯,于是问师兄如何是好。师兄答曰“你在后面加个&变成后台作业不就行了么?”。这么一想,对吼,自己怎么如此之笨,于是脚本变成了如下样子

多线程并行执行脚本

#!/bin/bash
path1="../"
files1=$(ls ${path1})

make clean
make

for file in $files1
do
  ./selector_ana background OS $path1 $file&
done
wait
echo "all done"

变化是在for循环执行语句的末尾加上‘&’符号让其变为后台子进程,由于交后台后运行交给了后台子进程,母进程可以立即执行下一个指令,这样for循环中的语句就可以并行执行了。在for循环后加入’wait’语句,表示等待之前所有母进程语句以及子进程执行完毕后才会执行接下来的语句。

想法是美好的,但当我兴奋的编写完脚本后开始运行时,灾难性的后果发生了。由于我要预处理的文件有100+之多,预处理程序也是一个比较耗费cpu的程序,100+的程序并行运行,直接导致了服务器节点的卡死。万幸当时是深夜没什么人,当时那个节点就我一个人,万幸我没用nohup,尝试几下kill打不出,Ctrl-C摁不出无果后果断强退终端甩给服务器sigh up信号让它终结。几分钟后节点恢复正常,也没造成什么损失获得邮件批评光荣成就。

多线程的首次尝试以失败而告终,难道我们只能傻傻的苦等单进程一个一个执行么?服务器卡死的原因是同时执行大量耗费高cpu的程序,如果我们有方法能够控制线程数量,让它只同时执行几个线程,这样既可以实现并发,又可以让线程数量在服务器合理负载内,问题就会迎刃而解。经过上网搜索后,终于得到了解决方法。

mkfifo命令,创建命名管道

mkfifo命令的作用时创建一个特殊文件叫做命名管道,它的主要目的是用来做进程间的通信,主要特点如下:

1.read没有数据,read会阻塞,而且read后数据是被删除

2.数据是有序的

3.打开的描述符号可以读写(two-way双工)(建议用只读或只写打开) 或者用shutdown函数关闭读或写关闭,变成单工

4.管道文件关闭后,数据不持久.(程序如不删除管道,程序结束后,无法读到数据)

5.管道的数据实际是存储在内核的缓冲中。(管道文件不是一个真实的文件,是内存的虚拟文件)

注:其实管道文件只是内存中的一个先进先出的数据结构,文件只是个载体,打开管道文件时只是对应了描述符是一个先进先出的内存结构而不是一个真实的文件。(当我们的程序运行后,我们删除那个文件,程序可照常运行不受任何影响)

以上内容抄自y_23k_bug的这篇博客

为了验证命名管道的特点,我们可以做个小实验。先创建一个命名管道tmp,然后往里面写数据

[zhucz@atlasui01 test]$mkfifo tmp
[zhucz@atlasui01 test]$echo a > tmp
echo b > tmp
echo c > tmp
read < tmp
read < tmp
read < tmp

我们看到在‘echo a > tmp’之后输得文字前面都没用户主机信息,用过linux的人都知道这代表输入的信息阻塞了。

此时,我们打开另一个终端,作出相反的操作

[zhucz@atlasui01 test]$read < tmp
[zhucz@atlasui01 test]$read < tmp
[zhucz@atlasui01 test]$read < tmp
[zhucz@atlasui01 test]$echo > tmp
[zhucz@atlasui01 test]$echo > tmp
[zhucz@atlasui01 test]$echo > tmp

如果你边做动作边观察之前的终端,就可以看到随着这边相反的操作进行,阻塞的信息都开始继续传输了,最后全部执行完毕,回到等待输入命令的状态。

[zhucz@atlasui01 test]$echo a > tmp
echo b > tmp
echo c > tmp
read < tmp
read < tmp
read < tmp
[zhucz@atlasui01 test]$echo b > tmp
[zhucz@atlasui01 test]$echo c > tmp
[zhucz@atlasui01 test]$read < tmp
[zhucz@atlasui01 test]$read < tmp
[zhucz@atlasui01 test]$read < tmp
[zhucz@atlasui01 test]$

也就是说,命名管道就像一个真正的管道一样,里面只能存一点水,当往里面注水后就阻塞,只有疏通(读取)了它才可以消除,当想要水却没水(没东西读)时也会阻塞(读入系统拿不到水),只有往里注水(写东西)才可消除。

当然有人会问,如果我开多个终端,多个同时写东西或多个同时读东西,会怎么样呢?这个你可以自己做个小实验验证,这里就不赘述了,只说一下结果:多个写一个读,读操作会把所有写操作的阻塞清除,就像多个源头往里注水,只要有一个出口(读)就会消除阻塞。多个读一个写则可能和系统有关,我的和多写一读情况一样,而网上有人的测试是只能消除其中一个读的阻塞。如果你想问多写多读情况。。。大哥动动脑子想想这种情况不可能发生的好伐。

神奇的文件描述符分配和可控数目的多线程

接下来就进入正题了,使用命名管道来管理线程数目。首先,我们来说下神奇的文件描述符分配。

文件描述符分配本身没什么好说的,就是类似‘exec 3<>a.txt’的语句,意思是定义文件描述符3,并把3重定向到a.txt,‘<’代表允许读,‘>’代表允许写。但是如果你给命名管道一个文件分配符,神奇的事情发生了

如果你只给读的话,你会发现前几步的读写阻塞很奇怪,之后你就会发现写操作不会有任何阻塞了!而且无论你执行多少次写,当你执行读的时候,读仍然会阻塞!只有在读阻塞时执行写操作,读的阻塞才会被消除并一口气把之前所有的写的东西全部吐出来

如果你只给写的话,前几步阻塞仍然奇怪,之后你会发现如果有n个写阻塞,一次读操作会清除掉所有写阻塞(和以前一样),但之后你还可以继续进行n-1次(前面已经执行一次了)读操作而不阻塞

而当你读写都给,就变成了二者的结合体(除了前几步的奇怪读写阻塞消失),写操作不会有任何阻塞,而当你进行过n次写后,你就可以执行n次读而不阻塞

关于文件描述符分配时exec到底干了什么导致了这样的情况,我本人也表示不清楚,目前我还没查到任何相关的材料,如果你知道告诉本人的话不甚感激。而对于最后一种情况,写无任何阻塞而读有次数限制,这给了我们一个通过命名管道和文件描述符分配来线程数量控制的好方法。具体思路是:

  • 创建命名管道,并以读写都给的方式分配文件描述符
  • 向命名管道进行n次写操作,n是你想控制的线程数量
  • for循环开始后台执行所有线程,每个线程正式执行前都对命名管道进行一次读操作,线程执行结束后对命名管道进行一次写操作

这样,因为能读的次数只能是之前写的次数n,所以没能读到命名管道的线程就只能阻塞并等待读到的线程执行结束执行写操作。就像一开始创建10个更衣室和10把钥匙,100位想试穿的顾客同时去抢夺这10把钥匙,抢到的去换衣服,没抢到的只能干等眼(阻塞)等待试穿顾客试穿完归还钥匙后下一波抢夺,这样同时执行的线程就只有10个了。

以这种逻辑下,可控数目的多线程代码如下:

#!/bin/bash

path1="../"
files1=$(ls ${path1})

make clean
make

[ -e tmp ] || mkfifo tmp   #如果tmp不存在则创建
exec 9<>tmp
rm -rf tmp      #由之前的管道文件引用的备注,删掉文件没有任何影响

for i in {1..5}
do
    echo >&9    # &9引用就是之前文件描述符分配给的9,
done

for file in $files1
do
    read -u9    # -u9就是读文件描述符9的文件
    {
    ./selector_ana background OS $path1 $file
    echo >&9    #执行任务完成后进行写操作“归还钥匙”
  }&            # 加&后台执行
done

wait
exec 9<&-       #结束后撤销文件描述符的读权限
exec 9>&-       #撤销文件描述符的写权限
echo "all done"

这样,终于可以即提高并发度节省时间,又不会把服务器撑爆被人投诉啦。