Swift Digest
SE-0512 | Swift Evolution

Document that Mutex.withLockIfAvailable(_:) cannot spuriously fail

Proposal
SE-0512
Authors
Jonathan Grynspan
Review Manager
John McCall
Status
Implemented (Swift 6.0)

01 何が問題だったのか

SE-0433 で追加された Mutex 型には、ロックを取りに行って、別のスレッドがすでに保持していれば待たずに nil を返す、という非ブロッキングなメソッド withLockIfAvailable(_:) があります。

let result: T? = mutex.withLockIfAvailable { value in
    // ロックを取れたときだけ実行される
    ...
}

戻り値が nil のとき、このメソッドは「失敗」したと呼ばれます。問題は、現在のドキュメントがどんなときに失敗しうるかを明記していない点です。類似 API は他の言語にもありますが、振る舞いが言語ごとに割れています。

  • POSIX の pthread_mutex_trylock()、Go の Mutex.TryLock()、Java の Lock.tryLock()、Kotlin の Mutex.tryLock()、Rust の mutex::try_lock()、Zig の std.Thread.Mutex.tryLock() は、いずれも「他のスレッドがロックを保持しているとき」にしか失敗しません。
  • 一方、C11 の mtx_trylock() と C++11 の std::mutex::try_lock() は、他のスレッドが保持していなくても失敗することを許しています。これを spurious failure(偽の失敗) と呼びます。

C / C++ が spurious failure を認めているのは、mutex の実装に weak な compare-and-exchange(CAS)操作を使えるようにするためです。weak な CAS は、誰もその値を書き換えていなくてもハードウェア都合で失敗することがあり、一部の CPU アーキテクチャでは strong な CAS より効率的に実装できます。

Swift 側のドキュメントは、この点について沈黙しています。Swift しか知らない読者は「明記されていないのだから起こらないはず」と読みますが、C や C++ から来た読者は「起きうるかもしれない」と身構えてしまうかもしれず、挙動を推測できません。

spurious failure が実際に混ざってくると、呼び出し側は「他スレッドがロックを持っていたから失敗」なのか「何の理由もなく失敗」なのかを区別できません。もしロックを保持しているのが 現在のスレッド自身 だった場合、失敗の原因を見分けられないまま「諦める」以外の対処(withLock(_:) へのフォールバックやリトライループ)をすると、そのままデッドロックに陥ります。Swift Testing などの実コードでも、この性質を前提に withLockIfAvailable(_:) が使われています。

なお、現在の Swift の Mutex の各プラットフォーム実装(Darwin の os_unfair_lock_trylock()、Linux/Android の強い CAS または FUTEX_TRYLOCK_PI、FreeBSD の UMTX_OP_MUTEX_TRYLOCK、OpenBSD の pthread_mutex_trylock()、Wasm の強い CAS、Windows の TryAcquireSRWLockExclusive())はいずれも spurious failure を起こさないことが確認されています。つまり「実装上は起きないのにドキュメントだけが曖昧」という状態でした。

02 どのように解決されるのか

Mutex.withLockIfAvailable(_:) が spurious failure を起こさないことを、ドキュメント上の保証として明記します。API のシグネチャや実装には変更がなく、既存の挙動に対して「将来にわたってこうである」と約束を与える変更です。

保証される振る舞い

withLockIfAvailable(_:)nil を返すのは、ロックが何らかのスレッド(現在のスレッドを含む)によって保持されているときに限られます。他に理由のない失敗は起こりません。

具体的には、戻り値に関するドキュメントが次のように強化されます。

  • 変更前: 「body の戻り値、または、ロックを取得できなかった場合は nil
  • 変更後: 「ロックを取得できた場合は body の戻り値。いずれかのスレッド(現在のスレッドを含む)がすでにロックを保持している場合は nil

さらに Discussion セクションに、次の趣旨の注記が加わります。

このメソッドは spurious failure を起こしません。他言語の類似関数(たとえば C の mtx_trylock())の振る舞いはプラットフォーム依存で、Swift とは異なる場合があります。

利用側への影響

この保証があることで、withLockIfAvailable(_:) の失敗は常に「他のスレッド(または自分自身)がロックを握っている」ことを意味すると仮定できます。たとえば、失敗時にブロッキングな withLock(_:) にフォールバックする、というよくあるパターンも安心して書けます。

let cache = Mutex<[Key: Value]>([:])

func read(_ key: Key) -> Value? {
    // まずはブロックせずに試す
    if let value = cache.withLockIfAvailable({ $0[key] }) {
        return value
    }
    // nil が返るのは誰かが保持しているときだけなので、
    // 改めてブロッキングで取りに行って問題ない
    return cache.withLock { $0[key] }
}

逆に、現在のスレッド自身がすでにロックを保持している状況で withLockIfAvailable(_:) が失敗した場合、ロックを諦めない限りデッドロックは避けられません。これはこの保証があってもなくても変わりませんが、「失敗=誰かが保持している」と断言できることで、再帰取得の回避などの設計判断が明確になります。

spurious failure を前提にガードしていたコードは、そのまま動き続けます(起こらない事象を待ち続けるだけです)。都合のよいタイミングで簡素化できます。

Future Directions

将来、新しいプラットフォームを Swift がサポートする際には、その Mutex 実装も spurious failure を起こさないように注意する必要があります。

また、weak CAS を明示的に選びたいユースケース向けに、Mutex.withLockIfAvailable(weak: true) のようなバリアントが追加される可能性があります。ほとんどのプラットフォームでは既存 API と同じ振る舞いになりますが、CAS 操作を自前で制御できるプラットフォームでは weak な操作を選択できる、というイメージです。これはあくまで今後の方向性の一つで、実現を約束するものではありません。