xargsで並列にコマンドを実行しつつ標準出力・エラーを区別出来るようにする

たまにシェル上での処理を並列実行したくなりますよね?単にバックグラウンドで実行するだけなら&だけでいいんですが、定期的に実行するような時間のかかるスクリプトを複数CPU使って速く終わらせたい場合はとてもあると思います。そういう時に便利なのがxargs(macOSに最初から入っているので良い)。

find . -name *.txt -print0 | xargs -0 -L1 -P4 ./script.sh

こんな感じで自分でfor-loopとか使って頑張らなくても勝手に標準入力から入力内容を読み取ってくれて、入力に応じて並列に処理を進めてくれる。(ちなみにfindの-print0とxargs -0は組み合わせて使うことを意図されていて、入力の分割を改行・空白ごとではなく\0 null文字でしてくれるようになるのでfindで見つけたファイル名に空白が含まれていても処理がおかしくなることがない。) -Pオプションがプロセス数の指定で最大4並列で指定したコマンドを実行できる。ただ、この時にエラーが発生した時にどの入力に対して怒ったのか分からないと困る時がありますよね。スクリプトの中で独自にエラーをログに残す処理を書いたりしても良いけど、既存のコマンドを単に組み合わせているだけの場合はラッパーなどを書くのは面倒臭い。

その場合は、こんな感じでxargsの"-I"オプションとsh -cで別のシェルでコマンドを起動することで標準エラー出力にprefixを付けることができる。

find . -name *.txt -print0 | \
xargs -0 -L1 -P4 -I{} sh -c "./script.sh {} 2>&1 | while read -r line; do echo \"{}:\$line:\"; done"
  1. xargs-Iオプションを使って処理する入力値を置換文字列(?)として定義しておく。そうすると実際に実行するコマンド部分の好きな箇所で入力値が扱えるようになる。(上の例では {} を置換文字列にしておいた)
  2. sh -cで並列化したいコマンドを実行する。コマンドの標準エラーを標準出力にリダイレクトしてパイプで渡せるようにする。
  3. 並列化したコマンドの出力をwhile readで行ごと読み込んでechoで再度出力する。その際に、xargsの置換文字列をprefixとして足せばエラーが発生した入力がなんだったのか区別がつく。whiile readのあたりは何使っても良いので ruby -e 'STDIN.each {|x| puts "{}:#{x}" }' みたいなのでも問題ない。

そんな感じでxcodebuildのimportLocalizationsを実行するfastlaneのpluginの並列化してみた。17言語ぐらいを逐次で実行すると3〜4分ぐらいかかるのが、全部同時に実行すると20秒前後で終わるようになる。ただし言語ファイルごとにこけることがあるのでエラーログが区別出来ないと運用上心許ない。

# Join file paths with null sequence to use "xargs -0" options
# so that 'xargs' can ensure that 'xargs' processes a given filename including even whitespaces
source_paths = params[:source_paths].map { |x| Shellwords.escape(x) }.join('\0')
project = Shellwords.escape(params[:project])
concurrency = Shellwords.escape(params[:concurrency])

# This value is used to pass a variable coming from xargs to the command executed.
xargs_param = "{}"

# xcodebuild command to import localizations
xcodebuild = %(xcodebuild -importLocalizations -project #{project} -localizationPath #{xargs_param})

# In order to distinguish error messages, this script append a filename as perfix to each line of output
error_logger = %(while read -r error; do echo \\"#{xargs_param}: \\$error\\"; done)

# 1. List xliff files that you want to import
# 2. xargs run sub shells that execute xcodebuild in prallel
sh "echo \"#{source_paths}\" | xargs -0 -L1 -P#{concurrency} -I#{xargs_param} sh -c \"#{xcodebuild} 2>&1 | #{error_logger}\""

https://github.com/ainame/fastlane-plugin-localization/blob/9c81565c5e15247e1ce83f382b380de90f9007b3/lib/fastlane/plugin/localization/actions/import_localizations_parallel_action.rb#L26