最近有个需求要批量下载一堆文件,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循环是单进程串行执行的,如果这些下载地址中出现一个特别大的文件,那么后面所有的文件都要等待这个特别大的文件下载完成之后才能下载,效率太低,那么有没有办法同时下载这些文件呢
我浏览了一下网上其他人的做法,主要参考了这个博主:
https://blog.csdn.net/hellojoy/article/details/77340238
如果将一堆语句用{}括起来,在末尾加一个 &,那么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循环每一次都会开启一个子进程,如果img.txt文件中下载链接里面特别多的话,会同时启动大量的进程,会消耗服务器大量资源。并且服务器CPU个数是有限的,进程太多,CPU在进程间的切换也会非常耗费时间,一般来说,进程数量和逻辑CPU数量相等时效率较高,那么要怎么准确控制进程的数量呢
linux中有个 mkfifo 命令可以创建出一个管道文件,如果往管道文件中写入数据,进程就会被阻塞,直到有另一个进程从管道文件中读取数据
再开一个窗口读取管道文件,前面被阻塞的进程就会被释放
mkfifo直接创建的管道其实类似一个长度为0的阻塞队列,当有数据进入到管道文件时,由于长度为0相当于已经满了,然后进程接被阻塞了,如果管道文件有长度,插入数据没有填满长度时,进程不会被阻塞。那如何创建一个有长度的管道呢?我查了一些资料,没有找到能指定长度的方法,但是发现如果给管道绑定文件描述符就可以扩展管道长度,管道的具体的长度和 ulimit -a 的配置有关
使用 exec 命令绑定文件描述符,注意文件描述符必须是0~255之间,并且0,1,2这三个文件描述符已经被操作系统用了,所以最少从3开始,我这里用了4
使用 read 命令一次读取一行,当管道文件中数据被读取完之后,进程被阻塞
有了管道之后改进脚本如下:
版本 3 (指定进程数量)
#!/bin/bash
# 创建一个管道文件
mkfifo mylist
# 给管道文件绑定文件描述符4
exec 4<>mylist
# 实现往管道文件中插入5个回车符,要开启几个子进程就插入多少个回车符
for ((i=0; i < 5; i++)); do
echo >mylist
done
for url in $(cat img.txt); do
# 子进程开始前先从管道文件中读取一个回车符,当读取完5个后就会被阻塞,下面代码就不会执行了
read mylist
} &
done
# 使用 wait 命令阻塞当前进程,直到所有子进程全部执行完
wait
echo "you have download all files"
# 全部结束后解绑文件描述符并删除管道文件
exec 4<&-
exec 4>&-
rm -f mylist
脚本写到这里功能就已经达到了,开启5个子进程同时下载,效率大幅提高。但仔细分析一下以上的代码其实已经可以做到实现进程池了,我的想法是把img.txt中的下载链接全部插入到管道文件,然后开启指定个数的子进程,每个子进程内部都不断从管道中获取数据,直到最终管道为空获取不到数据了再关闭子进程,线程池原理如下:
根据以上原理我将脚本改进如下:
版本 4 (线程安全问题)
#!/bin/bash
# 创建一个管道文件
mkfifo mylist
# 给管道文件绑定文件描述符4
exec 4<>mylist
# 开启5个子进程
for ((i=0; i < 5; i++)); do
# 这里的 & 会开启一个子进程执行
{
# 子进程内部不断从管道中读取数据,每读取到一行就开始下载
# read -t 1 指定超时时间,如果1s后还没有获取到数据则非0退出
while read -t 1 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
# 事先向锁文件中插入一个回车符(解锁)
echo >mylock
# 开启5个子进程
for ((i=0; i < 5; i++)); do
# 这里的 & 会开启一个子进程执行
{
# 子进程内部不断从管道中读取数据,每读取到一行就开始下载
# read -t 1 指定超时时间,如果1s后还没有获取到数据则非0退出
# 先读取锁文件(加锁),防止其他进程读取数据
while read -t 1 < mylock && read -t 1 url 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
如果有更好的想法欢迎留言讨论



