はじめに
xargs コマンドの -P オプションを使用すると、指定したコマンドを並列で実行できます。この記事では次期 POSIX (POSIX.1-2024の次、10年後ぐらい)で標準化される予定の -P オプションを使った並列処理についての注意点をまとめます。
シェルスクリプトで簡単に並列処理を行う場合、xargs コマンドを使うのが簡単です。しかし xargs コマンド自体の使い方は難しいということは知っておいてください。(並列処理の話とは関係なく)find | xargs を使ったパターンをよく見かけますが(xargs と同等程度に速い)find -exec {} + を使ったパターンのほうが簡単です(遅い find -exec {} \; と混同しないように)。必要ない場合 xargs コマンドを使わないほうが良いというのは、この記事のもう一つのテーマです。
最も簡単な並列処理の例
xargs コマンドを使った並列処理の最も簡単な例です。
seq 4 | xargs -P 4 -I{} sleep 2
このコマンドは sleep コマンドを同時に 4 プロセス並列で実行します。seq コマンドで 4 つの sleep コマンドを実行していますが、並列で実行されるため、全体の処理は 2 秒で処理は終了します。ちなみに -I{} は本来は実行する sleep コマンドの引数に {} と書いて、入力データを sleep の引数に置き換えるものですが、ここでは省略しているため入力データは捨てられます。
# -I{} の本来の使い方
# sleep 1、sleep 2、sleep 3、sleep 4 が実行される
seq 4 | xargs -P 4 -I{} sleep {}
-P 0 は多数のプロセスを生成する
多くの場合、-P 0 の並列実行数は CPU コア数ではありません。例えば次のようなコマンドを実行するとおよそ 2 秒で終了します。つまり 1000 プロセスが並列で実行していることになります。
seq 1000 | xargs -P 0 -I{} sleep 2
速く終了するのだから良いじゃないかと思うのは早計で、2 秒で終了するのは sleep コマンドが何もしないコマンドだからです。実際は 1000 個のプロセスが同時に起動して並列処理を行うため、システムリソースを過剰に使用して逆に遅くなってしまう場合があります。-P には最大のプロセス数を制限するようにしましょう。一般的には CPU コア数前後が最適です。
CPU 数を取得するには nproc コマンドが便利です。nproc コマンドは POSIX で規定されていませんが多くの環境で使えます。POSIX.1-2024 で標準化された方法として getconf NPROCESSORS_ONLN でも CPU 数を取得できますが、現時点では Linux や Solaris などでは実装されていない(正確には Linux では getconf _NPROCESSORS_ONLN で取得できる)ようです。次のようなコードを書けば、nproc コマンドがない環境(FreeBSD以外のBSD系Unix)でも nproc コマンドが使えるようになります。
if ! type nproc >/dev/null 2>&1; then
nproc() { getconf NPROCESSORS_ONLN; }
fi
nproc コマンドの呼び出しを xargs コマンドの引数に直接書くのもよくありません。なぜなら nproc コマンドの呼び出しが失敗したときに対応できないからです。nproc コマンドの出力は一度変数に入れてから使いましょう。変数に入れると「CPU数 - 1」のような書き方も見やすく書けます。
# xargsコマンドの引数に nproc コマンドを書くと nproc コマンドが失敗した場合に対応できない
seq 1000 | xargs -P "$(nproc)" -I{} sleep 2
# nproc による CPU 数が取得できない場合は 4 とする
NPROC=$(nproc 2>/dev/null) || NPROC=4
seq 1000 | xargs -P "$NPROC" -I{} sleep 2
# CPU 数 - 1 にする場合
seq 1000 | xargs -P $((NPROC - 1)) -I{} sleep 2
ちなみに次期 POSIX では、-P 0 を指定したときに「実行時間が最小になるような数を選択する」と規定されており、つまりそれは実質的に未指定であり、現在の無制限から CPU 数に応じて調整するように変更しても構わないような柔軟な文章で規定されています。将来の実装では多数のプロセスが生成されなくなるかもしれません。例えば組み込み用の BusyBox では最大 100 に制限されているようです。
長いファイル名は -I{} で扱えないことがある
一般的に -I オプションは次のように使います。
find . -name "*.txt" | xargs -P 4 -I{} gzip {}
しかし -I にはサイズ制限があり、長いパスでは使えないことがあります。以下の例では printf コマンドを使って長いパスを生成しています。
# 131067 + 5 (echo) = 131072 (128KB)
$ printf "%0131067d\n" 0 | xargs -I{} echo {}
xargs: argument list too long
# 補足: いずれにしろLinuxの場合は1つの引数に 128 KB の制限がある
$ /bin/echo "$(printf "%0131072d\n" 0)
(古い?)macOS や一部の BSD 系 Unix の実装では、制限を超えてもエラーにならず、黙って {} として扱われるので問題になることがあります。
$ printf "%0254d\n" 0 | xargs -I{} echo {}
000000000000000 ... 0000
# macOS Ventura 13.4 での出力(エラーにならない、ひどい!)
$ printf "%0255d\n" 0 | xargs -I{} echo {}
{}
# FreeBSD 13.3では次のようにエラーになるので最新のmacOSは修正されてるかも
$ printf "%0255d\n" 0 | xargs -I{} echo {}
xargs: command line cannot be assembled, too long
macOS や FreeBSD や NetBSD ではこのサイズを -S オプションで増やすことができます。ただし -S オプションは POSIX では標準化されておらず、GNU 版 xargs では使えません。
$ printf "%0131072d\n" 0 | xargs -S 10000000 -I{} echo {}
000000000000000 ... 0000
しかし、OpenBSD や Solaris では -S オプションが使えず、おそらく -I オプションを使う限りサイズ制限を逃れることはでません。問題になりそうな場合、-I オプションの使用は避けたほうが良いでしょう。
個数制限をしなければ並列実行の効果はない
-I オプションの隠された(?)効果は、1行1データにすることです。つまり -I オプションを指定すると、-L 1 オプションが指定されたのと同じ効果があります。
# -Iオプションを指定すると5個のコマンドが実行される
$ seq 5 | xargs -P 5 -I {} echo run {}
run 1
run 2
run 3
run 4
run 5
# -L 1 オプションを指定した場合も同様
$ seq 5 | xargs -P 5 -L 1 echo run
run 1
run 2
run 3
run 4
run 5
もし -I または -L 1 を指定しなければ次のようにまとめて実行されます。
$ seq 5 | xargs -P 5 echo run
run 1 2 3 4 5
つまりどういうことかというと、-P オプションで 5 並列で実行するように指定していたとしても並列で実行されないということです。xargs コマンドで並列処理を行う場合は -L オプションまたは -n オプションでコマンドに渡す引数の個数を制限するようにしましょう。ちなみに引数の個数は 1 つである必要はありません。場合によっては並列で実行するよりもある程度の個数をまとめて実行し、コマンド実行の数を減らしたほうが速い場合もあります。
-L オプションと -n オプションの違い
-L オプションと -n オプションはどちらも引数の数を制限するためのオプションですが、動作に違いがあります。
# -Lは1行が1データとなる
$ echo "1 2 3 4 5" | xargs -L 1 echo run
run 1 2 3 4 5
# -nはホワイトスペースで区切られたものが1データとなる
$ echo "1 2 3 4 5" | xargs -n 1 echo run
run 1
run 2
run 3
run 4
run 5
どちらを使えばよいかは状況次第ですが、ファイル名にスペースが含まれている場合を考慮すると -L オプションを使った方が良いと言えるでしょう。しかし -L オプションにも問題がないわけではありません。次のコードは 2 行のデータのはずですが、-L オプションは 1 つにまとめて実行してしまいます。これは 1 行目の最後にスペースが有るためです。
# 3の後ろにスペースがあるので、次の行とつながって 1 行として扱われる
$ { echo "1 2 3 "; echo "4 5"; } | xargs -L 1 echo run
run 1 2 3 4 5
ファイルパスの最後にスペースが来ることはまずありませんが注意が必要です。
余談ですが -l や -i のように同じような意味の小文字のオプションがありますが、これは古い Unix で歴史的に使われていたオプションです。オプションの仕様が POSIX に準拠しないという理由で、新たに作られたのが -L や -I です。したがって特に理由がない限り -l や -i を使う必要はありません。
クォーテーションが含まれるとエラーになる
-L オプションを使って 1 行 1 データにしたとしてもファイルパスにクォーテーションが含まれる場合に問題になります。
# シングルクォーテーションが含まれるとエラー
$ echo "test'test" | xargs -L 1 echo run
xargs: unterminated quote
# ダブルクォーテーションが含まれるとエラー
$ echo 'test"test' | xargs -L 1 echo run
xargs: unterminated quote
他にもバックスラッシュが含まれた場合に問題となります。
# バックスラッシュが消え去る
$ printf '%s\n' 'test\test' | xargs -L 1 echo run
run testtest
# バックスラッシュはxargsコマンドのメタ文字をエスケープするための特殊文字
$ printf '%s\n' 'test\"test' | xargs -L 1 echo run
run test"test
この問題は -L オプションだけではなく -n オプションにもあります。xargs コマンドは本来シンプルな行を入力するコマンドではなく、xargs 形式を入力するコマンドなのです。
POSIX 準拠の -0 オプションを使う
xargs の話をすると、結局は最後にこの話にたどり着きます。xargs コマンドは本来 xargs 形式を入力するコマンドなので、ファイルパスを入力できません。その問題を解決するためにできたのが -0 オプションで、多くの環境ですでに実装されていることから POSIX.1-2024 でも標準化されました。-0 オプションは入力するデータ形式を xargs 形式からヌル文字(\0)区切りのデータ形式に変更する機能です。正確にはデータの間にヌル文字が含まれるのではなく、データの終わりに改行の代わりにヌル文字が使われるヌル文字終端形式です。
-0 オプションを使うことでデータの区切りは改行やホワイトスペースではなくなります。
$ printf '%s\0' 1 2 3 | xargs -0 -L 1 echo run
run 1
run 2
run 3
$ printf '%s\0' 1 2 3 | xargs -0 -n 1 echo run
run 1
run 2
run 3
-0 オプションを指定したときに、-L オプションと -n オプションの違いはなくなるように思えまが、-0 オプションを指定したときは -n の使用を推奨します。なぜなら -L オプション(とついでに -I オプション)は POSIX で XSI オプションとして規定されており移植性がないことが暗示されるからです。そして実際に BusyBox xargs では -L オプションは使えません。
シェルスクリプトコードを組み立てない
これは並列処理に限らず、よく見かけるバッドプラクティスですが、シェルスクリプトのコードを文字列を加工して組み立てて実行しないようにしてください。それは eval コマンドと同じです。どういうコードかというとこのようなコードです。
# ヌル文字終端を使っていてもパスにダブルクォーテーションが含まれていると
# xargs自体は問題なくてもシェルの文法としてエラーが発生する
find . -name "*.tar" -print0 | xargs -0 -I{} sh -c 'gzip "{}"'
# 補足: xargsとは関係ないが、このような使い方も危険
find . -name "*.jpeg" | sed -E 's/(.*)\.jpg$/mv "&" "\1.jpg"/' | sh
良い書き方はコードを組み立てません。
# 末尾の「-」はshコマンドの使用で$0に相当する引数が必要なため
# ($0はエラーメッセージなどで使われる。基本的になんでもよい。)
find . -name "*.jpeg" -print0 | xargs -0 -n1 sh -c 'mv "$1" "${1%.*}.jpg"' -
OSコマンドインジェクションの脆弱性が発生するのは eval コマンドだけではありません。シェルスクリプトのコードを組み立てるときや、awkコマンドのコードを組み立てるときにも脆弱性は発生します。
# 非推奨: from や to の中に system(...) などの文字列が含まれていると危険
awk "$from"' <= $1 && $1 <= '"$to"
# 安全な書き方(コードを組み立てない)
awk -v from="$from" -v to="$to" 'from <= $1 && $1 <= to'
シェルスクリプト(やawk)のコードを組み立てるときは脆弱性を引き起こさないように、エスケープや文字チェックをしっかり行う必要があります。
結局どう書けばいいの?
xargs コマンドを使って並列処理を行うとき、まず -0 オプションを指定しデータの区切りをヌル文字終端にしてから、制限がある -I オプションを使わずに -n Nでコマンドに渡す個数を指定し、-P N で最大の並列実行数を指定します。そしてシェルスクリプトコードを組み立てないようにします。
NPROC=$(nproc 2>/dev/null) || NPROC=4
find . -name "*.png" -print0 | \
xargs -0 -n 1 -P "$NPROC" sh -c 'convert "$1" "${1%.*}.jpg"' -
# 補足: convert は ImageMagick に含まれる画像変換コマンド
対話シェルで実行するだけだから手抜きで十分(Linuxで動けば良い、または長いファイル名がなく、クォーテーションなども含まれない)というのであれば、次のような書き方でも問題ないでしょう。
find . -name "*.tar" | xargs -L1 -P4 gzip
find . -name "*.tar" | xargs -I{} -P4 gzip {}
手抜きで短く書くか、きっちり安全に書くか、場合によって使い分けてください。
さいごに
だから xargs コマンドの使い方は難しいんだってば。気軽に使おうとするなよ。これだけで十分だっけ? 足りなければ後で追加します。