
Swift 6 対応の前にーー用語・定義まとめ
はじめに
Xcode 16 がリリースされ、いよいよ Swift 6 の時代が到来しました。早速対応の準備をしようと記事を読み始めたところ……
「actor 隔離って何だろう?」
「Task はどうして必要なのか?」
「nonisolated はいつ使うの?」
このように、最初は混乱していました。Swift 6 対応の記事を読むと、「Swift Concurrency」という言葉が頻出し、この二つの関係がわからないまま、見慣れない用語の数々に戸惑っていました。
実は、Swift 6 では並行処理の安全性が言語レベルで強制されるようになります。つまり、Swift 6 対応とは、実質的に「Swift Concurrency」への対応を意味します。これを理解していないと、コンパイラが出す大量のエラーに頭を悩ませることになるでしょう。
チームで勉強会を開いても内容が断片的で理解が進まず、「基礎から整理する必要がある」と考えたことが、この記事を書くきっかけとなりました。
本記事では、Swift Concurrency の基本から、重要な用語の意味、実際の使い方までをわかりやすく解説します:
- Swift Concurrency の基本概念
- actor とデータ競合の防止
- Task とその使い方
- Sendable プロトコルの説明
- async/await の仕組み
Swift 6 への対応を進める際の参考になれば幸いです。
Swift Concurrency
Swift Concurrency は、Swift 5.5 で導入された新しい並行処理モデルである。
これにより、より安全な非同期処理が実現し、コードの記述もより簡潔で理解しやすくなった。
Swift 6 対応の記事でよく見かける actor
、Task
、async/await
構文は、このモデルから生まれた概念である。
「Concurrency」とは、「並行性」や「同時性」を意味する。
発表当初は iOS 15 以上でのみ利用可能であったが、Xcode 13.2 以降では iOS 13 以上でも使用できるようになった。
Swift 6 では非同期処理の安全性が言語レベルでチェックされ、データ競合(複数の処理が同時に同じデータにアクセスして問題が発生する状態)が発生する可能性のあるコードは、エラーとして扱われるようになる。
つまり、Swift 6 に対応することは、実質的に Swift Concurrency の安全性モデルに準拠することを意味する。
データ競合 / Data Race
データ競合とは、複数のスレッドが同じメモリ上のデータに対して同時に操作する状況を指す。具体的には、以下の3つの条件がすべて揃ったときに発生する:
- 複数のスレッドが同時に実行されている
- 少なくとも1つのスレッドがデータに書き込みを行っている
- 同じメモリ領域(変数やオブジェクト)にアクセスしている
データ競合が発生すると、以下のような問題が生じるリスクがある:
- 予測できない結果になる(どの書き込みが最終的に残るか分からない)
- アプリがクラッシュする可能性がある
- 再現性が低く、デバッグが難しい問題になる
- 環境やタイミングによって動作が変わるため、テストでも検出しづらい
データ競合の防止は Swift Concurrency の重要な目標の一つであり、アクターの導入はこの問題を解決するための中心的な機能である。
データ競合と競合状態の違い
似た言葉に「競合状態」があるが、これは少し違う概念である。
- データ競合
- 同じメモリ位置への同時アクセス(読み書き)の問題
- メモリレベルの低レベルな問題
- 競合状態
- スレッドの実行順序が不確定なため、同じ入力でも異なる結果になる状態
- ロジックレベルの問題
アクターはデータ競合を防止できるが、設計によっては競合状態が発生する可能性がある。
actor
アクターは、並行処理環境においてデータ競合からデータの安全性を守るための仕組みである。
アクターの特徴
- 参照型である
- シリアルエグゼキュータ(serial executor)を持つ
- 各アクターのインスタンスは独自のシリアルエグゼキュータを持つ
- 同じ型のアクターであっても、異なるインスタンスは別々のエグゼキュータを持つ
- 排他制御を行う
- アクター内のメソッドが呼ばれると、その処理はシリアルエグゼキュータに追加され、一度に一つの処理のみが順番に実行される
- これにより、複数の処理が同時に同じデータを変更する危険性を防ぐ
排他制御によるアクセス制限
外部かアクターに隔離されたメソッドやプロパティへアクセスする場合、その呼び出しは自動的にasync
(非同期)となる。これは、排他制御により「アクターが他の処理を実行中であれば、完了するまで待機する必要がある」ためである。
プロパティの制限
ただし、プロパティには特別なルールがある:
- getter(値の取得)は
async
にできる - setter(値の設定)は
async
にできない - そのため、外部からは読み取り専用(read-only)となる
コード例
開発者は自身でactor
を定義できる。
// class, struct のように actor キーワードで定義できる
actor MyActor {
private var count = 0
func counting(number: Int) {
let currentCount = count
// 実際の処理の前に少し遅延を入れて競合条件を再現しやすくする
Thread.sleep(forTimeInterval: 1.0)
count = currentCount + number
}
func getCount() -> Int {
return count
}
}
func testSafeCount() async {
let myActor = MyActor()
// 複数のタスクを同時に実行しても常にcounting1->counting2の順番で実行される
async let counting1 = Task {
for _ in 1...5 {
await myActor.counting(number: 100)
}
}
async let counting2 = Task {
for _ in 1...5 {
await myActor.counting(number: 100)
}
}
// すべてのタスクが完了するのを待つ
_ = await [counting1.value, counting2.value]
// 結果は常に1000
// class で実装すると、データ競合により 500 になることがある
let result = await myActor.getCount()
print(result)
}
エグゼキュータ / executor
エグゼキュータは、非同期のタスクを実際に実行するオブジェクトである。
エグゼキュータは以下を決定する:
- どこで:どのスレッドやコンテキストで実行されるか
- どのように:どのような順序や優先度で実行されるか
シリアルエグゼキュータ
シリアルエグゼキュータ(Serial Executor)は、エグゼキュータの一種で、次のような特徴がある:
- タスクを一度に一つずつ実行する
- タスクは順番通りに処理される
- 前のタスクが終わるまで次のタスクは待機する
アクターとシリアルエグゼキュータの関係
actor
(アクター)は内部にシリアルエグゼキュータを持ち、これにより一度に一つの処理を実行する仕組みを実現している。
- 各アクターインスタンスは独自のシリアルエグゼキュータを持つ
- これにより、アクター内の処理は一度に一つずつ実行される
- 結果として内部データへの同時アクセスが防止される
シリアルエグゼキュータは概念的にDispatchQueue
に似ているが、メインアクター以外のシリアルエグゼキュータがDispatchQueue
を用いて実装されているわけではない。
その他のエグゼキュータ
シリアルエグゼキュータの他に、グローバル並列エグゼキュータ(Global Concurrent Executor)も存在する。次のような特徴がある:
- 複数のタスクを同時に実行できる
- システムリソースに応じて並列度が調整される
- 特定のアクターに紐づかないタスクの実行に使われる
グローバルアクター / GlobalActor
グローバルアクターは、アプリケーション全体で一つのインスタンスのみ存在するアクターである。通常のアクターが複数のインスタンスを持てるのに対し、グローバルアクターはプロセス内で唯一のインスタンスしか持てない。
グローバルアクターは次のような特徴がある:
- 一つのエグゼキュータ: アプリ全体で一つのエグゼキュータを持つ
- 共有実行コンテキスト: このアクターに関連付けられたすべてのコードは同じ実行コンテキストで動く
- 属性(attribute)として使用:
@xxxActor
という形で他のコードに付与できる
属性(attribute)としての使い方
// クラス全体をMainActorに関連付ける
@MainActor
class MyViewController: UIViewController {
// このクラスのすべてのメソッドはメインアクターのエグゼキュータで実行される
}
// 特定のメソッドだけに適用することも可能
class DataManager {
func loadData() async -> [Data] {
// グローバル並列エグゼキュータ(Global Concurrent Executor)で実行される処理
}
@MainActor
func updateUI(with data: [Data]) {
// メインアクターのエグゼキュータで実行される処理
}
}
よく使われるグローバルアクター
- @MainActor: UIやUIKitの操作など、メインスレッドで実行する必要があるコードに使用
- @ModelActor: SwiftDataで使われる、データモデルへのアクセスを管理するアクター
独自のグローバルアクターを定義
開発者が独自のGlobalActor
を定義することができる。
@globalActor
actor MyGlobalActor {
static let shared = MyGlobalActor()
}
// 使用例
@MyGlobalActor
class GlobalActorClass {
// このクラスのすべてのメソッドはMyGlobalActorのエグゼキュータで実行される
}
// 個別のメソッドにも適用可能
class TrackingManager {
@MyGlobalActor
func track(_ event: String) {
// MyGlobalActorのエグゼキュータで実行される
}
}
メインアクター / MainActor
基本説明
メインアクターは、Swift Concurrency で提供される特別なグローバルアクターである。UIの更新など、メインスレッドで実行する必要がある処理を安全に扱うために使われる。
現時点では、メインアクターは内部でDispatchQueue.main
をラップしたカスタムエグゼキュータを使っている。そのため、メインアクターのエグゼキュータで実行されるコードは、メインスレッド上で実行される。
Swift Concurrency の考え方
Swift Concurrency の設計には以下のよう考え方がある:
- 「スレッド」という低レベルの概念から開発者を解放すること
- 代わりに「エグゼキュータ」「アクター」「タスク」といった高レベルの概念で考えることを可能にする
従来のコード:
// スレッドを直接意識したコード
DispatchQueue.main.async {
self.updateUI()
}
Swift Concurrencyのコード:
// エグゼキュータを意識したコード
await MainActor.run {
updateUI()
}
将来の方向性
Swift 言語全体の進む方向性として、SwiftUI の導入(2019 年)以降、Apple が宣言的 UI プログラミングへ舵を切ったことが明らかである。
Swift Concurrency は、その概念を並行処理の領域へ拡張したものである。Swift 言語は徐々に、スレッド管理などの低レベルな詳細から開発者を解放し、より意図や目的に基づいた高レベルなプログラミングモデルへと進化していくと考えられる。
現時点では「メインアクターのエグゼキュータで実行される」ことと「メインスレッドで実行される」ことは同じ意味であるが、将来的にはその内部実装が変更される可能性もある。
そのため、Swift Concurrency のコードを書くときはスレッドではなく、アクターやエグゼキュータの概念で考えることが重要である。これらの概念に慣れておくことで、将来的な仕様変更やさらなる移行にもスムーズに対応できるようになるであろう。
タスク / Task
Swift Concurrencyでは、非同期処理(async関数や非同期クロージャ)を実行する単位を「タスク」と呼ぶ。非同期の処理はすべて、何らかのタスクの一部として実行される。
Task API の定義
Task
は構造体であり、次のように定義されている:
@frozen
struct Task<Success, Failure> where Success : Sendable, Failure : Error
Success
: タスクが成功したときに返す値の型Failure
: タスクがエラーを投げるときの型Sendable
: スレッド間で安全に受け渡しできることを示すプロトコル
タスクの作成
よく使用されるタスクの作成方法はTask.init
である。
init(
priority: TaskPriority? = nil,
operation: sending @escaping @isolated(any) () async -> Success
)
Task.init
は、定義された位置のアクターのコンテキスト(どのアクターであり、どのスレッド上で動作するかなどの情報)を引き継ぐ。
一方、コンテキストを引き継がずに実行したい場合はTask.detached
を使用する。
Task.init
のほかにタスクをグループ化するTaskGroup
も存在する。
コード例
非同期関数は同期処理から直接呼び出すことができないため、通常はTask.init
や Task.detached
などのタスク内で実行する。
@MainActor
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// // コンテキストを引き継ぐので、MainActorのエグゼキュータ(現在の実装ではメインスレッド)で実行される
Task {
let size = await getSize()
}
// コンテキストを引き継がないので、グローバル並列エグゼキュータ(現在の実装ではバックグラウンドスレッド)で実行される
Task.detached {
let response = await getItems(limit: 100)
}
}
}
Sendable
Sendable
プロトコルは、並行実行(Concurrency)環境で「この型のデータは異なるスレッド間で安全に受け渡しできる」ことを示すマーカープロトコルである。
ただし、Sendable
への準拠だけでは、そのデータに対するすべての操作のスレッドセーフ性は保証されない。例えば、var
で定義された値型のプロパティは、複数箇所から同時変更される可能性がある。
準拠条件
Sendable
への準拠条件は、値型と参照型で異なる:
- 値型
- すべてのプロパティが
Sendable
に準拠している型、またはすべてのプロパティが不変(let)であること
- すべてのプロパティが
- 参照型
final
であること(継承できない)- スーパークラスがない、またはスーパークラスが
NSObject
であること - すべての保存プロパティ(Stored Properties)が
Sendable
に準拠している型、かつ不変(let)であること
// Sendableに準拠するクラス
final class SendableClass: Sendable {
let id: Int
let name: String
init(id: Int, name: String) {
self.id = id
self.name = name
}
}
Sendableでない型の制限
Sendable
に準拠していない型のインスタンスは以下の制限がある:
- アクター間で直接共有できない
- 非同期タスク境界を越えて送信できない
// Sendableに準拠していないクラス
class NonSendableCounter {
var count = 0
func increment() {
count += 1
}
}
actor CounterManager {
func processCounter(_ counter: NonSendableCounter) {
counter.increment()
}
func demonstrateActorSharing() {
let counter = NonSendableCounter()
let manager = CounterManager()
// コンパイルエラー:
// Sending 'counter' risks causing data races
Task {
await manager.processCounter(counter)
}
}
}
隔離 / isolated
Swift Concurrencyモデルにおける「隔離」とは、アクターによって外部からの直接アクセスから保護されている、つまり外部から隔離されている状態を指す。
アクター内に定義されているプロパティやメソッドは、デフォルトで隔離状態になっている。これにより、以下のような特徴がある:
- 外部からアクセスするには
await
キーワードを使った非同期呼び出しが必要 - アクター内部からは通常通りアクセス可能
- 一度に1つの処理のみが実行される(シリアルエグゼキュータによる制御)
デフォルトで隔離状態にあるため、通常は isolated
キーワードを明示的に記述する必要はない。
アクターの隔離機能により、複雑なロックやスレッド管理を開発者自身で実装する必要がなくなる。
非隔離 / nonisolated
非隔離(nonisolated)とは、アクターの隔離保証の対象外となる状態を指す。
nonisolated
キーワードを付与することで、該当プロパティーやメソッドはアクターの並列処理の制御対象外になる。これにより、以下のような状態になる:
- アクセス制限
await
キーワードなしで、同期的に外部からアクセス可能になる- アクター内部に隔離された可変データにアクセス不可になる
- 実行コンテキスト
- アクターのシリアルエグゼキュータ上では実行されない
- 呼び出し元のエグゼキュータ上、または適切なグローバルエグゼキュータ上で実行される
アクターの隔離保証の対象外となるため、内部で共有状態を変更する場合は開発者自身がスレッドセーフ性を確保する必要がある。
actor DataManager {
private var data: [String] = []
private let version = "1.1.0"
// 隔離メソッド: 変数にアクセスできる
func addData(_ item: String) {
data.append(item)
}
// 非隔離メソッド: 定数にはアクセスできる
nonisolated func getVersion() -> String {
return version
}
// 非隔離メソッド: 変数にアクセスできない
nonisolated func getData() -> [String] {
// コンパイルエラー:
// Actor-isolated property 'data' can not be referenced from a nonisolated context
return data
}
}
注意点
英語のドキュメントや記事を読む際は、「nonisolated」という用語の文脈に注意が必要である。この言葉は、単に「アクターの保護がない安全でない状態」を表す形容詞として使われる場合と、Swift言語のキーワード nonisolated
を指す場合がある。
中断 / suspense
Swift Concurrencyにおける「中断」(suspense)とは、非同期関数の実行を一時的に停止し、その処理が使用していたスレッドを解放して、他の処理に使えるようにする仕組みである。
中断点
await
キーワードは中断点(suspension point)を示す。この地点で、非同期関数は一時停止する可能性がある。
await
の右側の処理はその時点で空いているスレッド上で実行され、処理が完了すると、元の地点から処理が再開される。
func loadUserData() async throws -> UserData {
// 1. ここから処理開始
print("処理開始")
// 2. ここで中断される可能性がある
// fetchUserInfo()は空いているスレッドで実行される
let userInfo = try await fetchUserInfo()
// 3. fetchUserInfo()が完了したら、結果がuserInfoに代入され、ここから再開
// (元と同じスレッドとは限らない)
print("ユーザー情報取得完了")
// 4. ここでも中断される可能性がある
let preferences = try await fetchUserPreferences()
// 5. fetchUserPreferences()が完了したら、結果がpreferencesに代入され、ここから再開
return UserData(info: userInfo, preferences: preferences)
}
スレッドの解放
中断が発生すると以下の状況が発生する:
- 現在使用しているスレッドが解放される
- 解放されたスレッドは他の処理(別のタスクなど)に使われる
これにより、システムのスレッド使用効率が向上し、少ないスレッドで多くの非同期処理を扱うことが可能となる。
再開時の注意点
中断された非同期関数が再開されるとき、元と同じスレッドで実行される保証はない。
ただし、同じエグゼキュータ上での実行が保証されている。
async
asyncは関数が非同期処理を実行することを示す属性である。
async
キーワードを付与することにより、以下のことが可能となる:
- この関数は内部で処理を中断(suspend)する可能性があることを、コンパイラとランタイムに伝える
- 中断の仕組みを利用できるようになる
await
await
キーワードは、非同期関数内で実行が一時的に中断される可能性があるタイミングを示す。
実際に中断が発生するかどうかはランタイムが決定する。最適化により中断せずに実行される場合もある。
中断されている間、そのスレッドは他の処理の実行に使用できる。await
の右側の非同期処理が完了すると、中断されていた関数の実行が再開される。
awaitの使用条件
await
キーワードは非同期コンテキスト内でのみ使用可能である。具体的には、以下のようなケースに該当する:
async
とマークされた関数やメソッド内Task.init
、Task.detached
などのタスク作成API内のクロージャasync let
バインディング内
参考資料
記事・ブログ
公式資料