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 が出力されるようなことが起こらなくなりました。
「actor にする」は「Sendable にする」も満たす
Swift の class は「参照型」ですが、これを actor にすると「状態へのアクセスを内部で管理する参照型」にできます。そしてこれは Sendable が意味する「同時実行ドメイン間で安全に値を渡すことができる[1]」を満たします。つまり、「actor にする」ことは「Sendable にする」こととも言えます。
また、すべての actor は Actor protocol に適合しますが、この Actor も Sendable に適合している[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 への適合を付けてみましょう。
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 を用いる必要があります。
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 を使う
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