では先に挙げた3種類のサンプルプログラムを調べて、実際にシステムコールを呼び出している回数と、システムコールごとの処理時間を調べてみる。ここではtruss(1)を使うが、OSによってはこれがstraceや、dtrussになる。指定するオプションも変わることがあるので、末尾の説明を参考にしてほしい。
最初に、システムコールを使わずに作ったサンプルプログラムを調べてみよう。truss(1)を実行すると、以下のようになる。
% rm -f out % touch out % truss -Sc ./copy-fgetc-fputc syscall seconds calls errors readlink 0.000016482 1 1 lseek 0.000012292 1 0 mmap 0.000111746 8 0 open 0.000182704 5 1 close 0.000061180 4 0 fstat 0.000055872 3 0 write 0.017658352 320 0 break 0.000012851 1 0 access 0.000016482 1 0 sigprocmask 0.000148063 8 0 munmap 0.000027099 2 0 read 0.016463223 323 0 ------------- ------- ------- 0.034766346 677 2 %
12種類のシステムコールを合計677回呼び出していることが分かる。read(2)は320回、write(2)は323回呼び出されている。そして、システムコールの処理に限ると、かかった時間は0.03秒ほどということになる。この値は環境によって変わる。
次は、システムコールを使って1byteごとにデータコピーしたサンプルを調べてみよう。その結果は下記の通りだ。
% rm -f out % touch out % truss -Sc ./copy-read-write syscall seconds calls errors lseek 0.000011454 1 0 mmap 0.000080178 6 0 open 0.000110909 5 1 close 0.000121244 4 0 fstat 0.000020952 1 0 write 210.294700991 10485760 0 access 0.000015086 1 0 sigprocmask 0.000110349 8 0 munmap 0.000012292 1 0 read 247.661501980 10485763 0 ------------- ------- ------- 457.956685435 20971550 1 %
システムコールの合計呼び出し回数を見ると、2097万1550回にもなっている。システムコールの処理にかかった時間の合計は457秒を超えている。特に、read(2)とwrite(1)は、それぞれ1千万回以上呼び出されている。10Mbytesはおよそ1千万bytesになる。丁寧にファイルサイズの分だけシステムコールを呼び出したことが分かる。
最後に、10Mbytes分の配列を用意して、一度にコピーしたサンプルプログラムを調べてみよう。
% rm -f out % touch out % truss -Sc ./copy-read-write2 syscall seconds calls errors lseek 0.000014806 1 0 mmap 0.000078781 6 0 open 0.000075708 5 1 close 0.000056432 4 0 fstat 0.000021232 1 0 write 0.018489415 1 0 access 0.000015365 1 0 sigprocmask 0.000160914 8 0 munmap 0.000012292 1 0 read 0.004639396 4 0 ------------- ------- ------- 0.023564341 32 1 %
システムコールの合計呼び出し回数はわずか32回。システムコールの処理にかかった時間の合計も、約0.02秒と極めて短い。write(2)は1回、read(2)は4回呼び出されている。この連載で使用している環境では、プログラム実行前に必ずread(2)を3回呼び出す。つまりデータをコピーするためのread(2)とwrite(2)はそれぞれ1回だけ呼び出されたことになる。サンプルコードから予想できる動きだ。
以上、3種類のサンプルプログラムを比較すると、システムコールを使って、1byteずつデータをコピーするようにした2番目のプログラムは、システムコールの呼び出し回数がほかのプログラムよりもかなり多く、プログラム実行時間も長いという結果になった。システムコールを直接呼ぶプログラムで、その使い方を間違えると、あっという間にプログラムが役に立たないものになってしまうという例だ。
そして、システムコールの呼び出し回数が最も少なく、プログラムの処理時間が最も短いのは、10Mbytesのデータをまとめてコピーするようにしたプログラムということになった。これもシステムコールを利用したものだ。
ではこのサンプルが3種類の中で最も優れたものなのかと考えると、そう簡単には結論は出ない。それならば、巨大な配列(バッファ)を用意すればよいだけの話ではないかということになる。
では、今度は3種類のサンプルプログラムをそれぞれ実行し、メモリ消費量を比べてみたい。ただし、先に挙げたサンプルコードそのままでは、プログラムがあっという間に終了してしまうので、コードを追加しなければならない。
最初に挙げた、標準ライブラリの関数で作成したサンプルプログラムなら、最後の閉じカッコの前に以下のコードを挿入する。
fgetc(stdin);
残る2種類、つまりシステムコールを直接呼び出したサンプルプログラムの場合は、最後の閉じカッコの前に以下のコードを挿入してほしい。
read(STDIN_FILENO, b, 1);
こうしておけば、プログラムの処理が終わっても、[Enter]キーを入力するまでプログラムは待ち状態となる。この状態になったら、次のようにps(1)コマンドを使ってプログラムが消費しているメモリ量を調べてみよう。RSSの項目が実際に使っているメモリ量だ。表示はKbyte単位になっている。
まずは、標準ライブラリの関数を使ったサンプルでメモリ消費量を調べてみる。
% ps u 790 USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND daichi 790 0.0 0.1 10056 1120 2 S+ 2:43午後 0:00.12 ./copy-fgetc-fputc %
1120Kbytes(およそ1Mbytes)のメモリを消費していることが分かる。次は、システムコールを使って1byteずつデータをコピーするサンプルだ。
% ps u 790 % ps u 983 USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND daichi 983 0.0 0.0 3916 828 2 S+ 2:44午後 0:15.29 ./copy-read-write %
このサンプルは実行にかなり時間がかかったが、メモリ消費量は828Kbytesと少ない。最後に、10Mbytesをまとめて転送するサンプルだ。
% ps u 790 % ps u 1108 USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND daichi 1108 0.0 0.5 14156 11084 2 S+ 2:46午後 0:00.01 ./copy-read-write2 %
処理速度では、このサンプルが最も優れていたが、メモリ消費量を見ると、10Mbytes以上になっていることが分かる。当然と言えば当然だが、10Mbytesもの配列を用意したプログラムだ。それだけメモリ消費量も大きい。
ここで注目したいのが、標準ライブラリの関数を使ったサンプルだ。処理速度が速い割には、メモリ使用量が少ない。fgetc(3)やfputc(3)を使うと、FILE構造体経由でデータを読み書きする段階でバッファ処理が入るため処理が高速になるのだ。このため、巨大なバッファを持たなくても、それなりに速く処理を済ませてくれるのだ。このような特性の違いが、システムコールと標準ライブラリの違いをよく表している。
今回は標準ライブラリの関数であるfgetc(3)とfputc(3)、そしてシステムコールであるread(2)とwrite(2)を例に挙げて動作の違いを追ってみた。read(2)とwrite(2)は、その名前の通りデータを読み書きする機能を持つが、fgetc(3)やfputc(3)を使うと、途中でバッファ処理が入り、メモリ消費量を抑えながらそれなりに速く処理が済むようにしてくれている。
システムコールの動きを熟知しており、やりたいこともその利用シーンもはっきりしていれば、システムコールを便利に活用できるが、場面を選ばずに利用するには難がある。
システムコールを使いながら、自分でバッファ処理を実装し、ロックも実装し、という具合に自分で必要な処理を記述していくと、最終的にはFILE*、fgetc(3)、fputc(3)と同じものを自分で作るということになる。それなら最初からある標準ライブラリを使った方が便利だ。
そして、さらなる高速化を狙って自身で関数を実装してもほとんど意味はない。すでにバッファ処理を活用しているfgetc(2)とfputc(2)を使って、その上で自身でバッファ処理を記述するようなことをしても、さらなる高速化を期待できないばかりか、ソースコードが必要以上に複雑になる上に、処理速度が遅くなる。必要のない機能を自身で実装してみても、実は性能が落ちるなど、よい結果にならないということは、上達してきたプログラマが陥りがちな問題の1つだ。
FreeBSD/PC-BSDではdd(1)とtruss(1)を使ったが、Mac OS X Lion、Ubuntu 11.10、Solaris 11では、dd(1)の引数やtruss(1)と同じ機能を持つコマンドの名前が表1のように変わるので参考にしてほしい。
OS | dd | truss |
---|---|---|
FreeBSD 9.0 | if=/dev/random bs=1m count=10 | truss -Sc |
Mac OS X Lion | if=/dev/random bs=1m count=10 | sudo dtruss -c |
Ubuntu 11.10 | if=/dev/zero bs=1M count=10 | strace -s |
Solaris 11 | if=/dev/zero bs=1M count=10 | truss -c |
表1 主なUNIX/Linux OSで使えるddコマンドの引数と、trussコマンドと同じ機能を持つコマンドの名前 |
なお、ファイルコピーの実験をするときは、ファイルシステムが重複排除の機能や圧縮機能を持っていることがあるので注意してほしい。例えば、Solaris 11のZFS(Zettabyte File System)は重複排除機能も圧縮機能も備えている。このような場合は、実験をしてみても想定と異なる結果になることがある。
また、ファイルシステムや中間レイヤが提供しているキャッシュ機能などが影響して、1回目と2回目以降の実行結果が大きく異なることも多い。この点をよく注意して、何度か実行して変化を見た方が良い。
また、メモリ使用量はハードウェア環境やOSによって異なるが、この値が小さい方が優れているというものではない。そして、大きいと良くないということでもない。OSとしてはメモリを余らせるのではなく、使い切った方が性能が期待できるので、メモリ空間に余りがある限り使い切ろうとするカーネルもある。そして、反対の戦略を取るものもある。カーネルがメモリをサブシステムキャッシュに使いながら、ユーザーにそのことを知らせないものもある。OSの動き方は種類と場合によってケースバイケースなのだ。
BSDコンサルティング株式会社取締役/オングス代表取締役
後藤 大地
@ITへの寄稿、MYCOMジャーナルにおけるニュース執筆のほか、アプリケーション開発やシステム構築、『改訂第二版 FreeBSDビギナーズバイブル』『D言語パーフェクトガイド』『UNIX本格マスター 基礎編〜Linux&FreeBSDを使いこなすための第一歩〜』など著書多数。
Copyright © ITmedia, Inc. All Rights Reserved.