はじめに
プログラミングで頻繁に使われるにもかかわらず、誰も詳しく教えてくれない内容の一つがファイル操作です。これに関する資料や説明は見つかりにくいです。学校でCS(Computer Science)の知識を学ぶとしても、ファイル操作のような実務で必要な知識は学ばないか、概要だけにとどまることが多いです。
そのため、ファイルに関する問題に対処する際には、データをファイル全体で読み取って処理したり、ファイルハンドラを使っても表面的な理解とサンプルコードのパターンに従ってコードを書いてしまうことがよくあります。
本記事はファイル操作を理解するために必要な基本的なCS知識を習得するために作成されました。これを通じてファイルハンドラをより適切に扱えるようになることを期待します。
本記事はファイルハンドラの理解に必要な知識に焦点を当てており、OSのファイルシステムやディスクのハードウェア的な処理に関する説明については筆者の知識不足により、実際と異なる点がある可能性があることを予めご了承ください。
本論
ディスクの動作
コンピュータはデータを0と1で保存します。0と1の状態を1ビットで表現し、1ビットの状態が8つ集まると1バイトになります。ハードウェアディスクは連続した2進状態の保存空間を持っています。連続した保存空間を持つということは、データを探す際にインデックス番号でアクセスできて、迅速にデータへアクセスができることを意味します。コンピュータがデータを扱うときには、できるだけ速い方が望ましいため、可能な限り連続した保存空間を持つように設計されます。
コンピュータがハードウェアに記録されたデータを読み書きする場合、ブロックという単位でデータを読み書きします。たとえ小さなデータを取得する場合でも、1単位のブロックを読み込まなければなりません。ディスクへのアクセスはファイルシステムで管理され、ファイルシステムは1つのブロックのデータを読み込むために、少なくともブロックのサイズ以上のメモリ空間を使用する必要があります。ブロックはデータの読み書きの最小単位の概念です。
ディスクに保存されたファイルは頻繁に保存、削除、変更が行われます。一部のデータが削除された領域の空きスペースに新しいデータを保存する際、データのサイズが大きすぎて保存できない場合は、ファイルのデータを分割して別々の場所に保存する必要があります。ハードウェアはブロック単位でデータを保存するため、既存のブロックに保存スペースが不足している場合、新しいブロックにデータを保存するか、既存のブロックと新しいブロックにデータを分割して保存します。ブロックという保存単位で、ハードウェアはすべての保存スペースを連続的に構成せず、一定単位の連続した保存領域を持つようになっています。
ハードウェアの設計はさまざまな技術を使用しているため、1つのブロックであっても必ずしも連続した保存空間のみを持つわけではありません。SSDのようなストレージは、1ビット単位ではなくセル単位でデータを保存し、1〜4ビットのデータを1単位として保存するなどの技術が使用されています。
ファイルデータの変更
あるファイルのデータが変更されたと仮定しましょう。そのファイルの容量が大きい場合、複数のブロックにわたってデータが保存され、ファイルの容量が小さい場合は1つのブロック内に他のファイルのデータと一緒に保存されることがあります。例えば、複数のブロックにわたってデータが保存され場合ABCDEFG
というデータがあり、1つのブロックが4文字を保存すると仮定すると、ABCD
とEFGH
が2つのブロックに保存されます。ここでABCD123EFGH
のように123というデータが追加された場合、ABCD
123E
FGH_
と分割され、それぞれのブロックに保存されます(_は空の保存領域とします)。データの追加や削除によって各ブロックに保存されるデータセットが異なることがわかります。
既存のブロックを削除して再書き込みするよりも、新しいブロックにデータを追加する方が望ましい理由がいくつかあります。特定の領域に繰り返し書き込むとその領域の物理的寿命が短くなるため、ディスク全体の領域を均等に活用することが良いです。また、ブロック内の一部のデータを変更する際、同じブロック内にある他のデータに影響を与える可能性も考慮する必要があります。そのため、1つのブロックを再書き込みするよりも、新しいブロックを割り当てる方がリソース消費量が少なく効率的です。さらに、上書きを行わず既存のブロックをそのまま残すことで、問題が発生した場合にファイルシステムがデータを復旧できる可能性が高まるといった利点もあります。このため、ブロックを新たに書き換えるのは効率が悪く、書き込み作業はハードウェアの寿命を縮めるため、ABCD
123_
EFGH
のように既存のブロック(ABCD
とEFGH
)はそのままにして、追加されるデータのみ新しいブロック(123_
)に保存します。この時、ハードウェア上では連続した保存空間にデータが保存されていないものの、論理的には連結されたデータとして扱われます。
データを削除する場合、ABCD
123_
EFGH
からA、B、1、2を削除すると、削除されたデータは空白のまま残り、__CD
__3_
EFGH
となります。データを削除する際には、既存のデータをそのままにして、削除対象の保存空間のデータのみを消去する方式で動作します。ハードウェア上ではデータが不連続になりますが、論理的には連続した空間として扱えるため、効率的なデータ保存のために空白として残しておきます。
SSDが普及する前はハードディスクでデフラグを行うことがありました。読み書きの操作が増えるとハードウェアに保存されたデータが不連続となり、論理的にデータを連結する際にコンピュータリソースを多く消費するため、デフラグを行ってデータを物理的に連続した状態に保存し、論理的な接続作業にかかるリソース消費を抑えつつ高速に動作させることができました。しかし、SSDの場合、データが断片化されても分散された領域にアクセスするランダムアクセス速度が速いためハードディスクのように速度低下がほとんど発生しないので、デフラグが不要になりました。むしろSSDはセルごとの書き込み回数に限界があるため、頻繁なデフラグはセルの寿命を短縮します。
File handler
ファイルハンドラとは?
ファイルハンドラは、ファイルに記録されたデータをバイト単位で読み書きする機能を提供します。ファイルハンドラを使用すると、ファイル全体のデータをメモリにロードすることなく、ファイル内容の読み書きが可能です。
ファイルの全内容をメモリに事前にロードしないために、ファイルハンドラはファイルポインタという位置追跡機能を使用します。ファイルポインタは、ファイル内で現在読み取りまたは書き込みを行うための位置を指します。
なぜファイルハンドラを使用するのか?
ファイルは、バイト単位のサイズの場合もあれば、KB、MB、さらにはGBやそれ以上の大容量の単一ファイルの場合もあります。このような大きなファイルをプログラムで読み込みする時、ファイル全体をメモリにロードしようとすると、ファイルのサイズ以上のメモリ使用量が要求されます。
あるプログラムがファイルを処理する際に、渡されるファイルの容量は常に一定ではありません。同じ処理を行うプログラムでも、ユーザーがアップロードするファイルサイズに応じて、小さなファイルを処理することもあれば、大きなファイルを処理することもあります。プログラムが使用するメモリは、可能な限り一定の限界を設けて設計することが望ましく、無制限にメモリを消費すると、コンピュータが利用できるハードウェアメモリリソースやOSに割り当てられたメモリリソースを超えてしまい、プログラムが予期せず終了するクラッシュが発生します。
プログラムがクラッシュによって強制終了されると、アプリケーションを再起動するだけでなく、処理中だった作業が失われ、エラーが発生した際の例外処理などの後続処理が行われず、復旧が困難または不可能なさまざまな問題が発生する可能性があります。そのため、クラッシュによる終了をできる限り防ぐために、アプリケーションのメモリ管理を適切に行う必要があります。
ファイルハンドラを使用すると、ファイルデータの特定の位置までのデータをメモリに読み込んでプログラムで処理が可能です。処理が終わったら、次の特定の位置までのデータをメモリに読み込む作業を繰り返すことで、ファイル全体のデータではなく適度なサイズのデータのみをメモリに読み込んで処理するため、メモリ使用量を減らすことができます。ファイルハンドラを利用してメモリ使用量を抑えるロジックを作成することで、アプリケーションでのファイル処理に伴うメモリ問題を予防することが可能です。
ウェブアプリケーションでのファイル処理
ウェブアプリケーションは、多数のリクエストを処理するプログラムです。ウェブアプリケーションサーバーは複数のリクエストを同時に処理しますが、もし複数のユーザーがアップロードしたファイルを同時に処理する場合や、ユーザーが大容量のファイルをアップロードした場合、1つのリクエストを処理するのに多くのメモリを使用することになります。このような現象が複数のリクエストで同時に発生すると、ハードウェアやOSで利用可能なメモリリソースをすべて消費し、クラッシュが発生する場合があります。
多数のリクエストが同時に処理されるウェブアプリケーションのメモリは、すべてのリクエストが使用する総メモリ使用量に上限を設け、メモリ使用量が定められた上限を超えないよう管理することで、ウェブアプリケーションのクラッシュを防止できます。
処理するファイルの容量に上限が設定されていない場合、大量のデータをファイルから取得してメモリに読み込むと、メモリ不足によるクラッシュが発生するため、ファイルハンドラを使用してファイルデータを処理する際は、データを分割してメモリに読み込み処理する必要があります。ただし、分割して処理するとその分処理時間が長くなります。
phpの場合、処理時間が長くなると1つのリクエストプロセスがリソースを占有し続けるため、1つのプロセスが持つCPUおよびメモリリソースによって同時に処理できるリクエストの数が減少します。phpサーバーで同時に処理できるプロセス数はサーバー設定によって制限されているため、リクエストが処理されないと利用可能なプロセス数が減少し、サーバーの利用が難しくなる場合があります。そのため、時間がかかる可能性のあるファイル処理は、できるだけウェブアプリケーションサーバーではなく、キューシステムやバッチシステムを利用して処理するほうが望ましいです。
ファイルポインタとは?
本を読む際、以前読んだページがどこか、簡単に確認するためにしおりを使います。ファイルポインタはしおりの役割を果たし、ファイルを読み込む際にどこまで読み進めているかを示すために使用されます。
基本的にコンピュータがデータを読み取る基本単位はバイト単位です。メモリからバイト単位でデータを読み込み、ビット単位でデータを変更し、再度バイト単位で変数に割り当てる処理を行います。
コンピュータの読み取り単位がバイト単位であるため、ファイルポインタはバイトとバイトの間に位置するしおりのような概念と考えるとわかりやすいです。ファイルポインタは、バイトとバイトの間にどこまで読み込んだかを示す位置情報を扱う機能とみなすことができます。より正確には、ファイルポインタはファイルの開始点から何番目のバイトにあるかを示すインデックス値として、処理している時点の位置情報を保持しています。
ファイルハンドラが提供するデータの位置を示すファイルポインタを通じて、コンピュータはハードウェアの特定の位置にあるデータを読み取ったり、書き込んだり、変更する命令を出すことができます。
同時変更時のファイルポインタ
基本的に、1つのファイルハンドラが制御するファイルポインタは1つだけです。これは、ファイルデータを一度にすべて読み込むのではなく、指定したファイルポインタを移動させながら必要な分だけデータを取得する処理を行うためです。他の要因によりデータが変更されると、ファイルの開始位置からN番目までの文字が減少したり増加したりするため、N番目前後の文字を処理する際に期待する内容が得られない可能性があります。データが不正確に扱われるリスクがあるため、基本的に1つのファイルは1つのハンドラが占有し、1つのハンドラには1つのファイルポインタが割り当てられます。
1つのファイルに複数のファイルハンドラを同時に使用することもできますが、この場合、データの変更によってファイルポインタが指す値が変わる可能性があり、扱いが難しくなります。そのため、1つのファイルを1つのハンドラが占有するようにロックをかけて同時アクセスによるデータ破損を防止します。
「ABCDEFGH」という文字列が記載されているとします。1つのハンドラのファイルポインタP1がDとEの間に位置し、別のファイルポインタP2がFとGの間に位置しているとします。P1のインデックスはファイルの先頭から数えて4であり、P2のインデックスは6です。この状態で「123」というデータを追加すると、P1の位置で「123」が挿入され、P1のファイルポインタは「123」の後ろであるインデックス9に移動します。物理的には異なるブロックにデータが追加されても、論理的には接続されているため、「ABCD123EFGH」のように連続したデータとして扱われます。一方、P2のファイルポインタはインデックス6を指しているため、「123」の追加によって「ABCD123EFGH」というデータ内では同じインデックスが「2」と「3」の間を指すことになり、その位置にファイルポインタが移動します。P1ポインタを制御するファイルハンドラがABCD
の次のデータEFGH
を取得しようとした場合、データの変更によってABCD12
の次のデータであるEFGH
を取得することになります。1つのファイルデータを複数のファイルハンドラで同時に変更すると、ファイルポインタが保持するインデックスは同じですが、P2ファイルポインタがFとGの間に位置しているものの、実際には2と3の間に位置するように相対的な関係が変わるため、ファイルを扱う際に意図とは異なる動作を引き起こす可能性があります。
ファイル操作モード
ファイルの操作モードは、読み込みモード(r: read)、書き込みモード(w: write)、追加モード(a: add)、排他的作成モード(x: exclusive)に分かれています。
読み込みモード(r)は既存のファイルを読み込むために使用され、書き込みモード(w)は既存のファイルデータを削除して新たにデータを生成したり、新規ファイルを作成したりするために使用されます。追加モード(a)は、基本的にファイルの末尾にデータを追加するためのモードです。排他的作成モード(x)は、書き込みモードに似ており、既存のファイルを上書きしますが、すでにファイルが存在する場合にはファイルを作成せず、失敗するモードです。
ファイルハンドラを使用する目的は、ファイルのデータを一部だけメモリにロードするためです。そのため、データを読み取ったり、追加したり、変更するには、ファイルポインタの位置から新たにデータを読み込んだり変更したりする必要があります。ファイルハンドラでファイルを操作する際に1つのファイルを複数のファイルハンドラが操作する場合、同じファイル開始位置のインデックスを指していても、データの相対的な位置が変わることがあります。そのため、ファイルデータを変更する際には他のハンドラのファイルポインタが意図しないデータに影響を及ぼさないように、読み取りや書き込みを行えないようにロックをかけることが一般的です。
1つのファイル操作モードでファイルを操作する場合、他のモードでファイルを開けないようにファイルロック設定が必要です。これはファイルデータ内でファイルポインタの相対位置が変わる問題を防ぐためです。
書き込みモード(w)が存在するのに、なぜ追記モード(a)があるのでしょうか?ファイルにデータを書き込む方法に違いがあります。書き込みモード(w)は、ファイルを開く際に既存のデータを初期化した後、新しいデータを書き込みます。一方、追記モード(a)は既存のデータを保持し、ファイルの末尾にのみデータを追加するために使用されます。追記モード(a)は、ファイルの中間データを変更または挿入することはできず、主に既存の内容を保持しながら新しいデータを記録する必要がある場合に利用されます。例えば、ログファイルのように既存のデータを残しつつ新しい情報を継続的に追加する必要がある場合に有用です。追記モードはファイルを開く際に常にファイルポインタをファイル末尾に移動させるため、既存データの整合性を保ちながら新しいデータを追加できるという利点があります。
+ モード
各ファイル操作モードである読み取り(r)、書き込み(w)、追加(a)モードには、+オプションを追加して「r+」、「w+」、「a+」モードにすることができます。以下に各モードの動作を示します。
読み取りモードでのプラス (r+): 「r+」モードは、既存のファイルデータを読み取り、修正するために使用されます。このモードでは、「r+」モードで変更された際、ファイルの開始位置から任意のオフセットに移動した際に予期しない動作が発生しないように、可能な限りディスクブロックの構造を変更しない方向で動作します。ファイルサイズが変更されない場合、既存のブロック構造を維持したまま、すでに割り当てられたブロック内でデータを修正することができます。このとき、修正されたデータは、元のデータが含まれていた同じブロック位置に上書きされます。一方で、データサイズが変更された場合、ファイルシステムは必要に応じて新しいブロックを割り当てるか、既存のブロックに続けてデータを保存することがあります。(この動作は、ディスクおよびファイルシステムのアルゴリズムによって異なり、場合によっては既存のブロックを上書きせず、新しいディスクブロックを再割り当てする方法で動作することもあります。)
書き込みモードでのプラス (w+): 既存のファイルデータを読み取る必要がない場合に使用します。既存のファイルを「w」または「w+」で開くと、既存ファイルの長さが0に設定されるため、ファイル内のデータは削除されます。ファイルを開いた時点でファイルサイズが0になるため、書き込み処理の途中でエラーが発生しても、元のファイルデータは復元されません。書き込みモード(w)はデータの書き込みのみを行い、書き込み中に新たに追加したデータを読み取ることはできませんが、「w+」はデータを書き込みながら、追加したデータを読み取る機能を提供します。ただし、「w+」では、ファイルを開く前のデータを読み取ることはできず、ファイルを開いた後に追加したデータのみを読み取れる点に注意が必要です。
追加モードにプラス (a+):既存のファイルの末尾にデータを追加する「a」モードに「+」オプションを付けると、ファイル内容を読み込む機能も提供されます。ファイルの書き込みは常に末尾でのみ行われますが、「+」オプションを付けることで、ファイルの最後だけでなく他の場所のデータも読み込むことが可能になります。読み込みのためのファイルポインタはファイル内で自由に移動できますが、書き込みはファイルポインタに関係なく末尾にデータを追加します。(通常、ファイルポインタは1つですが、「a+」モードではファイルポインタが末尾に固定されるため、読み取りには読み取り専用の別のポインタの概念が使用されます。)
作成モード
phpでは、ファイル操作に読み込み、書き込み、追加、排他的作成モードに加えて、特別な作成モード(c: create)を利用することができます。
phpのfopen関数でのみ使用できる生成モード(c)は、書き込みモード(w)に似ていますが、書き込みモードが既存のファイルデータをすべて削除して新しいファイルを書き込むのに対し、データを削除せず、必要に応じてどの位置からデータを削除するかを指定できる機能を提供します。ファイルの先頭から指定したファイルポインタの位置までは既存のファイルデータを使用し、それ以降から新しいデータを書き込むことができる機能を持っています。
指定したインデックスのバイトまで既存のデータを保持し、その後は「w」モードのように新しいデータを書き込む用途で利用できます。ファイルを開いて指定の位置までファイルポインタを移動した後、ftruncate
関数を使用してファイルを切って、指定した長さ分の既存データを利用し、その後は書き込みモード(w)と同様に動作します。ftruncate
関数でファイルを切り詰める前に、外部ハンドラによるファイル操作を防ぐため、ファイルロックをかける必要があります。
作成モードにプラス (c+):「c」モードで「w」と同様にファイルを書き込むか、「c+」モードで「w+」と同様にファイルを書き込みながらデータを読み込む機能を提供します。
ファイルロック
ファイルハンドラでファイルを開くとき、自動的にロックはかかりません。そのため、ファイルを開いて操作する際には、ファイルロックをかけるコードを追加することが推奨されます。
LOCK_SH(共有ロック)
ファイルロックをかけたハンドラーと他のすべてのファイルハンドラが、ファイルを読み取ることはできるが、書き込むことはできないようにします。
LOCK_EX(排他的ロック)
ファイルロックをかけたハンドラーのファイル操作には制限がありません。しかし、他のハンドラーがファイルを読み取ったり書き込んだりすることはできないようにアクセスを制限します。
LOCK_UN(ロックを開放)
ファイルにかけられた共有ロック(LOCK_SH)または排他的ロック(LOCK_EX)を開放するために使用します。ロックをかけたハンドラーのみがロックを開放でき、開放されるまで他のハンドラーの操作は制限されます。
LOCK_NB(非ブロッキングロック)
通常、ファイルロックをかけるときに、他のハンドラによってファイルがすでにロックされている場合、ロックをかけられるまで待機し、その後コードが進行します。
LOCK_NBオプションは LOCK_SH | LOCK_NB
またはLOCK_EX | LOCK_NB
の形式で使用され、ロックがすでにかかっている場合は待機せず、直ただちに失敗を知らせます。
ファイルがロックされていて現在のハンドラーでロックをかけることができない場合、flock($fp, LOCK_SH | LOCK_NB)
flock($fp, LOCK_EX | LOCK_NB)
はfalseを返します。
バイトについての理解を深める
コンピュータに保存されるデータの単位はバイト単位になっています。コンピュータアーキテクチャにおいて、CPU、メモリ、ディスクなど各種のハドウェア処理で認識可能なアドレスを指定できる最小の単位がバイトです。ファイルポインタがバイト単位である理由も、ハードウェアの認識可能なアドレス単位がバイトであるためです。
ビット単位でアクセスする場合、バイト単位でデータを読み込んで変数に保存した後、それを操作する必要があります。ハードウェアがバイト単位を基本処理単位としているため、ビット単位でファイルポインタを移動することはできず、バイト単位で移動するように設計されています。ソフトウェアのデータ取得単位とハードウェアのデータ取得単位が異なると、非効率な変換コストが発生します。
ファイルの基本単位を8ビットのバイト単位にする絶対的な理由はありませんが、数字、英字、一部の特殊記号、制御文字を区別する単位として8ビットが標準として定義されました。コンピュータの歴史において初期の文字コードとしてASCIIコードが使われました。ASCIIコードは7ビットで数字、英字、一部の特殊記号、制御文字を表していましたが、コンピュータが扱える3桁の2進数(2^3)で8ビットが適当とされ、文字を8ビットで表すようになりました。初期は7ビットでASCIIコードが定義されていましたが、のちに8ビットで表記されるようになりました。初期のパーソナルコンピュータが8ビット単位でデータを処理していたため、8ビットを1バイトとして使用するのが標準になりました。
あまりに多くのビットを1バイトに指定すると1文字が占める容量が大きくなり、逆に少ないビットで1バイトを指定すると必要な文字を表現するために複数ビットや可変バイトを使うことになり、容量が増えてしまいます。パーソナルコンピュータが普及し始めた時期に、8ビットがデータ処理の基本単位として適切な妥協点とされました。
8ビットを1文字の単位とするエンコーディング方式はASCIIコードだけでなく、EBCDICコードもあります。EBCDICは数字、英字、一部の特殊記号、制御文字および多言語サポート文字で総255文字を扱えるように設計されています。日本語の場合、EBCDICフォーマットを利用する際に半角カナが追加され、使用されています。例えば、日本の銀行の通帳では名義が半角カナで名前が記載されていますが、これはEBCDICエンコーディングの形式を現在でも使用し続けているためです。COBOLを使った古い金融システムの改善が難しいこともあり、今でも通帳の名義は半角カナで表記されていると考えられます。
1バイトを超える文字の登場
多くの国でコンピュータが利用されるようになるにつれて、多言語対応の必要性が生じました。8ビットでは表現できない文字数を持つ国々が現れ、1つの文字を表現するために複数のバイトを使う文字列エンコーディング方式が導入されました。2バイトを使用して文字を表現する方式や、文字によって1バイトになる場合や2バイトになる場合がある可変バイト方式のエンコーディングもあり、ユニコードを含む文字集合の場合、最大4バイトまで使用することがあります。
プログラミングでファイルを処理する方法
- ファイル全体を一度に読み込む
- ファイルを開いて少しずつ読み込む
ファイルを分割して読み込む必要がある場合
ファイル全体を一度に読み込んでメモリにファイル内容を読み込む際、「ファイルデータを変数に格納するために必要なメモリサイズ」 + 「コード処理時に使用する追加メモリ」がphpプロセスで許可されているメモリより大きくなる可能性があります。もちろん、phpのメモリサイズやハードウェアのメモリサイズ、仮想メモリサイズを増やすことは可能ですが、ファイルの容量は一般的に無限に増える可能性があるため、制限されたメモリ内で動作するコードを書くことで、後のシステム拡張に対応できます。ファイルサイズがある程度小さい場合は、一度にファイルを読み込む方法を使用しても問題ありません。
ハンドラを使ってデータを分割して読む場合
ファイルハンドラを使用するということは、ファイル全体をメモリに読み込むのではなく、ファイルポインタを利用して必要な基準点から指定された量のバイトを読み取り、削除したり、基準点に新しいバイトを追加する方法を取ることができます。
ファイル全体をメモリに読み込むのではなく、必要な分だけ分割してメモリに読み込む方法で処理できるため、大容量のファイルを扱う際に有用な方法です。
phpでのファイル全体読み込み
file_get_contents
指定したファイルの全体のコンテンツを読み込み、phpの変数に保存できます。ファイルデータが大きい場合、phpプロセスで許可されているメモリ制限を超える可能性があるため、ファイルのサイズが制限されているという前提でのみ使用します。
phpでのファイルハンドラの使用
読み込むべきファイルのサイズが不明確である場合や、ファイルサイズが大きい場合、データを分割して処理するために使用します。
fopen
ファイルを開き、ファイルハンドラを返します。このファイルハンドラはストリームと呼ばれ、ファイルシステムポインタを扱えるリソースを返します。ファイルポインタリソースを返せずに失敗した場合には、falseを返す。
fseek
ファイルポインタの位置を指定したバイト分だけ移動します。オプションを指定しない場合は、SEEK_SETとして動作します。
SEEK_SET: ファイルの先頭を基準に指定したオフセット分バイトを移動します。
SEEK_CUR: 現在のファイルポインタの位置を基準に指定したオフセット分バイトを移動します。
SEEK_END: ファイルの末尾を基準に指定したオフセット分バイトを移動します。offsetを負の値に設定すると、前方に移動できることを利用します。
fread
ファイルハンドラが持つファイルポインタ位置から指定された連続したバイト数を読み取ります。連続したバイトが読み取れない場合、読み取った部分までのバイトを返します。この時、ファイルポインタは読み取ったバイト分だけ移動します。
fget
ファイルハンドラが持つファイルポインタ位置から、ファイルの改行文字またはEOFに達するまでバイトを読み取って返します。指定された連続したバイト数を読み取りますが、改行やファイル末尾が現れるまでのバイトのみを読み取ります。この時、ファイルポインタは読み取ったバイト分だけ移動します。
1行のデータサイズが大きい場合があるため、各行データのサイズが扱いやすいサイズである前提で使用する必要があります。処理するファイルの行サイズは、事前に検証処理を行い、一定のサイズ以下に制限するのが望ましいです。
fwrite
ファイルハンドラが持つファイルポインタ位置から、指定したバイト数のデータを変更します。ファイルの末尾であればデータが追加されますが、末尾でない場合は既存のデータが新しいデータに置き換えられます。この時、ファイルポインタは追加したバイト分だけ移動します。
flock
前述のファイルロックで紹介したLOCK_SH、LOCK_EX、LOCK_UN、LOCK_NBのオプションを活用して、ファイルにロックを掛けたり、ロックを開放する機能を提供します。
fclose
ファイルハンドラを閉じます。ファイルハンドラを閉じると、OSが管理しているファイルロックが開放され、このハンドラではなく他のハンドラによるファイル変更が可能になります。
ファイルデータを変更する際、頻繁なディスクアクセスを避けるために、ファイルシステムはfwrite
などでデータ変更を行ってもすぐにディスクに保存せず、メモリのバッファに保存します。もしディスクにまだ書き込まれていない場合、fclose
を使用することでバッファのデータがディスクに完全に書き込まれ、予期しない問題でプロセスが終了してもデータが記録されない問題を防ぐことができます。また、ファイルハンドラに関連するすべてのメモリ資源が解放され、不要なメモリの浪費やリークを防止します。
fclose
関数を使用した場合や、phpのガベージコレクタ(garbage collector)が動作してファイルストリームを回収する際に、自動的にファイルロックが開放されます。しかし、予期しないエラーによってfclose
がコールされない場合があります。エラーが発生すると、本来コールされるべきfclose
関数が呼ばれず、ガベージコレクタが回収するまでファイルロックが開放されない問題が発生する可能性があります。php-fpmなどのcgiモードを使用すると、phpプロセスが終了するとファイルロックが開放されますが、Swoole、FrankenPHP、Roadrunnerなどを使用する場合、プロセスが終了しないため、ファイルロックが開放されないことがあります。1つのプロセスで複数のリクエストを処理する方法を使用している場合、try-catch
を使用して、問題が発生した際にファイルハンドラーが終了するように処理する方が良いです。
fflush
fflush
関数を実行する時点でファイルシステムのバッファに保存されているデータをディスクに書き込むように命令します。予期しない問題が発生しても、処理中のところまでのデータを最大限残す必要がある場合、fflush
関数を使用することで、多少の速度低下を受け入れてでも、ファイルシステムにバッファされたデータをディスクへ保存しよう指示することが可能です。
ファイルポインタの移動
プログラミング言語のライブラリが提供するファイルストリームでバイトデータの読み取りや追加を行う関数を使用すると、ファイルポインタは読み取ったバイトデータ分だけ次の位置へと移動します。たとえば「abcdef」のうち、bとcの間のファイルポインタ位置からfread
やfwrite
で2バイトを読み取ったり変更したりした場合、ファイルポインタは読み取りや変更後、dとeの位置に移動します。このため、バイトの読み取りや書き込み操作の後にファイルポインタを別途移動させる必要がなく、より便利にデータを扱うことができます。
ファイルストリームを扱う例
以下の例は、php公式ドキュメントから引用したものです。
ループとファイルポインタ
$handle = fopen("http://www.example.com/", "rb");
if (FALSE === $handle) {
exit("Failed to open stream to URL");
}
$contents = '';
while (!feof($handle)) {
$contents .= fread($handle, 8192);
}
fclose($handle);
if (FALSE === $handle)
は、fopen
関数がファイルを開けなかった場合にfalseを返し、ファイル処理が不可能であることを示します。
feof
は、ハンドラのポインタがファイルの終端に達しているかを確認します。!feof($handle)
がtrueである限り、ポインタがファイルの終端に達していないため、ファイルの最後までループを繰り返します。
ループが回るたびにfread($handle, 8192)
で8192バイト分の文字を読み取り、ファイルハンドラのポインタを次の位置に進めます。freadでバイトを読み取るたびにポインタが自動的に進むため、ループ内でfreadを使用するだけでファイル全体のデータを取得できます。
一行ずつ読み込み
$fp = @fopen("/tmp/inputfile.txt", "r");
if ($fp) {
while (($buffer = fgets($fp, 4096)) !== false) {
echo $buffer;
}
if (!feof($fp)) {
echo "Error: unexpected fgets() fail\n";
}
fclose($fp);
}
fopen
は、指定した経路のファイルが見つからないときに警告(warning)を発生させます。関数の前に@を付けると、エラーや警告を出力しないようにすることができます。ファイルストリームの返還に失敗すると、falseを返還するfopen
関数の返還値は変わりません。
($buffer = fgets($fp, 4096)) !== false
では、$buffer = fgets($fp, 4096)
が返す値が$buffer
に格納され、$buffer
とfalse
が!==
で比較されます。
fgets($fp, 4096)
を使用するだけでファイルポインタが進むため、追加でポインタを移動する処理を行わなくてもファイルを読み取ることができます。
各行で正常にデータを取得できた場合、fgets($fp, 4096)
の値はfalseにはならず、もしそれ以上読み込む行がない場合、fgets($fp, 4096)
はfalseを返し、while
文の繰り返しは終了します。
これ以上読み込む行がないのに、!feof($fp)
でファイルの最後に到達していない場合、fgets($fp, 4096)
がfalseになった理由は行の最後に到達したことではなく、他の理由で失敗したことを意味します。これは各行のデータを読み込む際にどこで失敗したのかを示しています。ファイルの最後に到達した場合は、fclose($fp)
でファイルハンドラを終了します。
効率的なデータ取得方法
キャッシュ
ハードウェアは、ブロックより小さいデータを読み取る場合でも、最低限ブロック単位のデータを取得します。どうせ最小単位でブロック単位のデータを取得するのであれば、複数回に分けて小さいデータを読み取るよりも、一度にブロック単位でデータを読み取る方が、ハードウェアへのアクセス回数が少なくなり効率的だと思われるでしょう。しかし、ファイルシステムは一度読み取ったデータをキャッシュしているため、最低限ブロック単位のデータがキャッシュされます。たとえブロックサイズより小さいデータを取得しても、同じブロック内のデータであれば、ファイルシステムにキャッシュされているデータにアクセスするため、一度にブロック単位でデータを取得しても小さいデータを複数回読み取っても、ハードウェアストレージへの追加アクセスは発生しないため、速度低下はほとんどありません。
データは複数のブロックに分散して保存されるため、小さなデータを何度も読み取ったとしても、必ずしも1つのブロック内のデータを読み取るとは限りません。たとえブロック単位でキャッシュが行われていても、データが異なるブロックに分散している場合、キャッシュからデータを取得することはできず、最終的に記憶装置に再びアクセスしてデータを取得しなければなりません。
これを解決するために、ファイルシステムはハードウェアのデータをそのままキャッシュすることではなく、論理的に連続したデータを構成してキャッシュするアルゴリズムを使用することがあります。また、新しいファイルデータを作成する際、保存領域に余裕がある場合は、可能な限り連続したデータを保存するよう試みます。ファイルデータを変更する場合でも、書き込みモードでデータを修正すると、空き領域があるときには空ブロックに新しいデータを書き込むことで連続的な保存ができます。
このように、ディスク上のデータが連続して保存されることで、ファイルシステムが論理的に連続したデータを構成するためのアルゴリズムのリソースを減らせて、効率性を高めることができます。連続したデータをキャッシュしたファイルシステムでは、ブロック単位より小さいデータを複数回読み取る場合でも、キャッシュされたデータを活用することが可能です。
かつて、サーバーで定期的にハードディスクのデフラグを行っていた時代には、データを連続して保存できたため、ファイルシステムが連続データを再構成する際に必要なリソースを削減し、速度向上に寄与していました。しかし、デフラグを行わない場合、ファイルシステムが論理的に連続したデータを作成するためにリソースを消費する必要があり、効率が良くありませんでした。最近では、SSDを使用することで、データが断片化していてもディスクからデータを取得する速度が速く、ファイルシステムが論理的な連続データを作成する速度も速いため、性能への影響はほとんどありません。
512, 1024, 2048, 4096, 8192 ?
ファイルハンドラに関する例を見ると、ファイルデータを読み取る際に、512、1024、2048、4096、8192といった単位がよく使われていることがわかります。これは、ストレージがデータをブロック単位で読み込む仕組みと関係しています。通常、1つのブロックは4096バイトの大きさを持ち、ファイルシステムは効率的なデータ管理のために、このブロック単位の倍数でキャッシュを行います。ディスクのデータが断片化されていても、ファイルシステムは論理的に連続したデータをキャッシュするため、4096バイトの倍数でデータをキャッシュします。そのため、4096バイトの約数や倍数を用いてデータを読み取る方が効率的である場合があります。
例えば、4096 バイトのデータがファイル システム キャッシュに保存されると、この後、1000バイトずつ4回と96バイトを追加でアクセスするよりも、1024バイトずつ4回キャッシュからデータを取得する方がキャッシュ効率が向上します。また、ファイルシステムが複数のブロックをキャッシュできるため、場合によっては8192バイトのようなより大きな単位で読み取ることも効率的です。これは、メモリ使用量を最小化することを重視した2000年代以前のコーディングでは意識すべきポイントでしたが、現在では、ハードウェアの性能が大幅に向上したため、それほど重要視される必要はないと考えられます。
php言語でのファイルデータをキャッシュ
ファイルシステムではディスクのデータをキャッシュするバッファがありますが、php言語でファイルを読み書きする際には、php自体のファイルバッファが存在します。php独自のファイルバッファを使用することで、ファイルシステムのキャッシュからデータを直接取得するのより、早いファイル処理が可能になります。phpでfread
やfwrite
を使用する際に、もしphpのファイルストリームバッファにデータがなければ、phpはファイルシステムから直接データを取得する(freadの場合)か、またはファイルシステムにデータを渡す(fwriteの場合)必要があります。この過程でファイルシステムとphp間でデータ転送が行われ、これが原因で性能の低下や遅延が発生することがあります。また、同一ファイルに複数のphpファイルハンドラがアクセスする場合、ファイルシステムとphp間のデータ転送が完了するまでの間、他のファイルハンドラのアクセスを一時的にブロックし、衝突を防止しますが、ブロックによって遅延を引き起こします。このような遅延問題を解決するために、phpのファイルバッファにデータをキャッシュする方法が使用されます。
phpには、ファイル書き込みのバッファサイズを設定するstream_set_write_buffer
関数と、読み込みのためのstream_set_read_buffer
関数があります。この関数でバッファサイズを0に設定すると、phpのバッファにファイルデータがキャッシュされなくなります。この場合、複数のファイルハンドラが同じファイルにアクセスすると、ファイルシステムとphp間のデータ転送が完了するまで、他のハンドラのアクセスがブロックされます。その結果、同一ファイルには1つのハンドラしかアクセスできなくなり、ファイルシステムとphp間の間の同期処理が順次行われます。この方法ではファイルの整合性が保証されますが、その代わりに速度低下が発生します。
一方で、phpバッファサイズを適切に設定してデータをキャッシュする方法は、ファイルシステムとphp間の同期に要する遅延を軽減し、処理速度を向上させます。そのため、ロジック上で同一ファイルへの同時アクセスがなく、整合性を損なう心配がない場合は、phpのファイルバッファを適切に設定してキャッシュを活用することを検討するのが良いです。デフォルトでは、phpは自動的にファイルバッファサイズを設定しているため、phpコードを書く際に1つのファイルに1つのハンドラを割り当てるように設計すれば、ファイルバッファを個別に設定する必要はありません。ただし、大量のファイルデータをphpのファイルストリームにキャッシュしたい場合や、ファイルロックをかけるコードが欠落するケースが多い場合は、ファイルストリームのバッファサイズを調整することを検討しましょう。
エンコーディング
ファイルを読み込む際、バイト単位で読み取るとされますが、文字列のエンコーディングによっては、1つの文字が複数のバイトで構成される場合があります。また、言語ごとにバイトが示す値(ビット値)とその言語の文字との対応は、エンコーディング方式によって異なります。
phpでfget
やfread
などの関数を使ってバイトを読み取った結果は、特別な指示がない限り、普通知っている文字列として表示されるわけではありません。echoなどを用いてこのデータを文字列として表示しようとすると、phpはシングルバイトセットで文字列を表示します。デフォルトではこのシングルバイトセットは「ISO-8859-1」や「US-ASCII」エンコディングが使用され、「ISO-8859-1」形式のÀ Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï D Ð Ñ Ò Ó Ô Õ Ö × Ø Ù Ú Û Ü Ý Þ à á â ã ä å æ ç è é ê ë ì í î ï ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ
といった文字が表示されます。
UTF-8やSJISなどのマルチバイトエンコーディングの文字列が含まれているファイルであれば、このバイトデータを正確に文字列として表示するには、マルチバイトデータを適切に縛ってくれるエンコーディングを用いて、正しい文字列として出力する必要があります。phpでは、様々なエンコーディングの文字を読み取るために、「mb_」で始まる関数が提供されています。
さいごに
悟りのある者の心は知識を得、知恵のある者の耳は知識を求める。