为什么 shell 脚本很慢
速度慢的原因是,对于 5 亿行中的每一行,您都在强制您的 shell 创建 3 个进程,因此您的内核正在努力生成 15 亿个进程。假设它每秒可以处理一万个进程;你还在看 15 万秒,也就是 2 天。每秒 10k 进程很快;可能比你得到的要好十倍或更多。在我的 2016 年 15" MacBook Pro 上运行 macOS High Sierra 10.13.1,配备 2.7 GHz Intel Core i7、16 GB 2133 MHz LPDDR3 和 500 GB 闪存(大约 150 GB 空闲空间),我每秒处理大约 700 个进程,所以脚本名义上需要将近 25 天的时间来运行 5 亿条记录。
加快速度的方法
有一些方法可以使代码更快。您可以使用纯 shell、Awk、Python 或 Perl。请注意,如果您使用 Awk,它必须是 GNU Awk,或者至少不是 BSD (macOS) Awk — BSD 版本只是认为它没有足够的文件描述符。
我使用随机数据生成器创建了一个包含 100,000 个随机条目的文件,这与问题中的内容有些相似:
E1E583ZUT9 E1E583ZUT9.9 422255 490991884
Z0L339XJB5 Z0L339XJB5.0 852089 601069716
B3U993YMV8 B3U993YMV8.7 257653 443396409
L2F129EXJ4 L2F129EXJ4.8 942989 834728260
R4G123QWR2 R4G123QWR2.6 552467 744905170
K4Z576RKP0 K4Z576RKP0.9 947374 962234282
Z4R862HWX1 Z4R862HWX1.4 909520 2474569
L5D027SCJ5 L5D027SCJ5.4 199652 773936243
R5R272YFB5 R5R272YFB5.4 329247 582852318
G1I128BMI2 G1I128BMI2.6 359124 404495594
(使用的命令是即将重写的自制生成器。)前两列在模式X#X###XXX# 中具有相同的 10 个前导字符(X 表示字母,# 表示数字) ;唯一的区别在于后缀.#。这在脚本中没有被利用;一点也不重要。也不能保证第二列中的值是唯一的,如果出现 .2 条目,也不能保证出现 .1 条目作为键,等等。这些细节对于性能测量大多无关紧要。由于文件名使用字母数字字母前缀,因此 26 * 10 * 26 = 6760 个可能的文件前缀。对于 100,000 条随机生成的记录,这些前缀中的每一个都存在。
我编写了一个脚本来计时处理数据的各种方式。有 4 个 shell 脚本变体——由 OPLucas A 发布的一个变体;两个由chepner 发布(一个是 cmets),一个是我创建的。还有 dawg 创建的 Awk 脚本,chepner 发布的 Python 3 脚本的轻微修改版本,以及我编写的 Perl 脚本。
结果
结果可以通过这张表来总结(运行时间以经过时间或挂钟时间的秒数衡量):
╔═════════════════╦════╦═════════╦═════════╦═════════╦═════════╗
║ Script Variant ║ N ║ Mean ║ Std Dev ║ Min ║ Max ║
╠═════════════════╬════╬═════════╬═════════╬═════════╬═════════╣
║ Lucas A Shell ║ 11 ║ 426.425 ║ 16.076 ║ 408.044 ║ 456.926 ║
║ Chepner 1 Shell ║ 11 ║ 39.582 ║ 2.002 ║ 37.404 ║ 43.609 ║
║ Awk 256 ║ 11 ║ 38.916 ║ 2.925 ║ 30.874 ║ 41.737 ║
║ Chepner 2 Shell ║ 11 ║ 16.033 ║ 1.294 ║ 14.685 ║ 17.981 ║
║ Leffler Shell ║ 11 ║ 15.683 ║ 0.809 ║ 14.375 ║ 16.561 ║
║ Python 7000 ║ 11 ║ 7.052 ║ 0.344 ║ 6.358 ║ 7.771 ║
║ Awk 7000 ║ 11 ║ 6.403 ║ 0.384 ║ 5.498 ║ 6.891 ║
║ Perl 7000 ║ 11 ║ 1.138 ║ 0.037 ║ 1.073 ║ 1.204 ║
╚═════════════════╩════╩═════════╩═════════╩═════════╩═════════╝
原来的shell脚本比Perl慢2.5个数量级;当有足够的文件描述符可用时,Python 和 Awk 的性能几乎相同(如果没有足够的文件描述符可用,Python 就会停止;Perl 也是如此)。编写 shell 脚本的速度大约是 Python 或 Awk 的一半。
7000 表示需要打开的文件数 (ulimit -n 7000)。这是因为生成的数据中有 26 * 10 * 26 = 6760 个不同的 3 字符起始码。如果您有更多模式,您将需要更多打开的文件描述符以获得保持它们全部打开的好处,或者您将需要编写一种文件描述符缓存算法,有点像 GNU Awk 必须使用的算法,具有相应的性能损失。请注意,如果数据按排序顺序显示,以便每个文件的所有条目按顺序显示,那么您将能够调整算法,以便一次只打开一个输出文件。随机生成的数据没有按顺序排列,因此对任何缓存算法都有很大影响。
脚本
以下是本练习中测试的各种脚本。这些以及大部分支持材料都可以在 GitHub 上的soq/src/so-4747-6170 中获得。
并非所有使用的代码都存在于 GitHub 中。
Lucas A Shell — 又名 opscript.sh
cat "$@" |
while read line
do
PREFIX=$(echo "$line" | cut -f2 | cut -c1-3)
echo -e "$line" >> split_DB/$PREFIX.part
done
这是对cat 的一种并非完全无用的用法(参见UUoC — Useless Use of cat 进行比较)。如果没有提供参数,它将标准输入复制到while 循环;如果提供了任何参数,它们将被视为文件名并传递给cat,并将这些文件的内容复制到while 循环。原始脚本中有一个硬连线< file。在这里使用cat 没有可衡量的性能成本。 Chepner 的 shell 脚本也需要进行类似的更改。
Chepner 1 Shell — 又名 chepner-1.sh
cat "${@}" |
while read -r line; do
read -r _ col2 _ <<< "$line"
prefix=${col2:0:3}
printf '%s\n' "$line" >> split_DB/$prefix.part
done
Chepner 2 Shell — 又名 chepner-2.sh
cat "${@}" |
while read -r line; do
prefix=${line:12:3}
printf '%s\n' "$line" >> split_DB/$prefix.part
done
莱弗勒壳牌——又名jlscript.sh
sed 's/^[^ ]* \(...\)/\1 &/' "$@" |
while read key line
do
echo "$line" >> split_DB/$key.part
done
Awk 脚本 - 又名 awkscript.sh
exec ${AWK:-awk} '{s=substr($2,1,3); print >> "split_DB/" s ".part"}' "$@"
这在脚本的紧凑性方面胜出,并且在使用 GNU Awk 和足够的可用文件描述符运行时具有不错的性能。
Python 脚本——又名pyscript.py
这是一个 Python 3 脚本,是 Chepner 发布的内容的轻微修改版本。
import fileinput
output_files = {}
#with open(file) as fh:
# for line in fh:
for line in fileinput.input():
cols = line.strip().split()
prefix = cols[1][0:3]
# Cache the output file handles, so that each is opened only once.
#outfh = output_files.setdefault(prefix, open("../split_DB/{}.part".format(prefix), "w"))
outfh = output_files.setdefault(prefix, open("split_DB/{}.part".format(prefix), "w"))
print(line, file=outfh)
# Close all the output files
for f in output_files.values():
f.close()
Perl 脚本 — 又名 jlscript.pl
#!/usr/bin/env perl
use strict;
use warnings;
my %fh;
while (<>)
{
my @fields = split;
my $pfx = substr($fields[1], 0, 3);
open $fh{$pfx}, '>>', "split_DB/${pfx}.part" or die
unless defined $fh{$pfx};
my $fh = $fh{$pfx};
print $fh $_;
}
foreach my $h (keys %fh)
{
close $fh{$h};
}
测试脚本——又名test-script.sh
#!/bin/bash
#
# Test suite for SO 4747-6170
set_num_files()
{
nfiles=${1:-256}
if [ "$(ulimit -n)" -ne "$nfiles" ]
then if ulimit -S -n "$nfiles"
then : OK
else echo "Failed to set num files to $nfiles" >&2
ulimit -HSa >&2
exit 1
fi
fi
}
test_python_7000()
{
set_num_files 7000
timecmd -smr python3 pyscript.py "$@"
}
test_perl_7000()
{
set_num_files 7000
timecmd -smr perl jlscript.pl "$@"
}
test_awk_7000()
{
set_num_files 7000
AWK=/opt/gnu/bin/awk timecmd -smr sh awkscript.sh "$@"
}
test_awk_256()
{
set_num_files 256 # Default setting on macOS 10.13.1 High Sierra
AWK=/opt/gnu/bin/awk timecmd -smr sh awkscript-256.sh "$@"
}
test_op_shell()
{
timecmd -smr sh opscript.sh "$@"
}
test_jl_shell()
{
timecmd -smr sh jlscript.sh "$@"
}
test_chepner_1_shell()
{
timecmd -smr bash chepner-1.sh "$@"
}
test_chepner_2_shell()
{
timecmd -smr bash chepner-2.sh "$@"
}
shopt -s nullglob
# Setup - the test script reads 'file'.
# The SOQ global .gitignore doesn't permit 'file' to be committed.
rm -fr split_DB
rm -f file
ln -s generated.data file
# Ensure cleanup
trap 'rm -fr split_DB; exit 1' 0 1 2 3 13 15
for function in \
test_awk_256 \
test_awk_7000 \
test_chepner_1_shell \
test_chepner_2_shell \
test_jl_shell \
test_op_shell \
test_perl_7000 \
test_python_7000
do
mkdir split_DB
boxecho "${function#test_}"
time $function file
# Basic validation - the same information should appear for all scripts
ls split_DB | wc -l
wc split_DB/* | tail -n 2
rm -fr split_DB
done
trap 0
此脚本是使用命令行符号运行的:
time (ulimit -n 7000; TRACEDIR=. Trace bash test-script.sh)
Trace 命令将所有标准输出和标准错误记录到一个 lgo 文件,并将其回显到它自己的标准输出,它报告广义上的“环境”(环境变量、ulimit 设置、日期、时间、命令、当前目录、用户/组等)。运行完整的测试集只用了不到 10 分钟,其中四分之三用于运行 OP 的脚本。