第58回 プログラミングについて
ホームページのアクセス回数をファイルに記録することについてちょっと問題になっていたことがありました。回数を記録するファイルは通常のテキストファイルで、単純にその中に1行だけ回数を書き込んでおくものです(今回はUNIX上での話です)。
もっと単純に言い換えると、あるユーザに誰かがログインしたら回数を記録しているファイルの数字を1つ増やすようにするだけのことです。この動作を分かりやすく説明すると、
(1)データファイルからそれまでの回数を読み込む
(2)読み込んだ回数+1の値をデータファイルに書き込む。
という処理の順序になります。
一体何が問題になったかというと、同時に(本当に実際には同時ということはありえないのですが)複数のログインがあったときに正常に回数を更新できるかどうかということでした。対象にしているデータは1つの数値でしかないので上の処理は一瞬で行われるのですが、もしデータファイルが大きいものだったとするとそういう訳にはいきませんので、ファイルからの読み込みも書き込みも時間がかかると仮定します。
ファイルの読み書きに時間がかかるということは、複数のログインがほぼ同時に行われると最悪の場合、次のようになる可能性があります。
いまデータファイルには 5 が記録されているとし、3人(A太郎、Bノ助、C吉)からのログインがほぼ同時に行われたとします。
(1)A太郎 5 を読み込む。
(2)Bノ助 5 を読み込む。
(3)C吉 5 を読み込む。
(4)A太郎 6 を書き込む。
(5)Bノ助 6 を書き込む。
(6)C吉 6 を書き込む。
となってしまい本来ならば最終的には 8 になって欲しいのに 6 になってしまうのです。ホームページの作り方みたいな本などでは、誰でも(C言語を知らない人にでも)分かりやすいように、回数の記録にはシェルなどで行なう様に書かれています。例えば、データファイルが count.dat だったとすると、
#! /bin/csh -f
set n = `cat count.dat`
echo $n + 1 | bc > count.dat
といった具合になっています。UNIXを使ったことのない方にはちんぷんかんぷんかと思いますので、説明しましょう。
#! /bin/csh -f
はCシェルで動作しなさいという命令だと思って下さい。
set n = `cat count.dat`
には複数の命令が含まれていますので1つ1つ説明します。
cat count.dat
の cat はファイルの内容を標準出力に出力するコマンドで、この場合は count.dat の内容を標準出力に出力します。そして、このコマンドが `` で囲まれているので、標準出力に出力する代わりに、コマンドの結果として set n = 命令の右辺の値となり変数n に代入されます。
次に、
echo $n + 1 | bc > count.dat
は、変数 n の内容と + 1 という文字が echo コマンドで標準出力に出力されるのですが、パイプ(| 記号)によって bc というコマンドに渡されます。bc コマンドというのは卓上計算機みたいなもので、echo コマンドから 10 + 1 という文字列が渡されると、11 という文字を標準出力に出力します。 bc の結果はリダイレクション(> 記号)によって標準出力に出力する代わりに count.dat に書き込まれます。
このようにしてシェルを使ってアクセス回数を記録するようになっています。問題はこの標準入力と標準出力なのです。いま実験に2つのプログラムを作ってみましょう。
プログラム1:
#include <stdio.h>
main()
{
FILE *fp;
int line;
fp=fopen("count.dat","w");
if(fp==NULL){
printf("Open error!\n");
}
else{
line=0;
while(1){
getchar();
fprintf(fp,"Line %d\n",++line);
if(ferror(fp))printf("Error!\n");
}
}
}
プログラム2:
#include <stdio.h>
main()
{
FILE *fp;
char string[81];
fp=fopen("count.dat","r");
if(fp==NULL){
printf("Open error!\n");
}
else{
while(1){
getchar();
fgets(string,sizeof(string),fp);
if(ferror(fp))printf("Error!\n");
else if(feof (fp))printf("Eof!\n");
else printf("%s\n",string);
}
}
}
プログラム1は <Return> キーを押すと、count.dat ファイルに行番号を示すデータを書き込むものです。プログラム2は <Return> キーを押すと、count.dat ファイルから1行を読み込んで表示するものです。
この2つのプログラムを起動します(実験はUNIXワークステーションのXウインドウ上で行ないました)。すると、この2つのプログラムをどの順番で起動しても、起動時のファイルオープンのエラーが発生しないのです。
実はこれで正解なのです。というのも、標準出力も標準入力も基本的には同じバッファを使用しますので、シェルの機能のパイプを使用したときのことを考えてみると、あるコマンドが標準出力に出力したデータを、パイプを使用して別のコマンドの入力とすることができるということは、もしこのときに書き込み用のオープンと読み込み用のオープンが排他的だったとすると、このパイプの機能を実現することはできないことになってしまいます。この高水準入出力を行なう標準関数は基本的には標準入出力に対する動作と同じですので、類推するとこれでいいことになるのです。
起動された2つのプログラムのうち、読み込みを行なうプログラム2に対して<Return>キーを押すと、プログラム1がまだ何もファイルに書き込んでいませんので、画面には何も表示はされません。次にプログラム1に対して<Return>キーを入力し、プログラム2に<Return>キーを入力すると、 Line 1 と表示されます。このようにこれらの操作を何度繰返しても、書き込みは常に正常に行われ、読み込みもまだ読んでいないデータがあったときにはそれを表示し、ないときには表示されません。
ということは、高水準の入出力を行なう関数やシェルのコマンドのパイプやリダイレクションを使用すると、1つのファイルを複数のプロセスが同時に読み書きのアクセスをできるということになり、アクセス回数のカウントする場合正常に動作しない可能性があるということになります。
それでは、1つのファイルに対して複数のプロセスが同時にアクセスできないようにするには、どうすればいいのでしょうか。考えられることは、ファイルにアクセスを行なうプロセスがそのファイルを他のプロセスがアクセスできないように禁止することです。
そこで、ファイルにロックをかける関数を探したところ、flock というものがありました。この関数は低水準の入出力を行なう関数の中の1つで、オープンされているファイルに色々な方法でロックをかけることができるものです。そこで、次のプログラムで実験してみました。
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mode.h>
main()
{
char data[81],*s;
int lread;
int fd;
/* データファイルを読み書きアクセスでオープン
する。入出力はすべて低水準で行ないます。*/
fd=open("count.dat",O_RDWR,S_IREAD | S_IWRITE);
printf("File opened.\n");
/* ファイルをオープンした後、ファイルを排他的
にロックします。*/
printf("Try to lock ...\n");
flock(fd,LOCK_EX);
printf("File locked.\n");
/* ファイルをロックした後、ファイルからデータ
を読み込みます。*/
lread=read(fd,data,sizeof(data));
printf("Old count was red.\n");
/* 行末の <LF> を削除します。 */
s=data;
while(*s){
if(*s=='\n'){ *s='\0'; break; }
s++;
}
/* ファイルの内容を更新します。*/
sprintf(data,"%d\n",atoi(data)+1);
lseek(fd,0,SEEK_SET);
write(fd,data,strlen(data));
printf("New count was written.\n");
/* <Return> が押されるとプログラムを
終了します。*/
printf("Waiting <Return>...\n");
getchar();
close(fd);
}
データファイル count.dat には数字が1つだけ書かれているものとし、さっそく実行してみましょう。すると、順調に処理が行われ、 Waiting <Return>... のプロンプトでキー入力待ちの状態になりました。このプログラムはこのままの状態にしておいて、別のウインドウでこのプログラムを起動します。すると、この2つ目のプログラムは、Try to lock ... のプロンプトを表示したままそれ以上の処理を行ないません。多分、flock 関数が count.dat をロックしようとしたところ、他のプロセスが既にロックしているので、ロックできるまで待っているものと考えられます。そこで、最初のプログラムでリターンキーを押すと、この最初のプログラムは終了しました。それと同時に、2番目のプログラムは処理を開始し、Waiting <Return> ... を表示して待ちの状態になりました。このプログラムを同時にたくさん起動して実験しても、問題なく実行することができました。
flock 関数で以外と簡単に実現することができたので、ちょっと拍子抜けの感があったのですが、基本的にはこのやり方で問題はないと思います。実際のプログラムでは、エラー処理を行なわなければなりませんが参考にして下さい。
このように、シェルで記述するのが困難な場合はC言語で記述することも必要になることがあるのです。当然、C言語で書くと面倒でもシェルで書くとスッキリとできることがあります。要するにケースバイケースということですね。
それではまた次回。