Swift Concurrency連載第2回の主役はactorです。classでもstructでもない、4番目の型が言語に追加されました。
新しい型が必要になるほど深刻な問題がありました。その問題、データ競合から正確に見ていきましょう。
データ競合 — 最も厄介なバグが生まれる条件
データ競合(data race)の条件は明確です。2つ以上のスレッドが同じメモリに同時にアクセスし、そのうち少なくとも1つが書き込みを行うことです。
この条件が成立すると、結果は未定義動作です。値が壊れたり、クラッシュしたり、何事もなく通過したりします。
どの結果になるかは、その日のスケジューリング次第です。
final class Counter {
var value = 0
func increment() { value += 1 } // 読み取り-加算-書き込み, 3の手順
}
// 2つのスレッドが同時に increment()を呼ぶと
// 1000回呼び出しても、 valueが 1000とは限りません
value += 1はアトミックではありません。読み取り、加算、書き込みの3段階の間に別のスレッドが割り込むと、増分が消えます。
このバグが厄介なのは再現できないことです。タイミングが合ったときだけ発生するためテストは通り、リリース後に断続的なクラッシュレポートとして戻ってきます。
値型の回で「値型は共有されないため、競合の前提自体がなくなる」と説明しました。残るのは共有を目的とする参照型です。
従来の解決策はロックでした。ロック、セマフォ、直列DispatchQueueです。
どれも動きますが、共通の弱点があります。守れるかどうかが開発者の規律に全面的に依存することです。
1か所でもロック取得を忘れれば終わりで、コンパイラはそのミスを見つけられません。
よく聞く構図ですよね。オプショナル編のnilチェックやARC編の循環参照と同じく、Swiftは「規律に任されていたもの」を型システムへ引き上げる言語です。
次はデータ競合の番です。
actor — 自分の状態を守る型
一言で言えば、actorは「状態保護を内蔵した参照型」です。
actor Counter {
var value = 0
func increment() { value += 1 }
}
classをactorに変えるだけで、次の保証が得られます。この型の保存プロパティには、一度に1つの実行だけがアクセスできます。
各actorには直列実行器(serial executor)が付き、メソッド呼び出しは並び、一つずつ処理されます。incrementの3段階の間に別の呼び出しが割り込めないため、競合は成立しません。
重要なのは、これをコンパイラが強制する点です。actorの外から内部の状態やメソッドへアクセスするコードは、awaitを付けなければコンパイルできません。
let counter = Counter()
await counter.increment() // 外部では await が必須
print(await counter.value)
awaitが必要なのは、第1回で説明したsuspensionと同じ理由です。actorが別の仕事を処理中ならリクエストは列に並び、待っている間はスレッドを占有せず譲ります。
ロックのようにスレッドを眠らせて待つのではありません。関数が中断し、順番が来ると再開します。
一方、actor内部のコード同士はawaitなしで自由にアクセスできます。すでに隔離の内側だからです。
この「内側か外側か」の境界が、第1回で紹介したisolationの実体です。
@MainActorはこの概念のグローバル版です。「メインスレッド」という1つの直列コンテキストをグローバルactorにしたもので、UI状態の隔離に使います。
原理はカスタムactorと同じです。
reentrancy — actor最大の落とし穴
ここまで聞くとactorは万能に見えますが、設計上の落とし穴が1つあります。actorは再入性(reentrant)を許します。
actorメソッド内でawaitに出会うと、そのメソッドは中断し、actorは空き状態になります。その間に別の呼び出しがactorへ入り、実行できます。
元のメソッドが再開したとき、actorの状態は中断前と異なる可能性があります。
actor ImageCache {
var cache: [URL: Image] = [:]
func image(for url: URL) async -> Image {
if let cached = cache[url] { return cached }
let image = await download(url) // 中断ポイント — この間に別の呼び出しが入れます
cache[url] = image // 同じ URLがすでに保存されている可能性があります
return image
}
}
同じURLへのリクエストがほぼ同時に2つ来ると、両方がキャッシュミスを確認し、両方がダウンロードします。データは壊れません(actorが防ぎます)が、処理は重複して実行されます。
重要なルールにまとめると、actorは低レベルのデータ競合を防ぎますが、awaitをまたぐ論理的整合性は守りません。
awaitの前後で状態の前提が有効かを再確認するのは、今も設計者の役割です。上の例なら「進行中のダウンロードTaskをキャッシュに保存」するパターンで重複実行を防ぎます。
なぜこの設計なのでしょうか。再入性を禁止すると、actorはawait中も入口をロックする必要があり、互いに待つ2つのactorが永遠に停止するデッドロック(deadlock)が起こり得ます。
Swiftはデッドロックのないシステムを選び、論理的整合性は開発者に委ねました。トレードオフの方向を知っていれば、落とし穴を予測できます。
実務での配置 — どこにactorを使うか
actorが適するのは明確です。複数の非同期コンテキストが共有する可変状態の所有者です。
キャッシュ、コネクションプール、ダウンロードマネージャー、セッションストレージなどです。「この状態を誰が守るのか」という答えが必要な場所では、actorが候補になります。
逆に、適さない場所も明確です。1つ目は共有されない状態です。
1つの画面内だけで使うビューモデルなら、@MainActorクラスが適切です。カスタムactorにすると、UIアクセスのたびに不要なawaitが増えるだけです。
2つ目は不変データです。そもそも競合がないため、structやletで十分です。値型優先の考え方そのものです。
3つ目は呼び出し頻度が極端に高いホットパスです。actor境界を越える呼び出しには直列化コストがあるため、毎秒数十万回呼ぶ場所なら設計を見直すサインです。
バランスのためにもう1つ。単純なアトミックカウンターやフラグ1つを守るだけなら、actorは過剰かもしれません。
そのような場所では、Mutex(Swift 6のSynchronizationモジュール)のような低レベルツールの方が軽く、awaitも不要です。actorは「守る状態と、それを扱うロジックが一体」になっているときに力を発揮します。
まとめ
- データ競合は「同時アクセス+1つ以上の書き込み」で成立する未定義動作で、再現しにくいため最も厄介です。ロックは動きますが、規律に依存します。
- actorは状態保護を内蔵した参照型です。直列実行器がアクセスを順番待ちにし、外部からのアクセスにはコンパイラがawaitを強制します。
- @MainActorはメインスレッドをグローバルactorにしたもので、UI隔離の標準です。
- actorは再入性を許します。awaitの前後で状態が変わり得るため、低レベルの競合防止とは別に、論理的整合性を自分で守る必要があります。
- 適した場所は「共有可変状態の所有者」です。非共有状態、不変データ、極端なホットパスには過剰なツールです。
次回はこの隔離体系の最後のピース、Sendableを扱います。「隔離境界を越えても安全な型」とは何か、そしてSwift 6のstrict concurrencyが既存コードに出す警告をどう読むかを解説します。

![[Swift上級 #2] Swift actor完全解説:データ競合を防ぐ方法のカバー画像](/assets/images/posts/d7809f68-f42a-4283-a270-2c25867c9087/swift-actor-1.jpg)