双六工場日誌

平凡な日常を淡々と綴ります。

シェルスクリプトの中で1行ずつ変数を分割する際には、cutとかawkとか余計なプロセスを起動せずsetを使って分割した方が効率的

シェルスクリプトの中で、スペース区切りもしくはタブ区切りのレコードを扱うことがよくあると思います。

たとえば、前回のエントリ「AWS CLIとjqを使って、AWSのELBボリュームがアタッチされているEC2インスタンス名を出力するワンライナーを書いた - 双六工場日誌」のスクリプトの出力は以下のようになります。

i-ec56a9f5 vol-07d00601 servername

i-ec56a9f5 vol-8f550991 servername

このようなレコードの特定の列を取り出して、処理する際にどうするのが効率的か、というのがこのエントリのお題です。

非常に古い話題なので、昔からシェルスクリプトを書いている人には自明な話ではありますが、最近、シェルの標準機能の話を聞く機会がなく、失われつつある技術になってきている気がしているので、改めて確認ということで。

例として挙げたレコードから最初の1列目(インスタンスID)だけ取り出したい時、たとえばこんな方法があります。

line="i-ec56a9f5 vol-07d00601 servername"

instance_id=`echo $line | cut -d' ' -f1`
volome_id=`echo $line | cut -d' ' -f2`
instance_name=`echo $line | cut -d' ' -f3`

こうするとフィールドの切り出しごとに新しく"cut"のプロセスが起動されるため、ワンライナーや実行回数の少ないバッチ処理用のスクリプトの場合はいいのですが、監視スクリプトのような一定の頻度で呼び出されるものは、その差がボディーブローのように効いてくることがあります。

このようにプロセスフォークのコストが無視できない場合は(下線部追記)、シェルスクリプト内で各フィールドの値を使う場合は以下のように"set"で分割しましょう。

line="i-ec56a9f5 vol-07d00601 servername"

set -- $line
instance_id=$1
volome_id=$2
instance_name=$3

ここで使っている$1、$2、$3といった変数は"the positional parameters"と言われるもので、シェルスクリプトでは、スクリプトや関数の引数を参照する際に使っていることが多いと思います。この変数は"set --"のあとに、スクリプトの引数のようなイメージで、空白区切りの文字列を入れるとシェル内で再設定することができます。(シェルスクリプトの引数として与えていた値は消去されます)

この2つで、どれだけの差が出るのか、以下のような簡単なベンチマークを取ってみました。実行環境は、僕のMBA 2012です。

#!/bin/bash
set -ue
NUM_LOOPS=1000

use_cut(){
  for i in $(seq 1 $NUM_LOOPS)
  do
    line="i-ec56a9f5 vol-07d00601 servername"
    instance_id=`echo $line | cut -d' ' -f1`
    volome_id=`echo $line | cut -d' ' -f2`
    instance_name=`echo $line | cut -d' ' -f3`
    echo $instance_id
    echo $volome_id
    echo $instance_name
  done
}

use_set(){
  for i in $(seq 1 $NUM_LOOPS)
  do
    line="i-ec56a9f5 vol-07d00601 servername"
    set -- $line
    instance_id=$1
    volome_id=$2
    instance_name=$3
    echo $instance_id
    echo $volome_id
    echo $instance_name
  done
}

time use_cut
time use_set

このベンチマークの結果は以下の通り。setを使う方が、100倍以上高速でした。また、cut版ではsysのCPU時間が多くなっていますが、これは新しくプロセスをフォークして、そこと出入力するのにこれだけの時間がかかっているということを示しています。逆に、set版ではプロセス起動がないため、sysのCPU時間は非常に短くなっています。

  • cutを使った場合
real 0m10.927s
user    0m4.595s
sys 0m7.590s
  • setを使った場合
real 0m0.081s
user    0m0.076s
sys 0m0.005s

ちなみに、set版はcut版と違い、フィールド間の区切りが半角スペース、タブのどちらでも動作可能なので、使い勝手の面でもset版の方が上ですね。

また、シェルスクリプトということで、気になる互換性ですが、このsetの動作はPOSIX標準で定義されている方法なので、すべてのシェルで同様の動作になるはずです。cutも、どこの環境でも同じ動きにはなると思いますが、互換性という面でもset版の方が堅牢です。

また、setでの分割の際、フィールド間の分割には、"IFS"という特殊な変数が暗黙のうちの参照されています。これをいじることで、カンマ区切りのテキストでも同様のことが可能です。ただし、IFSをいじった場合は、同じシェルのフィールド分割すべてに効いてしまうので、使ったあとはすぐに戻すようにしてください。

また、これ以外にも特定の文字で区切られたフィールドを分割する方法はいろいろあります。また、カンマ区切りの例となってしまいますが、以下のエントリにはコメントを含めて、網羅的にその方法がまとまっていました。"<<<"で使う、ヒアストリングとかも便利ですね。

ただ、このうち配列を使う方法やパラメータ展開を使う方法は、bashzshなどそれらの配列が使えるシェルでないと動かないほか、bash/zsh配列では配列の最初のインデックスが異なっているなど、ハマりどころがあるので、その点の注意が必要です。

ちなみに、ここに上がっている方法すべてで、同等のベンチマークを試しましたが、やはりこの中では"set"を使うものが最も高速でした。次に変数展開と配列を使う方法が早く、その次にヒアストリングが続くという結果になりました。ヒアストリングが相対的に遅いのは、おそらく変数の読み書きに標準出入力を介するからだと思いますが、深追いはしていません。いずれもcut版よりはずっと高速に動作しました。以下のgistにベンチマークスクリプトと結果を貼っておきます。

また、ここで使っているパラメータ展開は、"man bash"で"parameter expansion"を検索するか、以下の記事を見るのが分かりやすいと思います。

ということで、このようにフィールドを区切って使う際に、どの方法を使うか悩まなくてよくなりましたね。

今日はこの辺で。

追記

この記事を公開したところ、Twitterで@さんから、以下の指摘をいただきました。ありがとうございます!

ブログ本文にも書いたように、"set --"は、シェルスクリプトに引数を与えたような動作になります。これに伴う注意点として、引数のテキストの中に" * ? {} [] ~"のような文字が含まれていると、シェルのコマンドライン上と同じようにglob展開が行われてしまいます。

これらの文字が引数に含まれる場合には、"set -f"を使ってglob展開を無効化することで、文字列をそのまま評価できるようになります。元に戻す場合は"set +f"です。使い方は以下のようなイメージになります。

set -f
set -- $line
instance_id=$1
volome_id=$2
instance_name=$3
(もろもろ処理)
set +f

また、"set --"で変数を分割するときは、シェルでの入力のように複数の半角スペースやタブ文字をまとめて一つのものとして扱ってしまうのも注意点です。

たとえば、複数レコードを読む際、その中に空白フィールドが含まれる場合は、フィールドがその分1つ詰められてしまい、期待した結果にならなくなります。こちらもシェルで引数を与えた場合の動作と同じと覚えておくとわかりやすいと思います。シェルの機能を使ったがための制約なのでご注意ください。

さらに追記ーawkを使ったら?

cut -d' ' -f1をawk ‘{print $1}’に置き換えたバージョンでもベンチマークを取りました。こちらは結果だけ。単純に置き換えただけの場合、awk版はcut版とほぼ同じか、若干遅いという結果です。awkを使う場合は、awkに合わせた最適化を行うのが筋だと思うので、全く同じように置き換えるのはawk不利かなとは思いますが、ベンチマークの条件を揃えた結果なので、ご容赦ください。

$ bash benchmark.sh > /dev/null
cut version

real    0m11.173s
user    0m4.660s
sys 0m7.770s

awk version

real    0m11.521s
user    0m4.964s
sys 0m7.846s

manについての追記

manの内容との関連の話があったので、"man bash"の該当のところを貼っておきます。この機能の説明は、setコマンドのオプションの説明の最後の方にあります。

"set --"とだけ入力して、引数を全く入れないと、$1, $2...のようなpositional parametersは、unsetされます。もとの値に戻すことはできないので、当初の引数を使いたい場合は事前に別の変数に入れておく必要があります。自分の場合は、引数の値はスクリプトの最初に、その値の内容を示す変数に代入しておき、その後はそちらの変数を使うことで混乱がないようにしています。

--      If  no  arguments  follow  this option, then the positional parameters are
                      unset.  Otherwise, the positional parameters are set to the args, even  if
                      some of them begin with a -.

またまた追記:自分が考えるこの機能の使いどころ

最初の方に「ワンライナーや実行回数の少ないバッチ処理用のスクリプトの場合はいいのですが」とは書いていますが、いろんな人からコメントをもらったので、自分が考える使いどころについても補足したいと思います。

この機能が向いている場面は、主に以下の2つかなと思います。

  1. シェルスクリプトの実行時間が長い場合やサーバのCPU使用率が上がっていて、なおかつ、CPU使用率の中のsysが高くなっている場合
    • 自分の経験の中では、監視スクリプトを多用しているZabbixサーバに監視スクリプトと開始対象を大量に追加した結果、sysが高くなり、性能劣化が起こったことがあります。
  2. 運用時のアドホックシェルスクリプトで、余計な時間を掛けずにスクリプトを書き上げたい時
    • フィールドごとにcut -f1とか書かずに、パッとやってしまいたい時

逆に、ワンライナーのようにシェルで直接実行するような局面には向いていないと思います。setでpositional parameterを設定すると、そのシェルを実行している間、明示的に変数をunsetしない限り影響が残ってしまいますし、そもそも、ワンライナーであれば、1回きりの実行で、プロセスフォークのコストが問題になることがほとんどないからです。

また、長く複雑な処理にも向いていないと思います。複雑なバッチで、中で起動しているプロセスが多くなると、このようにプロセスフォークの数を減らす施策は有効ではありますが、そのようなバッチの場合は、そもそも一つのプロセスで処理できるawk、P erl、RubyPythonといった一般的な言語処理系の方が効率的であることが多いと思います。*1

個人的には、AWS CLIなど、既存ツールを組み合わせて、短時間かつ短いスクリプトで目的の処理を行いたい場合はシェルスクリプトで書くことが多いですね。このようにやりたい機能ごとにツールを柔軟に組み合わせられるのがシェルスクリプトの強みだと思います。

逆に自分が処理内容まで作りこむ場合は、それに合わせたツールや処理系が向いていると思います。代表的なところだと、数値解析ならPython + 各種ライブラリ、高速な文字列処理を手早く書きたいならawkPerlでしょうか。チームの事情に合わせて、どこかの言語に寄せるなど、どの言語が最適かは個人個人の状況によるところがあるで一概にいえないところもありますが。

蛇足ですが、シェルスクリプトのマニアックな機能と言えば、「シェル芸」≒「ワンライナー」が非常に有名なので、そっちの話かと思った人もいたのかなとも想像しています。正直、記事を書いた時にはワンライナーでこの機能を使うことは想定していなかったので、改めて、使いどころを考えて追記してみた次第です。

そのほか良い使い方や使いどころがあれば、コメントいただけるとありがたいです。

*1:もちろん、CやJavaなど、ほかの処理系でも構いませんが、ここでは一旦割愛です