はじめに
前回の記事の項目の入出力について
本記事を通じて学んでいきたいと思います。
ゴール
・Unixシステムにおける入出力操作について理解する。
・ファイルオープン/読み取り/書き込み/クローズの操作を理解する。
・ファイルディスクプリタについて理解する。
入出力について
システムを構築する上で、インプットとアウトプットはシステムのIF部分となるので、非常に重要です。
Unixシステムでは入出力操作を統一されたIFを使って操作する事ができます。
そのIFとは、open/read/write/lseek/closeのみです。
これらのIFを使って入出力操作を実施する事ができます。
早速やってみましょう。
各種IFマニュアルについては以下コマンド確認可能です。
(第一引数はセクション数。システムコールはセクション2に記載されるので、2を指定する。)
man 2 open
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
static void do_rw(const char* fileName);
int main(int argc, char* argv[])
{
if(argc != 2){
fprintf(stderr, "need to filename\n");
return -1;
}
do_rw(argv[1]);
return 0;
}
static void do_rw(const char* fileName)
{
int fd;
ssize_t size;
char buf[1024];
if((fd = open(fileName, O_RDWR|O_APPEND)) == -1) {
fprintf(stderr, "open error\n");
return;
}
//標準入力のデータをファイルに出力する
while((size=read(STDIN_FILENO,buf,sizeof(buf))) > 0){
if(write(fd, buf, size)!=size) {
fprintf(stderr, "write error\n");
return;
}
}
if(size < 0) {
fprintf(stderr, "read error\n");
}
return;
}
ビルド後に以下コマンドで確認します。
# 出力先のファイルを作成します。
touch output
# パイプ(|)を使って、echoの結果をパイプ先のプロセスに渡します。
echo 'test' | ./sample output
# 最後にoutputの中身を確認します。
cat output
期待通り、outputと名のファイルに「test」の文字列が出力されていたと思います。
※パイプは標準出力の内容 標準出力を通じて出力した内容を後続のコマンドに渡す為のコマンドです。
コードの中身について簡単に説明します。
最初の#includeについてはディレクティブ命令と言って、
Cコンパイラの前段のプリプロセッサ向けの命令になります。
この#includeは<>で指定されたファイルをロードする事を指しています。
これらロードするファイル名については、IFのマニュアルに記載されていますので、確認してみてください。
C言語はエントリポイントをmainとしていますので、main関数から開始されます。
mainはコマンドライン引数を受け取れるようにパラメータが用意されています。
argcはパラメータの数が、argvにはコマンドラインで入力した文字列が設定されます。
パラメータの数ですが、今回「output」だけしか渡していませんが、パラメータの数としては2となります。
これは実行するファイルもパラメータとして渡される為です。(ここでは「./sample」)
その為、argv[0]には「./sample」が設定されているメモリアドレスが設定されています。
今回は、argv[1]に設定されている文字列へのアドレスに興味があるので、do_rwにargv[1]を渡しています。
次にdo_rwについてです。
本関数で今回の本題のIFを使っています。
まず、openメソッドで指定のファイル名のファイルをオープンします。
この時、第二引数で読み書き専用と追記モードを指定オープンしています。(他のオプションについてはマニュアルを参照してください。)
戻り値はファイルディスクプリタを返却します。
ファイルディスクプリタはファイルとのストリームを指します。
ファイルディスクプリタはファイルテーブルのレコードを指します。(ファイルテーブル内のinodeポインタがinodeテーブルにあるファイルを指します。)
以降のIFではここで取得できたファイルディスクプリタを使って、ファイル操作を行っていきます。
次にreadメソッドを使って、指定のストリームからデータを取得して、bufに取得したデータを格納しています。
オーバフローを起こさないように、第三引数でサイズを指定します。
ここで指定しているストリームですが、標準入力を指定しています。
Unixシステムでは標準入力のマクロとしてでSTDIN_FILENOが定義されているので、それを使用しています。
読み取れたバイト数が戻り値として返却されるので、読み取れたバイト数分をwriteを使って、
openで開いたストリームに対してデータを流して完了となります。
最後にcloseを明示的にしていませんが、慣習に沿って実施していません。
サーバアプリケーション等、継続して動作し続けるアプリケーションの場合は都度closeをするべきですが、
今回のように継続して動作する事がないアプリについてはcloseをしない事が多いです。
これはアプリケーションが終了した際、カーネル側で開通しているストリームを閉じる事を自動的に実施する為です。自動的で実施してくれる事についてはアプリで責務を負わずに、カーネルに任せる事が多いです。(ヒープ領域の開放についても同じ。)
ファイルディスクプリタについて
ファイルディスクプリタについて、もう少し踏み込みたいと思います。
ファイルディスクプリタは各プロセス毎に管理されます。
例として以下のようなコードを考えます。
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
int main(void)
{
pid_t pid;
int status;
if((pid=fork()) < 0)
fprintf(stderr, "fork error\n");
else if(pid==0){
//子プロセスの処理
close(STDOUT_FILENO);//標準出力へのストリームを閉じる。
printf("child output\n");//出力してみる。(何も表示されない。)
exit(0);
}else{
wait(&status); //子プロセスを待つ
printf("parent output\n");//子プロセスで標準出力のストリームを閉じているが、親プロセスとは関係ないので、ここの文字列は出力される。
}
exit(0);
}
#実行結果
./sample2
parent output
forkを使うとプロセスを複製する事ができるので、forkを使って複製して、子プロセス側で標準出力ストリームを閉じていますが、親プロセスの標準出力ストリームは閉じていないので、期待通り「parent output」の文字列が表示されます。これはプロセス毎にファイルディスクプリタを管理している為です。
上記で行った内容について以下イメージ画像です。
プロセスBが子プロセスを表しており、プロセスAが親プロセスを表しています。
ファイルディスクプリタはプロセス毎に管理されますが、ファイルテーブルとi-node(※1)はシステム全体で管理されます。
今回はforkを実施したので、子プロセスは親プロセスのディスクプリタも複製される為、同じファイルテーブルを指す事になります。
ファイルテーブルではファイルのオフセットと、ファイルステータスフラグ(読み取り、書き込み専用等のフラグ。open時に指定したフラグです。)、i-nodeテーブルへのポインタが格納されています。
i-nodeテーブルはファイルのデータと属性が管理されているテーブルになります。
ファイルの有りかや、所有者、パーミッション情報等が格納されています。
※1:Unixではv-nodeも実装されている。Linuxカーネルではi-nodeのみ。
但し、v-nodeの説明が無くてもほぼ差支えがない為、i-nodeのみ表現しています。
(v-nodeはファイルシステムで共通のIFを提供するデータ構造と思われます。。。)
プロセスAでオープンしたファイルに対して、プロセスBでも同じくオープンするとどうなるでしょうか。
この場合はファイルテーブルを指す場所が異なります。(i-nodeは同じ。)
試してみましょう。
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
pid_t pid;
int fd;
int status;
off_t offset;
char buf[1024];
ssize_t n;
fd = open("output", O_RDWR|O_TRUNC); //forkする前にオープンする
if((pid=fork()) < 0)
fprintf(stderr, "fork error\n");
else if(pid==0){
fd = open("output", O_RDWR|O_TRUNC);//同じファイルをオープンする
write(fd,"test",4);
offset = lseek(fd, 0,SEEK_CUR);
printf("child offset = %ld\n", offset);
exit(0);
}else{
wait(&status); //子プロセスを待つ
offset=lseek(fd,0,SEEK_CUR);
printf("parent offset = %ld\n",offset);
n=read(fd, buf, sizeof(buf)-1);
buf[n]='\0';
printf("read = %s\n", buf);
}
exit(0);
}
# 出力先のファイルを作成します。
touch output
#実行結果
./sample2
child offset = 4
parent offset = 0
read = test
再度同じファイルであっても開くと、異なるファイルテーブルを指す為、オフセットの値が異なる事がわかります。
(lseekは移動後のオフセットを返却するので、lseek(fd, 0, SEEK_CUR)で現在のオフセットから0移動した結果が得られるので、現在のオフセット位置を取得する事ができる。)
但し、同じファイルを開いている為、i-nodeは同じものを指します。
その為、子プロセスで出力した文字列を親プロセスで読み取ると、子プロセスで出力した内容が読み取れています。
(read後に終端文字を設定したのは、printfは指定した文字列には終端文字がある想定で動作している為です。)
上記結果を図で表現した結果は以下になります。
ファイルディスクプリタは使用していない最小値が使用されます。
その為、プロセスA(親プロセス)で開いたファイルはディスクプリタ3で管理されます。
プロセスB(子プロセス)で同じファイルを開いた場合は、ディスクプリタ4で管理されます。
(3はforkの際に親から複製されるので、使用されている為)
ディスクプリタが異なる為、ファイルテーブルの指し先が異なります。
しかし、同じファイルを開いている為、i-nodeテーブルの指し先は同じです。
ここで一つ問題があります。
プロセスAとプロセスBがディスクプリタが指すファイルテーブルは異なるが、
同一ファイルに対して、書き込みをした場合はどうなるでしょうか。
この場合は、管理されているファイルテーブルレコードが異なる為、管理されているオフセットが異なります。
従って、プロセスAとプロセスBが書き込みを行う際に、同一の位置に対して文字を書き込んでしまい競合が発生してしまいます。
(fork後に親プロセスが開いたディスクプリタを使う場合は、競合が発生しない。これは親と子で同じファイルテーブルを指している為。)
試してみましょう。
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
pid_t pid;
int fd;
int status;
off_t offset;
char buf[1024];
ssize_t n;
fd = open("output", O_RDWR|O_TRUNC); //forkする前にオープンする
if((pid=fork()) < 0)
fprintf(stderr, "fork error\n");
else if(pid==0){
fd = open("output", O_RDWR);//同じファイルをオープンする
write(fd,"aaaaaaaa",8);
exit(0);
}else{
wait(&status); //子プロセスを待つ
write(fd,"bbbbbbbbbb",10);
}
exit(0);
}
# 出力先のファイルを作成します。
touch output
#実行結果
./sample2
cat output
bbbbbbbbbb
子プロセスが書き込んだ位置に、親プロセスが書き込みを行った為、子プロセスで書き込んだ内容が失われています。
これを防ぐ為にはどうすれば良いでしょうか。
プロセスAが書き込んだ後に、どのようにして書き込み後のオフセットの位置を連携すればいいのでしょうか。
これを簡単に解決する方法として、O_APPENDフラグがあります。
O_APPENDフラグは、ファイルテーブルのファイルステータスフラグ上で管理されます。
動作としては、書き込みの度に、i-nodeテーブルのファイルのサイズをファイルテーブルのオフセットに設定してから書き込みを行います。
この動作の為、必ず末尾に書き込まれるようになります。
上記コードをO_APPNEDフラグを使って修正します。
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
pid_t pid;
int fd;
int status;
off_t offset;
char buf[1024];
ssize_t n;
fd = open("output", O_RDWR|O_TRUNC|O_APPEND); //forkする前にオープンする
if((pid=fork()) < 0)
fprintf(stderr, "fork error\n");
else if(pid==0){
fd = open("output", O_RDWR);//同じファイルをオープンする
write(fd,"aaaaaaaa",8);
exit(0);
}else{
wait(&status); //子プロセスを待つ
write(fd,"bbbbbbbbbb",10);
}
exit(0);
}
#実行結果
./sample2
cat output
aaaaaaaabbbbbbbbbb
無事上書きを回避できました。
複数プロセスで同一の資源を操作する場合は、競合が発生しうるので、アトミックな操作が必須となります。
(アトミックとはそれ以上分割できない最小の操作)
今回のO_APPENDフラグはアトミック操作の一つです。
他にも、fork後に親プロセスと子プロセスで同一ファイルを作成するケースも競合が発生します。
この場合はO_CREATEに合わせて、O_EXECを指定するとファイル作成がアトミックな操作になります。
次に、ストリームに流した文字列はいつディスク上のファイルに反映されるのでしょうか。
ディスクへの書き込みは非常に遅いので、性能に直結する部分になります。
その為、Unixでは遅延書き出しといって、書き出し命令を投げた後、カーネル上のバッファに一旦蓄えられて、後にファイルに反映する為に書き出しキューに入れます。その後、カーネル側のタイミングで書き込みが行われます。
但し、データベースアプリケーション等のファイルに書き込まれた事を保証する必要があるソフトウェアの場合は、
fsync()を使うと、バッファ上のデータをファイルに書き込むまで待ち処理を行ってくれます。
※補足ですが、ext3のファイルシステムではfsyncの使用は不要です。ext3デフォルトではfsync相当の動きになります。
⇒こちら誤りです。
https://qiita.com/Kenta_Omura/items/777cdd0f233c64ace8ac#comment-1b2969175ec0a78f19c6
最後に、ディスクプリタテーブルの記述子フラグの項目について説明して終わりたいと思います。 記述子フラグはプロセス毎に管理されるディスクプリタテーブルに設定される項目の為、 各プロセスで閉じる内容です。 (ファイルテーブルはプロセス共通の為、各プロセスで閉じる内容ではない事に注意。) ファイルディスクプリタテーブルの記述子フラグを参照するには、fcntl()を使用します。 fcntl()のマニュアルを見る限り、現状記述子フラグはFD_CLOEXECのみをサポートしています。 このFD_CLOEXECのフラグは fork後に親プロセスから引き継いだディスクプリタにおいて、execした際に引き継いだディスクプリタを閉じるかどうかを制御する為のフラグです。 execした後に閉じない場合は、別のプログラムのプロセスで、親プロセスから引き継いだディスクプリタを触れる事になります。これは脆弱性につながります。 そういった事を回避する為に用意されているフラグになります。 早速確認してみます。まずは、FD_CLOEXECを使用しない場合です。
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int fd;
pid_t pid;
int status;
char buf[1024];
if((fd = open("output", O_RDONLY)) < 0)
fprintf(stderr, "open error\n");
printf("fd flags = %d\n", fcntl(fd, F_GETFD));
if((pid=fork()) < 0)
fprintf(stderr, "fork error\n");
else if(pid == 0){
printf("child %d\n",getpid());
sprintf(buf, "ls -al /proc/%d/fd", getpid());
execl("/bin/sh", "sh", "-c",buf,(char*)0);
exit(-1);
}else{
wait(&status);
printf("parent %d\n", getpid());
sprintf(buf, "ls -al /proc/%d/fd", getpid());
system(buf);
}
exit(0);
}
# 出力先のファイルを作成します。
touch output
#実行結果
./sample3
fd flags = 0
child 144726
合計 0
dr-x------ 2 kenta kenta 0 1月 7 19:11 .
dr-xr-xr-x 9 kenta kenta 0 1月 7 19:11 ..
lrwx------ 1 kenta kenta 64 1月 7 19:11 0 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:11 1 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:11 2 -> /dev/pts/0
lr-x------ 1 kenta kenta 64 1月 7 19:11 3 -> /home/kenta/work/practice/qiita/output
parent 144725
合計 0
dr-x------ 2 kenta kenta 0 1月 7 19:11 .
dr-xr-xr-x 9 kenta kenta 0 1月 7 19:11 ..
lrwx------ 1 kenta kenta 64 1月 7 19:11 0 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:11 1 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:11 2 -> /dev/pts/0
lr-x------ 1 kenta kenta 64 1月 7 19:11 3 -> /home/kenta/work/practice/qiita/output
ファイルディスクプリタテーブルの記述子フラグは0で何もない状態です。
その為、子プロセスで他プログラムを起動してもディスクプリタは引き継いだままです。
上記のファイルディスクプリタ3が同じものを指しています。
では次に、FD_CLOEXECを使用してみましょう。
今回はfctrlを使用せずに、openでO_CLOEXECのフラグを指定します。
(フラグを論理和で追加するだけなので、コードは省略します。)
# 出力先のファイルを作成します。
touch output
#実行結果
./sample3
fd flags = 1
child 147420
合計 0
dr-x------ 2 kenta kenta 0 1月 7 19:22 .
dr-xr-xr-x 9 kenta kenta 0 1月 7 19:22 ..
lrwx------ 1 kenta kenta 64 1月 7 19:22 0 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:22 1 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:22 2 -> /dev/pts/0
parent 147419
合計 0
dr-x------ 2 kenta kenta 0 1月 7 19:22 .
dr-xr-xr-x 9 kenta kenta 0 1月 7 19:22 ..
lrwx------ 1 kenta kenta 64 1月 7 19:22 0 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:22 1 -> /dev/pts/0
lrwx------ 1 kenta kenta 64 1月 7 19:22 2 -> /dev/pts/0
lr-x------ 1 kenta kenta 64 1月 7 19:22 3 -> /home/kenta/work/practice/qiita/output
記述子フラグは1となっており、期待通りFD_CLOEXECフラグが立っている状態です。
また、実行結果から期待通り、子プロセスでexec後にはファイルディスクプリタ3は引き継がれていません。
まとめ
始めにopen/read/write/lseekのIFを使って、入出力操作を確認しました。
次に、ファイルディスクプリタが何を意味しているのか、ファイル操作の仕組みはどうなっているのかを図や
コードを書いて実際に動かしてみて確認しました。
入出力の操作については覚えるIFは5つと少なく、洗練されていると感じました。
このIFでカバーできない機能については
今回紹介はしませんでしたがioctlでカバーするようになっています。
ファイルディスクプリタについてはコードとイメージを関連付けながら確認した事で理解が深まりました。
今回の記事はこれで終わりです。
また、来週~