Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
183
Help us understand the problem. What are the problem?

POSIX 準拠のシェルスクリプトでは find | xargs よりも find -exec {} + を使うべき!

はじめに

find の出力を xargs にパイプで渡すというのはよく見かける使い方ですが、find -print0 | xargs -0 が使えない POSIX 準拠のシェルスクリプトでは find -exec {} + を使った方が良いです。安全かつ十分に速いからです。よく見かける -exec {} ; ではなく -exec {} + ですので間違えないようにしてください。多くのケースでは + の方が優れているのですが ; ばっかり使われているのを見ると、意外と知られてない気がします。

少しだけ予備知識として、-exec {} ;-exec {} \;; をバックスラッシュでエスケープするのがよく見る使い方ですが、これは -exec {} ';'-exec {} ";" と書くのと同じ意味です。; がシェルにとって特殊な意味を持つ文字だからエスケープする必要があります。わかってしまえば簡単な話ですが、シェルの仕様に詳しくないとこういう所で何だこりゃ?と思ってしまうんですよね。ちなみに + は特殊な文字ではないのでエスケープは不要です。

find -exec {} + を使うべき理由

1. POSIX が find -exec {} + を推奨している

POSIX が推奨してます。これはもう POSIX 準拠シェルスクリプトでは find -exec {} + 使うべきなのは明白でしょう。

Note that since input is parsed as lines, characters separate arguments, and , , and double-quote characters are used for quoting, if xargs is used to bundle the output of commands like find dir -print or ls into commands to be executed, unexpected results are likely if any filenames contain , , or quoting characters. This can be solved by using find to call a script that converts each file found into a quoted string that is then piped to xargs, but in most cases it is preferable just to have find do the argument aggregation itself by using -exec with a '+' terminator instead of ';'.

太字部分の訳(太字は私による修正)

「この問題は見つかったファイルをクォートした文字列に変換するスクリプトを呼び出して xargs にパイプすることで解決しますが、多くのケースでは -exec (ターミネーター は ; ではなく +) を使用し find 自身で引数の集約を行うことをお勧めします。」

2. スペース・改行やクォートがファイル名に含まれていても問題ない

見つかったファイル名にスペースや改行やクォートが入っていた場合に find | xargs では誤動作を起こしてしまいます。その解決方法が find ... -print0xargs -0 ... と一般的に言われているわけですが、これらのオプションは POSIX では規定されていません。じゃあ諦めるしか無いのか?というわけではなく、まさに -exec {} + が POSIX 流の解決策なのです。

問題ないですよと言うだけではアレなんで一応テストしてみましょう。

$ touch "f o o"
$ touch "b
a
z
"
$ touch "b\"a'z"

$ find . -type f -exec printf "[%s]\n" {} +
[./b
a
z
]
[./b"a'z]
[./f o o]

3. -exec {} + は速い

xargs にパイプで流した場合と大きく変わりません。-exec {} ; と比べた場合は大幅に速くなっています。そもそもなぜ xargs にパイプで流せば速いのかと言うと、外部コマンドの呼び出し回数を減らすことが出来るからです。たまに並列実行されるからと勘違いしている人がいますが、それは -P オプション(POSIX では規定されていない)を使った場合の話です。-P オプションを使わない場合は並列実行の効果は小さいです。

まず知っておかないといけない前提として外部コマンドの呼び出し非常に遅いということです。外部コマンドの処理が遅いのではなく起動が遅いのです。例えば最も処理が軽いと思われる /bin/true コマンドを 1 万回呼び出しただけでも 8 秒もかかってしまいます(CPU: Core i7 3770 3.4Ghz)。

$ time sh -c 'i=0; while [ $i -lt 10000 ]; do /bin/true; i=$((i + 1)); done'
real    0m8.110s
user    0m6.240s
sys     0m2.064s

# 参考 シェルビルトインの true コマンドであれば速い
$ time sh -c 'i=0; while [ $i -lt 10000 ]; do true; i=$((i + 1)); done'
real    0m0.026s
user    0m0.026s
sys     0m0.000s

同等の処理、つまり /bin/true を 1 万回呼び出す処理を xargs を使って書いても同じように遅いです(1 万回呼び出すために -n 1 オプションをつけています)

$ time seq 10000 | xargs -n 1 /bin/true
real    0m9.775s
user    0m7.557s
sys     0m2.771s

では、コマンド呼び出しが遅い問題をどうすれば解決できるか?それは以下のようにすることです。

/bin/true 1
/bin/true 2
/bin/true 3
     :
/bin/true 10000
# の代わりに

/bin/true 1 2 3 ... 10000
# と呼び出す

コマンド呼び出しが遅いならコマンド呼び出しの回数を減らせばいいじゃない。という考え方です。(もちろんコマンドは複数の引数を扱えるようになっている必要があります。)

-n 1 をつけない xargs が速いのは /bin/true を 1 つの引数ごとに呼び出すのではなく、1 万個の引数で 1 回だけ呼び出してるからです。

$ time seq 10000 | xargs /bin/true
real    0m0.007s
user    0m0.006s
sys     0m0.003s

さて、これと同じ話が -exec {} ;-exec {} + の間にも当てはまります。

# 1 万個のダミーファイルを作る
$ touch dummy{1..10000}.txt

# 1 ファイル毎に 1 万回呼び出している(true dummy1.txt; true dummy2.txt; ...)
$ time find . -type f -exec true {} \;
real    0m11.938s
user    0m8.271s
sys     0m3.846s

$ time find . -type f | xargs -n 1 true    # 参考 xargs 版
real    0m10.200s
user    0m7.942s
sys     0m2.844s

# 1万ファイルまとめて 1 回だけ呼び出している(true dummy1.txt dummy2.txt ...)
$ time find . -type f -exec true {} + 
real    0m0.016s
user    0m0.006s
sys     0m0.011s

$ time find . -type f | xargs true         # 参考 xargs 版
real    0m0.020s
user    0m0.005s
sys     0m0.024s

-exec {} +-exec {} ; よりも圧倒的に速く、xargs と同等であることが分かると思います。

4. -exec {} + は多すぎる引数を分割して呼び出す

でも xargs は引数が多すぎてエラーになる場合に自動的に分割して呼び出す機能が・・・。
はい、それは -exec {} + にもあります。以下は Linux での実験です。

$ echo dummy{1..100000}.txt | xargs touch
$ find . -name "dummy*.txt" -exec sh -c 'echo $# args, ${#*} bytes' -- {} +
8259 args, 131036 bytes
7810 args, 131029 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
7708 args, 131035 bytes
6851 args, 116467 bytes

$ echo $((8259 + 7811 + 7708 * 10 + 6850))
100000

$ echo 131036 / 1024 | bc -l # 引数サイズの制限は 128 KB っぽい
127.96484375000000000000

# xargs でもほぼ同じ
$ find . -name "dummy*.txt" | xargs sh -c 'echo $# args, ${#*} bytes'
8258 args, 131023 bytes
7809 args, 131013 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
7707 args, 131018 bytes
6850 args, 116450 bytes

この動作は POSIX find にもしっかり明記されています。

The size of any set of two or more pathnames shall be limited such that execution of the utility does not cause the system's {ARG_MAX} limit to be exceeded.

5. ファイルが一つもない時にコマンドが呼び出されることはない

2021-09-14 追記 AIX 7.2 の find の実装は、ファイルが一つもない時にエラーになるらしいです(下記コメント参照)。

xargs コマンドのちょっとした罠ですが一部の環境(Solaris、Linux、OpenBSD)では、ファイルが一つも見つからない場合でも、POSIX の仕様通りにコマンドが実行されてしまいます。(詳しくは「誰も知らない xargs の仕様と入力形式とPOSIXの罠 「ある環境では _ がきたらそこで処理終了ですよ」」参照)

find . -name "*.nomatch" | xargs printf "[%s]\n"
[]

それに対して -exec {} + であれば実行されることはありません。

find . -name "*.nomatch" -exec printf "[%s]\n" {} +

参照先の記事では、どの環境でもコマンドが実行されないようにいくつかのテクニックを使用していますが、実は find コマンドの場合にはそれらのテクニックは必要ないのです。

-exec {} + に置き換える時の注意点

エラー時の終了ステータスが異なる

-exec {} + を使用してコマンドでエラーになった場合 find コマンドの終了ステータスとなります。xargs を使用した場合は xargs コマンドの終了ステータスとなります。ただしその終了ステータスの仕様はバラバラのようです(POSIX では非 0 としか規定されていない)。また両者ともに別のコマンドにパイプで繋いでいる場合は、パイプの最後のコマンドの終了ステータスになります。

# Linux の場合
$ find . -exec sh -c 'exit 12' {} +
$ echo $?
1
$ find . | xargs sh -c 'exit 12'
$ echo $?
123

# macOS の場合
$ find . -exec sh -c 'exit 12' {} +
$ echo $?
12

$ find . | xargs sh -c 'exit 12'
$ echo $?
1

# Solaris の場合
$ find . -exec sh -c 'exit 12' {} +
$ echo $?$ find . | xargs sh -c 'exit 12'
$ echo $?
12

パフォーマンスが良いのはどちら?

Linux、macOS、FreeBSD では -exec rm {} + よりも xargs rm の方が速いですが、Solaris 11 では -exec rm {} + の方が速いです。おそらくシステムや実装によって異なると思われます。

Linux (物理マシン 4 CPU 8コア)

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" | xargs rm
real    0m1.053s
user    0m0.260s
sys     0m0.836s

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" -exec rm {} +
real    0m1.371s
user    0m0.290s
sys     0m1.074s

macOS (物理マシン 2 CPU 4 コア)

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" | xargs rm
real    0m11.387s
user    0m0.345s
sys     0m8.312s

$ echo dummy{1..100000}.txt | xargs touch
$  time find . -name "dummy*.txt" -exec rm {} +
real    0m13.459s
user    0m0.259s
sys     0m10.274s

FreeBSD (HyperV 上、4 CPU)

$ sysctl -n hw.ncpu
4

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" | xargs rm
real    0m1.793s
user    0m0.148s
sys     0m1.692s

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" -exec rm {} +
real    0m2.069s
user    0m0.131s
sys     0m1.941s

Solaris 11 (VirtualBox 上、2 仮想 CPU)

$ psrinfo -v
Status of virtual processor 0 as of: 09/11/2021 07:45:26
  on-line since 09/11/2021 14:08:38.
  The i386 processor operates at 2390 MHz,
        and has an i387 compatible floating point processor.
Status of virtual processor 1 as of: 09/11/2021 07:45:26
  on-line since 09/11/2021 14:08:39.
  The i386 processor operates at 2390 MHz,
        and has an i387 compatible floating point processor.

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" | xargs rm
real    0m10.125s
user    0m2.689s
sys     0m7.998s

$ echo dummy{1..100000}.txt | xargs touch
$ time find . -name "dummy*.txt" -exec rm {} +
real    0m6.385s
user    0m1.753s
sys     0m4.613s

xargs が必要な場合

-exec {} + を使えば xargs は不要になるとはいえ、それでも xargs の出番がなくなったわけではありません。

find 以外のコマンドと組み合わせる場合

当然ですが -exec {} +find コマンドの機能であるため、その他のコマンドでは使うことができません。その場合 xargs が必要です。

並列実行(-P)を利用する場合

find コマンドには並列実行機能はないので、これが必要な場合は xargs を使用します。

なぜ -exec {} + はあまり使われてないのか?

POSIX での採用と実装が遅かったからだと思われます。まず POSIX では 1997 年の Issue 5には -exec {} + はありません(-exec {} ; はあります)。-exec {} + が登場したのは 2004 年の Issue 6 からです。1988 年の SVR4 で実装されたらしいのですが、多くは 2000 年以降にようやく使えるようになったようです。さらに Debian で使えるようになったのは 2007 年です。一方 -print0 は商用 Unix では使えないとはいえ Linux や BSD ではそれよりも前の 1990 年代半ばから使えたわけで、オープンな世界で事実上 10 年近く先行していると言える -print0 の方が広く知られているのは当然のことでしょう。

ちなみに -exec {} + だけではすべてのユースケースに対応できるわけではないとして -print0 を POSIX に取り入れるという提案はいくつかあるようですが、なかなか進んでいないようです。

まとめ

  • POSIX 準拠のシェルスクリプトでは find | xargs よりも find -exec {} + を使うべき
  • Linux (GNU)、macOS (BSD) だけで動けばよいのであれば find -print0 | xargs -0 の方が良い
    • 良い理由はパフォーマンスの点でおそらく速いから。とは言え find -exec {} + でも十分速い
  • 並列実行したいならば find -print0 | xargs -0 -P を使う

さいごに

-exec {} ;-exec {} + の違いを説明している記事は検索すると結構見つかりますが、これを POSIX と絡めて POSIX 準拠のシェルスクリプトでは find -print0 | xargs -0 が使えないから -exec {} + を使うべきと解説している(日本語の)記事は軽く調べた限りでは見つかりませんでした。POSIX 準拠にこだわっている人は少ないのでしょうが、もしかしたら商用 Unix でシェルスクリプトを動かすことがあるかもしれません。そういう場合に備えて POSIX に準拠したやり方というのを知っておくのも良いのではないかと思います。

そして -exec {} + が比較的新しい機能で -exec {} ; の方が多く使われていることを踏まえると、この話もまた古い情報からなかなか更新されていないシェルスクリプトの情報の一つなのだと思います。いつまでも古い知識のままでいるのではなく、ソフトウェアと同じで知識もアップデートしなければいけません。より便利になっているというのに古いやり方を続け低い生産性のままでいるのはもったいない話です。

ということで、これからも POSIX に準拠したシェルスクリプトの書き方の新しめの情報を公開していきます!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
183
Help us understand the problem. What are the problem?