xargs -Pで安全に並列処理するシェルスクリプトの書き方

## はじめに

`xargs` コマンドの `-P` オプションを使用すると、指定したコマンドを並列で実行できます。この記事では[次期 POSIX （POSIX.1-2024の次、10年後ぐらい）で標準化される予定](https://www.austingroupbugs.net/view.php?id=1801)の `-P` オプションを使った並列処理についての注意点をまとめます。

シェルスクリプトで簡単に並列処理を行う場合、`xargs` コマンドを使うのが簡単です。しかし **`xargs` コマンド自体の使い方は難しい**ということは知っておいてください。（並列処理の話とは関係なく）`find | xargs` を使ったパターンをよく見かけますが（`xargs` と同等程度に速い）`find -exec {} +` を使ったパターンのほうが簡単です（遅い `find -exec {} \;` と混同しないように）。**必要ない場合 `xargs` コマンドを使わないほうが良い**というのは、この記事のもう一つのテーマです。

## 最も簡単な並列処理の例

`xargs` コマンドを使った並列処理の最も簡単な例です。

```sh
seq 4 | xargs -P 4 -I{} sleep 2
```

このコマンドは `sleep` コマンドを同時に 4 プロセス並列で実行します。`seq` コマンドで 4 つの `sleep` コマンドを実行していますが、並列で実行されるため、全体の処理は 2 秒で処理は終了します。ちなみに `-I{}` は本来は実行する `sleep` コマンドの引数に `{}` と書いて、入力データを `sleep` の引数に置き換えるものですが、ここでは省略しているため入力データは捨てられます。

```sh
# -I{} の本来の使い方
#  sleep 1、sleep 2、sleep 3、sleep 4 が実行される
seq 4 | xargs -P 4 -I{} sleep {}
```

## -P 0 は多数のプロセスを生成する

多くの場合、**`-P 0` の並列実行数は CPU コア数ではありません**。例えば次のようなコマンドを実行するとおよそ 2 秒で終了します。つまり 1000 プロセスが並列で実行していることになります。

```sh
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` コマンドが使えるようになります。

```sh
if ! type nproc >/dev/null 2>&1; then
  nproc() { getconf NPROCESSORS_ONLN; }
fi
```

`nproc` コマンドの呼び出しを `xargs` コマンドの引数に直接書くのもよくありません。なぜなら `nproc` コマンドの呼び出しが失敗したときに対応できないからです。`nproc` コマンドの出力は一度変数に入れてから使いましょう。変数に入れると「CPU数 - 1」のような書き方も見やすく書けます。

```sh
# 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` オプションは次のように使います。

```sh
find . -name "*.txt" | xargs -P 4 -I{} gzip {}
```

しかし `-I` にはサイズ制限があり、長いパスでは使えないことがあります。以下の例では `printf` コマンドを使って長いパスを生成しています。

```sh:Linuxの場合はファイルパス程度であれば問題ない
# 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 の実装では、制限を超えてもエラーにならず、黙って `{}` として扱われるので問題になることがあります。

```sh:macOSの場合255バイトの制限がある
$ 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 では使えません。

```sh:一部の実装では-Sオプションでサイズ制限を増やせる
$ printf "%0131072d\n" 0 | xargs -S 10000000 -I{} echo {}
000000000000000 ... 0000
```

しかし、OpenBSD や Solaris では `-S` オプションが使えず、おそらく **`-I` オプションを使う限りサイズ制限を逃れることはでません**。問題になりそうな場合、`-I` オプションの使用は避けたほうが良いでしょう。

## 個数制限をしなければ並列実行の効果はない

`-I` オプションの隠された（？）効果は、1行1データにすることです。つまり `-I` オプションを指定すると、`-L 1` オプションが指定されたのと同じ効果があります。 

```sh
# -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` を指定しなければ次のようにまとめて実行されます。

```sh
$ seq 5 | xargs -P 5 echo run
run 1 2 3 4 5
```

つまりどういうことかというと、`-P` オプションで 5 並列で実行するように指定していたとしても**並列で実行されない**ということです。`xargs` コマンドで並列処理を行う場合は `-L` オプションまたは `-n` オプションでコマンドに渡す引数の個数を制限するようにしましょう。ちなみに引数の個数は 1 つである必要はありません。場合によっては並列で実行するよりもある程度の個数をまとめて実行し、コマンド実行の数を減らしたほうが速い場合もあります。

## -L オプションと -n オプションの違い

`-L` オプションと `-n` オプションはどちらも引数の数を制限するためのオプションですが、動作に違いがあります。

```sh
# -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 行目の最後にスペースが有るためです。

```sh
# 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 データにしたとしてもファイルパスにクォーテーションが含まれる場合に問題になります。

```sh
# シングルクォーテーションが含まれるとエラー
$ echo "test'test" | xargs -L 1 echo run
xargs: unterminated quote

# ダブルクォーテーションが含まれるとエラー
$ echo 'test"test' | xargs -L 1 echo run
xargs: unterminated quote
```

他にもバックスラッシュが含まれた場合に問題となります。

```sh
# バックスラッシュが消え去る
$ 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` オプションを使うことでデータの区切りは改行やホワイトスペースではなくなります。

```sh
$ 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` コマンドと同じ**です。どういうコードかというとこのようなコードです。

```sh:バッドプラクティス
# ヌル文字終端を使っていてもパスにダブルクォーテーションが含まれていると
# xargs自体は問題なくてもシェルの文法としてエラーが発生する
find . -name "*.tar" -print0 | xargs -0 -I{} sh -c 'gzip "{}"'

# 補足: xargsとは関係ないが、このような使い方も危険
find . -name "*.jpeg" | sed -E 's/(.*)\.jpg$/mv "&" "\1.jpg"/' | sh
```

良い書き方は**コードを組み立てません**。

```sh:グッドプラクティス
# 末尾の「-」はshコマンドの使用で$0に相当する引数が必要なため
# （$0はエラーメッセージなどで使われる。基本的になんでもよい。）
find . -name "*.jpeg" -print0 | xargs -0 -n1 sh -c 'mv "$1" "${1%.*}.jpg"' -
```

**OSコマンドインジェクション**の脆弱性が発生するのは `eval` コマンドだけではありません。シェルスクリプトのコードを組み立てるときや、awkコマンドのコードを組み立てるときにも脆弱性は発生します。

```sh:awkのコードを組み立てるようなコードもevalコマンドと同等に危険
# 非推奨: 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` で最大の並列実行数を指定します。そしてシェルスクリプトコードを組み立てないようにします。

```sh:きっちり書いた場合（シェルスクリプト用）
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で動けば良い、または長いファイル名がなく、クォーテーションなども含まれない）というのであれば、次のような書き方でも問題ないでしょう。

```sh:手抜きの方法（特定の条件でだけ動けば良い場合、対話シェル用）
find . -name "*.tar" | xargs -L1 -P4 gzip

find . -name "*.tar" | xargs -I{} -P4 gzip {}
```

手抜きで短く書くか、きっちり安全に書くか、場合によって使い分けてください。

## さいごに

だから `xargs` コマンドの使い方は難しいんだってば。気軽に使おうとするなよ。これだけで十分だっけ？ 足りなければ後で追加します。