🔒

SwiftのMutexで、並行安全にミュータブルで同期的なクラスプロパティを使う

に公開

概要

iOS 18, macOS 15以降でMutexというAPIがSwiftで利用可能になったため、その使い方の紹介です。

https://developer.apple.com/documentation/synchronization/mutex

ミュータブルなクラスプロパティは並行安全ではない

Swift6で並行安全に対するコンパイラチェックが入ったことで、アクター境界を超える場合にミュータブルなプロパティを持つクラスなどは使用できなくなったのですが、以下は無理やりコンパイラを黙らせて並行安全でないことを示すコードです。

1万回、num += 1を並行に実行しています。結果としてnum10000になっておらず、9652になっているため、並行処理が競合して何回か処理が失われていることがわかります。

import Synchronization

// @unchecked Sendableで、ミュータブルなプロパティを許容
final class Test: @unchecked Sendable {
    var num: Int = 0
    
    // ランダムで数ナノ秒待機後にnumに1加算する
    // @Sendableで並行安全性チェックを無効化している
    @Sendable func add() async throws {
        try await Task.sleep(nanoseconds: .random(in: 1...9))
        num += 1
    }
    
    func run() async throws {
        // 10000回、並行でadd()を呼び出す
        try await withThrowingTaskGroup(of: Void.self) { group in
            for _ in 1...10000 {
                group.addTask {
                    try await self.add()
                }
            }
            for try await _ in group {}
        }
    }
}

let sut = Test()
try await sut.run()
print(sut.num) // 9652

Mutexを使用してロックをかけてから書き込むと並行安全に

以下のコードのようにMutexを使うと、コンパイラを無理やり黙らせることなくSendableに準拠することができ、値も10000になり処理が失われていないことがわかります。

import Synchronization

final class Test: Sendable {
    // macOS v15以降で使用可能なMutex
    let num: Mutex<Int> = .init(0)
    
    // ランダムで数ナノ秒待機後にnumに1加算する
    func add() async throws {
        try await Task.sleep(nanoseconds: .random(in: 1...9))
        num.withLock { $0 += 1 }
    }
    
    func run() async throws {
        // 10000回、並行でadd()を呼び出す
        try await withThrowingTaskGroup(of: Void.self) { group in
            for _ in 1...10000 {
                group.addTask {
                    try await self.add()
                }
            }
            for try await _ in group {}
        }
    }
}

let sut = Test()
try await sut.run()
print(sut.num.withLock { $0 })

Discussion