【Swift Concurrency】Actorとは?データの競合を防ぐ方法と使い方
この記事からわかること
- Swift Concurrencyとは?
- データの競合とは?
- Actorの使い方
- isolated/nonisolatedキーワードの違い
index
[open]
\ アプリをリリースしました /
友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-
posted withアプリーチ
環境
- Xcode:16.0
- iOS:18.0
- Swift:5.9
- macOS:Sonoma 14.6.1
公式リファレンス:Concurrency
公式ドキュメント:Concurrency
Swift Concurrencyとは?
Swift Concurrency(同時実効性)とはiOS15(Swift 5.5)から導入された仕組みの1つで非同期プログラミングをより利用しやすくするための機能を提供しています。例えばよく利用されるasync
やawait
キーワード、Task
構造体などはSwift Concurrencyから提供されており、非同期処理を実装する際に利用されるcompletionHandler(コールバック関数)の弱みである可読性の低下を解消することができるようになりました。
データの競合
Actor
がなんたるかを理解する前にマルチスレッドプログラミングで起こりうる大きな問題である「データの競合」について理解しておきます。
異なるスレッドから単一のデータにアクセスできるようになっているおかげでタイミングの制御が難しくなりデータに不整合が生じる可能性が発生します。
これを防ぐためにはスレッドAからのデータの操作中にスレッドBからの編集を受け付けないようにするといった排他制御を行う必要があります。
データの競合を実際に発生させてみる
例えば以下のようなクラスがあるとします。
class Score {
private var value: Int = 0
func update(score: Int) {
value = score
print("Score:\(value)")
}
}
このクラスのvalue
プロパティをさまざまなスレッドから同時に更新処理を実行してみます。期待としては順番はさておきScore:200
、Score:300
、Score:400
となるように思います。
let score = Score()
Task {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
score.update(score: 200)
print("非同期処理1")
}
Task {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
score.update(score: 300)
print("非同期処理2")
}
Task {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000)
score.update(score: 400)
print("非同期処理3")
}
しかし実行してみると出力は以下のようになりました。今回はたまたま400
でしたが全て200
になったり、300
になったりもします。
Score:400
非同期処理2
Score:400
非同期処理3
Score:400
非同期処理1
これを防ぐための手段としてActor
が活用できます。
Actorとは?
Actor
もSwift Concurrencyから提供されている機能の1つで、データの排他制御を行うことができるスレッドセーフなオブジェクトです。Actor
はクラスや構造体と同じような振る舞いを持つ型で、クラスと同じく参照型のオブジェクトになります。
Swift ConcurrencyのTask
などを使用することで並行処理が安全に実行できるようになりましたが共有のデータを操作する際にはデータ競合のリスクが発生します。Actor
は並行コード間で安全にデータを操作するためのオブジェクトで、インスタンスには1つのタスクのみがデータにアクセスできるようになっています。
Actor
はactor
キーワードを使用して定義します。先ほど発生していたデータの競合のコードをActor
に置き換えてみます。
actor Score {
private var value: Int = 0
func update(score: Int) {
value = score
print("Score:\(value)")
}
}
Actor
のメソッドを呼び出す場合はawait
をつける必要があります。
let score = Score()
Task {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000) // 1秒停止
await score.update(score: 200)
print("非同期処理1")
}
Task {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000) // 2秒停止
await score.update(score: 300)
print("非同期処理2")
}
Task {
try await Task.sleep(nanoseconds: 1 * 1_000_000_000) // 3秒停止
await score.update(score: 400)
print("非同期処理3")
}
出力が期待通りに動作するようになりました。
Score:200
非同期処理1
Score:300
非同期処理2
Score:400
非同期処理3
文法はクラスと一緒
先述しましたがActor
はクラスと同じく参照型のオブジェクトでした。基本的な文法はクラスと同じで、プロパティやメソッドの定義、イニシャライザの実装、プロトコルの継承なども可能になっています。
actor Score: Identifiable {
private var value: Int = 0
func update(score: Int) {
value = score
print("Score:\(value)")
}
}
ただしActor
を継承してサブActorを作るといったことはできないようになっています。
actor Score {}
actor GameScore: Score {} // Actor types do not support inheritance
プロパティやメソッドの呼び出しは非同期
Actor
に定義したプロパティやメソッドを外部から呼び出す場合は自動的に非同期になります。そのためawait
キーワードを使用する必要があり、外部からの参照では別タスクが呼び出し中であればそのタスクの完了を待ってから実行されることになります。
print(await score.value)
await score.update(score: 200)
ただし内部から呼び出す場合は非同期を意識することなく普通に参照することが可能になっています。
actor Score {
private var value: Int = 0
func update(score: Int) {
value = score
print("Score:\(value)")
}
}
nonisolated
Actor
が1つのタスクのみのアクセスに制御している状態をisolate
(隔離)と呼びます。Actor
のプロパティやメソッドがisolate
されていることで通常の参照とは異なり、await
を使用して呼び出すことが強制されます。
actor
キーワードで定義したオブジェクトのプロパティやメソッドは自動的にisolate
されている状態(isolated
)になりますが、特定のメソッドに対してisolate
したくない場合が出てきます。これを解決するのがnonisolated
キーワードです。
例えば以下のようなプロパティを特に参照せず処理だけを行うようなoutput
メソッドを定義したとして、このままでは外部から呼び出すときにawait
が必要になってしまいます。
actor Score {
private(set) var value: Int = 0
func output(score: Int) {
print("Score:\(score)")
}
}
そこでnonisolated
を付与することでこのメソッドが隔離対象から除外されるのでawait
がなくても呼び出すことができるようになります。
nonisolated func output(score: Int) {
print("Score:\(score)")
}
nonisolated
は定数let
には付与できますが変数var
には付与することができません。var
に付与できてしまうとActor
を使用するメリット自体が失われるので当然と言えば当然かもしれません。またlet
には付与できますが、そもそもActor
で定義した定数はisolate
されないのでただ明示的に記述するだけになります。
actor Score {
nonisolated let id: Int
nonisolated var category: Int = 0 // 'nonisolated' cannot be applied to mutable stored properties
private(set) var value: Int = 0
/// もちろんisolateされているプロパティを参照しているメソッドはnonisolatedできない
nonisolated func update(score: Int) {
value = score // Actor-isolated property 'value' can not be mutated from a nonisolated contex
print("Score:\(value)") // Actor-isolated property 'value' can not be mutated from a nonisolated contex
}
/// これはOK
nonisolated func output() {
print("ID:\(id)")
}
}
isolated
isolate
されないようにするnonisolated
キーワードと対をなすisolate
するためのisolated
キーワードも存在します。
例えば引数にActor
を受け取るメソッドを定義する際にそのまま実装するとエラーになります。
func calcScore(score1: Score, score2: Score) -> Int {
score1.value + score2.value // Actor-isolated property 'value' can not be referenced from a nonisolated context
}
isolated
キーワードを付与することでエラーを吐かなくなります。isolated
が1つでも付与された関数はisolate
された状態になるため呼び出す際にはawait
が必要になります。
let score1 = Score(id: 0, category: "English", value: 10)
let score2 = Score(id: 0, category: "Mathematics", value: 50)
func calcScore(score1: isolated Score, score2: isolated Score) -> Int {
score1.value + score2.value
}
Task {
print(await calcScore(score1: score1, score2: score2))
}
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。