ファイルロックと新OFDロック

jirou senju
2014/11/06 10:30
[Programmer's High]

UNIXシステムには古くから実装され、現在ではPOSIX、SUSにより標準化されているファイルロックですが、実は予想外の動作に驚かされることがあります。この状況を打開する新しいファイルロックがLinux v3.15で導入されました。

排他制御とファイルロック

プログラミングでは、排他制御の必要に迫られることが多くあります。複数の処理の流れが同じデータを使用する場面の多くが、これに該当します。スピンロック、セマフォ、mutexなど手法はさまざまですが、いずれも目的はデータの保護にあります。逆に言えば、データを保護していないロック(排他制御)があれば、シグナルやソケットなどのプロセス間通信を検討すべきと言えます。

処理の流れの代表例はプロセスです。処理対象が自プロセスのメモリ内のみに存在するデータならば、プロセスのメモリ空間は本来独立しており他プロセスが使用することはできません。このため、排他制御は通常不要です。しかし、プロセスが一般に使用するデータのうち、他プロセスからも常態的に使用可能なものがあります。ファイルです。すべてのファイルが他プロセスから常に使用可能というわけではありませんが(古くから存在するjail環境や最近流行しているコンテナではファイルシステム空間を分離可能)、基本的にはファイルとはシステムグローバルに存在し、自プロセスが使用中のファイルでも他プロセスが随時使用可能です(もちろんアクセス権限は必要)。自プロセスが更新途中のファイルを他プロセスに使用されたくない、または逆に自プロセスが読み取り途中のファイルを他プロセスに更新されたくないなどの場面で排他制御が必要になります。これはファイルI/O動作を制限する目的ではなく、ファイルデータの保護という目的です。

ここでmutexなどのロックオブジェクトを用いることも可能ですが、そうするとロックオブジェクトをどこに配置するかという新たな問題に出会います。複数のプロセスが同じロックオブジェクトを使用できなければ意味がありません。システムグローバルなロックオブジェクトを作成する方法はSystem Vセマフォ(semop(2))、POSIXセマフォ(sem_wait(3)sem_post(3))などがありますが、その実装を見るとシステムグローバルなファイルを利用しています(Linux/Glibcでは)。すなわち、固定パスのtmpfs(例えば/dev/shm)にファイルを作成し、ファイルの内容をmmap(MAP_SHARED)によりプロセスメモリに割り当て、メモリ上のオブジェクトとして使用する方式です。

セマフォなどのロックオブジェクトはもちろんひとつの方法ですが、ファイルデータを保護する目的ならば、対象ファイル自体をロックする方がより直接的、効率的と言えます(Linux/Glibcでのロックオブジェクトはどうせ別のファイル操作になるわけですし)。

ロックレベル――アドバイザリロックと強制ロック

従来からあるファイルロックはその強制力の点からまず2つに分類できます。アドバイザリロック(advisory lock)と強制ロック(mandatory lock)です。本記事では二者の違いについてはあまり言及しません。一般に使用されるものはアドバイザリロックが多いためと、すでに良書が充分に解説しているためです(『Linuxプログラミングインタフェース』オライリー・ジャパン刊など)。本記事では、

  1. オープン
  2. ロック
  3. I/O
  4. アンロック

という処理の流れを前提に、要点を簡単に述べるに留めます。

アドバイザリロックは「作法」的な位置付け
アドバイザリロックが効果を発揮するのは、対象のファイルを読み書きするプロセス全員が同じロック/アンロックの方式に従った場合に限定される。すなわち、自プロセスがすでにファイルロックを獲得していても、他プロセスが、故意にしろ事故にしろ、ロックせずにI/Oすることを止めることはできず、その他プロセスのI/Oは通常通り実行される。
強制ロックを使用するには環境から
強制ロックは他プロセスのI/Oをブロックさせる(またはエラーとする)強制力を持つ。プログラムコードはそのままに、マウントオプションと対象ファイルのパーミションビットを変更することで強制ロックを実現する。

無視されてしまうならば、アドバイザリロックなど無意味であるということにはなりません。ファイルに限らず排他制御は本来「作法」のようなものです。対象がすでに存在しそのアドレスなどが既知であれば、ロックを獲得して初めてアクセス可能になるわけではなく、ロックせずともいつでも使用できます。これはセマフォやmutexの場合でも変わりません。ファイルロックの場合では、そのファイルを処理する関連プロセスが同じファイルロック作法に従っていれば、期待通りの排他制御を実現できます。オブジェクト指向言語を用いればこの「作法」を強制できることは経験者ならばよくご存知でしょう。オブジェクト指向言語では、システムがI/Oをブロックさせる(またはエラーとする)強制力を発揮するのではなく、プログラミングの段階でデータをクラス内に隠蔽し、「作法」を実装したメソッドのみを公開することで「作法」を強制できます。しかし、非オブジェクト指向言語の場合は、関連プロセスそれぞれが同じ作法を実装しなければならず(もちろんライブラリ化が効果的です)、また作法を守らない無関係のプロセスは、パーミションビット、ファイルオーナなど、別の手段により閉め出す必要があります。

実際に使用されるファイルロックもアドバイザリロックが多いようです。以降、本記事でもアドバイザリロックを前提に解説します。

二種類のロック動作――flock(2)とPOSIXロック

UNIXシステムでは、似た機能を二種類の方法で実装することがたびたびあります。そのほとんどは、その機能の発祥がBSDかSystem Vかという歴史的経緯によるものです。ファイルをロックするシステムコールもBSD発祥のflock(2)と、System V発祥のfcntl(2)の二種類があります。fcntl(2)はPOSIXにより標準化されており、それぞれBSDロック、POSIXロックという呼び方もあります。機能的に共通する部分はあっても実装が異なるため、協調動作はしません。少なくとも現在のLinuxカーネルでは協調動作しません(古いバージョンのLinuxや他のOSでは協調動作するものもあるようです)。すなわち、flock(2)によりファイルロック(BSDロック)を設けていてもfcntl(2)によるファイルロック(POSIXロック)を閉め出すことにはなりません。

また、lockf(3)というライブラリ関数もあり、その名前からflock(2)のラッパかと思わされてしまいそうですが、実体はfcntl(2)です(glibcでは)。つまりlockf(3)flock(2)を閉め出しません。

flock(2)とPOSIXロックに共通する動作を挙げます。

  • 共有ロック、排他ロックが可能(またはリードロック、ライトロック)。
  • ロックしたfdをクローズすると自動的にアンロックする。

/proc/locks

Linuxでは、現在システム内に存在する全ファイルロックを/proc/locksから確認でき、獲得済みファイルロックの

  • ロック種類(FLOCK/POSIX/OFDLCK、ADVISORY/MANDATORY/MSNFS、READ/WRITE/RW/NONE/UNLCK
  • プロセスID
  • ファイル情報(デバイス番号、inode番号)
  • ファイル内のロック範囲

が得られます。獲得待ちのロック要求も確認でき、この場合はロック種類の冒頭に->が付加されます。

/proc/locksの例

1: POSIX  ADVISORY  WRITE 11636 08:11:205 0 EOF
1: -> POSIX  ADVISORY  READ  11640 08:11:205 0 EOF
2: FLOCK  ADVISORY  WRITE 2437 00:0c:5305 0 EOF
3: POSIX  ADVISORY  READ  2229 00:0c:4290 4 4
    :

2プロセス間の排他制御

排他ロック(ライトロック)の場合、一般に期待されるのは(アプリケーションが定めたロック作法に従う)他プロセスをブロックさせる動作です。簡単に確認できる動作ですが、単純なテストプログラムを付属ソースファイル一式に含めておきます(sibling.c)。これは兄弟プロセス(すなわち継承などの関連性を持たない二プロセス)で同じファイルをロックし、相手プロセスがファイルロックを保持している限り、自プロセスはロックを獲得できないことを確認するプログラムです。/* do something */の部分は、本来ならばファイルI/Oを実行しますが、テストプログラムでは単にスリープすることにしました。

兄弟プロセスによる同一ファイルのロック――sibling.c(抜粋)

#include "libtp.h"

struct lock_ops *ops;

static void lock_it(char *fname)
{
            :
        fd = open_and_pat(fname, O_RDWR, pat, sizeof(pat));
                                                        ファイルオープン
        err = ops->lock(fd, NULL);                      ファイルロック

        /* do something */
        sleep(1);
        GREP_LOCK(pat);                                 /proc/locksを確認

        err = ops->unlock(fd, NULL);                    アンロック
            :
}

int main(int argc, char *argv[])
{
            :
        fname = argv[1];
        method = argv[2];
        ops = lock_ops(method);

        for (i = 0; i < 2; i++) {
                pid[i] = fork();
                if (!pid[i]) {
                        lock_it(fname);                 2つの子プロセスで実行
                        exit(0);
                }
        }
            :
}

sibling.cにはコマンドラインパラメータを2つ与えます。ロックするファイル名と、flock(2)とPOSIXロックのどちらを使用するかを表す文字列です。また、本記事用の自作ライブラリを使用しているので簡単に補足します。

int open_and_pat(char *fname, int flags, char pat[], int n)
ファイルをオープンし、ファイルディスクリプタを返す。また、そのファイルを表すデバイス番号、inode番号の文字列を生成し、patへ代入する。この文字列を/proc/locks内の検索に使用する。
void GREP_LOCK(char *pat)
ファイルを表すデバイス番号、inode番号の文字列を受け取り、/proc/locks内を検索し、その結果を表示する。
struct lock_ops *lock_ops(char *str)

与えられた文字列から、flock(2)fcntl(2)のインタフェースの差異を吸収する簡易抽象化構造体を返す。

struct lock_ops {
    int (*lock)(int fd, char *prefix);
    int (*unlock)(int fd, char *prefix);
};

引数str"bsd""flock"のいずれかを渡せば、flock(2)の単純ラッパ関数をメンバに持つstruct lock_opsを指すポインタを返す。POSIXロック(fcntl(2))を指定する場合は"posix"または"setlkw"を与える。"fcntl"と指定しても良いが、後述するOFDロックと区別しにくくなるため、記事内に提示するサンプルプログラムの実行例では使用しない。

sibling.cは期待通りの実行結果を示します。2プロセスが同じファイルに対しロックを試み、先にファイルロックを獲得したプロセスが明示的にアンロックするまで、後のプロセスはロックを獲得できず待たされます。flock(2)でもPOSIXロックでも同じように動作することが確認できます。

sibling.cの実行結果

/tmp/testfile is (dev 0806, inum 11)                    対象ファイルは dev=08:06, inum=11

$ ./sibling /tmp/testfile flock
pid 2831 is wanting ...
pid 2831 acquired flock                                 先のプロセスがロック獲得
pid 2832 is wanting ...                                 後のプロセスはロック解放待ち
lock_it:28: 1: FLOCK  ADVISORY  WRITE 2831 08:06:11 0 EOF
lock_it:28: 1: -> FLOCK  ADVISORY  WRITE 2832 08:06:11 0 EOF
pid 2831 releases                                       先のプロセスがロック解放
pid 2832 acquired flock                                 後のプロセスがロック獲得
lock_it:28: 2: FLOCK  ADVISORY  WRITE 2832 08:06:11 0 EOF
pid 2832 releases

$ ./sibling /tmp/testfile posix
pid 2834 is wanting ...
pid 2834 acquired posix
pid 2835 is wanting ...
lock_it:28: 1: POSIX  ADVISORY  WRITE 2834 08:06:11 0 EOF
lock_it:28: 1: -> POSIX  ADVISORY  WRITE 2835 08:06:11 0 EOF
pid 2834 releases
pid 2835 acquired posix
lock_it:28: 2: POSIX  ADVISORY  WRITE 2835 08:06:11 0 EOF
pid 2835 releases

1プロセスでのデッドロック?

では次に兄弟プロセスではなく、単一プロセスで同じファイルを二度オープンし、2つのfdに対し順次排他ロック(ライトロック)する場合を考えてみましょう。すでにロックを獲得しているならば、二度目のファイルロックで自らデッドロックするように思えますが、実際にそうなるでしょうか?

1プロセスによる同一ファイルに対する2fdのロック――two-opens.c(抜粋)

struct lock_ops *ops;
char pat[16];

static int lock_it(char *fname)
{
            :
        fd = open_and_pat(fname, O_RDWR, pat, sizeof(pat));
        snprintf(a, sizeof(a), "fd %d", fd);
        err = ops->lock(fd, a);
            :
}

int main(int argc, char *argv[])
{
            :
        fname = argv[1];
        method = argv[2];
        ops = lock_ops(method);

        for (i = 0; i < 2; i++)
                fd[i] = lock_it(fname);                 子プロセスを作成せず二度実行
        GREP_LOCK(pat);
            :
}

two-opens.cの実行結果

$ ./two-opens /tmp/testfile flock none
fd 3 pid 2839 is wanting ...
fd 3 pid 2839 acquired flock
fd 4 pid 2839 is wanting ...                            後のロックが先のロックの解放待ち
                                                        デッドロック
$ fgrep -Hw 2839 /proc/locks
/proc/locks:1: FLOCK  ADVISORY  WRITE 2839 08:06:11 0 EOF
/proc/locks:1: -> FLOCK  ADVISORY  WRITE 2839 08:06:11 0 EOF
                                                        自プロセスは処理を進められない
                                                        ため先のロックを解放できない

$ ./two-opens /tmp/testfile posix none
fd 3 pid 2842 is wanting ...
fd 3 pid 2842 acquired posix
fd 4 pid 2842 is wanting ...
fd 4 pid 2842 acquired posix                            2つのロックがともに成功
main:50: 1: POSIX  ADVISORY  WRITE 2842 08:06:11 0 EOF  実際に獲得したロックはひとつ

例にはアンロック処理を含めてありませんが、付属ソースコード一式にあるtwo-opens.cではアンロック/クローズ処理も実装してあります。上記実行例にある"none" はアンロック、クローズいずれも実行しない指定です。

実際に試してみると、flock(2)は予想通りデッドロックするのに対し(もちろんノンブロッキングモードを使用しない場合)、POSIXロックは二度目のロックも成功します。同一ファイルの同一バイト範囲に対するPOSIXロックのライトロック(F_WRLCK)が同時に複数存在することはあり得ません。この場合は、同一プロセスが同じPOSIXロックを獲得しようとしても何も変わらず、エラーにもならないと仕様として定められています。二度ロックしたと言っても存在しているロックはひとつのため、一度のアンロック操作でロックは解放されます。では、二度目のアンロックはエラーになるのかと言うとそうでもなく、POSIXロックのアンロックは常に成功する仕様です。すなわち、一度目のアンロックは期待通りロックを解除後成功を返し、二度目のアンロックは何もしないままやはり成功を返しエラーにはなりません。

同一fdでのデッドロック?

two-opens.cを変形させてもうひとつ例を挙げます。two-opens.cでは同じファイルを二度オープンし、2つのfdに対しロック操作を実行しましたが、今度は同じfdを二度ロックしてみます。やはり排他ロック(ライトロック)ですので、通常ならば自らデッドロックするような動作に思えます。

1プロセスによる同一fdに対するロック――one-open.c(抜粋)

    :
fname = argv[1];
fd = open_and_pat(fname, O_RDWR, pat, sizeof(pat));
method = argv[2];                               オープンは一度
ops = lock_ops(method);

for (i = 0; i < 2; i++) {
        err = ops->lock(fd, NULL);              同一fdを二度ロック
        GREP_LOCK(pat);
}
    :

one-open.cの実行結果

$ ./one-open /tmp/testfile flock
pid 2860 is wanting ...
pid 2860 acquired flock
main:25: 1: FLOCK  ADVISORY  WRITE 2860 08:06:11 0 EOF
pid 2860 is wanting ...
pid 2860 acquired flock
main:25: 1: FLOCK  ADVISORY  WRITE 2860 08:06:11 0 EOF

$ ./one-open /tmp/testfile posix
pid 2861 is wanting ...
pid 2861 acquired posix
main:25: 1: POSIX  ADVISORY  WRITE 2861 08:06:11 0 EOF
pid 2861 is wanting ...
pid 2861 acquired posix
main:25: 1: POSIX  ADVISORY  WRITE 2861 08:06:11 0 EOF

one-open.cを実際に試してみると、flock(2)もPOSIXロックもデッドロックせず、二回のロックに成功します。POSIXロックの方は先のtwo-opens.cの例から仕様としてエラーにならない動作を理解できますが、flock(2)の方は何故でしょう。対象ファイルは同じなのですし、two-opens.cとの動作の違いの原因として考えられるものはfdしかありません。

整理のため、ここまでの内容を簡単に表にまとめておきます。

表1:各テストプログラムの動作

テストプログラム 説明 flock(2)(BSDロック) fcntl(2)(POSIXロック)
sibling.c 2プロセスで同一ファイルをロック 期待通りにブロックする
two-opens.c 単一プロセスで同一ファイルを二度オープンし、2つのfdを順次ロック ブロックする(デッドロック) 2つのロックはともに成功する
one-open.c 単一プロセスで同一fdを二度ロック 2つのロックはともに成功する

ファイルロックの識別

表1を眺めながら、fdに注目してみます。同じファイルをオープンしただけのファイルディスクリプタfdですが、二度オープンした結果の2つのfdと、ひとつのfdを二度使用する場合では何が異なるのでしょうか?

ファイルディスクリプタfdはファイルをオープンした結果として得られ、その値こそ単なる整数ですがカーネルに渡せばオープンしたファイルを表現するという大きな意味を持ちます。自明ですが、カーネル内にはファイルオープンの状態を表現する構造体オブジェクトが作成されています。オープン時に指定したモード(O_RDWRO_APPENDなど)や、I/Oする現在位置などを保持する構造体です。プロセスはこの内部構造体を指すポインタを配列として管理しており、オープンにより得られたfdはこの配列の添字です。

また、ご存じのようにdup(2)を用いると自プロセス内でfdを複製できますし、fork(2)すれば親子プロセスで共有する状態になります。これは値こそ異なるかもしれないけれど、複数のfdが同じカーネル内の構造体を参照する状態です。すなわち、配列要素の添字は異なるけれど、要素の値が同じ状態です。この要素が指すカーネル内の構造体をオープンファイル情報(open file description、OFD)と言います。ディスクリプタを「記述子」と訳すと、こういう場面で理解がしにくいかもしれません。英単語としてfile descriptionを指すのがfile descriptorと考えた方が分かりやすいと思います。

flock(2)

two-opens.cone-open.cflock(2)の動作の違いは、、fdが意味するOFD(オープンファイル情報)が大きな役割を果たしており、すなわちflock(2)のロックはOFDに対応します。OFD自体が「自分が表すファイルを、このプロセスがロックしている」という情報を持つわけではなく、別途カーネル内に作成されたファイルロックを表す構造体との対応関係が作成されます(俗に言う紐付け)。同一プロセスが同一対象に対し同一ロックを実行しても、POSIXロック同様にflock(2)でもデッドロックしません。ここで同一対象と判断する根拠がflock(2)ではOFDです。複製ではないfdの場合は(先に挙げたtwo-opens.c)、OFDが異なるため同一対象と判断されず別ロックとみなされ、その結果デッドロックします。

OFDとの対応関係を判断するflock(2)の動作を、別のテストプログラムから確認してみましょう。

複製したfdに対するアンロック/クローズ

オープンの結果得られたfdは複製可能です。すなわち、fdの値は異なっても、その意味するところであるOFDは同一です。fddup(2)すると、flock(2)のロック/アンロックはどのように動作するでしょうか。複製した結果、2つ存在するfdのどちらかひとつをアンロックすると、もう一方のfdではロックはどうなるでしょうか。

複製したfdに対するflock(2)のアンロック/クローズ――flock-dup.c(抜粋)

    
fname = argv[1];
method = argv[2];
ops = lock_ops(method);
p = argv[3];
if (!strcmp(p, "unlock"))
        do_close = 0;
else if (!strcmp(p, "close"))
        do_close = 1;

fd[0] = open_and_pat(fname, O_RDWR, pat, sizeof(pat));
printf("got fd %d\n", fd[0]);
fd[1] = dup(fd[0]);                             fdを複製
printf("got fd %d\n", fd[1]);

snprintf(a, sizeof(a), "fd %d", fd[0]);
err = ops->lock(fd[0], a);                      元のfdをロック
GREP_LOCK(pat);

snprintf(a, sizeof(a), "fd %d", fd[1]);
if (do_close) {
        printf("%s closes\n", a);
        err = close(fd[1]);                     複製したfdをクローズ
} else
        err = ops->unlock(fd[1], a);            複製したfdをアンロック
GREP_LOCK(pat);
    

flock-dup.cのアンロック結果

$ ./flock-dup /tmp/testfile flock unlock
got fd 3
got fd 4
fd 3 pid 2863 is wanting ...
fd 3 pid 2863 acquired flock
main:39: 1: FLOCK  ADVISORY  WRITE 2863 08:06:11 0 EOF  元のfdのロックを獲得
fd 4 pid 2863 releases                                  複製したfdをアンロック
main:48: pid 2863 has no locks on 08:06:11              元のfdのロックが解放される

flock-dup.cの実行結果は、flock(2)のロックはOFDに対応するという仕様を裏付けており、fdをロック後に複製したfdをアンロックすると、元のfdがアンロックされます。fdの値は異なるけれど、内部で参照するOFDが同一なためです。

さらに、ファイルクローズ時には自動的にアンロックされるという仕様を踏まえ、上記の明示的アンロックの代わりに複製したfdをクローズするとどうなるでしょうか。やはりflock(2)は自動的にアンロックされるでしょうか。同じくflock-dup.cにクローズ動作を指定し、確認してみます。

flock-dup.cのクローズ結果

$ ./flock-dup /tmp/testfile flock close
got fd 3
got fd 4
fd 3 pid 2864 is wanting ...
fd 3 pid 2864 acquired flock
main:39: 1: FLOCK  ADVISORY  WRITE 2864 08:06:11 0 EOF  元のfdのロックを獲得
fd 4 closes                                             複製したfdをクローズ
main:48: 1: FLOCK  ADVISORY  WRITE 2864 08:06:11 0 EOF  元のfdのロックは維持される

上記の実行結果からは、複製したfdだけをクローズしてもflock(2)はアンロックされないことが分かります。複製元のfdはまだ有効であり、OFDが生き残っているためです。OFDは対応するすべてのfdがクローズされた時点で初めて削除され、この時にロックとの対応関係も消滅、すなわちアンロックされます。先に挙げた「ロックしたfdをクローズすると自動的にアンロックする」という動作を正確に言い直せば、「あるOFDに対応するすべてのfdをクローズすると、そのOFDに対応するflock(2)によるロックはアンロックされる」となります。

fdを複製する方法はdup(2)以外にもあります。fork(2)しても子プロセスはfdの複製を持ち、OFDを共有します。すなわち、flock(2)によるファイルロックは子プロセスへ継承されます。OFDを共有する以上、fork(2)後に子プロセスがアンロックした場合に、親プロセスが獲得したflock(2)によるロックもアンロックされます。プロセスが異なってもOFDは同じものであるため、基本的にdup(2)の場合と同じ動作になります。

子プロセスによるflock(2)のアンロック/クローズ――flock-fork.c(抜粋)

    :
fname = argv[1];
method = argv[2];
ops = lock_ops(method);
p = argv[3];
if (!strcmp(p, "unlock"))
        do_close = 0;
else if (!strcmp(p, "close"))
        do_close = 1;

fd = open_and_pat(fname, O_RDWR, pat, sizeof(pat));
err = ops->lock(fd, NULL);                      親プロセスがロック
GREP_LOCK(pat);

pid = fork();                                   子プロセスの作成
if (!pid) {
        if (do_close) {
                printf("pid %d closes\n", getpid());
                err = close(fd);                子プロセスがクローズ
        } else
                err = ops->unlock(fd, NULL);    子プロセスがアンロック
        exit(0);
} else
        wait(NULL);
GREP_LOCK(pat);
    :

flock-fork.cの実行結果

$ ./flock-fork /tmp/testfile flock unlock
pid 2869 is wanting ...
pid 2869 acquired flock
main:36: 2: FLOCK  ADVISORY  WRITE 2869 08:06:11 0 EOF  親プロセスがロック
pid 2870 releases                                       子プロセスがアンロック
main:50: pid 2869 has no locks on 08:06:11              ロックが解放される

$ ./flock-fork /tmp/testfile flock close
pid 2871 is wanting ...
pid 2871 acquired flock
main:36: 1: FLOCK  ADVISORY  WRITE 2871 08:06:11 0 EOF  親プロセスがロック
pid 2872 closes                                         子プロセスがクローズ
main:50: 1: FLOCK  ADVISORY  WRITE 2871 08:06:11 0 EOF  ロックは維持される

ファイルロックとプロセスID

本記事を執筆中にOpenVZプロジェクト、CRIUプロジェクト(Checkpoint/Restore in Userspace、クリウ)のコアメンバがLKMLへこんな問題と改造パッチ案を投げかけました。曰く、

  • /proc/locksのPIDの欄に表示されるflock(2)のプロセスIDが正確ではない。
  • flock(2)のロックは継承されるため、ロックを獲得した親プロセスがfork(2)後にファイルをクローズした、親プロセスが終了したなどの場合に発生する。すなわち、/proc/locksに親プロセスIDが表示されたままだが、実際にファイルロックを保持しているのは子プロセスである。
  • 現状の/proc/locks からでは、実際にファイルロックを保持しているプロセスを特定できない。
  • flock(2)LOCK_TESTフラグを新規に設けたい。このフラグにより、指定した種類のロックが対象ファイルに設定されているか否かを調べる。

LKML上ではちょっとよく分からないという反応があったため、具体例を挙げた補足も投稿されました。

  • LOCK_EX(排他ロック)の場合はまだ良い。次の例では子プロセスのみがファイルロックを獲得している。この場合は親プロセス、子プロセスそれぞれでflock(LOCK_EX)を試み、成功した子プロセスのみがすでにロックを獲得していると判断できる。
pid = fork();
fd = open("/foo"); /* both parent and child has _different_ files */
if (pid == 0)
        /* child only */
        flock(fd, LOCK_EX);
  • しかし、次のLOCK_SH(共有ロック)の例では

    1. /fooをオープンした親プロセス
    2. 子プロセス(最終的に終了している)
    3. 子プロセスからロックを継承した孫プロセス

の3プロセスがあり、ファイルロックを保持しているのは孫プロセスのみである。

pid = fork();
fd = open("/foo"); /* yet again -- two different files */
if (pid == 0) {
        flock(fd, LOCK_SH);
        pid2 = fork();
        if (pid2 != 0)
                exit(0);
}

この場合に/proc/locksが表示するプロセスIDはロックを設定した子プロセスのものであり、現在実際にロックを保持しているプロセスを特定する術がない。

  • 先に挙げたflock(LOCK_EX)同様にflock(LOCK_SH)を試みても、親プロセスでも孫プロセスでも成功してしまうため、実際にロックを保持しているプロセスを特定できない。
  • 仮に親プロセスと孫プロセスからflock(LOCK_EX)を試みると、孫プロセスのみが成功する。これで判断できるかと思いきや、もし他プロセスが/fooLOCK_SHしていれば、どのプロセスからもflock(LOCK_EX)が成功せず、やはりプロセスの特定にはいたらない。

考えてみれば、POSIXロックにはロックを獲得せずにロックの存在を調べるF_GETLKがあるのに、flock(2)には相当する機能がありません。ここで提案されたLOCK_TESTF_GETLKに相当すると言えます。

後述するOFDロックを実装した開発者もLOCK_TESTに賛同し、mainlineに実装される見込みが高くなりました。ただ、OFDロックもそうでしたが、このような新機能の追加はライブラリ(glibcプロジェクト)やマニュアルページ(man-pagesプロジェクト)も巻き込んで進めなければならず、LKML以外にも多くの人の賛同を得なければならないめ、時間は多少かかるかもしれません。

閑話休題:CRIUとfile handle

LOCK_TESTを提案したのはCRIUプロジェクトのコアメンバですが、筆者は最近CRIUに触れる機会があり初めて中を覗いてみました。すると、前記事「ファイルオープンと新フラグ」で採り上げたfile handleシステムコールを活用していることが分かり、意外な発見でした。Checkpoint/Restoreとは、てっきり従来からある、稼働中のシステムの全メモリをディスクへダンプするようなことだろうと勝手に想像していましたが、CRIUでは"in userspace"という名前が表すように別のアプローチを採っていました。

プロセスのファイルオープンの状態をダンプ(checkpoint作成)する処理で、/proc下から得たfile handleを保存し、その後のプロセスの状態を再現(restore)する処理では、open_by_handle_at(2)を用いファイルをオープンするアプローチと分かり、"in userspace"に納得してしまいました。カーネルにダンプ/リストアする機能を追加するのではなく、ユーザプロセスで必要な情報を得て処理するアプローチです(多くの情報を/procから得るようです)。すべてのオープンをfile handleのみで再現するわけではないようですが、カーネルメモリをダンプしないアプローチは注目に値すると思います。

従来のカーネルメモリをダンプするアプローチではデバッグ目的ばかりだったと思いますが、CRIUでは最近人気のDockerなどのコンテナを動作させたまま、ダンプし別環境で再現することで「移動」を実現する、時間のかかるシステム起動処理の完了時点の状態をダンプし、次回起動時には再現することで時間短縮を図るなどの用途も想定しているようです。

tmpfsなど一部のファイルシステムではfile handle(inode番号)の永続性を期待できず別途対応が必要だと思いますし、まだまだ実用化は不透明ですが、面白いアプローチだと思います。

POSIXロック

さて、flock(2)がOFDを基にロック対象が同一か否かを判断するのに対し、POSIXロックでは何を根拠に判断するのでしょうか? 先に結論を言ってしまいますが、OFDではなく、プロセスID(およびinode)により判断しています。この動作を確認するには、先に挙げたtwo-opens.cを改造したtwo-opens-one-close.cが分かりやすいでしょう。

二度のオープンと一度のクローズ

同じファイルを二度オープンし(すなわち複製ではない)、POSIXロックによりひとつのfdをロック後、別fdをクローズしてみます。POSIXロックは影響を受けるでしょうか?一般的、感覚的には、fdが異なるという理由からPOSIXロックは影響を受けないことが期待されますが、その期待は裏切られます。

fdのクローズによるPOSIXロックのアンロック――two-opens-one-close.c(抜粋)

    :
fname = argv[1];
method = argv[2];
ops = lock_ops(method);

for (i = 0; i < 2; i++)
        fd[i] = open_and_pat(fname, O_RDWR, pat, sizeof(pat));
                                                同じファイルを二度オープン
snprintf(a, sizeof(a), "fd %d", fd[0]);
err = ops->lock(fd[0], a);                      fd[0]をロック
GREP_LOCK(pat);

printf("fd %d closes\n", fd[1]);
close(fd[1]);                                   fd[1]をクローズ
GREP_LOCK(pat);
    :

two-opens-one-close.cの実行結果

$ ./two-opens-one-close /tmp/testfile posix
fd 3 pid 2882 is wanting ...
fd 3 pid 2882 acquired posix
main:30: 1: POSIX  ADVISORY  WRITE 2882 08:06:11 0 EOF  fd 3をロック
fd 4 closes                                             fd 4をクローズ
main:34: pid 2882 has no locks on 08:06:11              fd 3がアンロックされる

実行結果が示す通り、同じファイルをオープンしたfd 4をクローズしただけで、fd 3に対するPOSIXロックはアンロックされてしまいます。二度明示的にオープンしているのですからOFDは2つ存在しています。OFDが異なるにもかかわらず、POSIXロックはアンロックされる仕様です。

このPOSIXロックのアンロック動作は弊害が大きく、以前から問題視されてきました。例えば、コールしたライブラリ関数が内部で同じファイルをオープン/クローズしただけでも(すなわち別fd)、既に自プロセスが獲得済みのロックが意図せずアンロックされてしまう問題が起こります。もちろん逆の場合もあります。ライブラリ関数が内部でロックしておいても、自プロセス内のどこかでオープン/クローズすればやはり意図せずアンロックされてしまいます。同様に、オープン時にO_CLOEXECを指定したfdをロック後にexecした場合や、dup2(2)による間接的なファイルクローズでも予想外のアンロックが発生します。

また、fork(2)した場合では、OFDと対応関係を持つflock(2)のロックは自動的に子プロセスに継承されるのに対し、POSIXロックはOFDとの対応関係を持たないため継承されません。この動作もたびたび問題視されます。プログラマから見ればfdをロックしたという意識があり、子プロセスも同じfdを持っているのだからと考えてしまうためだと思います。

さらに、ややマイナになるかもしれませんが、マルチスレッド時に問題になることもあります。先に挙げたtwo-opens.cが示すように、同一プロセスからのPOSIXロックは複数回成功します。この動作ではスレッド間排他制御を実現できません。

参考のため、先に挙げたflock-dup.cflock-fork.cでPOSIXロックを実行した例、およびtwo-opens-one-close.cflock(2)を実行した例も挙げておきます。ロックに対応する情報は何かを意識しながら、動作の違いを見比べてください。

POSIXロックを指定したflock-dup.cflock-fork.cの実行結果

$ ./flock-dup /tmp/testfile posix unlock
got fd 3
got fd 4
fd 3 pid 2358 is wanting ...
fd 3 pid 2358 acquired posix
main:39: 1: POSIX  ADVISORY  WRITE 2358 08:06:11 0 EOF  元のfdのロックを獲得
fd 4 pid 2358 releases                                  複製したfdをアンロック
main:48: pid 2358 has no locks on 08:06:11              元のfdのロックが解放される

$ ./flock-dup /tmp/testfile posix close
got fd 3
got fd 4
fd 3 pid 2359 is wanting ...
fd 3 pid 2359 acquired posix
main:39: 3: POSIX  ADVISORY  WRITE 2359 08:06:11 0 EOF  元のfdのロックを獲得
fd 4 closes                                             複製したfdをクローズ
main:48: pid 2359 has no locks on 08:06:11              元のfdのロックが解放される

$ ./flock-fork /tmp/testfile posix unlock
pid 2366 is wanting ...
pid 2366 acquired posix
main:36: 1: POSIX  ADVISORY  WRITE 2366 08:06:11 0 EOF  親プロセスがロック
pid 2367 releases                                       子プロセスがアンロック
main:50: 1: POSIX  ADVISORY  WRITE 2366 08:06:11 0 EOF  ロックは維持される

$ ./flock-fork /tmp/testfile posix close
pid 2368 is wanting ...
pid 2368 acquired posix
main:36: 3: POSIX  ADVISORY  WRITE 2368 08:06:11 0 EOF  親プロセスがロック
pid 2369 closes                                         子プロセスがクローズ
main:50: 3: POSIX  ADVISORY  WRITE 2368 08:06:11 0 EOF  ロックは維持される

flock(2)を指定したtwo-opens-one-close.cの実行結果

$ ./two-opens-one-close /tmp/testfile flock
fd 3 pid 2374 is wanting ...
fd 3 pid 2374 acquired flock
main:30: 1: FLOCK  ADVISORY  WRITE 2374 08:06:11 0 EOF  fd 3をロック
fd 4 closes                                             fd 4をクローズ
main:34: 1: FLOCK  ADVISORY  WRITE 2374 08:06:11 0 EOF  fd 3はアンロックされない

表2:各テストプログラムの動作(2)

テストプログラム 説明 flock(2)(BSDロック) fcntl(2)(POSIXロック)
flock-dup.c 複製したfdをアンロック 元のfdもアンロック
複製したfdをクローズ ロックを維持 アンロック
flock-fork.c 子プロセスをアンロック アンロック ロックを維持
子プロセスをクローズ 維持
two-opens-one-close.c 同一ファイルの 別fdをクローズ ロックを維持 アンロック

fcntl(2)による新OFDロック――linux-3.15

ファイルクローズ時のアンロック動作と継承の二点(およびスレッド間排他制御)は、POSIXロックの大きな問題とされており、Linuxではv3.15(2014年6月)より新たなファイルロックを導入しました。当初はfile private lockと名付けられましたが(F_SETLKの代わりにF_SETLKPを用いる)、名前が悪いと指摘され、v3.15が正式にリリースされる少し前にOFDロックと改称されたものです(open file description lock。F_OFD_SETLKを用いる)。名前から既にピンと来ているかも知れませんが、POSIXロックが抱える上記の問題点を解決し、flock(2)と同様の動作を実現するものです。

  • POSIXロックと相互に認識/協調する。すなわちF_OFD_SETLKF_SETLKを閉め出せる。
  • OFDと対応関係を持ち、fork(2)した子プロセスはロックを継承する。
  • 対応する一fdがクローズされても、OFDが存在する限り自動的にはアンロックしない。

位置付けとしては、flock(2)を認識せずPOSIXロックを認識するのでPOSIXロックの亜種になると思いますが、その動作はflock(2)にならいます。ただ、後述するPOSIXロックの長所である、ファイルロックの変換、分割/マージでは処理対象になりません。この点ではPOSIXロックとは別物として扱われます。

OFDロックの議論が熱かった頃だったと思うのですが、「これは将来POSIXで標準化されることになっている」という話を、どこかで読んだ記憶があります。確かOFDロックの開発者かその周辺がPOSIX策定に関わっているとかなんとか。しかし、今LKMLなどを検索しても見つけられませんでしたので、記憶違いかもしれません。ただ、変換、分割/マージの対応が決定されれば、なんらかの標準に取り込まれる可能性は充分にあると思います。

新種のロックを導入せずとも、既存のロックの仕様/動作を変更した方が簡単ではないかという意見もあるかもしれません。しかし、既存のアプリケーションに与える影響が大きく、現実にこの手は採れないでしょう。アプリケーションの動作が変わったり、機能しなくなる恐れがあります。カーネル開発者が理想とすることのひとつに「既存アプリケーションに変更を強いるようなカーネル改造はバグである」というものがあります。しかしその一方で、カーネルバグを修正する議論の際に、既存アプリケーションの動作が変わる恐れがあるという指摘に対し、「そんなアプリケーションはもともとバギーなんだ。動いていたとしても単なるラッキーでそう見えただけさ。そんなバギーなもんは相手にせんで良い」という意見もたびたび見かけます。もちろん「バグだけれど動作を維持しなければならない」という意見も多くあります。

さらに言えば、OFDロックを導入してもLinuxカーネルソースコードがいきなり複雑になるわけではなく、変更量はそれほど大きくありません。基本的な差異は内部構造体のstruct file_lockのfl_onwerフィールドの操作です。POSIXロックではプロセスが持つfdの配列を代入しますが、flock(2)ではOFDを代入します。OFDロックもflock(2)にならい、OFDを代入する点が変更の核となる処理です。

本記事で取り上げなかったPOSIXロックの長所

ここまでの内容でPOSIXロックの方が劣るような印象を受けた方も居るかもしれません。動作に問題があるのはその通りですが、優れている点もあります。例えば、次の点があります。

  • ロック範囲はファイル全体に限定されず、バイト単位で指定可能(このためレコードロックとも呼ばれる)。
  • ロック種別(リードロック、ライトロック)をアトミックに変換可能。
  • 上記二点から、既存ロックの一部分だけの種別変換も可能。例えば、広い範囲のライトロックの中間部分だけをリードロックに変換すると、ライトロック、リードロック、ライトロックの3つに分割される。
  • デッドロック検知、回避機能を備える。
  • POSIXにより標準化されている。

OFDロックの実行

OFDロック導入のために新たなシステムコールを新設したわけではなく、fcntl(2)にコマンドフラグを追加する形で実装されました。インタフェースはPOSIXロックと同様で、移行しやすいと思います。F_GETLKF_SETLKF_SETLKWそれぞれに対応するF_OFD_GETLKF_OFD_SETLKF_OFD_SETLKWを追加しただけのインタフェースです。ひとつだけ注意点があり、引数のstruct flockのl_pidはゼロで初期化しておく必要があります。また、POSIXロックが備えるデッドロック検知、回避機能は実装されていません。

それではこれまで使用したテストプログラムを改造し、新OFDロックの動作を確認してみます。すべて先に挙げたテストプログラムですので、flock(2)、POSIXロックでの結果と見比べてみてください。

本記事用に自作したライブラリはOFDロックにも対応していますので、すべてのテストプログラムでflock(2)、POSIXロック、OFDロックの動作の違いを確認できます。もちろんOFDロックの実行にはバージョンがv3.15以上のLinuxカーネルが必要です。

OFDロックの実行結果

$ ./sibling /tmp/testfile ofd
pid 2837 is wanting ...
pid 2837 acquired ofd                                   先のプロセスがロック獲得
pid 2838 is wanting ...                                 後のプロセスはロック解放待ち
lock_it:28: 2: OFDLCK ADVISORY  WRITE 2837 08:06:11 0 EOF
lock_it:28: 2: -> OFDLCK ADVISORY  WRITE 2838 08:06:11 0 EOF
pid 2837 releases
pid 2838 acquired ofd                                   後のプロセスがロック獲得
lock_it:28: 1: OFDLCK ADVISORY  WRITE 2838 08:06:11 0 EOF
pid 2838 releases

$ ./two-opens /tmp/testfile ofd
fd 3 pid 2843 is wanting ...
fd 3 pid 2843 acquired ofd
fd 4 pid 2843 is wanting ...                            後のロックが先のロックの解放待ち
                                                        デッドロック
$ fgrep -Hw 2843 /proc/locks
/proc/locks:2: OFDLCK ADVISORY  WRITE 2843 08:06:11 0 EOF
/proc/locks:2: -> OFDLCK ADVISORY  WRITE 2843 08:06:11 0 EOF
                                                        自プロセスは処理を進められない
                                                        ため先のロックの解放できない

$ ./one-open /tmp/testfile ofd                          一fdではデッドロックしない
pid 2862 is wanting ...
pid 2862 acquired ofd
main:25: 1: OFDLCK ADVISORY  WRITE 2862 08:06:11 0 EOF
pid 2862 is wanting ...
pid 2862 acquired ofd
main:25: 1: OFDLCK ADVISORY  WRITE 2862 08:06:11 0 EOF

$ ./flock-dup /tmp/testfile ofd unlock
got fd 3
got fd 4
fd 3 pid 2867 is wanting ...
fd 3 pid 2867 acquired ofd
main:39: 1: OFDLCK ADVISORY  WRITE 2867 08:06:11 0 EOF  元のfdのロックを獲得
fd 4 pid 2867 releases                                  複製したfdをアンロック
main:48: pid 2867 has no locks on 08:06:11              元のfdのロックが解放される

$ ./flock-dup /tmp/testfile ofd close
got fd 3
got fd 4
fd 3 pid 2868 is wanting ...
fd 3 pid 2868 acquired ofd
main:39: 2: OFDLCK ADVISORY  WRITE 2868 08:06:11 0 EOF  元のfdのロックを獲得
fd 4 closes                                             複製したfdをクローズ
main:48: 2: OFDLCK ADVISORY  WRITE 2868 08:06:11 0 EOF  元のfdのロックは維持される

$ ./flock-fork /tmp/testfile ofd unlock
pid 2877 is wanting ...
pid 2877 acquired ofd
main:36: 1: OFDLCK ADVISORY  WRITE 2877 08:06:11 0 EOF  親プロセスがロック
pid 2878 releases                                       子プロセスがアンロック
main:50: pid 2877 has no locks on 08:06:11              ロックが解放される

$ ./flock-fork /tmp/testfile ofd close
pid 2879 is wanting ...
pid 2879 acquired ofd
main:36: 2: OFDLCK ADVISORY  WRITE 2879 08:06:11 0 EOF  親プロセスがロック
pid 2880 closes                                         子プロセスがクローズ
main:50: 2: OFDLCK ADVISORY  WRITE 2879 08:06:11 0 EOF  ロックは維持される

$ ./two-opens-one-close /tmp/testfile ofd
fd 3 pid 2883 is wanting ...
fd 3 pid 2883 acquired ofd
main:30: 2: OFDLCK ADVISORY  WRITE 2883 08:06:11 0 EOF  fd 3をロック
fd 4 closes                                             fd 4をクローズ
main:34: 2: OFDLCK ADVISORY  WRITE 2883 08:06:11 0 EOF  fd 3のロックは維持される

本記事では、POSIXロックが抱える問題点を解説し、その対策としてLinuxカーネルに追加された新OFDロックを紹介しました。今後も折にふれLinuxカーネルの新機能を紹介したいと思います。

(初出: H26/9)

Copyright (C) 2014 SENJU Jiro

本記事のサンプルコードは、以下のリンクよりダウンロードすることができます。記事と合わせてご参照ください。
また、本稿で取り上げた内容は、記事の執筆時の情報に基づいており、その後の実装の進展、変更等により、現在の情報とは異なる場合があります。
本連載「プログラマーズ・ハイ」では読者からのご意見、ご感想を切望しています。今後取り上げるテーマなどについてもご意見をお寄せください。

Bookfair

O'Reilly Japanのセレクションフェア、全国の書店さんで開催
[ブックフェアのページへ]

Feedback

皆さんのご意見をお聞かせください。ご購入いただいた書籍やオライリー・ジャパンへのご感想やご意見、ご提案などをお聞かせください。より良い書籍づくりやサービス改良のための参考にさせていただきます。
[feedbackページへ]