前回、フィルタコマンドとして「grep(1)」の基本的な使い方について説明した。引数にパターン(キーワード)を指定すること、そして特別な指定の記述方法として行頭を表す「^」、行末を表す「$」を紹介した。これらを知っているだけでも、かなり込み入ったフィルタ処理を行うことができる。

フィルタコマンドでもう1つ覚えておきたいのが、再帰的な検索を実行するオプション「-r」だ。grep(1)は、コマンドの処理速度自体が最高に速い。特に現在、どのUNIX系OSでもデフォルトの実装として採用されている「GNU grep」は、全文検索エンジンとして使ってもよいと思えるくらい速い。

現在のPCはメモリも充分に搭載しているため、以前は全文検索エンジンを導入したり、何らかのソフトウェアをセットアップしたりする必要があったような処理が、今ではもう「grep -r」で済むようになっている。

grep(1)は、パイプラインを経由してフィルタ的に動作させることもできる。だが、オプション「-r」を指定した状態で引数にディレクトリを与えると、そのディレクトリ以下のファイルとディレクトリを再帰的に全部検索する。これはとても強力な機能なので、ぜひ覚えておいていただきたい。

再帰検索してみよう

例えば、ソフトウェアのバグを修正する必要に迫られて、「dbopen(3)」という関数の中身を調べなければならなくなったとする。ここまで聞いて、大半のサーバ管理者はすでに逃げ出したくなっていると思うが、ちょっと待ってほしい。これは、調査対象が関数(ソースコード)に限らず、設定ファイルやテキストファイルでも応用できる話なので、例えの1つとして読んでもらえればと思う。

多くの場合、ソースコードは/usr/src/に展開される。試しにgrep(1)に-rオプションを指定して再帰検索を実施すると、次のような出力を得ることができる。


% grep -r 'dbopen(' /usr/src
/usr/src/usr.bin/tsort/tsort.c:     (db = dbopen(NULL, O_RDWR, 0, DB_HASH, NULL)) == NULL)
/usr/src/usr.bin/finger/util.c:     (db = dbopen(NULL, O_RDWR, 0, DB_BTREE, NULL)) == NULL)
/usr/src/usr.bin/cap_mkdb/cap_mkdb.c:   if ((capdbp = dbopen(capname, O_CREAT | O_TRUNC | O_RDWR,
/usr/src/lib/libc/gen/getpwent.c:static DB  *pwdbopen(int *);
/usr/src/lib/libc/gen/getpwent.c:pwdbopen(int *version)
/usr/src/lib/libc/gen/getpwent.c:       (res = dbopen(_PATH_SMP_DB, O_RDONLY, 0, DB_HASH, NULL)) == NULL)
/usr/src/lib/libc/gen/getpwent.c:       res = dbopen(_PATH_MP_DB, O_RDONLY, 0, DB_HASH, NULL);
/usr/src/lib/libc/gen/getpwent.c:           st->db = pwdbopen(&st->version);
/usr/src/lib/libc/gen/getpwent.c:       (st->db = pwdbopen(&st->version)) == NULL) {
/usr/src/lib/libc/gen/getpwent.c:       (*db = dbopen(NULL, O_RDWR, 600, DB_HASH, 0)) == NULL)
/usr/src/lib/libc/gen/getpwent.c:           st->db = pwdbopen(&st->version);
/usr/src/lib/libc/gen/getpwent.c:       (st->db = pwdbopen(&st->version)) == NULL) {
/usr/src/lib/libc/gen/getcap.c:         if ((capdbp = dbopen(pbuf, O_RDONLY, 0, DB_HASH, 0))
/usr/src/lib/libc/net/getservent.c:     st->db = dbopen(_PATH_SERVICES_DB, O_RDONLY, 0, DB_HASH, NULL);
/usr/src/lib/libc/db/test/dbtest.c: if ((dbp = dbopen(fname,
/usr/src/lib/libc/db/test/hash.tests/thash4.c:  if (!(dbp = dbopen( NULL, O_CREAT|O_RDWR, 0400, DB_HASH, &ctl))) {
/usr/src/lib/libc/db/test/hash.tests/tcreat3.c: if (!(dbp = dbopen( "hashtest",
/usr/src/lib/libc/db/test/hash.tests/driver2.c: if (!(db = dbopen("bigtest", O_RDWR | O_CREAT, 0644, DB_HASH, &info))) {
/usr/src/lib/libc/db/test/hash.tests/tread2.c:  if (!(dbp = dbopen( "hashtest", O_RDONLY, 0400, DB_HASH, &ctl))) {
/usr/src/lib/libc/db/test/hash.tests/tseq.c:    if (!(dbp = dbopen( "hashtest", O_RDONLY, 0400, DB_HASH, NULL))) {
/usr/src/lib/libc/db/test/hash.tests/tdel.c:    if (!(dbp = dbopen( NULL, O_CREAT|O_RDWR, 0400, DB_HASH, &ctl))) {
/usr/src/lib/libc/db/test/hash.tests/tverify.c: if (!(dbp = dbopen( "hashtest", O_RDONLY, 0400, DB_HASH, &ctl))) {
...略...
/usr/src/usr.sbin/sa/db.c:  if ((*mdb = dbopen(NULL, O_RDWR, 0, DB_BTREE, bti)) == NULL)
/usr/src/usr.sbin/sa/db.c:  if ((ddb = dbopen(dbname, O_RDONLY, 0, DB_BTREE, bti)) == NULL) {
/usr/src/usr.sbin/sa/db.c:  if ((ddb = dbopen(dbname, O_RDWR|O_CREAT|O_TRUNC, 0644,
%

ここでは、FreeBSD 11.0-RELEASEのソースコードを全文検索しているため、約7万ファイルを検索した結果が表示されていることになる。このままだと、一致する行が多すぎる。FreeBSDのソースコードには「関数は、関数名が行頭に付く」というルールがあるので、このルールを利用して検索パターンの先頭に「^」を付けてコマンドを実行してみる。


% grep -r '^dbopen(' /usr/src
/usr/src/lib/libc/db/db/db.c:dbopen(const char *fname, int flags, int mode, DBTYPE type, const void *openinfo)
/usr/src/contrib/mdocml/mandocdb.c:dbopen(int real)
/usr/src/contrib/sendmail/src/aliases.5:dbopen(3),
/usr/src/crypto/heimdal/configure:dbopen(NULL, 0, 0, 0, NULL)
%

すると、どうやらdbopen(3)という関数の実体は「/usr/src/lib/libc/db/db/db.c」というファイルに書いてあるみたいだな、ということがわかる。後はソースコードを読むだけだ。

ファイルが7万個もあると、もはや人力で探すのは不可能である。一昔前ならば「find(1)」というコマンドとgrep(1)を組み合わせて検索していたところだが、今はgrep(1)単体で処理できる。なんとも便利になったものだ。

全部キャッシュに載るから2回目以降はオンメモリ速度

そして、注目すべきは実行速度にある。次のgrep(1)の実行結果を見てほしい。

  秒数 コマンド
1回目 88.35秒 grep -r ‘dbopen(’ /usr/src
2回目 14.76秒 grep -r ‘dbopen(’ /usr/src

まったく同じコマンドを実行しているのに、1回目と2回目では実行速度がかなり違うことがおわかりいただけるだろう。今回のケースだと、2回目は6倍ほど高速になっている。

これはファイルシステムやカーネルの実装によって異なるのだが、大抵2回目は高速になる。使用OSはFreeBSD 11.0-RELEASEだが、このOSはメモリに空きがある限り、できるだけディスクキャッシュとして利用しようとする。

つまり、1回目で7万個のファイルを全てチェックすると、そのデータは全部メモリ上にキャッシュされる。つまり、2回目移行はオンメモリのテキストデータに対してgrep(1)の処理が行われることになる。そりゃ速いはずだ。

カーネルがディスクキャッシュをどのように保持するかがわかっていると、それを加味したシェルスクリプトなどを組める。そのため、「同じような処理をしているはずなのに、なぜか処理が速い」シェルスクリプトが出来上がる。一見、匠の技のように見えるが、その裏側にはこういった理由があるのだ。

「賢い」パターン指定でさらに高速に

今度は、指定するパターンを変えた場合の結果を見てみよう。3回目は、パターンとして「^dbopen(」を指定した。

  秒数 コマンド
1回目 88.35秒 grep -r ‘dbopen(’ /usr/src
2回目 14.76秒 grep -r ‘dbopen(’ /usr/src
3回目 1.67秒 grep -r ‘^dbopen(’ /usr/src

メモリに全てキャッシュされる前と比べると約53倍、キャッシュされた後と比べてもおよそ9倍ほど高速に動作していることがわかる。

「^dbopen(」のように「^」を付けてパターンを指定した場合、grep(1)は行頭から「dbopen(」に一致するかどうかをチェックしていく。つまり、行頭をチェックして一致しなかったら次の行まで処理をスキップできる。「dbopen(」を指定した場合は、行末まで1文字ずつ調べていくところを、「^」を指定すればそれを行頭だけのチェックで済ますことができる。比較回数が減るわけだから、結果的に処理が高速になるというわけだ。

このように、grep(1)はもともと処理速度が速いが、賢くパターンを指定するとさらに速くなる。ここにOSのキャッシュ機能が加わることで、より高速な処理が可能になるというわけだ。grep(1)の使い方を知っているかどうかで、作業負荷が大きく変わる。ぜひ積極的に活用していただきたい。