MainActor の隔離境界を再考する ~クラシルリワードでの取り組み~
はじめに
どうも、tattsun です 🙌
今回は 「MainActor の隔離境界」 について、クラシルリワードで実施した取り組みとその成果を紹介します。
背景:なぜ MainActor の隔離境界が重要なのか?
Swift 6 対応を進める中で、Strict Concurrency への適応が必要となりました。特に、「どの範囲を MainActor で隔離すべきか」という課題に直面しました。この決定は Strict Concurrency の対応方針に影響を与えるため、慎重な検討が求められます。
さらに、設計の意図や各コンポーネントの責務が時間とともに曖昧になっていたという課題もありました。そこで、構成の見直しと責務の再定義を行い、MainActor の隔離境界を明確化することを目的にプロジェクトを進めました。
MainActor とは?
MainActor は、UI の更新やメインスレッドでの処理が必要な場合に使用する Swift の Concurrency 属性です。
@MainActor が付与されたクラス、構造体、関数、メソッドは、コンパイラによってメインスレッド上での実行が保証され、安全かつ効率的な UI 操作を実現できます。
既存のクラシルリワードの Actor 隔離状態について
まず、現状の Actor 隔離状態を確認しました。どの部分が MainActor で動作しているのか、以下の図で可視化しました。
現在の設計では、Service と Store が MainActor と Sendable の両方を含む状態になっています。SDK や Apple 標準 API の仕様上、MainActor にする必要がある部分もありますが、レイヤーごとの設計を見直すことを目的として、例外扱いしました。
Actor Context の切り替えによる負荷検証
Concurrency の登場により、並行処理はスレッドをブロックするのではなく、スレッドを再利用する設計 になりました。しかし、頻繁な Actor Context の切り替えがパフォーマンスにどの程度影響を与えるかを事前に検証する必要があると考えました。
検証結果
10万回の処理を実行し、各 Actor Context の切り替えにかかる時間を計測しました。
Actor Context の切り替え | 処理にかかった時間(秒) |
---|---|
MainActor → MainActor(function実行) | 1.0007 |
MainActor → MainActor(stored property更新) | 0.0201 |
MainActor → MainActor(Task.init) | 3.4104 |
MainActor → actor | 0.7118 |
MainActor → Non-isolated (Task.detached) | 4.5007 |
Non-isolated → MainActor | 0.7704 |
Non-isolated → actor | 0.0533 |
MainActor → Sendable(function実行) | 1.0262 |
MainActor → Sendable(stored property更新) | 0.0193 |
クラシルリワードでは、ViewModel (MainActor) から Service や Dispatcher (Sendable) を利用するケースが多いため、Actor Context を引き継ぐ形になります。そのため、明示的に MainActor にする必要はないと判断しました。
ただし、処理をバックグラウンドに移行したい場合は Task.detached を利用する必要があります。(10万回の実行で約3秒ほどのオーバーヘッドなので無視できるレベルではありますが、影響を考慮しておくと良いでしょう。)
負荷検証のサンプルコード
import Foundation
/// 平均時間を測定する共通関数(MainActorで実行)
/// - Parameters:
/// - label: 測定対象のラベル(例: "MainActor -> actor")
/// - repetitions: 処理を繰り返す回数
/// - iterations: 測定を繰り返す回数(デフォルト: 30回)
/// - block: 測定対象の処理
@MainActor
func measureAndAverage_on_MainActor(_ label: String, iterations: Int = 30, block: @escaping () async -> Void) async {
var totalElapsedTime: Double = 0
for _ in 0..<iterations {
let start = DispatchTime.now()
await block()
let end = DispatchTime.now()
let elapsed = Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000
totalElapsedTime += elapsed
}
let averageTime = totalElapsedTime / Double(iterations)
let formattedAverageTime = String(format: "%.4f", averageTime) // 少数第4位まで出力
print("[\\(label)] Average time over \\(iterations) iterations: \\(formattedAverageTime) seconds")
}
/// 平均時間を測定する共通関数(Non-isolatedで実行)
/// - Parameters:
/// - label: 測定対象のラベル(例: "MainActor -> actor")
/// - repetitions: 処理を繰り返す回数
/// - iterations: 測定を繰り返す回数(デフォルト: 30回)
/// - block: 測定対象の処理
func measureAndAverage_on_NonIsolated(_ label: String, iterations: Int = 30, block: @escaping () async -> Void) async {
var totalElapsedTime: Double = 0
for _ in 0..<iterations {
let start = DispatchTime.now()
await block()
let end = DispatchTime.now()
let elapsed = Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000
totalElapsedTime += elapsed
}
let averageTime = totalElapsedTime / Double(iterations)
let formattedAverageTime = String(format: "%.4f", averageTime) // 少数第4位まで出力
print("[\\(label)] Average time over \\(iterations) iterations: \\(formattedAverageTime) seconds")
}
// MARK: - Types
actor ActorIsolated {
private var counter = 0
func countUp() async {
counter += 1
}
}
struct NonIsolated {
nonisolated(unsafe) private static var counter = 0
nonisolated func countUp() {
NonIsolated.counter += 1
}
}
@MainActor
struct MainActorIsolated {
@MainActor
private var counter = 0
@MainActor
mutating func countUp() {
counter += 1
}
}
struct SendableViewData: Sendable {
var counter = 0
mutating func countUp() {
counter += 1
}
}
// MARK: - Methods
@MainActor
func measure_MainActor_to_MainActor_execute_Function_Switches(repetitions: Int) async {
var mainActorIsolated = MainActorIsolated()
await measureAndAverage_on_MainActor("MainActor -> MainActor (execute function)") {
for _ in 0..<repetitions {
mainActorIsolated.countUp()
}
}
}
@MainActor
func measure_MainActor_to_MainActor_execute_TaskFunction_Switches(repetitions: Int) async {
var mainActorIsolated = MainActorIsolated()
await measureAndAverage_on_MainActor("MainActor -> MainActor (Task.init)") {
for _ in 0..<repetitions {
await Task {
mainActorIsolated.countUp()
}.value
}
}
}
@MainActor
func measure_MainActor_to_MainActor_update_StoredProperty_Switches(repetitions: Int) async {
var mainActorIsolated = MainActorIsolated()
await measureAndAverage_on_MainActor("MainActor -> MainActor (update stored property)") {
for _ in 0..<repetitions {
mainActorIsolated.counter += 1
}
}
}
@MainActor
func measure_MainActor_to_Actor_Switches(repetitions: Int) async {
let actorIsolated = ActorIsolated()
await measureAndAverage_on_MainActor("MainActor -> actor") {
for _ in 0..<repetitions {
await actorIsolated.countUp()
}
}
}
@MainActor
func measure_MainActor_to_NonIsolated_Switches(repetitions: Int) async {
let nonIsolated = NonIsolated()
await measureAndAverage_on_MainActor("MainActor -> Non-isolated (Task.detached)") {
for _ in 0..<repetitions {
await Task.detached {
nonIsolated.countUp()
}.value
}
}
}
nonisolated func measure_NonIsolated_to_MainActor_Switches(repetitions: Int) async {
var mainActorIsolated = MainActorIsolated()
await measureAndAverage_on_NonIsolated("Non-isolated -> MainActor") {
for _ in 0..<repetitions {
await mainActorIsolated.countUp()
}
}
}
nonisolated func measure_NonIsolated_to_actor_Switches(repetitions: Int) async {
var actorIsolated = ActorIsolated()
await measureAndAverage_on_NonIsolated("Non-isolated -> actor") {
for _ in 0..<repetitions {
await actorIsolated.countUp()
}
}
}
@MainActor
func measure_MainActor_to_Sendable_execute_function_Switches(repetitions: Int) async {
var viewData = SendableViewData()
await measureAndAverage_on_MainActor("MainActor -> Sendable (execute function)") {
for _ in 0..<repetitions {
viewData.countUp()
}
}
}
@MainActor
func measure_MainActor_to_Sendable_update_StoredProperty_Switches(repetitions: Int) async {
var viewData = SendableViewData()
await measureAndAverage_on_MainActor("MainActor -> Sendable (update stored property)") {
for _ in 0..<repetitions {
viewData.counter += 1
}
}
}
// MARK: - Action
Task {
let repetitions = 100_000 // 10万回の処理を実行
await measure_MainActor_to_MainActor_execute_Function_Switches(repetitions: repetitions)
await measure_MainActor_to_MainActor_update_StoredProperty_Switches(repetitions: repetitions)
await measure_MainActor_to_Actor_Switches(repetitions: repetitions)
await measure_MainActor_to_NonIsolated_Switches(repetitions: repetitions)
await measure_NonIsolated_to_MainActor_Switches(repetitions: repetitions)
await measure_NonIsolated_to_actor_Switches(repetitions: repetitions)
await measure_MainActor_to_Sendable_execute_function_Switches(repetitions: repetitions)
await measure_MainActor_to_Sendable_update_StoredProperty_Switches(repetitions: repetitions)
}
Service と Store の MainActor の必要性
それぞれの責務を改めて整理しました。
- Service: 処理の共通化や MainActor で処理する必要のないロジックを扱う
- Store: アプリ全体で状態を保持する
今回の検証結果を踏まえ、Service や Store を明示的に MainActor にする必要はないとの結論に至りました。
結果と成果
今回の取り組みによって、クラシルリワードのアーキテクチャを整理し、MainActor の隔離境界を明確化 しました。その結果、以下の効果が得られました。
✅ 設計の一貫性が向上
✅ チーム内での設計意図の共有が容易に
✅ Strict Concurrency への対応方針が明確化
まとめ
MainActor の隔離境界を見直し、構成や責務を整理することで、プロジェクト全体の保守性と開発効率を向上 させることができました。
今回の経験が、他のプロジェクトやチームの参考になれば幸いです。
最後までお読みいただき、ありがとうございました!🎉
Discussion