我在设计一个多处理的bash脚本时遇到了一些问题,这个脚本通过网站,找到链接并在每个新页面上进行一些处理(它实际上收集了电子邮件地址,但这对问题来说是一个不重要的细节)。
该脚本应该像这样工作:
编程本身很简单,问题来自两个限制和脚本需要的功能。
现在,我设法提出了一个实现,它为队列使用两个文件,其中一个存储已经处理过的所有URL,以及其他一个已找到但尚未处理的URL。
主要过程简单地生成一堆子进程,这些进程共享队列文件,并且(在循环中直到要处理的URL为空的队列中)从URLs-to-be-processed-queue
弹出一个URL,处理页面,尝试将每个新找到的链接添加到URLs-already-processed-queue
,如果成功(该URL尚未存在),也将其添加到URLs-to-be-processed-queue
。
问题在于你不能(AFAIK)使队列文件操作成为原子,因此锁定是必要的。锁定POSIX兼容的方式是......恐怖......慢恐怖。
我这样做的方法如下:
#Pops first element from a file ($1) and prints it to stdout; if file emepty print out empty return 1
fuPop(){
if [ -s "$1" ]; then
sed -nr '1p' "$1"
sed -ir '1d' "$1"
return 0
else
return 1
fi
}
#Appends line ($1) to a file ($2) and return 0 if it's not in it yet; if it, is just return 1
fuAppend(){
if grep -Fxq "$1" < "$2"; then
return 1
else
echo "$1" >> "$2"
return 0
fi
}
#There're multiple processes running this function.
prcsPages(){
while [ -s "$todoLinks" ]; do
luAckLock "$linksLock"
linkToProcess="$(fuPop "$todoLinks")"
luUnlock "$linksLock"
prcsPage "$linkToProcess"
...
done
...
}
#The prcsPage downloads it, does some magic and than calls prcsNewLinks and prcsNewEmails that both get list of new emails / new urls in $1
#$doneEmails, ..., contain file path, $mailLock, ..., contain dir path
prcsNewEmails(){
luAckLock "$mailsLock"
for newEmail in $1; do
if fuAppend "$newEmail" "$doneEmails"; then
echo "$newEmail"
fi
done
luUnlock "$mailsLock"
}
prcsNewLinks(){
luAckLock "$linksLock"
for newLink in $1; do
if fuAppend "$newLink" "$doneLinks"; then
fuAppend "$newLink" "$todoLinks"
fi
done
luUnlock "$linksLock"
}
问题是我的实现很慢(比如非常慢),几乎是如此慢以至于使用超过 2 10没有意义(减少锁等待帮助很大)子进程。你实际上可以禁用锁(只是注释掉luAckLock和luUnlock位)并且它工作得很好(而且速度要快得多)但是seds -i
每隔一段时间就会出现竞争条件,它只是没有&感觉没错。
最糟糕的(我认为)锁定在prcsNewLinks
,因为它需要相当多的时间(大部分时间基本上都是运行)并且实际上阻止其他进程开始处理新页面(因为它需要)从(当前锁定的)$todoLinks
队列中弹出新URL。
现在我的问题是,如何做得更好,更快,更好?
整个脚本是here(它包含一些信号魔法,很多调试输出,通常不是那么好的代码)。
顺便说一句:是的,你是对的,在bash中这样做 - 以及更符合POSIX标准的方式 - 是疯了!但它的大学任务所以我有点必须这样做//虽然我觉得我并没有真正期望我解决这些问题(因为竞争条件更频繁地出现,只有当有25个以上的线程可能不是一个理智的人会测试的时候)。
代码注释:
答案 0 :(得分:2)
首先,您是否需要实施自己的HTML / HTTP抓取?为什么不让wget
或curl
通过网站递送给您?
您可以将文件系统滥用为数据库,并使您的队列成为单行文件的目录。 (或文件名为数据的空文件)。这将为您提供生产者 - 消费者锁定,生产者触摸文件,消费者将其从传入目录移动到处理/完成目录。
这就是多个线程触及同一个文件Just Works。所需的结果是url在“incoming”列表中出现一次,这就是当多个线程创建具有相同名称的文件时所获得的结果。由于您需要重复数据删除,因此在写入传入列表时不需要锁定。
1)下载页面
2)解析所有链接并将它们添加到队列
对于找到的每个链接,
grep -ql "$url" already_checked || : > "incoming/$url"
或
[[ -e "done/$url" ]] || : > "incoming/$url"
3)做一些不重要的处理
4)从队列中弹出一个URL并从1)
开始
# mostly untested, you might have to debug / tweak this
local inc=( incoming/* )
# edit: this can make threads exit sooner than desired.
# See the comments for some ideas on how to make threads wait for new work
while [[ $inc != "incoming/*" ]]; do
# $inc is shorthand for "${inc[0]}", the first array entry
mv "$inc" "done/" || { rm -f "$inc"; continue; } # another thread got that link, or that url already exists in done/
url=${inc#incoming/}
download "$url";
for newurl in $(link_scan "$url"); do
[[ -e "done/$newurl" ]] || : > "incoming/$newurl"
done
process "$url"
inc=( incoming/* )
done
编辑:将URL编码为不包含/
的字符串留给读者练习。虽然将/
到%2F
的urlencoding可能会运行得很好。
我正在考虑将网址移动到每个线程的“处理”列表,但实际上如果您不需要能够从中断恢复,那么您的“完成”列表可以改为“排队和完成”列表。我不认为它对mv "$url" "threadqueue.$$/
或其他东西实际上有用。
“done /”目录会变得非常大,并且开始使用大约10k文件减慢速度,具体取决于您使用的文件系统。将“完成”列表维护为每行一个URL的文件或数据库(如果有单个命令快速的数据库CLI界面)可能更有效。
将完成列表维护为文件并不错,因为您永远不需要删除条目。你可以在不锁定它的情况下逃脱,即使是附加它的多个进程也是如此。 (我不确定如果线程B在线程A打开文件和线程A写入之间的EOF写入数据会发生什么。线程A的文件位置可能是旧的EOF,在这种情况下它会覆盖线程B的条目,或者更糟,只覆盖它的一部分。如果你确实需要锁定,那么flock(1)
可能会有用。它会锁定,然后执行你传递给args的命令。)
如果由于缺少写入锁定而导致文件损坏,则可能不需要写入锁定。与必须为每次检查/追加锁定相比,“完成”列表中的偶然重复条目将是一个微小的减速。
如果您需要严格正确地避免多次下载相同的URL,您需要读者等待作者完成。如果您最后只能sort -u
电子邮件列表,那么在添加列表时读取旧拷贝并不是灾难。然后编写者只需要互相锁定,读者就可以只读取文件。如果他们在作家设法写一个新条目之前就打了EOF,那就这样吧。
我不确定是否一个线程在将条目从传入列表中删除之前或之后将条目添加到“完成”列表中是否重要,只要它们在处理之前将它们添加到“完成”即可。我当时认为某种方式可能会使比赛更有可能导致重复的完成条目,并且不太可能重复下载/处理,但我不确定。