事例別!Strict Concurrency対応方法
はじめに
Swift 6への移行にあたって、Strict Concurrencyの対応は避けては通れない作業です。
この記事では、実際によく起きるであろう問題に対して、エラーベース/やりたいことベースで対応方法をまとめました。
この記事に出てくるビルド結果は、明記されていない限り全てSwift 5モード + StrictConcurrencyでビルドした結果です。
Swift 5モード + StrictConcurrencyで警告になるものの多くは、Swift 6モードではコンパイルエラーになりますので「なんだ警告か」と思わず全て対処する必要があります。
プレイグラウンド
この記事に出てくるコードは全てリポジトリ(mtj0928/strict-concurrency-tips)にまとめて公開してあります。
このリポジトリにはあらかじめ、Swift 5モード
、Swift 5モード + StrictConcurrency
, Swift 6モード
の3つのビルド設定を用意しています。
ビルド設定による結果の違いを、同じソースコードで確認できて便利ですので、ぜひご利用ください。
(Swift 6モードが必要なのでXcode 16が必要です)
異なるビルド設定をSchemeで簡単に変更できます
同じソースコードに対する実行結果
Swift 5での実行結果
Swift 5 + StrictConcurrencyでの実行結果
Swift 6での実行結果
[actor-1] UIView/UIViewControllerがMainActorに隔離される
UIに関係するものはMainActor
に隔離され、MainActor
以外からはアクセスできません。
以下のpresent
関数はMainActor
に隔離されていないので、警告になります。
func present(_ viewController: UIViewController) {
let newViewController = UIViewController() // ⚠️警告
viewController.present(newViewController, animated: true) // ⚠️警告
}
presnet
関数をMainActor
に隔離することでコンパイルエラーを解決できます。
+@MainActor
func present(_ viewController: UIViewController) {
let newViewController = UIViewController()
viewController.present(newViewController, animated: true)
}
(ref: MainActor-1)
[actor-2] 呼び出し箇所が多くてMainActorを今すぐ付けられない
上で見たようにUIを触っているコードはMainActor
から触る必要があります。
しかし、MainActor
に隔離すると、その関数を呼び出している箇所も隔離する必要があり、さらにそれを呼び出している箇所も隔離する必要があり...、と芋づる式に多くの場所の変更が求められます。
影響範囲が狭いのであれば一度に対応することもできますが、呼び出している箇所が例えばプロジェクトの中に多数あり、一つMainActor
をつけるだけで多くのエラーが出る場合など、一度に対応できない場合もあります。
そういう場合は@preconcurrency
をつけることで、その影響範囲を限定できます。
+@preconcurrency @MainActor
func present(_ viewController: UIViewController) {
let newViewController = UIViewController()
viewController.present(newViewController, animated: true)
}
例えば以下のようにpresent
関数を呼び出しているpresentCaller
関数の場合、本来はMainActor
に隔離されていないのでpresent
関数を呼び出せないのですが、Swift 5モードの間は呼び出すことができます。
func presentCaller(_ viewController: UIViewController) {
// - Swift 5: 警告/エラーなし
// - Swift 5 + StrictConcurrency: ⚠️警告
// - Swift 6: 🚨エラー
present(viewController)
}
(ref: MainActor-2)
[actor-3] UIに関するdelegateがMainActorに隔離されていない
Appleが提供するSDK、およびサードパーティーのライブラリの中には、UIに関するdelegateも存在します。
AppleのSDKは比較的StrictConcurrencyの対応をしていますが、サードパーティーのライブラリの中には対応がなかなかされないものもあるでしょう。
例えば、以下の様なコードを考えてみます。
// 外部のライブラリが定義したプロトコル
public protocol FooViewDelegate {
func didTapFooView(_ fooView: FooView)
}
// 自分が定義したVieController
final class FooViewController: UIViewController, FooViewDelegate {
func didTapFooView(_ fooView: FooView) { // ⚠️警告
// ...
}
}
このコードは警告が発生します。
その原因はFooViewDelegate
はどのactor
にも隔離されていないdidTapFooView
関数を要求していますが、FooViewController
はMainActor
に隔離されているので、protocol
が要求している関数と実装されている関数が異なるインターフェースと認識され、コンパイラに指摘されてしまいます。
これに対する解決方法は以下の2種類あります。
preconcurrency conformance
-
nonisolated
+MainActor.assumeIsolated
preconcurrency conformance
Xcode 16から使える機能です。
@preconcurrency
をつけることでactor
の違いによる指摘が抑制されます。
-final class FooViewController: UIViewController, FooViewDelegate {
+final class FooViewController: UIViewController, @preconcurrency FooViewDelegate {
func didTapFooView(_ fooView: FooView) {
// ...
}
}
この方法はprotocol
が定義されているライブラリがまだ活発に開発をされており、近い将来StrictConcurrencyに対応されることが見込まれる場合に有効です。
nonisolated + MainActor.assumeIsolated
nonisolated
は関数やプロパティをactor
に隔離しないようにする機能です。
これをつけることで、didTapFooView
関数はもうMainActor
に隔離されないので、protocol
が要求している関数と実装されている関数が同じインターフェースと認識されます。
final class FooViewController: UIViewController, FooViewDelegate {
- func didTapFooView(_ fooView: FooView) {
+ nonisolated func didTapFooView(_ fooView: FooView) {
// ...
}
}
しかしこの方法だと、didTapFooView
関数の中でMainActor
に隔離されている値に同期的にアクセスできず、Task
を発行して非同期にする必要があります。
final class FooViewController: UIViewController, FooViewDelegate {
nonisolated func didTapFooView(_ fooView: FooView) {
// MainActorに隔離されているFooViewの値にアクセスするには、
// Taskを発行してアクセスする必要がある(=非同期になってしまう)
Task { @MainActor in
let foo = fooView.value
// ...
}
}
}
これを避ける方法がassumeIsolated
です。
これは今いるコードがそのactor
の上で実行されていると仮定し、そのactor
に切り替えます。
final class FooViewController: UIViewController, FooViewDelegate {
- func didTapFooView(_ fooView: FooView) {
+ nonisolated func didTapFooView(_ fooView: FooView) {
+ // 同期的にMainActorに切り替える
+ MainActor.assumeIsolated {
+ let foo = fooView.value
+ // ...
+ }
}
}
どちらの方法もdidTapFooView
関数がメインスレッド以外から呼ばれていた場合、危険な方法です。
メインスレッドから呼ばれていることを保証できない場合、nonisolated
を付与してTask
を発行する方法が安全です。
(ref: MainActor-3)
[Sendable-1] Sendableじゃない型をactorに渡せない
以下のコードはFooActor
にSendable
じゃないNonSendableClass
を渡していて警告が出ます。
actor FooActor {
func doSomething(_ object: NonSendableClass) { /* ... */ }
}
struct Foo {
let fooActor = FooActor()
func doSomething(_ object: NonSendableClass) async {
await fooActor.doSomething(object) // ⚠️警告
}
}
Sendable
ではない型のインスタンスをactor
間で共有することができません。
Foo
のdoSomething
関数はどのactor
にも隔離されておらず、FooActor
と隔離されていない関数の間で、Sendable
ではないインスタンスを共有することになり、データ競合を起こす可能性があります。
一番シンプルな解決方法は、共有するインスタンスをSendable
にすることです。
actor FooActor {
- func doSomething(_ object: NonSendableClass) { /* ... */ }
+ func doSomething(_ object: SendableClass) { /* ... */ }
}
struct Foo {
let fooActor = FooActor()
- func doSomething(_ object: NonSendableClass) async {
+ func doSomething(_ object: SendableClass) async {
await fooActor.doSomething(object)
}
}
どうしてもSendable
にできない場合、別の方法としてsending
キーワードを関数の引数に付与することでも解決できます。
actor FooActor {
func doSomething(_ object: NonSendableClass) { /* ... */ }
}
struct Foo {
let fooActor = FooActor()
- func doSomething(_ object: NonSendableClass) async {
+ func doSomething(_ object: sending NonSendableClass) async {
await fooActor.doSomething(object)
}
}
sending
キーワードをつけるだけで解決できならこの方法一択にも思いますが、実際は使用できるシチュエーションは限定的で、呼び出し側での制限もあります。
例えば以下のコードは警告が出ます。
let nonSendable = NonSendableClass()
await Foo2().doSomething(nonSendable)
print(nonSendable.value) // ⚠️警告
詳しく別の記事を書きましたので、そちらを参照してください。
(ref: Sendable-1)
[Sendable-2] ミュータブルなclassをSendableにできない
次のようなミュータブルなclass
をSendable
にしたいシチュエーションを考えます。
ただこのCounter
は可変な状態を持っているので、Sendable
を付与するだけで警告が出てしまいます。
final class Counter: Sendable {
private var value = 0 // ⚠️警告
func increment() -> Int {
value += 1
return value
}
}
参照型をSendable
にしたい場合、まずはactor
にすることを検討してください。
(actor
は自動でSendable
になります)
-final class Counter: Sendable {
+final actor Counter {
private var value = 0
func increment() -> Int {
value += 1
return value
}
}
しかし、場合によってはどうしてもactor
にできない場合があります。
そういう場合、ロック機構を使って内部の状態をデータ競合から防ぐ必要があります。
final class Counter: Sendable {
private let valueLock = OSAllocatedUnfairLock<Int>(initialState: 0)
func increment() -> Int {
return valueLock.withLock { value in
value += 1
return value
}
}
}
これについて詳しくはこちらの記事で紹介されているので、参照してください。
(ref: Sendable-2)
[Sendable-3] 外部モジュールの型がSendableじゃない
外部ライブラリのStrict Concurrency対応が進んでおらず、スレッドセーフなのにSendable
に準拠していない場合があります。
そういう場合、actor
間でインスタンスを共有できずに不便です。
// MARK: - FooModule (外部モジュール)
public final class NonSendableButThreadSafe {
// スレッドセーフな実装
}
// MARK: - 自分のモジュール
import Module
func doSomething() {
let nonSendable = NonSendableButThreadSafe()
Task {
nonSendable.doSomething() // ⚠️警告
}
nonSendable.doSomething()
}
このような場合、@preconcurrency import
、もしくは@unchecked Sendable
が使えます。
@preconcurrency import
はimport文の前に@preconcurrency
をつけることで、そのモジュールの型はactor
間で共有されても警告が出ません。
@preconcurrency import Module
この方法を用いた場合、Moduleの中の型で、本来はSendable
であるべきではない他の型もactor
間で共有できてしまうので注意が必要です。
他の解決方法として、Sendable
ではない型に@unchecked Sendable
をつけることでコンパイラのチェックなしでSendable
にすることができます。
この方法だと型単位でコントロールできますが、他のファイルや他のモジュールに影響がありますので注意が必要です。(とはいえ本当にスレッドセーフなら悪影響はないと思いますが)
// Xcode 16未満
extension NonSendableButThreadSafe: @unchecked Sendable {}
// Xcode 16以上 (Xcode 16からは`@retroactive`をつける必要があります)
extension NonSendableButThreadSafe: @retroactive @unchecked Sendable {}
どちらの方法を用いたとしても、その型が本当にスレッドセーフかどうかドキュメントやコードを確認する必要があります。
(ref: Sendable-3)
[グローバル変数-1] Sendableなインスタンスをグローバル変数にする
グローバル変数はどのコードからでもアクセスできます。つまり、どのスレッドからでもアクセスできてしまい、場合によってはデータ競合を引き起こす可能性があります。
enum Foo {
static var sendableValue = SendableStruct() // ⚠️警告
}
DispatchQueue.main.async {
Foo.sendableValue = SendableStruct() // メインスレッドから更新できてしまう
}
Task { @MainActor in
Foo.sendableValue = SendableStruct() // MainActorから更新できてしまう
}
DispatchQueue.global().async {
Foo.sendableValue = SendableStruct() // バックグラウンドスレッドから更新できてしまう
}
Task {
Foo.sendableValue = SendableStruct() // 隔離されていないTaskから更新できてしまう
}
この場合、以下の選択肢が取れます。
-
static let
にする - computed propertyにする
-
actor
に隔離する -
nonisolated(unsafe)
をつける
static let
にする / computed propertyにする
一つ目のstatic let
と二つ目のcomputed propertyはsetterがないので、データ競合が起きません。
この方法が取れるならこの方法が間違いない方法です。
enum Foo {
// 方法 1
static let sendableValue = SendableStruct()
// 方法 2
static var sendableValue: SendableStruct {
SendableStruct()
}
}
actorに隔離する
グローバル変数へのアクセスをactor
に隔離することで、複数のスレッドから同時に読み書きされることがなく、データ競合が起きません。
enum Foo {
@MainActor static var sendableValue = SendableStruct()
}
DispatchQueue.main.async {
_ = Foo.sendableValue // メインスレッドからなら値を取得できる
Foo.sendableValue = SendableStruct() // メインスレッドで変数の上書きもできる
}
Task { @MainActor in
_ = Foo.sendableValue // MainActorからなら値を取得できる
Foo.sendableValue = SendableStruct() // MainActorで変数の上書きもできる
}
DispatchQueue.global().async {
// バックグラウンドスレッドからは更新できない
// Foo.sendableValue = SendableStruct()
}
Task {
// 非同期でなら取得できる
_ = await Foo.sendableValue
// 隔離されていないTaskからは更新できない
// Foo.sendableValue = SendableStruct()
}
nonisolated(unsafe)
をつける
四つ目のnonisolated(unsafe)
については、コンパイラのチェックを入れない方法です。
データ競合が起きる可能性があり、ロック機構など、なにかしらの方法でデータ競合が起きないように手動で管理する必要があります。
あまり安全な方法ではないので、どうしようもない場合の最後の手段として使うに留めるべきです。
enum Foo {
nonisolated(unsafe) private static var _sendableValue = SendableStruct()
private static let lock = NSLock()
// どこからでも更新できるので、lock機構などでデータ競合を手動で防ぐ必要がある
static var sendableValue: SendableStruct {
get {
lock.withLock { _sendableValue }
}
set {
lock.withLock { _sendableValue = newValue }
}
}
}
DispatchQueue.global().async {
_ = Foo.sendableValue
Foo.sendableValue = SendableStruct()
}
Task {
_ = Foo.sendableValue
Foo.sendableValue = SendableStruct()
}
(ref: GlobalVariable-1)
[グローバル変数-2] Sendableではないインスタンスをグローバル変数にする
StrictConcurrencyにおいて、Sendable
に準拠していない型のインスタンスをグローバル変数で管理するのはかなり難しいです。
まずはなんとか対象の型をSendableにして、上の[グローバル変数-1]で紹介している方法がとれないか考えてみてください。
それでも難しい場合に取れる方法について紹介します。
まずは以下のようなコードを考えます。Sendable
の時と同様、あらゆるスレッドから変数の読み書きが同期的にできてしまいます。
enum Foo {
static var nonSendableInstance = NonSendableClass() // ⚠️警告
}
DispatchQueue.main.async {
_ = Foo.nonSendableInstance
Foo.nonSendableInstance = NonSendableClass()
}
Task { @MainActor in
_ = Foo.nonSendableInstance
Foo.nonSendableInstance = NonSendableClass()
}
DispatchQueue.global().async {
_ = Foo.nonSendableInstance
Foo.nonSendableInstance = NonSendableClass()
}
Task {
_ = Foo.nonSendableInstance
Foo.nonSendableInstance = NonSendableClass()
}
考えられる対応策は以下の3つです。
- computed propertyにする (
Sendable
の場合と同様なので省略) -
actor
に隔離する -
nonisolated(unsafe)
をつける
actorに隔離する
Sendable
の場合と同様actor
で隔離すること自体は可能です。
enum Foo {
@MainActor
static var nonSendableInstance = NonSendableClass()
}
DispatchQueue.main.async {
_ = Foo.nonSendableInstance // 値を取得できる
Foo.nonSendableInstance = NonSendableClass() // 変数の更新もできる
}
Task { @MainActor in
_ = Foo.nonSendableInstance // 値を取得できる
Foo.nonSendableInstance = NonSendableClass() // 変数の更新もできる
}
しかしSendable
ではない場合、少し状況が違います。
MainActor
以外の場所からアクセスしてしてまうと、Sendable
ではない変数を複数のactor間で共有することになり、警告が出てしまいます。
Task {
_ = await Foo.nonSendableInstance // ⚠️警告
// 更新できない
// Foo.nonSendableInstance = NonSendableClass()
}
そのため、Sendable
ではない値をactor
に隔離したとき、その値を他のactor
からはたとえ非同期であっても取得することができません。
nonisolated(unsafe)
をつける
この方法を用いる場合、Sendable
の時以上に気を付ける必要があります。
Sendable
の時と同様にグローバル変数へのアクセスをロック機構を用いてガードしてみます。
enum Foo {
nonisolated(unsafe) private static var _nonSendableInstance = NonSendableClass()
private static let lock = NSLock()
static var nonSendableInstance: NonSendableClass {
get {
lock.withLock { _nonSendableInstance }
}
set {
lock.withLock { _nonSendableInstance = newValue }
}
}
}
グローバル変数へのアクセスをロックしているので一見安全そうに見えますが、複数のactor
間でnonSendableInstance
のインスタンスが共有できてしまうため、安全ではありません。
例えば、以下のようなコードはnonSendableInstance
のプロパティを複数のスレッドから更新しており、これはデータ競合を引き起こす可能性があります。
DispatchQueue.global().async {
let nonSendableInstance = Foo.nonSendableInstance
nonSendableInstance.value = ...
}
DispatchQueue.global().async {
let nonSendableInstance = Foo.nonSendableInstance
nonSendableInstance.value = ...
}
そのため、nonSendableInstance
というグローバル変数だけをロックするのではなく、そのインスタンスを使っている箇所もロックする必要があります。
enum Foo {
static let lock = NSLock()
static var nonSendableInstance = NonSendableClass()
}
DispatchQueue.global().async {
Foo.lock.withLock {
let nonSendableInstance = Foo.nonSendableInstance
nonSendableInstance.value = ...
}
}
DispatchQueue.global().async {
Foo.lock.withLock {
let nonSendableInstance = Foo.nonSendableInstance
nonSendableInstance.value = ...
}
}
これでデータ競合を引き起こすことはありませんが、今後の開発で常に注意する必要があり、避けられるなら避けた方が良い方法です。
(ref: GlobalVariable-2)
[Others-1] deinitがactorに隔離されない
deinit
がactor
に隔離されずに困ることが時々あります。
@MainActor class Foo {
private var nonSendableObserver: NonSendableObserver?
deinit {
nonSendableObserver?.stop() // ⚠️警告
}
}
これはMainActor
に隔離されていないdeinit
から、MainActor
に隔離されているnonSendableObserver
にアクセスしているからです。
このような場合、nonSendableObserver
をSendable
にすることで解決できます。
もし、Sendable
にすることができない場合は、以下の方法が考えられます。
-
nonisolated(unsafe)
を付け足す -
MainActor
に隔離された型でラップする
nonisolated(unsafe)を付け足す
nonSendableObserver
がMainActorに隔離されたFoo
クラスの中でしか触らないのであれば、nonisolated(unsafe)
をnonSendableObserver
に付け足す方法が一番シンプルです。
@MainActor class Foo {
nonisolated(unsafe) private var nonSendableObserver: NonSendableObserver?
deinit {
nonSendableObserver?.stop()
}
}
この方法はシンプルですが、deinit
以外の場所ではMainActor
からしか触らないように注意する必要があります。
この方法は基本的にはMainActor
からしか触らず、Foo
インスタンスが解放される時のみMainActor
以外からのアクセスを許可することで、同時にアクセスされる心配がなく、データ競合を発生させません。
getterを公開したり、他のnonsiolated
関数の中で触る場合には安全でない可能性があるので気をつけてください。
@MainActor class Foo {
// 🚨getterが公開されてる
nonisolated(unsafe) private(set) var nonSendableObserver: NonSendableObserver?
deinit {
nonSendableObserver?.stop()
}
func startObserve() {
nonSendableObserver?.observe()
}
// 🚨deinit以外の隔離されていない関数でアクセスしている
nonisolated func stop2() {
nonSendableObserver?.stop()
}
}
この方法はkoherさんがコメントで教えてくださいました。
ありがとうございます。
MainActorに隔離された型でラップする
もしなにかしらの事情でnonSendableObserver
を外に公開する必要があったり、他のnonisolated
な関数の中で触る必要がある場合は、別の方法としてnonSendableObserver
をMainActor
でに隔離した別の型でラップすることで解決できます。
@MainActor class MainActorObserver {
private var internalObserver: NonSendableObserver?
func stop() { internalObserver?.stop() }
}
@MainActor class Foo {
private(set) var mainActorObserver: MainActorObserver?
deinit {
Task { @MainActor [mainActorObserver] in
mainActorObserver?.stop()
}
}
}
(ref: deinit-1)
おわりに
できるだけ思いつくパターンを列挙しましたが、これで全ての対応を網羅できているとは思いませんので、もし漏れている内容がありましたら、コメントで教えてください。
また、間違ったことを記述していたり、より良い方法があれば、ぜひ教えてください。
Discussion
deinit
の例は、nonSendableObserver
がFoo
インスタンスの内部からのみの利用で安全であれば(MainActor
isolatedなメソッドとdeinit
からのみのアクセスであれば、メソッド同士は排他的だしdeinit
が呼ばれるときにインスタンスメソッドが呼ばれていることはないので、同時にアクセスされることはないので)、nonisolated(unsafe)
にするのはどうでしょう?コメントありがとうございます!
actor
の中でnonisolated
を書くことも多くはないでしょうし、シンプルで良い方法に思いました。そのアイデアを追加させていただきました。ありがとうございました!
nonisolated(unsafe)を付け足す
素晴らしい記事をありがとうございます…!
こちらの記述が気になったのですが、
actor
にできない場合はあるのでしょうか?「したくない」場合はあると思うのですが、「できない」場合があるのかわからなかったので気になりました…!
コメントありがとうございます。
考えられるケースとしてはいくつかありますが、一番現実的なケースだと呼び出し箇所が多すぎる場合です。
actorにすると、呼び出し箇所でSwift Concurrencyを使うことが強制されます。
新規プロジェクトなら問題にならないかもしれませんが、すでにリリースされている大規模なアプリだと、まだまだSwift Concurrencyが使えないコードも多いと思います。そういう場合actorにできないと思います。(技術的には可能ですが、現実的には不可能な場合という意味です。)
なるほど、それなら納得です…!
ありがとうございます🙏✨