はじめに
Xcodeに怒られたから、よくわからないけど@MainActorをつけた、もしくは非同期だからとりあえずasync/awaitを書いて「動いてるからまぁいっか」で終わらせた経験ありませんか?
正直、私はあります。良くないとは思いつつ根本理解を放置していました。
が、エラー監視ツールで飛んできた「File IO on Main Thread」というエラー修正に取り組む中で、メインスレッドやバックグラウンド処理について根本から理解をしようという気持ちになりました。
今回はそこでの学びを整理しながら、メインスレッド・GCD・Swift Concurrencyの関係と使い分けを、「自分で判断できる」ことを目標に記事を書きたいと思います!
対象読者
-
async/awaitや@MainActorを実務で使ってはいるが、仕組みがモヤモヤしている方 - Xcodeのコンパイルエラーに言われるがまま
@MainActorをつけたことがある方 - GCDとSwift Concurrencyの使い分けに自信がない方
そもそもメインスレッドとは?
iOSアプリを起動すると、OSはそのアプリにメインスレッドという1本の通り道を提供します。
そして、このメインスレッドが画面の描画やタップの受け取りといったUIの更新を一手に担っているというわけです。
Q.なぜUIの更新はメインスレッドでしかできないのか?
→SwiftUIやUIKitの描画の仕組み(RunLoop)がメインスレッドで動いているから。もし複数のスレッドから同時にUIをいじってしまうと、データが壊れるなど最悪クラッシュにも繋がる。
- UI の更新 → メインスレッドで
- 重い処理(通信・画像処理など) → メインスレッドの外で
こういうシンプルなルールで実装をしていけばいいわけです。
では、「メインスレッドの外で」処理をするためにどうしたらいいか、次章からお話ししていきます。
GCD(Grand Central Dispatch)
GCDはAppleが長らく提供してきたスレッド管理の仕組みです。
一言でいうと、「この処理をどのキューで実行してほしいか」を開発者が指定をする方式で、キューにタスクを入れると、裏側でGCDがスレッドを割り当てて実行してくれます。
Q.キューってなんだっけ?
→一時的にデータを格納するデータ構造のこと。LIFO(Last-In-First-Out)なスタックとは違い、FIFO(First-In-First-Out)方式で、行列のように先に並んだタスクから順に処理される。
よく使われるキューとしては、
-
DispatchQueue.main— メインスレッド上のキュー、UI 更新はここで行う -
DispatchQueue.global(qos:)— バックグラウンド用のキュー、重い処理はここで行う
この2つを押さえておけば大丈夫です。
DispatchQueue.global(qos: .userInitiated).async {
// バックグラウンドで重い処理
let result = processData()
DispatchQueue.main.async {
// メインスレッドに戻して UI を更新
self.label.text = result
}
}
上記のように、「バックグラウンドで処理して、終わったらメインに戻す」というのが典型的なGCDコードのパターンです。
...が、GCDには構造的な辛さがあります。
- コールバックが入れ子になって読みにくい
- タスクのキャンセル機構がないので自前で管理が必要
- データ競合をコンパイラが検出できない
特に3つ目は、手元で再現しなくても本番でたまにクラッシュをするといったバグにつながります。こうした課題を解決するために生まれたのが、Swift Concurrencyです。
Swift Concurrency
「どのキューで実行するかを手動で指定する」GCDとは違い、Swift Concurrencyは「どのスレッドを割り当てるかはSwiftのランタイムが自動でやってくれる」仕組みです。
「この処理は時間がかかるから、終わるまで待ってね」ということをasync/awaitで宣言することでSwift Concurrencyを使うことができます。
GCDとSwift Concurrencyのコードの違いを見比べてみます。
// ---- GCD 版 ----
func loadData() {
DispatchQueue.global().async {
let result = self.processData()
DispatchQueue.main.async {
self.label.text = result
}
}
}
// ---- async/await 版 ----
func loadData() async {
let result = await processData()
label.text = result
}
下のコードのほうが、ネストがなく上から下に自然と読めるようになっているのがわかるかと思います。
やっていることはどちらも「重い処理を待って、UIを結果で更新する」で、違いはありませんが、async/awaitを使えばまるで同期処理のように書けて読みやすいのが魅力です。
また大事なポイントとして、awaitはスレッドをブロックしません。awaitは処理を「中断」するだけで、スレッド自体は解放されて他の仕事に使い回されます。GCDでもasyncを使えばスレッドをブロックせずにタスクを投げられますが、その場合は結果をコールバックで受け取る必要がありました。awaitはコールバックなしで「中断 → 再開」を実現できるのが大きな違いです。
Task
async関数は非同期のコンテキストからしか呼び出せません。ボタンタップのような同期コンテキストからはTaskを使って呼び出すことができます。
// ボタンタップなどの同期コンテキストから
Task {
let data = await fetchUser()
nameLabel.text = data.name
}
Taskは「ここからは非同期の世界に入るよ」という入り口だと思えばわかりやすいです。
押さえておきたいTaskの性質としては、
Task { }は作られた場所のActorを引き継ぐ
たとえば@MainActorがついたクラスの中でTaskを書くと、その中のコードもメインスレッドで実行されます。なので、上のコードでわざわざメインスレッドに戻す処理を書かなくても安全にUI更新ができるというわけです。
Task.detached { }は引き継がない
こちらは呼び出し元がメインスレッドだったとしても、バックグラウンドで実行されます。「完全に独立した処理」を走らせたいという場合でなければ、基本的には普通のTask { }を使うのが安全です。
ですが、detachedを使うべき場面もあります。たとえば、@MainActorなクラスでファイルIOのような重い処理を行う場合です。
// @MainActorなクラス内のメソッド
private func prefetchContent(url: URL) {
let key = url.absoluteString
guard !processingKeys.contains(key) else { return }
processingKeys.insert(key)
// Task { } だとメインスレッドで実行されてしまう
// ファイルI/Oをバックグラウンドで行うため、意図的にdetachedを使う
Task.detached(priority: .userInitiated) {
guard !cache.exists(forKey: key) else { return }
let data = await self.download(from: url)
guard let data else { return }
try? cache.store(data, forKey: key)
}
}
ここでTask { }を使ってしまうと、@MainActorの引き継ぎによってキャッシュの存在チェックやファイル保存がメインスレッドで走ってしまいます。そうならないために、意図的にTask.detachedによって切り離しているわけです。
基本的にはTask { }、@MainActor内で重い処理をバックグラウンドに逃がしたいときだけTask.detachedと覚えておけば大丈夫だと思います。
async let
たとえば「ユーザー情報の取得」と「アイコン画像の取得」という複数の処理を同時に進めたい場合、GCDとSwift Concurrencyのasync letで実装がどう変わるかを比較してみます。
// ---- GCD 版(DispatchGroup)----
func loadProfile() {
let group = DispatchGroup()
var userData: User?
var iconImage: UIImage?
group.enter()
fetchUser { result in
userData = result
group.leave()
}
group.enter()
fetchIcon { result in
iconImage = result
group.leave()
}
group.notify(queue: .main) {
guard let user = userData, let icon = iconImage else { return }
self.display(user: user, icon: icon)
}
}
// ---- async let 版 ----
func loadProfile() async throws {
async let user = fetchUser()
async let icon = fetchIcon()
let (userData, iconImage) = try await (user, icon)
display(user: userData, icon: iconImage)
}
async letで宣言した時点で処理が開始され、awaitで結果を受け取ります。エラーが起きたらtryで自然にキャッチできますし、片方が失敗したらもう片方も自動でキャンセルされます。
GCD版ではコードが冗長になったり、エラーハンドリングやキャンセル処理も全部自前で書く必要がありましたが、async letであればかなりシンプルに書くことができます。
Actor
GCDの章で「データ競合をコンパイラが検出できない」という辛さがあると挙げました。これを解決してくれるのがActorです。
Actorを使えば排他制御が簡単にできるようになります。
actor ImageCache {
private var store: [URL: Data] = [:]
func get(_ url: URL) -> Data? {
return store[url]
}
func set(_ url: URL, data: Data) {
store[url] = data
}
}
actorは「専用の窓口が1つだけある受付カウンター」のようなもので、複数のリクエストが来ても必ず1件ずつ順番に処理が実行されます。
また、actorの外からgetやsetを呼ぶ際はawaitが必要になります。
let cache = ImageCache()
let data = await cache.get(url) // awaitが必要 → 競合が起きない
このawaitはコンパイラが強制するので、うっかりawaitをつけ忘れても、実行される前にコンパイルエラーとなるため実行時のバグを防ぐこともできます。
@ MainActor
ここまでくると、@MainActorの正体も自ずとわかってくるんじゃないかなと思います。
@MainActorは「この処理はメインスレッドで実行するよ」とコンパイラに伝える仕組みです。
GCDでいうところのDispatchQueue.main.async { }と同じような役割ですが、こちらはこの記載を忘れてもコンパイルが普通に通ってしまいました。
しかし@MainActorでは、つけ忘れるとコンパイラに「つけないとダメだよ!」と教えてもらえるので、うっかりミスを防ぐことができます。
@MainActorはクラス・メソッド・プロパティなど色々な場所につけることができ、クラス全体につけた場合は内部のプロパティやメソッドの更新がすべてメインスレッドで行われることが保証されます。@Publishedなプロパティの変更が必ずメインスレッドで起きるため、UI更新で問題が起こることはありません。
ですが、@MainActorを万能薬のようにどこにでもつければいいというものでもないです。次の章で実務の実例を紹介しますが、メインスレッドで実行する必要がない処理にまで@MainActorをつけてしまうと、かえってパフォーマンス問題の原因になることもあります。
実務で起こった事例
この記事を書こうと思ったきっかけでもある事例を用いて、実際にどんな問題が起こるのかをみていきます。
エラー監視ツールに飛んできたエラーは「File IO on Main Thread」というもので、ファイルのキャッシュ確認や読み込みがメインスレッドで行われていたことが問題となっていました。
この問題の解決をする中でポイントとなったのは以下の3つです。
@MainActorを外す判断
キャッシュを管理するクラスに@MainActorが元々ついていたのですが、そのクラス内で行なっているファイルIOはメインスレッドで行う必要がない処理です。
@MainActorを削除して、呼び出し側をTaskを使ってバックグラウンドで実行するように修正をしました。
冒頭で触れた 「Xcode に怒られたからとりあえず@MainActorをつけた」 の逆パターンで、不要な@MainActorがむしろパフォーマンス問題の原因になっていたわけです。
GCDとasync/awaitの混在
当初の修正で、withCheckedContinuation + DispatchQueueという橋渡しパターンを使っていたのですが、レビューで指摘が入りTask.detached(priority:)を使ってシンプルに記述し、Swift Concurrencyに寄せる形で整理をしました。
非同期化に伴う排他制御の追加
バックグラウンドに移したことで、高速スクロール時に同じファイルのダウンロードが重複して走るという問題が浮上しました。メインスレッド上でURLの重複確認を行う処理を追加し、Task.detachedへ入る前にガードする形で対応を行いました。
押さえておきたいポイント
actorは内部のデータを守ってくれますが、awaitの前後で状態が変わっていないという保証はありません。
actor Counter {
var value = 0
func incrementAfterDelay() async {
let current = value // ① この時点でvalueは0
await Task.sleep(for: .seconds(1))
value = current + 1 // ② 0 + 1 = 1 をセット
}
}
これを同時に2回呼び出した場合、どうなるでしょうか?
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
group.addTask { await counter.incrementAfterDelay() } // 1回目の呼び出し
group.addTask { await counter.incrementAfterDelay() } // 2回目の呼び出し
}
// counter.valueが2になってほしいけど、実際は1になりうる
どちらの呼び出しもawaitで中断した瞬間にactorの「窓口」を一旦手放します。すると、1回目が①でcurrent = 0を取得 → 2回目も①でcurrent = 0を取得 → どちらもvalue = 0 + 1をセット、となる可能性があります。2回呼んだのに結果が1にしかならない、というわけです。
対策としてawaitをまたいで「読み取り → 加工 → 書き戻し」をする場合は、awaitの後に改めて最新の値を確認するか、処理をawaitなしの同期メソッドに切り出して1ステップで完結させると良いかと思います。
Taskの章のTask.detached { }の例で紹介したコードで、ダウンロード(await)の前後でキャッシュの存在チェック(cache.exists)を2回行っていたのですが、あれはまさにこの再入を考慮した設計でした。
まとめ
- GCD
「どのキューで実行するか」を開発者が手動で指示する仕組み。開発者自身で管理しないといけないことが多くなる。 - Swift Concurrency
「何を待つか」を宣言する仕組み。スレッド管理はランタイムに任せられる。
迷った場合はSwift Concurrencyを選ぶと基本的に間違いはないはずです。GCDは既存コードとの橋渡しに使うくらいで十分だと思います。
この記事を書くきっかけになった「File IO on Main Thread」の修正はたった数十行の変更でしたが、メインスレッドの役割・@MainActorの適切な使いどころ・GCDとSwift Concurrencyの混在リスクなど、学びが凝縮された経験でした。「なんとなく動いてるからヨシ!」 で済ませていた頃の自分よりは、自信を持ってコードを書けるようになっている気がします。
同じようにモヤモヤを感じている方にとって、この記事が理解の一助になれば嬉しいです。
参考リンク