🔒

Swift の actor を使いたくない時でもロックで値を保護して Sendable にする(OSAllocatedUnfairLock)

2024/05/11に公開

🧑‍⚕️「ちょっとそのオブジェクト、Sendable にしておいてよ」
🧑「あっ… はい……」

まとめ

  • Swift 6 に向けた対応のひとつとして、オブジェクトの Sendable 対応を行うことがあるかもしれない
  • Swift としては actor にすることで解決する
  • でも actor にできないときは、自前のロックなどで状態へのアクセスを保護することで Sendable にする
  • Apple プラットフォームで使える OSAllocatedUnfairLock を使ったロックの方法を紹介したい

この記事をおすすめしたい人

  • とあるオブジェクトを Sendable にしたいが、そのために actor を用いると都合が悪い場面に遭遇した方
  • actor を使う方法以外にオブジェクトを Sendable にする方法を知りたい方
  • os_unfair_lock でロックして状態へのアクセスを保護し、@unchecked Sendable しているコードを書いている方
  • NSLock 以外の Apple プラットフォームにおけるロックの方法を知りたい方

データ競合の発生

次のような Counter を考えます。

Counter.swift
import Foundation

final class Counter {
    private var value = 0
    
    func increment() -> Int {
        value += 1
        return value
    }
}

これを以下のように2つの Task から実行してみます。

let counter = Counter()

Task.detached {
    print(counter.increment())  // 🍎
}

Task.detached {
    print(counter.increment())  // 🍏
}

これを実行したとき、以下のようなパターンの出力が得られる可能性があります。

🍎 🍏
1 1 ❌ データ競合
1 2 ✅ 意図した通り
2 1 ✅ 意図した通り
2 2 ❌ データ競合

実行のタイミングによって🍎と🍏の両方に 12 が出力されるような、データ競合が起きたりします。これを回避するために「値の保護」、つまり Sendable にしたいです。

actor で値を保護する

Swift Concurrency が使える環境において、「値の保護」と聞くと思い浮かべるのが actor(・global actor)です。Counter を actor にしてみましょう。

Counter.swift
import Foundation

actor Counter {
    private var value = 0
    
    func increment() -> Int {
        value += 1
        return value
    }
}

差分は唯一、Counterfinal class から actor へ変更したのみです。

- final class Counter {
+ actor Counter {

これを以下のように2つの Task から実行してみます。

let counter = Counter()

Task.detached {
    print(await counter.increment())  // 🍎
}

Task.detached {
    print(await counter.increment())  // 🍏
}

これを実行したとき、以下のようなパターンの出力が得られる可能性があります。

🍎 🍏
1 2 ✅ 意図した通り
2 1 ✅ 意図した通り

actor にすることによって、データ競合である🍎と🍏の両方に 12 が出力されるようなことが起こらなくなりました。

「actor にする」は「Sendable にする」も満たす

Swift の class は「参照型」ですが、これを actor にすると「状態へのアクセスを内部で管理する参照型」にできます。そしてこれは Sendable が意味する「同時実行ドメイン間で安全に値を渡すことができる[1]」を満たします。つまり、「actor にする」ことは「Sendable にする」こととも言えます。

また、すべての actor は Actor protocol に適合しますが、この ActorSendable に適合している[2]ことから、これを確かめることができます。

actor 外からのアクセスには await キーワードが必要

上記における Counter.increment() を呼び出す側のコードの差分も見てみましょう。

  Task.detached {
-     print(counter.increment())
+     print(await counter.increment())
  }

Counter が actor へと変更されたことにより、その actor 外からの increment() の呼び出しは中断が発生する可能性が生まれました。それを示すため、increment() を呼び出す際には await キーワードが必要となり、また await キーワードを用いるには concurrency がサポートされている環境下である必要があります。

今回のコード例では Counter.increment()Task 内で実行しているため「concurrency がサポートされている環境下」となり await キーワードを用いることができます。

actor にせずに Sendable にしたい

場合によっては「concurrency がサポートされている環境下」を用意することができず、await キーワードを用いることができないパターンも考えられます。

しかし、await キーワードが使えない環境ではエラーとなってしまいます。そこで Counter を actor にせずに class に戻し、Sendable への適合を付けてみましょう。

Counter.swift
  import Foundation

- actor Counter {
+ final class Counter: Sendable {
      private var value = 0
      
      func increment() -> Int {
          value += 1
          return value
      }
  }

すると、Stored property 'value' of 'Sendable'-conforming class 'Counter' is mutable と警告が出てきてしまいました。

この value をロックで守ることによって警告を消してみましょう。

OSAllocatedUnfairLock でロックする

では、OSAllocatedUnfairLock を使ったロックの方法を見てみます。

valueOSAllocatedUnfairLock を使った protectedValue に変更、increment() の実装は withLock(_:) を使ったものへ変更します。

  import Foundation
+ import os

  final class Counter: Sendable {
-     private var value = 0
+     private let protectedValue = OSAllocatedUnfairLock(initialState: 0)
      
      func increment() -> Int {
+         protectedValue.withLock { value in
              value += 1
              return value
+         }
      }
  }

これにより Counter から var がなくなり、protectedValueSendable である OSAllocatedUnfairLock になったので、Counterを class のまま Sendable にすることができました。

OSAllocatedUnfairLock の注意点

OSAllocatedUnfairLock を使ったコード例をもう少しみてみましょう。

前章のコード例では OSAllocatedUnfairLock に直接、保護したい値を入れて守っていましたが、OSAllocatedUnfairLock に保護したい値を渡さずにロック機能だけを使うこともできます。

let lock = OSAllocatedUnfairLock()
lock.withLock {
    // 保護が必要な処理
}

また、withLock(_:) を用いず、lock()unlock() をペアで用いる方法もあります。

let lock = OSAllocatedUnfairLock()
lock.lock()
// 保護が必要な処理
lock.unlock()

ただし、この方法を用いる場合は注意が必要です。この lock()unlock() は同じスレッドから呼び出す必要があるとされています。

When using this approach, you must call unlock() from the same thread you use to call lock().

https://developer.apple.com/documentation/os/osallocatedunfairlock

そのため、例えば「concurrency がサポートされている環境下」でこの lock()unlock() の間に await するような処理が挟まる場合は、この方法を使うことができません。

❌ lock() と unlock() の間に await する処理は加えてはならない
let lock = OSAllocatedUnfairLock()
lock.lock()
await something()
lock.unlock()  // ❌ `lock()` したときと同じスレッドである保証がない

このような場合は、そもそも actor を用いる方向で考え直してみましょう。

最後に、OSAllocatedUnfairLock は再帰ロックではありません。再帰ロックが必要な場合は NSRecursiveLock を用いる必要があります。

Swift から os_unfair_lock を使うのは安全ではないとされている

OSAllocatedUnfairLock は Swift 専用で、iOS 16.0+、macOS 13.0+ 他から利用可能になった新しいオブジェクトです。これよりも前は Objective-C にある os_unfair_lock を使う方法がありました。しかし、OSAllocatedUnfairLock のドキュメントには以下のように書かれています。

However, it’s unsafe to use os_unfair_lock from Swift because it’s a value type and, therefore, doesn’t have a stable memory address. That means when you call os_unfair_lock_lock or os_unfair_lock_unlock and pass a lock object using the & operator, the system may lock or unlock the wrong object.

https://developer.apple.com/documentation/os/osallocatedunfairlock

しかし、OSAllocatedUnfairLock は struct ですが値型として機能しないように作られており、上記のような危険がないとされています。os_unfair_lock を Swift から使っているコードがあれば、OSAllocatedUnfairLock へ移行しましょう。

OSAllocatedUnfairLock が使えない環境では NIOLock を使う

OSAllocatedUnfairLockos に含まれており、iOS 16.0+、macOS 13.0+、tvOS 16.0+、watchOS 9.0+、visionOS 1.0+ でのみ利用できます。バージョンの古い環境や Linux・Windows 等の環境をサポートしたい場合は、例えば swift-nio にある NIOLock が利用できます。NIOLockSendable に適合しています。

その他

今回は os に含まれている OSAllocatedUnfairLock という、Swift 自体にはない仕組みについて紹介しました(つまり import os が必要)。これを Swift 自体にも Mutex として搭載する[3]提案が SE-0433 として存在します(本記事公開時点でステータスは Accepted)。

また、「値の保護」を行うには actor や本記事で述べたロックの他にも、アトミックやシリアルディスパッチキュー(DispatchQueue など)もあります。

参考

脚注
  1. Apple Developer Documentation の Sendable にある values can safely be passed across concurrency domains を日本語で表現した。 ↩︎

  2. Actor が適合する AnyActorSendable に適合するため。 ↩︎

  3. 厳密には、Apple プラットフォーム(Darwin)では os_unfair_lock、Linux では futex、Windows では SRWLOCK… といった具合に、各環境ごとに異なる実装へアクセスされるラッパーとして Mutex を追加しようという話。 ↩︎

Discussion