- バックエンド
- PdM
- 急成長中の福利厚生SaaS
- Other occupations (24)
- Development
- Business
- Other
なぜSwiftのUI更新はメインスレッドでなければならないのか?
Photo by Fredrik Solli Wandem on Unsplash
こんにちは!
ウォンテッドリーでiOSエンジニアとして働いている湊(https://www.wantedly.com/id/kota_minato)です。
この記事は2025年夏のウォンテッドリーのアドベントカレンダーの22日目の記事として執筆しています。普段はウォンテッドリーでiPhoneアプリの開発を担当していますが、今回扱うテーマはiOSに限らず多くのUIプログラミングに共通する基礎的な内容です。
「UIはなぜメインスレッドで更新する必要があるのか?」
この問いは、UI(User Interface)と非同期処理の基本的な関係性を理解する上で重要です。モバイル・Web問わず、UIを扱うすべての開発者にとって、一度は深く考えてみるべきテーマだと感じ、この記事を執筆しました。
目次
はじめに
UIはスレッドセーフではない
なぜ“メインスレッド”なのか?
GCDとRunLoopの責任分離
おわりに
はじめに
Swiftでアプリを開発していると、「UIの更新はメインスレッドで行う必要がある」という言葉を何度も見聞きしたことがあると思います。 例えば以下のようなクラッシュです。
Fatal Exception: NSInternalInconsistencyException
Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread.
実際にこのようなクラッシュメッセージが出た際に、 DispatchQueue.main.async { ... } を使って解決した経験がある方は少なくないでしょう。
UIはメインスレッドで更新するべきであることは、アプリ開発における基本事項といえますが、その背景にある仕組みや設計思想について、あらためて言語化して整理する機会は多くありません。私自身、以前は「なんとなくクラッシュするから」「Xcodeに怒られるから」と、深く考えずに .main.async を使っていました。その経験があったからこそ、本記事ではあらためて「UIはなぜメインスレッドで更新する必要があるのか?」という疑問に向き合ってみたいと思います。
とくにSwift 5.5以降は @MainActor によって、UIのスレッド問題が“見えにくく”なっています。その裏にはOSやランタイムによって保たれているルールが存在しており、それを理解することでコードの動作や設計意図をより深く捉えることができるようになります。
@MainActor
func updateUI(text: String) {
self.label.text = text
}
この記事では「スレッドセーフ」という考え方から始まり、RunLoop の仕組みやGCD(Grand Central Dispatch)との関係までを整理しながら、UIとメインスレッドの関係について解説します。UI更新がなぜメインスレッドで行われる必要があるのか、その背後にある原理や設計意図を理解することで、日々書いているコードへの理解がより深まることを目指します。
UIはスレッドセーフではない
私たちが日常的に扱うアプリ画面は、ボタンやリストといった UI コンポーネントの集合に見えますが、実際にはそれだけではありません。レイアウト計算、描画命令の発行タイミング、入力イベントの受付状態 など複数の処理が密接に連動し、破綻なく表示される画面を成立させています。重要なのは、これら画面出力に関わる内部状態が スレッドセーフには設計されていない という点です。
複数スレッドから同時にUIを操作すると、内部状態の不整合・描画崩れ・クラッシュを招く可能性があります。理論上はロックや同期処理を導入して安全性を高めることも可能ですが、UIの描画はフレーム単位でのタイミングが重要な処理であり、そこに重い同期処理を差し込むのは現実的ではありません。処理の遅延は、ユーザー体験を損ないます。
こうした背景から、iOS(UIKit/SwiftUI)をはじめとする現代の多くのUIフレームワークでは、この複雑性とパフォーマンスの問題を回避するため、「UIはたったひとつのスレッドのみが操作する」というシンプルな設計方針が採用されています。そして、その唯一の操作を行うスレッドこそが、メインスレッドです。
なぜ“メインスレッド”なのか?
UIは、なぜメインスレッドで更新しなければならないのか。その理由を理解するうえで重要なのが、RunLoopという仕組みです。
RunLoopは、アプリがタップやスクロールなどのイベントを受け取り、適切なタイミングで描画処理を行うためのイベントループです。ユーザーの操作、描画処理、アニメーション、タイマー、通知。こうしたUIに関わるすべての処理が、RunLoopの中で順序立てて実行されます。
そして、このRunLoopが動いているのが、まさに メインスレッド です。アプリがなめらかに動作するのは、この一連の処理が正しいタイミングで実行されているためです。もしUIを別スレッドから操作すると、この流れに割り込むことになり、描画のズレや、最悪の場合クラッシュの原因になります。したがってUIの更新はRunLoopが動作しているメインスレッドの中で行う必要があるのです。
/// メインスレッドで処理を行う
DispatchQueue.main.async {
self.tableView.reloadData()
}
これは「お作法」ではなく、アプリ全体の調和を保つための重要な設計ルールです。
GCDとRunLoopの責任分離
非同期処理の代表的な存在である Swift のDispatchQueueを用いた GCD は、バックグラウンドでの重い処理実行や、main.asyncを介したメインスレッドへの復帰など、広範な用途で利用されています。ここでは、メインスレッドに戻す必要性について整理します。
理解するべき点は、GCDとRunLoopがそれぞれ異なるレイヤーの仕組みであるということです。GCDは並行処理の実行制御を担いますが、UIの状態や順序の整合性については関与しません。また、Swiftのコンパイラは、「どのスレッドから変数にアクセスしているか」を完全には判断できません。つまり、データ競合が起きても事前に検知できないのです。
一方、RunLoopは、UIイベントの処理順を厳密に管理し、描画やアニメーションが期待通りに行われるように制御します。この領域にある責任は、GCDの側からは見えていません。したがって、UIの更新が必要な処理では、自分の手で明示的に .main.async を書く必要があります。これは単なるおまじないではなく、「ここから先はUI側の責任範囲である」という意思をコードで表明する手段です。
おわりに
本記事では、「なぜUIの更新はメインスレッドで行うべきなのか?」という問いを軸に、UIがスレッドセーフでない理由や、RunLoopとGCDの責任の違いについて掘り下げてきました。この理解があると、普段の .main.async にもきちんとした意味が宿ります。そして、UIを“なんとなく触る”のではなく、“理解して扱う”感覚に一歩近づけるはずです。
ぜひこれからは、「なんとなくメインに戻す」ではなく、「ここでメインに戻す理由があるから戻す」と説明できるコードを書いていきましょう。