Linux shell实现多进程(进程池)

本文介绍了如何使用Linux Shell脚本实现多进程下载,从串行执行到并行执行,再到控制进程数量和实现生产者消费者模型。通过创建管道和使用文件描述符,限制了同时运行的子进程数量,解决了资源消耗问题。然而,在多进程读取同一管道时出现了线程安全问题,通过引入锁文件来解决。最终推荐使用控制进程数量的方案,避免了线程安全问题。

Linux shell实现多进程(进程池)

备注:这里只是用下载举一个耗时任务的例子,真正的下载还受到带宽的影响,带宽慢一个进程就吃满了,进程多也未必管用

需求是根据一个给定的 img.txt 文本下载一批文件,文本中每一行都是一个文件下载链接,样式如下:
img.txt

http://www.test.com/download/a.zip
http://www.test.com/download/b.zip
http://www.test.com/download/c.zip
...

直接for循环+wget下载,脚本如下:
版本 1 (串行执行)

#!/bin/bash

for url in $(cat img.txt); do
	echo "download: ${url}"
    wget ${url}
done
echo "you have download all files"

这样做效率较低,因为for循环是单线程串行执行的,同一时间只能下载一个文件,如何开启多个进程同时下载这些文件呢

shell创建子进程参考了这位博主:

https://blog.csdn.net/helimin12345/article/details/107592084

将一堆语句用{}括起来,在末尾加一个 &,那么shell就会启动一个子进程去执行{}里面的内容,wait 命令可以阻塞当前线程,等待这些子进程全部执行完之后再执行剩下的语句,改进脚本如下:
版本 2 (并行执行)

#!/bin/bash

for url in $(cat img.txt); do
	# 这里的 & 会开启一个子进程执行
	{
		echo "download: ${url}"
	    wget ${url}
    } &
done
# 使用 wait 命令阻塞当前进程,直到所有子进程全部执行完
wait
echo "you have download all files"

改进后的脚本其实有问题,for循环每一次都会开启一个子进程,如果下载链接特别多的话,会同时启动大量的进程,消耗服务器大量资源。并且服务器CPU数量是有限的,进程太多,CPU在进程间的调度切换也会非常耗费时间,效率反而降低。一般来说,进程数量和CPU数量相等时效率较高,如何控制进程的数量呢

实现多进程一个比较好的方式是使用阻塞队列,可以使用rabbitmq,kafka,redis等消息队列中间件,不过为了写个脚本还要部署一套中间件特别麻烦。linux中自带就有一个mkfifo 命令可以创建出一个管道,如果往管道中写入数据,进程会被阻塞,直到有另一个进程从管道中读取数据,同样的如果从管道中读取数据而管道中没有数据时,进程也会被阻塞,直到有另一个进程向管道中写入数据。根据这个特点可以用来实现生产者消费者模型,但是实测会丢失数据,具体原因未知:
在这里插入图片描述
另起一个窗口从管道中读取数据
在这里插入图片描述
直接使用mkfifo创建的管道会出现数据丢失,但如果使用 exec 命令给管道绑定文件描述符,再使用管道就不会出现数据丢失了
这里要注意0,1,2,255这四个文件描述符已经被操作系统用了,我这里用了4
在这里插入图片描述
实测另起一个窗口从管道中读取数据不会丢失,可以看出管道是先进先出的队列
在这里插入图片描述
补充一下:可以通过命令 ls -lh /proc/$$/fd 查看系统文件描述符
在这里插入图片描述
新建一个管道,给管道绑定文件描述符,向管道中事先写入几条数据,然后每开启一个子进程就读取1条数据,子进程结束前再向管道中补充1条数据,通过这种方式可以做到控制进程数量,脚本改进如下:
版本 3 (控制进程数量,推荐使用这种多进程方式)

#!/bin/bash

# 创建一个管道
mkfifo mylist
# 给管道绑定文件描述符4
exec 4<>mylist
# 事先往管道中写入4条数据(我这里是4个回车符),要开启几个子进程就写入多少条数据
for i in {1..4}; do
    echo >mylist
done

for url in $(cat img.txt); do
    # 创建子进程前先从管道中读取一条数据,当循环4次后管道空了,主进程就会被阻塞
    # 一直等到管道中又有新数据了主进程才被唤醒继续执行
    read <mylist

    # 这里的 & 会开启一个子进程执行
    {
        echo "download: ${url}"
        wget ${url}
        # 子进程结束前往管道中补充1条数据(我这里是补充1个回车符)
        # 被阻塞的主进程才能读取到数据继续执行,这样就保持子进程数量始终最多4个
        echo >mylist
    } &
done
# 使用 wait 命令阻塞当前进程,直到所有子进程全部执行完
wait
echo "you have download all files"

# 全部结束后解绑文件描述符并删除管道
exec 4<&-
exec 4>&-
rm -f mylist

以上脚本已经做到控制子进程数量了,但是创建子进程的开销还是有的。如果直接使用管道在进程间传递数据,把所有下载链接全部写入到管道,然后开启指定个数的子进程,每个子进程内部都不断从管道中读取数据,直到管道为空读取不到数据了再关闭子进程,可以省去创建子进程的开销,性能更高。原理如下:
在这里插入图片描述
根据以上原理,脚本改进如下:

版本 4 (生产者消费者模型)

#!/bin/bash

# 创建一个管道
mkfifo mylist
# 给管道绑定文件描述符4
exec 4<>mylist

# 开启4个子进程
for i in {1..4}; do
    # 这里的 & 会开启一个子进程执行
    {
        # 子进程内部不断从管道中读取数据,每读取到一行就开始下载
        # read -t 1 指定超时时间(如果单条数据特别大需要调长超时时间),如果1s后还没有获取到数据则非0退出
        while read -t 1 url <mylist; do
            echo "download: ${url}"
            wget ${url}
        done
    } &
done
# 将img.txt中的链接全部插入到管道中
for url in $(cat img.txt); do
    echo ${url} >mylist
done
# 使用 wait 命令阻塞当前进程,直到所有子进程全部执行完
wait
echo "you have download all files"

# 全部结束后解绑文件描述符并删除管道
exec 4<&-
exec 4>&-
rm -f mylist

要注意的是必须先开启子进程,然后再将数据写入到管道。因为管道是有长度的,如果先将数据写入到管道,再开启子进程,如果数据量很大超过了管道长度,那么主进程会被一直阻塞,根本执行不到后面开启子进程。如果先开启子进程,再向管道文件中写入数据,数据一写入管道马上就被子进程读取消费,不会出现主进程被阻塞执行不了的问题。实际测试发现有线程安全问题,多个子进程从同一个管道中读取数据时,可能会读取到同一份数据。解决该问题必须在子进程读取管道时加锁。可以通过再引入一个管道实现加锁解锁,脚本如下:

版本5 (解决线程安全问题)

#!/bin/bash

# 创建一个管道
mkfifo mylist
# 给管道绑定文件描述符4
exec 4<>mylist
# 再创建一个管道(锁文件),用于解决线程安全问题
mkfifo mylock
# 绑定文件描述符5
exec 5<>mylock

# 事先向锁文件中插入1条数据(解锁)
echo >mylock

# 开启4个子进程
for i in {1..4}; do
    # 这里的 & 会开启一个子进程执行
    {
        # 先读取锁文件(加锁),由于锁文件中只有1条数据,读取完之后锁文件空了其他子进程再读取时只能等待
        while read -t 1 <mylock && read -t 1 url <mylist; do
            # 读取到业务数据后立即写入1条数据到锁文件(解锁),让其他子进程继续读取数据
            # 这里其实有bug,加解锁和读取业务数据之间没有保证原子性,如果单条业务数据特别大,可能导致读取业务数据超时,进程退出
            # 那么锁文件会一直为空处于加锁状态,其他进程也会因为获取不到锁而退出,导致任务执行不全
            echo >mylock

            echo "download: ${url}"
            wget ${url}
        done
    } &
done
# 将img.txt中的链接全部插入到管道中
for url in $(cat img.txt); do
    echo ${url} >mylist
done
# 使用 wait 命令阻塞当前进程,直到所有子进程全部执行完
wait
echo "you have download all files"

# 全部结束后解绑文件描述符并删除管道
exec 4<&-
exec 4>&-
rm -f mylist
exec 5<&-
exec 5>&-
rm -f mylock

使用 read -t 结束进程会导致加解锁和读取业务数据之间没有原子性,如果读取业务数据超时会导致无法执行到解锁。尽量不要使用 read -t 来结束进程,可以在插入完业务数据后,再向管道中插入一些和业务数据无关的进程结束标识符,进程内部获取数据后进行检测,如果是进程结束标识符则结束进程,不是则执行正常业务逻辑。如果无法和业务数据区分该方法也无法使用,暂时没想到其他方法。

#!/bin/bash

# 创建一个管道
mkfifo mylist
# 给管道绑定文件描述符4
exec 4<>mylist
# 再创建一个管道(锁文件),用于解决线程安全问题
mkfifo mylock
# 绑定文件描述符5
exec 5<>mylock

# 事先向锁文件中插入1条数据(解锁)
echo >mylock

# 开启4个子进程
for i in {1..4}; do
    # 这里的 & 会开启一个子进程执行
    {
        # 先读取锁文件(加锁),由于锁文件中只有1条数据,读取完之后锁文件空了其他子进程再读取时只能等待
        while read <mylock && read data <mylist; do
            # 读取到业务数据后立即写入1条数据到锁文件(解锁),让其他子进程继续读取数据
            echo >mylock
            # 判断是否是进程结束标志
            if [[ "PROCESS_END" == ${data} ]]; then
                break
            fi

            echo "download: ${data}"
            wget ${data}
        done
    } &
done
# 将img.txt中的链接全部插入到管道中
for url in $(cat img.txt); do
    echo ${url} >mylist
done
# 插入完业务数据后,再插入对应进程数量的进程结束标志(注意区别业务数据)
for i in {1..4}; do
    echo "PROCESS_END" >mylist
done
# 使用 wait 命令阻塞当前进程,直到所有子进程全部执行完
wait
echo "you have download all files"

# 全部结束后解绑文件描述符并删除管道
exec 4<&-
exec 4>&-
rm -f mylist
exec 5<&-
exec 5>&-
rm -f mylock

综上所述:还是推荐使用版本3,逻辑更简单且不会出错
完结,如果有更好的想法欢迎留言讨论

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值