SQLite が複数スレッド/プロセスからの読み書きを排他するのに用いている pthread_mutex/fcntl の挙動を確認する

linuxsqlite

ファイルベースの DB である SQLite のクライアントライブラリが複数スレッドやプロセスからの読み書きを排他する際に用いている pthread_mutex と fcntl の挙動を確認する。

pthread_mutex

同一プロセスのスレッド間はアドレス空間を共有しているので、その上の mutex によってロックを取ることができる。

#include <pthread.h>
#include <stdio.h>
#include <string.h>

#define THREADS 4
#define ITERS 1000000

static long counter = 0;
static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static int use_mutex = 0;

static void *worker(void *arg) {
    for (int i = 0; i < ITERS; i++) {
        if (use_mutex) pthread_mutex_lock(&mu);
        counter++;
        if (use_mutex) pthread_mutex_unlock(&mu);
    }
    return NULL;
}

int main(int argc, char **argv) {
    if (argc > 1 && strcmp(argv[1], "lock") == 0) use_mutex = 1;
    pthread_t th[THREADS];
    for (int i = 0; i < THREADS; i++) pthread_create(&th[i], NULL, worker, NULL);
    for (int i = 0; i < THREADS; i++) pthread_join(th[i], NULL);
    printf("counter=%ld (expected=%ld)\n", counter, (long)THREADS * ITERS);
    return 0;
}

ロックを取ることでスレッドの更新が競合せずインクリメントできていることが確認できる。

$ gcc -O2 -pthread counter.c -o counter
$ ./counter
counter=2021586 (expected=4000000)
$ ./counter lock
counter=4000000 (expected=4000000)

fcntl

fcntl はファイルディスクリプタに対して様々な操作を行う POSIX 標準のシステムコール。F_RDLCK/F_WRLCK を渡すと指定したオフセット l_start からバイト数 l_len 分の共有/排他ロックを取ることができる。なおアドバイザリーロックなので無視して読み書きすることはできる。

テーブルを作成し、BEGIN IMMEDIATE で書き込みロックを取った状態で 3 秒待ってからコミットする writer をバックグラウンドで起動し、その間に別プロセスから INSERT を試みると database is locked で弾かれる。

$ sqlite3 test.db 'CREATE TABLE t(v TEXT);'
$ (
{
  echo "BEGIN IMMEDIATE;"
  echo "INSERT INTO t VALUES('x');"
  echo ".system sleep 3"
  echo "COMMIT;"
} | sqlite3 test.db
) &
$ sleep 1
$ sqlite3 test.db "INSERT INTO t VALUES('blocked');"
Error: stepping, database is locked (5)

writer を strace で観察すると、BEGIN IMMEDIATE のタイミングで F_WRLCK が取られているのが見える。

$ strace -f -e trace=fcntl,fcntl64 sh -c '...' 2>&1 | grep F_WRLCK
fcntl(4, F_SETLK, {l_type=F_WRLCK, l_whence=SEEK_SET, l_start=1073741825, l_len=1}) = 0
fcntl(4, F_SETLK, {l_type=F_WRLCK, l_whence=SEEK_SET, l_start=1073741824, l_len=1}) = 0
fcntl(4, F_SETLK, {l_type=F_WRLCK, l_whence=SEEK_SET, l_start=1073741826, l_len=510}) = 0

SQLite は 1073741824 (0x40000000) から 512 バイトを、ロックを表現するために予約している

Reader は SELECT などの読み取り時に PENDING_BYTE (1073741824) の F_RDLCK を一時的に取ってから SHARED region (1073741826 からの 510 バイト) の F_RDLCK を取って SHARED 状態になる。F_RDLCK は複数プロセスで持てるので Reader 同士は並行して読める。 ちなみに 510 バイトと幅広いのは読み書きを区別できなかった Win95/98/ME 時代の名残のようだ。

Writer は Reader を長時間待たせないよう COMMIT 直前まで排他ロックを取らず段階的にロックを上げていく。 まず Reader と同じ SHARED 状態になった後、RESERVED_BYTE (1073741825) の F_WRLCK を取って RESERVED 状態になる。 他 Writer は RESERVED_BYTE の F_WRLCK が取れないので BEGIN IMMEDIATE に失敗するが Reader には影響しない。 また、トランザクション中の INSERT/UPDATE による変更はメモリ上に保持されるので Reader は元データを読み続けられる。 COMMIT 時はまず PENDING_BYTE に F_WRLCK を取って PENDING 状態になることで、新たな Reader が SHARED 状態になれなくして、既存の Reader が SHARED 状態じゃなくなるのを待ち、SHARED region の F_WRLCK を取って排他ロックを取りデータを書き込む。