simotin13's message

simotin13といいます。記事の内容でご質問やご意見がありましたらお気軽にコメントしてください\^o^/

flockでは排他できない

ずいぶんと煽ったタイトルをつけてみましたが、Linuxでファイルアクセスを排他しようとして苦労したので書いておきたいと思います。

結論

結論から申しますと、上書きモード(fopenのオプションで言うと"w")で書き込みを行うファイルに対してはflockで排他ができません。

flockを普段から使っていなかった私にとってはこのふるまいに割と衝撃を受けたのですが、確信を得るために検証コードでの動作確認と、ファイルシステム(システムコールからVFSあたりまで)のコードを読んでみました。
もし、この記事の内容で認識が違ってそうな点などありましたらコメントやTwitter(@simotin13)でご指摘頂けると幸いです。

なお、この記事で書いている確認用コードは以下のレポジトリにまとめています。
github.com

ファイルアクセスの排他の必要性について

一般的に、複数のプロセスから同一のファイルにアクセスする場合ファイルへのアクセスを排他する必要があります。
各プロセスが読み出ししかしないのであればファイルの中身は変わらないため排他は不要ですが、書き込みを行うプロセスが存在する場合は排他を取らないと不整合が生じる可能性があります。

ファイルの不整合を起こしてみる

排他の必要性を理解するため、実際に同じファイルに書き込みと読み込みを別々のプロセスから行ってみましょう。

まずはファイルを書き込む側のコードです。
ここでは仮に write_file.c としましょう。

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int writeFile(char *filePath, char *contents)
{
    int fd = open(filePath, O_WRONLY | O_TRUNC | O_CREAT, 0644);
    if (fd < 0)
    {
        perror("open");
        return -1;
    }

    size_t len = strlen(contents);
    char *p = contents;
    while(0 < len) {
        int size = write(fd, p, len);
        if (size == 0) {
            break;
        } else if (size < 0) {
            perror("write error");
            return -2;
        }

        len -= size;
        p += size;
    }

    fsync(fd);
    close(fd);
    return 0;
}

int main(int argc, char **argv)
{
    // 無限に test.txt に書き込みを行う
    while(1)
    {
        int ret = writeFile("test.txt", "123ABC");
        if (ret < 0) {
            printf("writeWithoutLock failed...\n");
            break;
        }

        printf("test.txt write success...\n");
    }

    return 0;
}


続いて、ファイルの読み込みを行う側のコードです。 ここでは仮に read_file.c としましょう。

// gcc read_file.c -o read
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int readFile(char *filePath, char *buf, size_t bufSize)
{
    int fd = open(filePath, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    size_t pos = 0;
    while(pos < bufSize) {
        size_t size = read(fd, &buf[pos], sizeof(buf));
        if (size == 0) {
            break;
        } else if (size < 0) {
            perror("read error");
            return -2;
        }

        pos += size;
    }

    fsync(fd);
    close(fd);
    return 0;
}

int main(int argc, char **argv)
{
    // 無限に test.txt を読み込む
    while(1)
    {
        char buf[1024];
        memset(buf, 0, 1024);

        int ret = readFile("test.txt", buf, 1024);
        if (ret < 0) {
            printf("readWithoutLock failed...\n");
            break;
        }

        // 読み込んだ内容が "123ABC" でなければエラー(不整合が発生した)
        if (strcmp(buf, "123ABC") != 0) {
            // 書き込んだ内容と同じ文字列が読み込めていない...
            printf("read mismatch:[%s]\n", buf);
            break;
        }

        // 書き込んでいる内容(123ABC)が読み込めていれば成功
        printf("test.txt [%s] read success...\n", buf);
    }

    return 0;
}

上記の書き込みと読み出しのプログラムを実行してみましょう。

$  gcc write_file.c -Wall -o write
$ ./write
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
test.txt write success...
...

無限ループにより書き込み側は永遠にtest.txtへの書き込みが繰り返されます。

続いて、読み出し側を実行してみます。

$ gcc read_file.c -Wall -o read
$ ./read
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
read mismatch:[]

読み出し側も無限ループにより読み出しが繰り返されますが、途中で書き込まれている内容と読み出している内容が一致せずに終了しました。
もちろん、書き込み側のプログラムが動いていない状態で読み出し側のプログラムを実行すると無限にtest.txtの読み出しが繰り返されます。

flockによる排他の実現

排他の必要性を確認できたので排他処理を実装してみます。

Linuxでは排他用のシステムコールとして、flock関数が提供されています。
linuxjm.osdn.jp

上記の不整合が起きるコードをflockを使って排他を取るように書き換えると以下のようになります。

書き込み側(write_file.c)

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/file.h>

int writeFile(char *filePath, char *contents)
{
    int fd = open(filePath, O_WRONLY | O_TRUNC | O_CREAT, 0644);
    if (fd < 0)
    {
        perror("open");
        return -1;
    }

    flock(fd, LOCK_EX);

    size_t len = strlen(contents);
    char *p = contents;
    while(0 < len) {
        int size = write(fd, p, len);
        if (size == 0) {
            break;
        } else if (size < 0) {
            perror("write error");
            return -2;
        }

        len -= size;
        p += size;
    }

    fsync(fd);
    flock(fd, LOCK_UN);
    close(fd);
    return 0;
}

int main(int argc, char **argv)
{
    // 無限に test.txt に書き込みを行う
    while(1)
    {
        int ret = writeFile("test.txt", "123ABC");
        if (ret < 0) {
            printf("writeWithoutLock failed...\n");
            break;
        }

        printf("test.txt write success...\n");
    }

    return 0;
}

読み込み側(read_file.c)

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/file.h>

int readFile(char *filePath, char *buf, size_t bufSize)
{
    int fd = open(filePath, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    flock(fd, LOCK_EX);

    size_t pos = 0;
    while(pos < bufSize) {
        size_t size = read(fd, &buf[pos], sizeof(buf));
        if (size == 0) {
            break;
        } else if (size < 0) {
            perror("read error");
            return -2;
        }

        pos += size;
    }

    fsync(fd);
    flock(fd, LOCK_UN);
    close(fd);
    return 0;
}

int main(int argc, char **argv)
{
    // 無限に test.txt を読み込む
    while(1)
    {
        char buf[1024];
        memset(buf, 0, 1024);

        int ret = readFile("test.txt", buf, 1024);
        if (ret < 0) {
            printf("readWithoutLock failed...\n");
            break;
        }

        // 読み込んだ内容が "123ABC" でなければエラー(不整合が発生した)
        if (strcmp(buf, "123ABC") != 0) {
            // 書き込んだ内容と同じ文字列が読み込めていない...
            printf("read mismatch:[%s]\n", buf);
            break;
        }

        // 書き込んでいる内容(123ABC)が読み込めていれば成功
        printf("test.txt [%s] read success...\n", buf);
    }

    return 0;
}

実行

実行してみます。writeの方は前回同様test.txtへの書き込みが繰り返し行われます。
readの方も実行してみると、

flockにより test.txtへのアクセスを排他できたので、今回は読み込み側は無限に動きま...あれ!?途中で終了してしまいました...

$ ./read
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
test.txt [123ABC] read success...
read mismatch:[]

ということでここからがこの記事の本題になります。
何度か読み出し側のプログラムを動かしたり、起動する順番を変えてみたりして結果をよく見てみると、成功する場合と失敗する場合があり、失敗する場合はreadの結果が0byteになっていました。

flockの動作確認

まず、本当に排他ができているかどうか怪しいので、flockを入れた直後にsleep(5); のような待機処理を入れると、ロックを獲得できなかった方のプロセスは5秒待たされることは確認できます。しかし何回か読み出しを行った後で失敗するという結果に違いはありませんでした。つまりflockの呼び出しによる排他自体はうまくできているが、読み出し処理には何か問題があると考えられます。
flockが成功しているのであれば、flockを呼び出す前の時点でファイルの中身が意図しないデータ(0byte)になっている可能性が高いと考えられます。

排他を失敗させる要因

flockの使い方をよく見てみると、flockは引数としてファイルディスクリプタを渡してあげる必要があります。
必然的にファイルをあらかじめopenしておく必要があるわけです。ファイルの内容がおかしくなっているとしたらこのopen~flockまでの区間が怪しいと考えられます。

openとtruncate

上記のflockを入れてみたコードでは、readの戻り値が時々0byteになっていました。書き込みプロセスでは123abcを書いているので、本来であればreadの戻り値は6byteになっていることが期待されますが、なぜ0byteになるのでしょうか?

いろいろ、コードを変更したりして悩んでいる中で open時に指定するフラグの O_TRUNC の動作が気になりました。

O_TRUNC の動作は

書き込み可モードでオープンされている (つまり、O_RDWR または O_WRONLY の) 場合、 長さ 0 に切り詰め (truncate) られる。

とされています。
つまり、(まだflockをかけていない)openを呼び出した時点で、O_TRUNC の指定によってファイルが0byteになり、その直後に読み出し側がファイルをロックしたしまうとまさにはまっていた状態が起こりえそうです。(これだ!こいつのせいだ)

truncate は誰がいつ行うのか?

上記の仮説は正しそうに思いましたが、O_TRUNC 指定時にファイルを切り詰めるのはどのタイミングで行われるのでしょうか?
そもそもopen以外のタイミングで切り詰められているとしたら上記の仮説は成り立ちません。

ということでLinuxカーネルのopenのソースコードを読んでみることにします。

カーネルソースコードのバージョンはlinux-5.15.95 になります。

ファイルシステムに関するソースコードは fsディレクトリにあります。
openシステムコールは文字通り open.c で実装されています。

// open.c 
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
	if (force_o_largefile())
		flags |= O_LARGEFILE;
	return do_sys_open(AT_FDCWD, filename, flags, mode);
}

上記のシステムコールのエントリ関数からは do_sys_open関数が呼ばれています。openの処理はdo_sys_openからさらにいくつかの関数呼び出しを経て、namei.cで実装されている do_filp_open 関数を呼び出しにたどり着きます。

// namei.c
struct file *do_filp_open(int dfd, struct filename *pathname,
		const struct open_flags *op)
{
	struct nameidata nd;
	int flags = op->lookup_flags;
	struct file *filp;

	set_nameidata(&nd, dfd, pathname, NULL);
	filp = path_openat(&nd, op, flags | LOOKUP_RCU);
	if (unlikely(filp == ERR_PTR(-ECHILD)))
		filp = path_openat(&nd, op, flags);
	if (unlikely(filp == ERR_PTR(-ESTALE)))
		filp = path_openat(&nd, op, flags | LOOKUP_REVAL);
	restore_nameidata();
	return filp;
}

怪しいと目星をつけているフラグであるO_TRUNCで検索して、このフラグを参照している箇所を探してみると

do_open関数の中に、思い切りそれらしいコードがありました。

/*
 * Handle the last step of open()
 */
static int do_open(struct nameidata *nd,
		   struct file *file, const struct open_flags *op)
{
	struct user_namespace *mnt_userns;
	int open_flag = op->open_flag;
	bool do_truncate;
	int acc_mode;
	int error;

	if (!(file->f_mode & (FMODE_OPENED | FMODE_CREATED))) {
		error = complete_walk(nd);
		if (error)
			return error;
	}
       
        // 中略

	do_truncate = false;
	acc_mode = op->acc_mode;
	if (file->f_mode & FMODE_CREATED) {
		/* Don't check for write permission, don't truncate */
		open_flag &= ~O_TRUNC;
		acc_mode = 0;
	} else if (d_is_reg(nd->path.dentry) && open_flag & O_TRUNC) {
		error = mnt_want_write(nd->path.mnt);
		if (error)
			return error;
		do_truncate = true;
	}
	error = may_open(mnt_userns, &nd->path, acc_mode, open_flag);
	if (!error && !(file->f_mode & FMODE_OPENED))
		error = vfs_open(&nd->path, file);
	if (!error)
		error = ima_file_check(file, op->acc_mode);
	if (!error && do_truncate)
		error = handle_truncate(mnt_userns, file);
	if (unlikely(error > 0)) {
		WARN_ON(1);
		error = -EINVAL;
	}
	if (do_truncate)
		mnt_drop_write(nd->path.mnt);
	return error;
}

O_TRUNC が指定されていると handle_truncate, mnt_drop_writeを呼び出しています。
do_open関数までの呼び出しは、 do_filp_open -> path_openat -> do_open の流れになります。

handle_truncate関数からはdo_truncate関数を呼び出していますが、このdo_truncateを呼び出す際のlengthに0を指定することでファイルシステムオブジェクト(struct iattr)を更新しています。
iattrの変更をファイルシステムドライバへと通知していきますが、とりあえずO_TRUNC 指定したopen時にtruncateが行われてそうということは分かりました。*1

つまり、以下のようなフローで排他が行われると読み出し側で正しく排他できない状態になりそうです。

  1. 書き込みプロセスがファイルをopenする(この時truncateによりファイルの中身は0byteになる)
  2. 読み込みプロセスがファイルをopenする(ファイルは既に0byteになっている)
  3. 読み込みプロセスがファイルをflockする(書き込み側はwriteできない)
  4. 読み込みプロセスがファイルをreadする→結果は0byte

上書き保存するファイルを排他するにはどうしたらいいのさ?

上記の仮説が正しそうだということはわかりましたが、

どうすれば上書き保存するファイルへのアクセスを正しく排他できるのか?

という疑問は残ります。
open時にtruncateされることが分かった以上、openより前のタイミングで排他をとることで正しく排他ができます。
Linuxではプロセス間の排他にはいくつか機能がありますが、ここではセマフォを使った排他を行う例を書いておきたいと思います。

書き込み側(書き込み関数のみ)

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <semaphore.h>

int writeFile(char *filePath, char *contents)
{
    sem_t *sem = sem_open("/sem_lock", O_CREAT, 0744, 1);
    sem_wait(sem);

    int fd = open(filePath, O_WRONLY | O_TRUNC | O_CREAT, 0644);
    if (fd < 0)
    {
        perror("open");
        return -1;
    }

    size_t len = strlen(contents);
    char *p = contents;
    while(0 < len) {
        int size = write(fd, p, len);
        if (size == 0) {
            break;
        } else if (size < 0) {
            perror("write error");
            return -2;
        }

        len -= size;
        p += size;
    }

    fsync(fd);
    close(fd);

    // close後にsem_closeを呼び出すことで排他制御を解除する
    sem_post(sem);
    return 0;
}

読み出し側(読み出し関数のみ)

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <semaphore.h>

int readFile(char *filePath, char *buf, size_t bufSize)
{
    // open前にsem_openを呼び出すことで ファイルアクセスを排他制御する
    sem_t *sem = sem_open("/sem_lock", O_CREAT, 0744, 1);
    sem_wait(sem);

    int fd = open(filePath, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    size_t pos = 0;
    while(pos < bufSize) {
        size_t size = read(fd, &buf[pos], sizeof(buf));
        if (size == 0) {
            break;
        } else if (size < 0) {
            perror("read error");
            return -2;
        }

        pos += size;
    }

    fsync(fd);
    close(fd);

    // close後にsem_closeを呼び出すことで排他制御を解除する
    sem_post(sem);
    return 0;
}

上記のセマフォを使ったコードでは読み出し側も test.txtからの読み出しが繰り替えされました。
ちなみにセマフォsem_unlinkを呼び出さないとリソースが残ってしまいます。上記のコードでは手抜きのため sem_unlinkの呼び出しは行っておりません。
このため、ctrl-cでプログラムを止めるとリソースが残り、次回起動時に無限に待たされてしまいます。

#include <semaphore.h>
int main(int argc, char **argv)
{
    sem_unlink("/sem_lock");
}

のようなプログラムを動かすなど、適切にセマフォのリソースを解放してください。

参考

flockについてはLinuxシステムプログラミングで説明されています。今回私がはまっていたようなO_TRUNC指定時の振る舞いについては記載されていませんでした。

コード例についての補足

上記のコードでは、open,read,writeといったシステムコールAPIを使う形で説明していますがこれには少し理由があります。
通常C言語でのプログラミングでは、 fopen,fread,fwriteといった標準ライブラリの関数を使うことが一般的です。ライブラリを使う場合ライブラリ内でのバッファリングが行われるため、今回の排他動作の説明上、バッファリングによって想定していない動作によって排他が失敗する可能性を回避するためシステムコールを使って説明しています。排他に関する基本的なふるまいは標準ライブラリを使う場合も同じになります。
*2
この辺の話題は上記の「Linux プログラミングインターフェース」の55章「ファイルロック」でも記載されています。

また、C言語のコードで説明しましたが、C++の場合ファイル読み書きにはstd::ifstreamやstd::ofstreamを利用することが一般的ですがこれらのストリームオブジェクトからはファイルディスクリプタを得る一般的な方法はありません。

まとめ

ということで、flockでは上書き保存で開くファイルを排他できないということが分かりました。
実際にはかなり高頻度でファイルにアクセスしない限りは発生しないためほぼ無視できるような気もしますが、常駐プロセス同士がファイルを通して情報共有するような場合は注意が必要そうです。

*1:物理デバイスへの書き込みはこのタイミングで行われている保証はなさそうですが、概念的にはopen時であってそう

*2:ちなみにfopenした場合、ファイルディスクリプタはfileno(FILE *)で取得できます。