UNIX 6th code reading - パイプ

はじめに

今回はパイプを見ていきます。

ソースコードunix/pipe.cを中心に追っていきます。

パイプの仕組み

パイプとはプロセス間の通信を実現する仕組みのことです。

各プロセス毎に固有のアドレス空間を持っているため、通常は他プロセスが持っているデータにアクセスすることができません。プロセス間でデータをやり取りするために生まれたのがパイプです。

パイプはファイルシステムを使って実現されます。

root diskのinode(=パイプ)を取得し、そのinode(が指すデータ領域)越しにデータをやりとりします。パイプの容量は4,096bytesとなっており、パイプがいっぱいになったら自分は寝て、読む側のプロセスを起こしパイプのデータを読み取ってもらいます。読み取ったデータはパイプから取り除きます。逆にパイプから読むデータがなくなったら自分は寝て、書く側のプロセスを起こしパイプにデータを書き込んでもらいます。

書く側と読む側の関係は、OSの教科書などにでてくる生産者消費者問題の関係にあたると思います。

イメージはこんな感じです。

パイプはpipeシステムコールと、dupシステムコール、forkシステムコールを組み合わせて実現されています。

pipe( )

まずはpipeシステムコールを解説します。

pipe( )(7723行目)はpipeシステムコールのハンドラです。

u.u_ofile[ ], file[ ]にread用のエントリとwrite用のエントリを確保します。そして、root diskからinodeを取得し、read, write用のfile構造体両方からそのinodeを指すようにします。

絵にするとこんな感じです。

ユーザプログラムにはread, write用のファイルディスクリプタ(=u.u_ofile[ ]のエントリ)が返されます。ユーザプログラムはそれらのファイルディスクリプタに対し、通常ファイルと同じように読み書きをすることでパイプ越に通信を行うことができます。

writep( )

write用ファイルディスクリプタに対し書き込みを行うと、writeシステムコール->write( )->rdwr( )->writep( )と処理が移っていきます。

writep( )(7805行目)がパイプに対し書き込み処理を行うメイン関数です。

writep( )は以下のことを行います。

  • パイプにデータを書き込む
  • パイプにデータが書き込まれるのを待っているプロセスがいたら起こす
  • パイプがいっぱい(4,096bytes)になったら寝る


パイプの実態は単なるファイルなので、通常ファイルと同じようにwritei( )を使って書き込みます。

readp( )

read用ファイルディスクリプタを使って読み込みを行うと、readシステムコール->read( )->rdwr( )->readp( )と処理が実行されていきます。

readp( )(7758行目)がパイプから読み込み処理を行うメイン関数です。

readp( )は以下のことを行います。

  • パイプからデータを読み込む
  • パイプがいっぱいで、データが読み込まれるのを待っているプロセスがいたら起こす
  • パイプが空になったら寝る


パイプの実態は単なるファイルなので、通常ファイルと同じようにreadi( )を使って読み込みます。

forkを使って親子間で通信

以上でパイプの基本的な流れは説明しましたが、このままでは同一プロセスでしかやり取りができません。

forkと組み合わせると親子プロセス間で通信を行うことができます。

forkで子プロセスを作成すると、u.u_ofile[ ]の内容はコピーされます。pipeの後にforkを実行して、片方のプロセスのread, 他方のプロセスのwriteをcloseすることで、親子プロセス間でデータのフローが生まれます。

絵で書くとこんな感じです。


dupを使ってstdin, stdoutのオーバーライド

さらにdupシステムコールを使うことで、シェルでよく使う

% ls | cat

このようなパイプ処理が実現できます。

各プロセスはu.u_ofile[0], u.u_ofile[1], u.u_ofile[2]にstdin, stdout, stderrに相当するファイルディスクリプタを持っています。

この割当てはユーザランドのgettyが行っているようです。機会があればgettyソースを追ってみるつもりです。

プロセスがstdin, stdout, stderrに出力するときは、writeシステムコールを使ってu.u_ofile[0, 1, 2]に対して出力処理を行っています。

上記のようなパイプ処理はこのstdin, stdoutが指す先をパイプにオーバーライドすることで実現されています。(なお、リダイレクションも同じように出力先をオーバーライドすることで実現されているようです)

pipe, forkを実行した後に、片方のstdout(=u.u_ofile[1]), 他方のstdin(=u.u_ofile[0])をcloseします。その後に、それぞれwrite, read用のファイルディスクリプタdupで複製します。

dupはu.u_ofile[ ]の最初の空きエントリにコピーするので、closeしたstdout, stdinの部分にwrite, read用のファイルディスクリプタがコピーされます。

そしてpipeで生成したwrite, read用のファイルディスクリプタをcloseすれば完了です。

絵で描くとこんな感じです。

これで、各プロセス(プログラム)は普通にstdout(=u.u_ofile[1]), stdin(=u.u_ofile[0])に入出力をしているだけのに、実際はパイプを使ってプロセス間で通信が行われている、という状況が実現されます。

パイプの考察

パイプの実態は単なるファイルです。ディスクに対するI/Oが発生するので、以下の点が気になります。

  1. I/O処理が終る前にプロセスが切り替わることで、おかしなデータを読み書きしてしまわないか
  2. I/Oのレイテンシによるプロセス間通信性能の低下


まず、I/O待ちに伴うプロセス切り替えについて考えます。

readp( ), writep( )の先頭でinodeのロックを行い、最後にロックの解除を行います。そのため、例えば書き込む側がディスクに対して書き込み処理を実行し、I/Oが完了するまで寝るとします。ここで読む側のプロセスに遷移したとしても、inodeにロックがかかっているので読み出し側のプロセスはロックが解除されるまで寝ることになります。つまり、ディスクI/O処理が完了しないと読み出し側も処理を続けることはできません。このように、ディスクI/O待ちでプロセス間の通信がおかしくなることはありません。

次にI/Oのレイテンシによるプロセス間通信性能について考えます。

読み出し側はblock bufferによるディスクキャッシュが効くので、基本的にディスクまで読みには行かないはずです。しかし、書き込み側が呼び出すwritei( )の中では
ディスクに対する書き込みが発生し得ます。

writei( )の中で、block単位(512bytes)で書き込む場合はbawrite( )を使っています。非同期書き込みのためプロセスはディスクI/Oの完了は待たないのですが、ディスクI/O処理が完了するまでblock bufferのB_BUSYフラグが立つので、読む側もディスクI/O処理が終るまで待たないといけません。

block単位未満の場合はbdwrite( )を使うので、すぐにはディスクには書きにいきません。しかもblock bufferのB_BUSYフラグは落ち、B_DONEフラグが立つので、読む側はすぐにそのblock bufferを使用することができ、かつ、ディスクキャッシュが効いているのでディスクまで読みに行きません。(ただし、bdwrite( )実行後は、書き込み側が続けて次のデータを書き込むケースが多いはずなので、bdwrite( )のすぐ後に読む側に遷移するのはデータの最後の部分くらいだと思います)

このように、block単位以上のデータをパイプで通信する場合はディスクに対する書き込みが発生する可能性が高いでしょう。(データ量/512bytes回?)

パイプの場合は必ずbdwrite( )を使用するようにすれば、ディスクに対するアクセスを減らすことができるので、パイプ通信の性能を上げることができるのではないでしょうか。

ただし、block bufferのために確保されてるメモリはかなり少ない(15 x 512bytes)ので、二つ以上のプロセスが同時にパイプサイズをフルに使う通信を行う(4,096bytes x 2以上)と、bufferがあふれてしまい、bdwrite( )により遅延書き込み状態にあるblock bufferもディスクに対して書き込みに行く可能性が高くなります。資源が少ないので、余計なことはせずに、基本的にディスクまで書きにいくようにしているのかもしれません。

追記:パイプサイズは8block(8 x 512bytes)分の容量があるので、あるblockのI/O処理は別blockのユーザプログラムレベルでの処理に隠避されて、性能の劣化はあまり見えないのかもしれません

パイプを使用するメリット

パイプを使用するのが、ファイルに一時出力するのと比べてどのようなメリットがあるかを考えます。

# パイプを使用する
% ./hoge | ./fuga

# ファイルに一時出力する
% ./hoge > tmp
% ./fuga tmp

パイプの場合は、パイプのサイズがmax 4,096bytesと決まっているので、以下のようなメリットがあると考えられます。

  • 使用するリソースが少なくて済む


4,096bytes書きこむと、読む側に遷移が移るので、必要なディスクリソースは4,096bytesで済みます。ファイルに一時出力する場合は、その分だけディスクリソースが必要になります。

  • ディスクキャッシュが効きやすくなる


パイプに書いた後に、読む側にすぐ遷移することが期待できるので、block bufferのディスクキャッシュが効きやすくなります。また、パイプサイズが4,096bytesなので、単独のパイプ通信を見ると確保されているblock buffer全サイズより小さいためにblock bufferがあふれることがなく、他のプロセスに邪魔をされなければ必ずディスクキャッシュが効きます。

一方、ファイルに一時的に吐き出す方式だと、block buffer全サイズより大きいファイルを吐き出すとディスクキャッシュが効かなくなることが想定されます。

このように、パイプを使う方式は

  • 通信サイズにスケーラビリティがある
  • 必要なリソースが少なくて済む
  • ディスクキャッシュにより性能が上がる


といったメリットがあると思います。


コードメモ

pipe( )
  • 7728-7730 : ialloc( )を使って、root deviceのinodeを新たに取得する。失敗したらエラー
  • 7731-7736 : falloc( )を使って、read用にu.u_ofile[ ]とfile[ ]のエントリを新たに割当て。取得したu.u_ofile[ ]のindex(ファイルディスクリプタ)がuserのr0に格納されているのでローカル変数rに退避しておく。割当てに失敗したら先ほど取得したinodeを解放してからreturn
  • 7737-7745 : falloc( )を使って、write用にu.u_ofile[ ]とfile[ ]のエントリを新たに割当て。取得したu.u_ofile[ ]のindex(ファイルディスクリプタ)がuserのr0に格納されているので、r1にコピー。そして退避しておいたread用のファイルディスクリプタをr0にコピー。これでr0にread用のファイルディスクリプタが格納され、r1にwrite用のファイルディスクリプタが格納された状態になる。これらがユーザプログラムへの返り値となる。割当てに失敗したらread用に取得したu.u_ofile[ ], file[ ]のエントリをクリアし、取得しておいたinodeを解放してからreturn
  • 7746-7752 : read, write用のfile構造体, inodeの初期化。read用のfile構造体にFREAD, FPIPEフラグを立て、write用のfile構造体にFWRITE, FPIPEフラグを立てる。read, writeどちらのfile構造体からも、先に取得したroot deviceのinodeを指すようにする。inodeの参照カウンタは2にして、参照フラグ・更新フラグを立てる。i_modeはIALLOCにする
readp( )
  • arguments
    • fp : read用のfile構造体のポインタ
  • 7768 : plock( )を実行してロックをかける(なんのため?)
  • 7772 : 現状のデータを読み尽くしていたら。自分は寝てwrite側にデータを供給してもらう
    • 7773 : データを少しでも読んでいたら
      • 7774-7775 : 読み終わったデータは用済みなのでファイルオフセットとファイルサイズを0クリアする
      • 7776-7779 : inodeのmodeがIWRITEになっていたら、write側のプロセスが寝ているという意味なので、IWRITE modeをリセットしてwrite側のプロセスを起こす。write側のプロセスはip+1で寝ている
    • 7786 : ロックの解放
    • 7787-7788 : inodeの参照カウンタが2より減っていたら、つまり、write側のプロセスうが終了していたらreturnする。パイプ処理の完了
    • 7789-7791 : inodeのi_modeにIREADフラグを立てて、ip+2で寝る。write側に起こされたらloopに戻って処理再開
  • 7795-7797 : オフセットを設定してからreadi( )を実行してデータを読み出す
  • 7798 : データを読み終わったらファイルオフセットを更新
  • 7799 : inodeのロックを解放
writep( )
  • arguments
    • fp : write用のfile構造体ポインタ
  • 7815 : plock( )を使ってinodeをロック
  • 7816-7819 : データを書き尽くしたらinodeを解放し、u.u_countを0にクリアし、returnする
  • 7825-7830 : inodeの参照カウンタが2より小さくなっていたら、つまり、まだ書き込むデータがあるのでread側のプロセスが先に終了してしまっていたらエラー処理。inodeのロックを解放してからu.u_errorにEPIPE(PIPEでエラー発生)を設定した後にシグナルを投げてreturn
  • 7835-7840 : パイプがいっぱいになっていたら、inodeのi_modeにIWRITEフラグをセットし、prele( )でinodeのロックを解放したのちに、ip+1で寝る。read側に起こされた後はloopに戻って処理を続行
  • 7844-7847 : u.u_offsetにファイルサイズを設定し、u.u_countはパイプのサイズ(4096bytes)を超えないように設定。ローカル変数cには書き込むデータの残りbytes数を格納
  • 7848 : writei( )を実行し、データを書き込み
  • 7849 : prele( )を実行してinodeのロックを解放
  • 7850-7853 : inodeのi_modeにIREADフラグが立っていたら、read側プロセスうが寝ているという意味なので(?)、IREADフラグをリセットした後にread側プロセス(ip+2で寝ている)を起こす
  • 7854 : loopに戻って処理継続
plock( )
  • arguments
    • ip : inode pointer
  • 7868-7872 : inodeのロックが解除されるまで、inodeにIWANTフラグを立てて寝る
  • 7872 : inodeにロックをかける
prele( )
  • arguments
    • ip : inode pointer
  • 7888 : inodeのロックを解除
  • 7889-7892 : inodeのロック解除を待っているプロセスがいたら、IWANTフラグをリセットしてからそのプロセスを起こす
rdwr( )

以前追ったソースコードだが、パイプがらみの処理を見ていなかったので再確認。

read, writeシステムコールが実行され、共通処理としてrdwr( )が呼ばれる。

  • 5746 : u.u_base, u.u_count, u.u_segflgを設定した後に、読み書き対象のファイルがパイプならばこのif文の中が実行される。readならばreadp( )を実行し、writeならばwritep( )を実行する
closef( )

こちらも以前追ったソースコードだが、パイプがらみの処理を再確認する。

  • 6649 : closeするファイルがパイプだったら。file構造体が指すinodeのIREAD, IWRITEフラグをリセットし、read, writeプロセスを起こす。(もし、パイプの読み書きを待っているプロセスがいた場合、ここで起こさないと寝たままになってしまうから?)

終わりに

ようやくファイルシステム周りのソースコードをほぼ追いつくすことができました。UNIX v6カーネルソースコードの探検も、もう少しで終りそうです。

次回はキャラクタ型のファイルの扱いを見ていきます。