Swift の actor を使いたくない時でもロックで値を保護して Sendable にする(OSAllocatedUnfairLock)
🧑⚕️「ちょっとそのオブジェクト、Sendable
にしておいてよ」
🧑「あっ… はい……」
まとめ
- Swift 6 に向けた対応のひとつとして、オブジェクトの
Sendable
対応を行うことがあるかもしれない - Swift としては actor にすることで解決する
- でも actor にできないときは、自前のロックなどで状態へのアクセスを保護することで
Sendable
にする - Apple プラットフォームで使える
OSAllocatedUnfairLock
を使ったロックの方法を紹介したい
この記事をおすすめしたい人
- とあるオブジェクトを
Sendable
にしたいが、そのために actor を用いると都合が悪い場面に遭遇した方 - actor を使う方法以外にオブジェクトを
Sendable
にする方法を知りたい方 -
os_unfair_lock
でロックして状態へのアクセスを保護し、@unchecked Sendable
しているコードを書いている方 -
NSLock
以外の Apple プラットフォームにおけるロックの方法を知りたい方
データ競合の発生
次のような Counter
を考えます。
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 |
❌ データ競合 |
実行のタイミングによって🍎と🍏の両方に 1
や 2
が出力されるような、データ競合が起きたりします。これを回避するために「値の保護」、つまり Sendable
にしたいです。
actor で値を保護する
Swift Concurrency が使える環境において、「値の保護」と聞くと思い浮かべるのが actor(・global actor)です。Counter
を actor にしてみましょう。
import Foundation
actor Counter {
private var value = 0
func increment() -> Int {
value += 1
return value
}
}
差分は唯一、Counter
を final 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 にすることによって、データ競合である🍎と🍏の両方に 1
や 2
が出力されるようなことが起こらなくなりました。
Sendable
にする」も満たす
「actor にする」は「Swift の class は「参照型」ですが、これを actor にすると「状態へのアクセスを内部で管理する参照型」にできます。そしてこれは Sendable
が意味する「同時実行ドメイン間で安全に値を渡すことができる[1]」を満たします。つまり、「actor にする」ことは「Sendable
にする」こととも言えます。
また、すべての actor は Actor
protocol に適合しますが、この Actor
も Sendable
に適合している[2]ことから、これを確かめることができます。
await
キーワードが必要
actor 外からのアクセスには 上記における Counter.increment()
を呼び出す側のコードの差分も見てみましょう。
Task.detached {
- print(counter.increment())
+ print(await counter.increment())
}
Counter
が actor へと変更されたことにより、その actor 外からの increment()
の呼び出しは中断が発生する可能性が生まれました。それを示すため、increment()
を呼び出す際には await
キーワードが必要となり、また await
キーワードを用いるには concurrency がサポートされている環境下である必要があります。
今回のコード例では Counter.increment()
を Task
内で実行しているため「concurrency がサポートされている環境下」となり await
キーワードを用いることができます。
Sendable
にしたい
actor にせずに 場合によっては「concurrency がサポートされている環境下」を用意することができず、await
キーワードを用いることができないパターンも考えられます。
しかし、await
キーワードが使えない環境ではエラーとなってしまいます。そこで Counter
を actor にせずに class に戻し、Sendable
への適合を付けてみましょう。
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
を使ったロックの方法を見てみます。
value
を OSAllocatedUnfairLock
を使った 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
がなくなり、protectedValue
も Sendable
である 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
するような処理が挟まる場合は、この方法を使うことができません。
let lock = OSAllocatedUnfairLock()
lock.lock()
await something()
lock.unlock() // ❌ `lock()` したときと同じスレッドである保証がない
このような場合は、そもそも actor を用いる方向で考え直してみましょう。
最後に、OSAllocatedUnfairLock
は再帰ロックではありません。再帰ロックが必要な場合は NSRecursiveLock
を用いる必要があります。
os_unfair_lock
を使うのは安全ではないとされている
Swift から 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
を使う
OSAllocatedUnfairLock
は os に含まれており、iOS 16.0+、macOS 13.0+、tvOS 16.0+、watchOS 9.0+、visionOS 1.0+ でのみ利用できます。バージョンの古い環境や Linux・Windows 等の環境をサポートしたい場合は、例えば swift-nio にある NIOLock
が利用できます。NIOLock
も Sendable
に適合しています。
その他
今回は os に含まれている OSAllocatedUnfairLock
という、Swift 自体にはない仕組みについて紹介しました(つまり import os
が必要)。これを Swift 自体にも Mutex
として搭載する[3]提案が SE-0433 として存在します(本記事公開時点でステータスは Accepted)。
また、「値の保護」を行うには actor や本記事で述べたロックの他にも、アトミックやシリアルディスパッチキュー(DispatchQueue
など)もあります。
参考
-
Apple Developer Documentation の Sendable にある
values can safely be passed across concurrency domains
を日本語で表現した。 ↩︎ -
厳密には、Apple プラットフォーム(Darwin)では
os_unfair_lock
、Linux ではfutex
、Windows ではSRWLOCK
… といった具合に、各環境ごとに異なる実装へアクセスされるラッパーとしてMutex
を追加しようという話。 ↩︎
Discussion