前回まで、mmap(2)によるコピー処理の高速化について紹介してきた。mmap(2)にはまた、システムコールが呼ばれる回数を削減し、処理速度を高速化するという効果もある。今回は、共有メモリの観点から、その機能を紹介する(編集部)
前回まではコピープログラムを例に挙げながら、mmap(2)を使うことでコピー処理を高速化できることを紹介してきた。連載第1回目で説明したように、処理速度を高速化したければ、基本的にはシステムコールは少ない方がよい。システムコールを実行すれば、その分プログラムとカーネルの間でのデータのコピーや処理の切り替えが発生し、それだけ処理速度が遅くなる。
しかし、例外的に処理を高速化できるものもある。その代表格がmmap(2)だ。mmap(2)はシステムコールだが、このシステムコールを利用すると、最終的にはシステムコールが呼ばれる回数を削減できる。その最も顕著な例が共有メモリの機能となる。今回は共有メモリの観点から、mmap(2)システムコールの効果を紹介していく。
なお、サンプルプログラムの作成には、連載「いまさら聞けないVim」の第8回で紹介した環境を利用する。OSによるコマンドの違いなどは本連載の第2回にまとめてあるので、こちらも参考にしていただきたい。
UNIX系のOSでは(一部特定の機能を除いて)それぞれのプロセスのメモリアドレス空間は独立している。このため、プロセス間でデータをやりとりしようとした場合には(独立したソフトウェア同士がデータのやりとりをしようとした場合には)、ファイルシステムを経由したり、パイプを使ったり、ソケットを使ってネットワーク通信などを実施する必要がある。
インタラクティブシェルで、例えば「cat ファイル | grep キーワード」のような処理を実行したことがあると思うが、これはcat(1)とgrep(1)という異なるプロセス(ソフトウェア)に関して、シェルがpipe(2)を使ってパイプを生成し、cat(1)の標準出力をgrep(1)の標準入力に設定して一方通行でデータ通信できるようにしたものだ。
このようにメモリアドレス空間を完全に分離させることには、あるソフトウェアが誤ってメモリ上のデータを破壊してしまっても、ほかのプロセス(ソフトウェア)には影響を与えないという利点がある。また、方法はどうであれ、どのタイミングでデータがやってきたのか、どのタイミングでデータを送ったのか、という処理をそれぞれのソフトウェア側で簡単に判断できるという特徴がある。
半面、この方法は処理が「遅くなりがち」という課題も抱えている。特に小さいデータを何度もやりとりするようなケースでは性能を発揮できない。その分だけシステムコールが発生するからだ。上記の例であれば、cat(1)からgrep(1)にデータを渡す段階で、少なくとも2回はシステムコールが呼ばれることになる(write(2)とread(2))。
この部分を高速化することはできないだろうか——それがmmap(2)が開発された大きな理由の1つとなる。mmap(2)は共有メモリの機能を提供し、共有されたアドレス空間の処理にはシステムコールが発生しない仕組みになっている。
これまで何度かmmap(2)を使ったコピープログラムを紹介してきた。例えば次のソースコードだ。
#include #include #include int main(void) { int fdi, fdo, fsize = 104857600; char *i, *o; fdi = open("in", O_RDONLY); fdo = open("out", O_RDWR); i = mmap(NULL, fsize, PROT_READ, MAP_SHARED, fdi, 0); o = mmap(NULL, fsize, PROT_WRITE, MAP_SHARED, fdo, 0); while (fsize--) *o++ = *i++; }
このソースコードでは、入力元のファイルと出力先のファイルをmmap(2)でマッピングし、データをポインタ経由でコピーしている。read(2)もwrite(2)も使用していないが、これでファイルがコピーされる。mmap(2)でマッピングされたデータはシステムコールを呼ぶことなく、自動的に同期される。メモリ上で操作した内容はファイルにも反映される。
mmap(2)によってマッピングされる領域は、同一プロセスの内部のみならず、別のプロセスにおいても共有できる。その場合、あるプロセス(ソフトウェア)で施された変更は、同じファイルをマッピングしている別のプロセス(ソフトウェア)に対しても自動的に適用される。つまり、その領域は共有されることになる。
ただし、「mmap(2)がこうした機能を実現している」というのにはちょっと語弊がある。mmap(2)はこうした機能を利用するためのインターフェイスにすぎない。ファイルシステムや仮想メモリ空間がシームレスに統合するようにOSの機能が開発されてきた結果がこうした機能になっており、mmap(2)はその機能を利用するためのスイッチのような役割を持っている。
概念的な話ばかりしていても想像しにくいので、実際にソースコードを使って説明していこうと思う。
次のソースコードは、実行時に2つのプロセス(親プロセスと子プロセス)に分かれて、親プロセスから子プロセスに対してパイプ経由で100万回、"a"というデータを送信する処理を行っている。処理の本質だけを追いたいので、ファイルディスクリプタのクローズやエラーハンドリングの処理は書いていない。
#include <unistd.h> #include <stdio.h> int main(void) { int fd[2], i, rp=1000000; pipe(fd); if (0 == fork()) { char b[2] = {'\0'}; for (i = 0; i < rp; i++) read(fd[0], b, 1); } else { for (i = 0; i < rp; i++) write(fd[1], "a", 1); } }
fork()を呼ぶと、自分自身をコピーして子プロセスを生成するようになる。UNIX系OSを最も特徴付ける処理だ。上記のソースコードであれば、if() {}の最初の括弧の中が子プロセスで処理され、else {}が親プロセスによって実行されることになる。fork()で分離する前にpipe(2)システムコールでパイプを作成してあるので、fork()した後はこのパイプを経由してwrite(2)/read(2)を繰り返す処理になっている。
コンパイルしてtime(1)で処理内容を調べてみると、次のようになる。
% clang ipc_pipe.c % /usr/bin/time -lph ./a.out real 0.73 user 0.11 sys 0.52 736 maximum resident set size 3 average shared memory size 3 average unshared data size 118 average unshared stack size 80 page reclaims 0 page faults 0 swaps 0 block input operations 0 block output operations 0 messages sent 0 messages received 0 signals received 178208 voluntary context switches 492 involuntary context switches %
自発的に17万8208回もコンテキストスイッチが起こっていることが分かる。write(2)/read(2)のシステムコール関連でI/O待ちが発生し、コンテキストスイッチが起こっていることが推測できる。
truss(1)でシステムコールの実行回数を調査すると次のようになる。なお、この場合truss(1)で捕捉できるのは親プロセスの方だけである点に注意しておきたい。
% truss -c ./a.out syscall seconds calls errors fork 0.000027727 1 0 lseek 0.000004469 1 0 mmap 0.000035175 6 0 open 0.000021521 3 1 close 0.000010264 2 0 fstat 0.000010429 1 0 write 5.109862915 1000000 0 access 0.000007366 1 0 sigprocmask 0.000079952 12 0 pipe 0.000005876 1 0 munmap 0.000005048 1 0 read 0.000036749 2 0 ------------- ------- ------- 5.110107491 1000031 1 %
write(2)システムコールが100万回呼ばれていることが分かる。そのようにプログラムを組んでいるので当たり前なのだが、あまり効率のよい処理とはいえないわけだ。
Copyright © ITmedia, Inc. All Rights Reserved.