はじめに
ファイルを書込むときは、予期しない電源断を考慮して書込む必要があります。
考慮していない場合、中途半端なファイルや空ファイルが生成され、
致命的な問題につながる場合もあります。
組込み製品などを開発していると、ファイルが破損する問題が見つかり、
「ジャーナリング機能があるのに何故?」という経験のある方も多いのではないでしょうか。
今回は、予期せぬ電源断でもファイル破損が起きにくい書き方を紹介します。
ファイル破損が起きる原因
ずばり、アプリケーションからデータを書込んでも、
すぐに記憶デバイス(HDD、SSD、FlashROM、等)へ書き出されるわけではないためです。
アプリケーションからファイルへデータを書込んだ場合、以下のような階層を通って書き込まれます。

ストリームバッファ
標準Cライブラリは、fopen(ファイル作成)、fwrite(書込み)、fflush(同期) 等の
ファイル操作を行う関数を提供しています。これらの関数は、FILE 構造体を用いて操作します。
FILE 構造体内部にストリームバッファがあり、fwrite() 関数はこのバッファに書き込みます。
fflush() を呼び出すと、ストリームバッファの内容をシステムコールを呼び出して書き込みます。
注意
fflush() を呼び出したとしても、後に説明するカーネルのキャッシュにデータが滞留します。
ストリームバッファに書き込みたいデータが滞留している間に電源断が発生すると、
書込んだはずのファイルが壊れる現象が起きます。

カーネルのキャッシュ
I/Oの遅いハードウェア制御を行ったり、一定量のデータを一気に書き込まないといけない場合などがあり、
カーネルには「ディスクキャッシュ(Disk Cache)」と呼ばれるキャッシュがあります。
ハードウェアと同期がとれていないキャッシュは「ダーティキャッシュ(Dirty Cache)」とも呼ばれます。
このキャッシュは、カーネルがお暇なタイミングで書き出されるため、
ダーティキャッシュにデータが滞留している間に電源断が発生すると、
先のストリームバッファと同様に書込んだはずのファイルが壊れる現象が起きます。
よくある対策方法
ファイルが壊れることを防ぐため、様々な対策が行われています。
ここでは、一般的に行われている対策をご紹介いたします。
読込み・書込みのパーティションを分離・2面管理
書き込みを行うファイルやディレクトリを限定し、パーティションを分けてしまう考え方です。
書込みを行うパーティションを2面用意し、疑似的なRAID1(ミラーリング)風にするわけです。
「ファイルを書き換えるから破損する」という考えのもと、書き込みを強制的に防ぐため、
パーティション・ファイルシステムを読込み専用(Read Only)としてマウントします。
この領域は書き込みを行えませんので、そうそう壊れる心配はありません。
そして、書き込みを行うファイルを1つのパーティションに纏めて2面用意し、
片方を読み書き(Read-Write)としてマウントします。
破損を検知した際は反対側をマウントして復旧させ、RAID1風にするわけです。
一時ファイルを作成してリネーム
実際に使用するファイル名とは異なるファイル名で一時ファイルを作成し、
書き込みが完了した後に正式なファイル名へリネームします。
例えば、「README.txt」というファイルを書込みたい場合、「README.txt.tmp」を一時的に作成し、
書き込みを完了した後に「README.txt.tmp」⇒「README.txt」へリネームします。
書込み途中のファイルが残っても、正規ファイルへは影響がありません。
ブラウザーのダウンロード時や Microsoft の Word/Excel などでファイルを保存する時など、
変な名前のファイルができるのはこのためです。

正しい書込み方
では、どのようにファイルを書込めば破損のリスクを回避することができるのでしょうか。
完全には回避できないですが、以下の考え方で書き込みを行うと壊れにくくなります。
システムコールのみを使用
上述したとおり、標準Cライブラリが持っている「ストリームバッファ」を排除します。
これは簡単ですね。ライブラリ関数を使わなければ良いのです。

fopen()、fwrite() の代わりに、oepn()、write() を使用します。
fflush() の代わりに、fsync() を使用します。
参考
fsync() については、「fsync:ファイル記述子を指定した sync」にまとめています。
カーネルのディスクキャッシュをハードウェアに書き出すことを保証できます。
ジャーナル機能を活用
最近のファイルシステムには、ジャーナル機能がついています。
ファイルの追加・削除などの管理領域を更新する際に履歴を残し、
管理領域の破損を防ぐ機能です。
これを万能な機能だと思っておられる方も多いと思いますが、
ファイルエントリーの追加・削除など、簡単なログしか残りません。
そのため、ジャーナル機能を有効に活用するための書き込み方を行う必要があります。
では、どのように書込めばいいのか?
これまでの話をまとめ、C言語で記述する場合の手順をご紹介いたします。
- unlink(一時ファイル)
ジャーナル機能のケアのために必要になります。
最後の rename の途中で電源断となり、正規ファイルの追加だけがログとして残ると、
一時ファイルと正規ファイルの両方が同じ inode を指すことになるためです。
その状態で一時ファイルを操作すると、意図せず正規ファイルにも影響が出ます。 - open(一時ファイル)
適切なオプション、アクセス権を設定して一時ファイルを生成します。 - write(一時ファイル, データ)
書込みたいデータを必ず書ききる必要があるため、my_write 関数を用意して
指定されたサイズを必ず書き込むようにします。 - fsync(一時ファイル)
ディスクキャッシュ(ダーティキャッシュ)をハードウェアに書出し(同期)ます。
この関数は、同期が完了するまで戻ってこないため、I/Oの遅いデバイスへ書き込む場合は
性能に注意する必要があります。 - close(一時ファイル)
ここまでくれば、一時ファイルの書き込み完了が保証されます。 - rename(一時ファイル, 正規ファイル)
ジャーナル機能により、「正規ファイル追加」と「一時ファイル削除」がロギングされます。
サンプルコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <errno.h> #include <stdio.h> #include <string.h> #include <unistd.h> #define FILE_NAME "myfile.txt" #define TMP_FILE_NAME "myfile.txt.tmp" static ssize_t my_write(int fd, const char *buf, size_t size) { ssize_t done = 0; size_t s = size; while (s > 0) { done = write(fd, (void *)buf, s); if (done < 0) { if (errno == EINTR || errno == EAGAIN) continue; else return done; } buf += done; s -= done; } return size; } int main(void) { int fd, ret; const char text[] = "This is test."; unlink(TMP_FILE_NAME); fd = open(TMP_FILE_NAME, (O_CREAT | O_TRUNC | O_WRONLY), 0666); if (fd < 0) return -1; ret = my_write(fd, text, strlen(text)); if (ret != strlen(text)) { close(fd); return -1; } fsync(fd); close(fd); rename(TMP_FILE_NAME, FILE_NAME); return 0; } |
その他:Tips
Windowsのファイルシステム NTFS は、ファイルレコードと呼ばれる情報でエントリを管理しています。
レコードは、どのファイルのデータが HDD/SSD 上のどこにあるかが記録する管理領域です。
ファイルシステムのバージョンにより異なりますが、1レコードのサイズが 1MiB~4MiB となっており、
大きなファイルを扱えるようになっています。
そのため、HDD/SSD の容量を無駄にしないようにするため、レコードサイズよりも小さいファイルは
レコードに直接データが記録されます。
レコードに実データが書き込まれた場合、実データは NTFS のジャーナル機能が働きません。