no-image

ファイルディスクリプタとシステムコールについての勉強メモ

バイト先の勉強会でファイルディスクリプタやシステムコールについて少し調べて発表したので、それについて紹介します。

スライドはここにあります。

概要

CPUにはユーザーモードとカーネルモードと呼ばれるモードがあり、僕らは普段、ユーザーモードでアプリケーションを実行しています。

ユーザーモードはいくつか制限のあるモードで、ファイルの読み込みや書き込みもできません。

え、普通にPythonとかのコードで、ファイル読み込みとかできてるじゃんってなるかもですが、これはシステムコールというものを呼んで、一時的にカーネルモードに切り替わって処理をしています。

とかいうのを、以下で一つずつ見ていきます。

ファイルディスクリプタとは

ファイルディスクリプタとは一言で言うと、「ファイルへの参照を抽象化したキー」のことです。

日本語では「ファイル記述子」といい、コード上などでは略して「fd」などと記述されることが多いです。

【画像引用・参考】inode and file descriptor table Interaction | EduSagar

上図の引用元サイトの説明がわかりやすかったです。

上図の一番左はプロセスごとに作られるファイルディスクリプタテーブルです。

プロセスが作られると同時にテーブルも作成され、作成時にはすでに0,1,2は埋まっており、それぞれ標準入力、標準出力、標準エラー出力が割り当てられています。

その後、プロセス内でファイルがOpenされたりすると3から連番にC言語のint型の整数値が割り振られていきます。

上図中央のグローバルファイルテーブルは全てのプロセスによって開かれたファイルのテーブルです。

このテーブルを仲介してinodeテーブルを参照します。

inodeというのはファイルの場所など、ファイルについての属性情報が書かれたデータのことです。

各プロセスのファイルディスクリプタテーブルから出た矢印はグローバルファイルテーブルにユニークに向きます。

これを見ると、わざわざグローバルファイルテーブルが存在する意味なくない?と感じますが、これらはdup()fork()などの特別なシステムコールを発行するときに必要になります。

【参考】I/O redirection using dup() system call | EduSagar

そして、システムコール経由でファイルディスクリプタをカーネルに渡すことで、カーネルはそれに対応するファイルにアクセスすることができます。

ファイルディスクリプタを実際に見てみる

そのプロセスがいくつのファイルディスクリプタを持っているかを確認してみます。

今回はDockerでCentOSを立ち上げ、そこで見てみます。

まずは、簡単にただ「hello world」と出力し、30秒間スリープするプログラムをGoで書いて実行してみます。

これをhello.goなんて名前で保存し、$ go run hello.goで実行します。

30秒でプロセスが閉じられてしまうので、急いでpsコマンドでPIDを確認します。

$ ps alx

以下のように表示されたので、PIDが「1533」のファイルディスクリプタを確認してみます。

$ ls /proc/1533/fd

特にファイルを開いたりしていないので、デフォルトの0,1,2だけが確認されました。

次はプログラム内でファイルをOpenして、ファイルディスクリプタが増えるのを確認してみます。
ファイルを1000個Openし、そこに文字列を書きこんでいます。
あえてCloseはしていません。

実行してみると、以下のように1000個の数字が表示されます(一部省略しています)

こんなふうにしてファイルディスクリプタを確認できることがわかりました。

ファイルディスクリプタの上限

ファイルディスクリプタにも一応上限はあって、ulimit -nというコマンドで確認できます。

このCentOS上で実行すると1048576でした。

これを超える量のファイルをOpenするようなプログラムを実行すると「Too many open files」のようなエラーが出力されます。

Webサーバーを立てて、同時接続数を増やしたいときなど、上限数を増やしたいときは、/etc/security/limits.confらへんをいじると設定できるのですが、詳細は割愛します。

CPUの動作モードについて

さきほども軽く触れましたが、CPUにはいくつかの動作モードがあります。

それの前にまずは、リングプロテクションについて見てみます。

リングプロテクションとは

リングプロテクションとは複数の特権レベルの階層構造のことです。

Wikiにあった下図を見ると直感的に理解できるかと思います。

【画像引用元】リングプロテクション – Wikipedia

Ring 0が最も特権レベルの高い階層で、Ring 3が最も低い階層です。

リング間には特別なゲートがあり、そこを通らないと外側から内側へ入れないようになっています。

悪意のあるプログラムが内側の重要な部分に簡単にアクセスできないようにするなどの理由でこんな構造になっています。

CPUの2つのモード

リングプロテクションでは0~3の4階層ありましたが、UNIXやWindowsでは0と3しか使用していません。

CPUの種類によって異なりますが、この2つの階層が多くのCPUの2つのモードに割り当てられています。

カーネルモード

スーパーバイザーモードとか、特権モードとも呼ばれるモードで、完全に無制限にCPUの命令を実行できます。

入出力操作の開始や、全メモリ空間へのアクセスなど、任意の命令を実行できます。

ユーザーモード

一般的なアプリケーションが動作するモードで、一部の命令が実行できなっかたり、一部のメモリ空間にアクセスできなかったりと制限があるモードです。

システムコールとは

システムコールは特権モードでOSの機能を呼ぶことです。

システムコールすることで、特権モードでのみ許されている機能をユーザーモードのアプリケーションから実行することができます。

プロセスは通常、ユーザーモードで動作していますが、システムコールを発行するとカーネルに処理を依頼し、CPU上で割り込みというイベントが発生します。

これによって、ハードウェア的にユーザーモードからカーネルモードに移行し、カーネルモードで処理を実行できます。

OSごとにシステムコールが用意されており、Linuxでは337個、FreeBSDでは537個あるみたいです。

以下のサイトにそれぞれのシステムコール一覧を見ることができます。

例えば、OpenとかCloseとかReadとかWriteなどです。

この前の記事で書いたプロセスのforkなどもシステムコールで用意されています。

【参考】

ないとどうなるか

システムコールがないということは、カーネルモードで命令を実行できないということです。

つまり、ユーザーモードでのみでの動作になります。

ユーザーモードでは、計算はできますが、入出力操作ができないので、計算結果を画面に出力したり、結果をファイルに保存したり、外部のwebサービスなどへの送信などができません。

【参考】

Go言語での実装

GoのコードでもGoto Definitionなどで関数の定義元にジャンプしていくとシステムコールまでたどり着くことができます。

例えばos.Create()関数の定義元を何ステップか踏んで辿っていくと、syscall.Open()という部分を見つけられます。

これで、まさにシステムコールのOpenを呼んでいることが確認できました。
さらに辿っていくとアセンブリ言語まで到達できます。

【参考】go/asm_darwin_amd64.s at master · golang/go

 

さいごに

長くなりました。

Linuxカーネルのコミッターさんたちや、Goなど言語の実装者さんたちの抽象化したAPIの実装はとてつもないなと、色々調べてて思いました。

というのも、普段僕らがプログラミングするときは、言語自体に用意されている関数が、何を引数にとって、何が返り値なのかを知っているだけで、多くの場合、内部実装を気にせずに利用することができます。

これってほんとにすごくて、アプリケーションの共同開発をしていると、度々そのコンポーネントの中身を見に行ったりしています。

内部実装を読むことも大切ですが、自分の作ったそれを他の人が簡単に使えるというのも技術力の見せ所な気がします。

参考