はじめに
Swift Concurrencyが正式登場して数年が経ち、async
/await
、Task
、actor
を採用したアプリも珍しくなくなりました。
しかし「Actorを使ったらUIがカクついた」ことはありませんか?
大きな要因として考えられるのがActor hopです。Actorのプロパティにアクセスするたびに実行コンテキストが呼び出し元とActorを行き来し、この時にわずかなオーバーヘッドが発生します。
UIスクロールやリアルタイムストリーム処理ではこのわずかなオーバーヘッドが蓄積し、UIがカクつく場合があります。
Swift 6.2 (Xcode 26)に追加された nonisolated(nonsending)
は、呼び出し元ActorのExecutorをそのまま再利用できるため、Actor hopを原理的にゼロにできます。
Actorを使ったらパフォーマンスが出ない、Xcode 26の新機能を追いかけたいという方はぜひ参考にしてみてください。
Actor hopとは
Actor内部状態にアクセスすると呼び出し元ExecutorからActor用Executorへとコンテキストスイッチ(hop)が発生します。1回は数µs〜数十µsでも、UIスクロールや大量ループでは合算レイテンシがボトルネックになります。
3つの実行モードとhopの違い
実行モード | 実行場所 | hopする? | Sendable制約 |
---|---|---|---|
①actor-isolated | そのActor専用シリアルExecutor | 外部から呼ぶと必ずhop | なし |
②旧nonisolated | Actor外で動く共通スレッドプール*1 | 毎回hop | あり |
③新nonisolated(nonsending) | 呼び出し元Actorと同じExecutor | hopなし | なし |
MainActor ─┬─① hop UIが詰まりがち
├─② hop 毎回外へ移動
└─③ hopなし★ UI上で完結
*1 共通スレッドプール=SwiftRuntimeが管理する並列実行環境。公式には“global concurrent executor”と呼ばれ、APIglobalConcurrentExecutor
が用意されている。
Xcode 26で試すにあたって
Xcode 26にてnonisoated(nonsending) by Default
フラグが YES の場合、nonisolated
は原則呼び出し元Actorと同じExecutorで動作します。
本記事ではこのフラグの挙動については詳しく触れません。
興味がある方は以下の記事が参考になりましたのでご覧ください。
本記事で紹介するコードはこのフラグの値に関係なく動作するよう以下のように記載しています。
旧挙動 = @concurrent nonisolated
新挙動 = nonisolated(nonsending)
サンプルベンチマーク
簡単なサンプルコードを用意し、①actor-isolated, ②旧nonisolated, ③新nonisolated(nonsending)のパフォーマンスを比較してみました。
/// 1KBの疑似フレーム
/// ※ 実アプリならUIImageやDataなどを想定
struct Frame {
// 0で埋めた1KBのバイト列を保持するだけ
let bytes = [UInt8](repeating: 0, count: 1024)
}
actor Cache {
private let frame = Frame()
/// actor-isolated (hopあり)
func readWithActorIsolated() -> Frame { frame }
/// 旧nonisolated (hopあり)
@concurrent
nonisolated func readWithConcurrent() async -> Frame { frame }
/// nonisolated(nonsending) (hopなし)
nonisolated(nonsending) func readWithNonsending() async -> Frame { frame }
}
上記のようなActorを用意し、以下のViewから1万回呼び出してみます。
struct ContentView: View {
@State private var result = "(未計測)"
var body: some View {
VStack(spacing: 20) {
Text("Actor Hop Benchmark")
.font(.headline)
// 計測結果ラベル
Text(result)
.font(.system(.body, design: .monospaced))
// ボタンを押すと並列タスクでベンチを実行
Button("10,000回読み込みを計測") {
Task.detached { await runBenchmark() }
}
.buttonStyle(.borderedProminent)
}
.padding()
}
/// 関数をそれぞれ1万回実行し、計測結果を`@State`変数へ反映
@MainActor
private func runBenchmark() async {
// actor-isolated (hopあり)の処理時間
let timeActorIsolated = await measure { await Cache().readWithActorIsolated() }
// 旧nonisolated (hopあり)の処理時間
let timeConcurrent = await measure { await Cache().readWithConcurrent() }
// nonisolated(nonsending) (hopなし)の処理時間
let timeNonsending = await measure { await Cache().readWithNonsending() }
// ラベルを更新
result = """
actor-isolated: \(String(format: "%.2f", timeActorIsolated )) ms
旧nonisolated: \(String(format: "%.2f", timeConcurrent )) ms
nonisolated(nonsending): \(String(format: "%.2f", timeNonsending)) ms
"""
}
/// 与えられたクロージャを1万回`await`し、経過時間をmsで返す
private func measure(_ work: @escaping () async -> Frame) async -> Double {
let start = DispatchTime.now()
// 処理を1万回呼び出し
for _ in 0..<10000 { _ = await work() }
let end = DispatchTime.now()
return Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1000000
}
}
ベンチマーク結果

nonisolated(nonsending)
は約10倍高速だということがわかりました。
実行環境
- MacBook Pro 13-inch, 2018
- 2.7 GHz クアッドコアIntel Core i7
- MacOS Sequoia 15.5(24F74)
- Xcode 26.0 beta 2 (17A5241o)
- Simulator 26.0 (1051)
- iPhone 16 Plus iOS 26.0 (23A5276e)
実行モードの使いどころの指針
目的 | 実行モード | 理由 |
---|---|---|
同じ実行コンテキストで回数の多い軽量処理を最小コストで回したい | nonisolated(nonsending) |
呼び出し元Executorをそのまま使うためhopなし・Sendable 確認も不要 |
CPUコアをフルに使い並列化したい(重い計算・I/Oなど) | @concurrent nonisolated |
Actor外の並列スレッドプールに逃がして実行、バックグラウンド向き |
共有の可変状態を安全に操作したい | actor-isolated | 専用シリアルExecutorで順序・排他を自動保証 |
まとめ
-
nonisolated(nonsending)
によるActor hop排除で多発箇所では大幅に高速化することが期待できる - 旧挙動が必要な箇所には
@concurrent
を明示しよう - ベンチマークを確認しつつ安全に移行しましょう
参考
- https://forums.swift.org/t/i-was-playing-with-measuring-actor-performance/75005
- https://zenn.dev/yosshi4486/articles/69a9454b2b3439
- https://forums.swift.org/t/overhead-of-using-actors-at-scale/79466
- https://michaellong.medium.com/swift-6-2-approachable-concurrency-default-actor-isolation-4e537ab21233