はじめに
Swift Concurrencyを学習していると、async/awaitやTaskとともにactorというキーワードを目にします。
Actorは、複数のタスクから共有されるデータを安全に扱うための仕組みです。しかし、次のような疑問を持つ方もいるのではないでしょうか。
- Actorは何のために存在するのか?
- 通常の
classとは何が違うのか? - なぜActorのメソッドを呼び出す際に
awaitが必要なのか?
本記事では、これらの疑問を解消できるように、データ競合の基本からActorの使い方、注意点までをコード例を交えて解説します。
1. Actorとは?
Actorとは、Swift Concurrencyで共有された可変状態を安全に扱うための参照型です。
通常のクラスと同様にプロパティやメソッドを定義できますが、Actorには、隔離された状態へのアクセスを制御する仕組みがあります。
これにより、複数のタスクから同じデータにアクセスする際のデータ競合を防ぎやすくなります。
Actorは、actorキーワードを使って定義します。
actor Counter {
private var count = 0
func increment() {
count += 1
}
}
この例では、CounterというActorがカウンターの値を保持しています。
countはCounterの内部で管理されており、privateによって外部から直接アクセスできないようにしています。
では、なぜこのような仕組みが必要なのでしょうか。
まずはデータ競合について確認します。
2. データ競合とは?
データ競合(Data Race)とは、複数の処理が同じメモリ領域に対して、適切な同期なしに競合するアクセス(少なくとも一方が書き込み)を行う状態を指します。
例えば、次のようなクラスを考えます。
final class Counter {
var count = 0
func increment() {
count += 1
}
}
1つのタスクから使う分には問題なく動作します。しかし、複数のタスクから同じインスタンスのincrement()を同時に呼び出すと、更新結果が失われる可能性があります。
count += 1は単純に見えますが、概念的には次の3つの処理を含んでいます。
- 現在の
countを読み取る - 読み取った値に1を加算する
- 加算した値を書き戻す
countが0のとき、2つの処理がほぼ同時に値を読み取ると、両方が0を読み取り、それぞれ1を書き戻す可能性があります。本来は2になるはずの値が1になってしまうわけです。
Swift6とデータ競合
Swift6では、並行処理の安全性チェックが強化され、危険なデータ共有をコンパイル時に検出できるようになっています。
ただし、すべてのデータ競合を無条件に検出できるわけではありません。例えば、@unchecked Sendableを使うと、型の安全性についてプログラマー自身が責任を負うことになります。
実際に壊れる例
実際にデータ競合が発生する可能性のあるコードを見てみましょう。
以下は、Swift5で検証することを想定した例です。
final class UnsafeCounter: @unchecked Sendable {
var count = 0
func increment() {
count += 1
}
}
func runUnsafe() async {
let counter = UnsafeCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask {
counter.increment()
}
}
}
print(counter.count)
}
withTaskGroupを使い、1000個の子タスクから同じカウンターを更新しています。
このコードでは、複数のタスクが同期なしにcountを読み書きするため、データ競合が発生する可能性があります。実行結果が1000未満になる場合もありますが、必ず発生するわけではありません。
ここで登場する@unchecked Sendableについて説明します。
Sendableは、並行実行の境界を越えて値を安全に受け渡せることを表す仕組みです。@unchecked Sendableを付けると、コンパイラによる安全性の検証を一部省略し、プログラマーがその型の安全性を保証することになります。
今回はデータ競合の仕組みを説明するために使用していますが、実際のコードで安易に使用するべきではありません。
Actorで解決する
同じ処理をActorで実装すると、次のようになります。
actor Counter {
private var count = 0
func increment() {
count += 1
}
func currentCount() -> Int {
count
}
}
func runSafe() async {
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask {
await counter.increment()
}
}
}
print(await counter.currentCount()) // 1000
}
Actorは、隔離された状態へのアクセスを制御し、同じActorの処理が同時にその状態を変更しないようにします。
この例では、increment()の処理がそれぞれ完結するため、1000回の加算が正しく反映され、結果は1000になります。
このように、Actorを利用することで、共有された可変状態を安全に管理できます。
3. classとactorの違い
classをactorに変更すると、管理している状態へのアクセスがActor Isolation(アクター隔離)によって保護されます。
Actor Isolationとは、Actorが管理する状態に対して、どこからどのようにアクセスできるかを制御する仕組みです。
主な違いを表にまとめます。
| 項目 | class |
actor |
|---|---|---|
| 型の種類 | 参照型 | 参照型 |
| 状態へのアクセス制御 | 自動では同期されない | Actor Isolationによって保護される |
| 外部からの隔離されたメソッド呼び出し | 通常は同期呼び出し | 原則awaitが必要 |
| クラス継承 | 可能 | 不可 |
Sendable |
条件を満たせば準拠可能 | 暗黙的に準拠 |
| 主な用途 | 一般的なオブジェクト管理 | 共有状態の安全な管理 |
classでも、ロックなどを利用して自分で排他制御を実装すれば、安全に共有状態を管理できます。
一方、Actorでは、言語が提供する隔離の仕組みを利用して、共有状態へのアクセスを安全に管理できます。
ただし、Actorを使えばすべての処理が自動的に非同期になるわけではありません。
Actorの内部では、自身の隔離されたプロパティや同期メソッドに通常どおりアクセスできます。一方、Actorの外部から隔離された状態にアクセスする場合は、非同期の呼び出しが必要になります。
4. Actorの使い方
Actorのインスタンスは、通常のクラスと同様に生成できます。
let counter = Counter()
メソッドを呼び出すときは、awaitを付けます。
Task {
await counter.increment()
let count = await counter.currentCount()
print(count) // 1
}
Actorの外部から隔離されたメソッドを呼び出す場合、原則としてawaitが必要です。
increment()だけでなく、値を取得するcurrentCount()にも必要です。これは、Actorの状態にアクセスするために、呼び出し元の処理が中断される可能性があるためです。
なお、Taskは非同期処理を開始するために使用しています。すでにasync関数の中にいる場合は、新しいTaskを作成する必要はありません。
例えば、次のように呼び出すこともできます。
func incrementCounter(_ counter: Counter) async {
await counter.increment()
}
5. なぜawaitが必要なのか?
次のコードを考えてみます。
func printCount(counter: Counter) {
let count = counter.currentCount() // コンパイルエラー
print(count)
}
このコードはコンパイルできません。
currentCount()はActorに隔離されたメソッドなので、外部から呼び出す際には非同期の呼び出しが必要です。
関数をasyncに変更し、呼び出しにawaitを付けると解決します。
func printCount(counter: Counter) async {
let count = await counter.currentCount()
print(count)
}
awaitは、呼び出し先のActorが他の処理を実行中であれば、呼び出し元が中断される可能性があることを示しています。必ず実際に中断されるわけではありません。
また、Actorの内部から自身の隔離された同期メソッドを呼び出す場合は、通常awaitは不要です。ただし、asyncメソッドを呼び出す場合は、Actorの内外を問わずawaitが必要です。
このように、awaitは「ここで中断される可能性がある」という目印であり、Actorの隔離境界を越えてアクセスする際にも重要な役割を持っています。
6. Actorを使う際の注意点
Actorはデータ競合を防ぐために便利ですが、すべての問題を自動的に解決するわけではありません。
特に覚えておきたいポイントを2つ紹介します。
6.1 Actorは特定のスレッドではない
Actorは、特定のスレッドを占有して処理を実行する仕組みではありません。Actorの役割は、隔離された状態へのアクセスを制御することです。
そのため、通常のActorのメソッドが毎回同じスレッドで実行される保証はなく、awaitの前後でスレッドが変わることもあります。
UIの更新など、メインアクターで隔離された処理が必要な場合は、@MainActorを使用します。
@MainActor
final class ViewModel {
var title = ""
func updateTitle() {
title = "更新しました"
}
}
@MainActorは、宣言をメインアクターに隔離するための仕組みです。通常のActorと同様に、状態へのアクセスを制御します。
6.2 awaitをまたぐ処理には注意する(Reentrancy)
Actorのメソッドがawaitで中断されている間、同じActorの別の処理が実行されることがあります。これをReentrancy(再入可能性)と呼びます。
actor Example {
var value = 0
func update() async throws {
value = 1
try await Task.sleep(for: .seconds(1))
print(value) // 1とは限らない
}
func reset() {
value = 0
}
}
update()がTask.sleepで中断されている間に、別のタスクからreset()が呼ばれると、valueは0に書き換えられます。
そのため、print(value)の時点で値が1であるとは限りません。
Actorは隔離された状態への同時アクセスを防ぎますが、awaitをまたぐ処理全体の整合性までは保証しません。
対策としては、awaitの後に前提条件を再確認する、awaitをまたがずに状態の更新を完結させる、といった方法が考えられます。
7. まとめ
本記事では、SwiftのActorについて、基本的な仕組みと使い方を解説しました。
- Actorは、共有された可変状態を安全に扱うための参照型
-
classと異なり、状態へのアクセスがActor Isolationで保護される - 外部から隔離された状態にアクセスする場合、原則として
awaitが必要 - Actorは特定のスレッドを占有する仕組みではない
-
awaitをまたぐ処理では、Reentrancyを考慮する必要がある
Actorは、Swift Concurrencyにおいてデータ競合を防ぐための重要な仕組みです。
まずは簡単なサンプルコードで、classとActorの違いを実際に確認してみてください。
次のステップとして、@MainActorやSendable、nonisolatedについて学ぶと、Swift Concurrencyの理解がさらに深まります。