3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Swift6】Actorはデータ競合を防ぐが競合状態を防がない〜メモリリークによるゾンビ実行を可視化するデバッグ術〜

Last updated at Posted at 2025-12-20

ジョブカン事業部のアドベントカレンダーも21日目になりました!

はじめに

こんにちは!
ジョブカン事業部でモバイルアプリエンジニアをやっている @tmg_tl と申します。

Swift6移行に関する話題が、最近増えてきたように思います。
「Sendableにしたからこれでスレッドセーフだ! 前よりも不具合が減るはず!」

しかし、Actorへの移行によって「データ競合」は防げても、「競合状態」までは防げないという事実は意外と見落とされがちです。
今回は、Actorで競合状態が発生することにより困ってしまうパターンと、Xcode上でインスタンス数を確認するためのツールについて紹介したいと思います。

本記事では、Swift6移行手順についての解説はありません。
Swift6移行については、Swiftコミュニティ公式の移行ガイド(The Swift Concurrency Migration Guide)などを参考にしてください。

TL;DR

  • Actorはデータ競合(Data Race)を防ぐものの、競合状態(Race Condition)は防がない
  • 呼び出し元でメモリリークが発生している場合、Actorは律儀にすべて処理してしまう
  • 静的解析(コンパイラ)だけでなく、動的解析(メモリグラフ・Instruments)でも「実行時のインスタンス数」をチェックしたい

Actorが守るもの、守らないもの

まず、タイトル部分の整理です。
Actorを導入することで防げるのはデータ競合(Data Race)です。

データ競合(Data Race)
複数のスレッドが同時に同じメモリ領域にアクセス(書き込み or 読み書き)を行い、アプリがクラッシュしたりメモリが破壊されたりすること。
Actorはこれを防ぎます。

競合状態 / 論理的な競合(Race Condition)
処理の順序やタイミングによって、意図しない計算結果になってしまうこと。
Actorはこれを防ぎません。

雑に説明すると、Actorは一度に1つのタスクしか実行しません。
しかし待ち時間(await)が発生している隙に、他のタスクが割り込んで実行される可能性があります。

【検証】並列アクセスで再現してみる

例として、「在庫管理Actor」を作ってみます。
「現在の在庫を確認」→「在庫があれば減らす」という処理です。

actor InventoryManager {
    // 在庫数
    private(set) var stockCount: Int = 10

    func purchase(quantity: Int) async {
        // 1. 在庫チェック
        guard stockCount >= quantity else {
            print("在庫不足です")
            return
        }
        // 2. 決済処理(1秒)
        // ここでawaitするため、Actorのロックが外れる
        // この1秒の間に、他のタスクが「1. 在庫チェック」を通過してしまう
        try? await Task.sleep(for: .seconds(1))
        // 3. 在庫の更新
        // await明け:stockCount がチェック時と同じである保証はもうない
        stockCount -= quantity
        print("購入成功: 残り \(stockCount) 個")
    }
}

このActorに対し、意図的に並列アクセスを行ってみます。

let manager = InventoryManager()
// 在庫10個に対し、10個の注文を同時に2回投げる
await withTaskGroup(of: Void.self) { group in
    group.addTask { await manager.purchase(quantity: 10) }
    group.addTask { await manager.purchase(quantity: 10) }
}

実行結果:

購入成功: 残り 0 個
購入成功: 残り -10 個

在庫がないのに決済がどちらも成功してしまいました。

これはActorの再入可能性によるもので、1つ目のタスクが await で待機している間に2つ目のタスクが侵入し、「まだ在庫は10個ある」と判断してしまったためです。

Actorの落とし穴:再入可能性(Reentrancy)

SwiftのActorは「Reentrant(リエントラント)」です。

Actor内のメソッド実行中に await(サスペンションポイント)に到達すると、処理が一時停止し、Actorのロックが解除されます。
その結果awaitの前に行った前提確認(在庫があるか?)が、await から戻ってきたときには 前提が嘘になる(状態が変わっている)可能性 が発生してしまいます。

一見不便ですが、これはデッドロック(処理の詰まり)を防ぐための仕様です。
もしActorが await 中にロックを解除しないと、自分自身への呼び出しや、相互に依存する処理が永遠に終わらなくなるリスクがあるためです。

とはいえ…

「決済処理だったらトランザクションをちゃんと張るよ」とか、
「実際の開発でこんなaddTaskを使った並列アクセスなんて意図的に書かないよ」とか、
並列アクセスに対して丁寧な方も多いでしょう。

しかし、「コード上は1回しか呼んでいないのに、実行環境要因で呼び出し元が複数存在する」ケースならどうでしょうか?

メモリリークによるActorへの擬似的な並列アクセス

ここからが本題です。

並列処理など書いた覚えはないのに、上記と同じ現象が起きる状況。
たとえば画面遷移の実装ミスや循環参照などによる、ViewModelのメモリリーク が挙げられます。

本記事では、UIからの入力を受け取ってActorを呼び出す役割のクラスを便宜上「ViewModel」と呼びますが、アーキテクチャに合わせて適宜 Presenter, Interactor, UseCase, Store (Reducer) などに読み替えてください。

たとえばこんなシチュエーション

  • Actor
    • アプリ内に1つだけの TransactionManager(シングルトン)
  • ViewModel
    • 本来画面に1つだが、実装バグで複数存在(1つは画面に表示、それ以外は破棄されず裏に残ったゾンビ)
  • イベント
    • ユーザーがボタンを「1回」タップ
    • NotificationCenterやCombine等のイベント購読により、全てのViewModelが同時に反応

Actorがシングルトンでない場合、ViewModelそれぞれがActorを作成することになるため、こちらも同様にメモリリークとなります。

Legacy環境(DispatchQueue / Combine)との違い

非Actorの環境では、こうした不整合が起きると逆に 「バグとして顕在化しやすい」 側面がありました。

  • selfnil アクセスでクラッシュして止まる
  • Combineのストリームが破棄されていて反応しない
  • スレッドセーフではないため、同時書き込みでクラッシュする(Fail Fast)

Actor環境の「行儀の良さ」がアダになる

一方、Actorは非常に堅牢で行儀が良いのが特徴です。

  1. ゾンビVMがシングルトンActorの処理を呼ぶ → await で待機
  2. 正規VMがシングルトンActorの処理を呼ぶ → Actorが使用中なので、行列に並ばせて待機
  3. ゾンビVMの処理が完了(1回目の処理実行)
  4. 正規VMの処理が開始(2回目の処理実行)

結果として、Actorはクラッシュもさせず、リクエストを無視もせず、
すべてのリクエストを順序正しく、確実に実行してしまいます。

その結果、1回のユーザー操作で処理が複数回走る現象が発生してしまいます。
もしこれが、決済処理やDB操作のような重複実行が許されない処理だったとしたら……

なんか大変そうだなぁって思います。

Actor側でできる防衛策

ViewModelが複数存在する場合でも、たとえば以下の様な対応を行うことで複数回の処理が走ることを防ぐことができます。

  • フラグ管理
    • isProcessing みたいなフラグでガードする
  • 状態チェック
    • 処理開始前だけでなく、awaitから戻ってきた後にも状態(在庫数など)を再チェックする
  • 冪等性の担保
    • 処理にリクエストIDを割り振り、同じリクエストIDなら弾くロジックを入れる

これらは暫定対応ではありますが、Actorの堅牢化にも役立ちます。
でもせっかくなら、ViewModelが複数作成されることを防ぎたいですよね。

「動的解析」でインスタンスの生存期間を確認する

Swift6対応において、「コンパイルエラー(静的解析)が消え、Swift6でビルドできること」だけがゴールではありません。
Actorの特性上、「意図したインスタンス数で動いているか」という実行時のチェック(動的解析)も、これまで以上に重要になります。

例えば、ナビゲーターエリア(左側)の Debug Navigator からメモリ使用量の推移が確認できます。

debug_navigator.png

画面遷移を繰り返しているのにグラフが階段状に増え続けて下がらない場合、メモリリークの疑いがあります。
しかしこれだけでは、どのクラスがどれぐらいリークしているかまではわかりません。

というわけで、ここからは実際にゾンビViewModelを見つけるための5つのステップを紹介します。

1. ObjectIdentifier(ログによる簡易チェック)

一番手軽なのは、ログにインスタンスのアドレスを出力することです。
ObjectIdentifier を使うと、オブジェクトの一意なID(メモリアドレス)を取得できます。

func purchase() {
    print("Purchase request from: \(ObjectIdentifier(self))") 
    Task {
        await actor.purchase()
    }
}

実行結果

Purchase request from: ObjectIdentifier(0x000060000291cc40)

画面を入り直すたびに異なるアドレスが表示されるのは正常ですが、
「1回のタップで異なるアドレスのログが複数行出る」場合は、メモリリークが発生しています。

2. Debug Memory Graph

Xcodeの標準機能であるメモリグラフデバッガを使います。
これはアプリ実行中のメモリの状態をスナップショットとして撮影し、可視化してくれる機能です。

手順:

  1. アプリを起動し、メモリリークが発生していそうな画面への遷移を行う
  2. Xcodeの下部デバッグバーにある「メモリグラフ」アイコン(円形が3つ繋がったマーク)をクリック
    memory_graph.png
  3. 左側のナビゲーターで対象のクラス名を確認
    memory_graph_leak.png

InventoryManagerは1つですが、PurchaseViewModelが2つありますね。
無事メモリリークしていました。よかったよかった。

今回利用した、意図的にメモリリークを発生させるサンプルコード
ContentView.swift
// プロジェクト作成時に自動生成される App ファイルはそのまま

import SwiftUI

/// 1. Shared Actor
actor InventoryManager {
    static let shared = InventoryManager()

    func purchase(from id: String) async {
        print("Actor: 在庫引き落とし処理開始 (Request: \(id))")
        try? await Task.sleep(for: .seconds(1))
        print("Actor: 処理完了 (Request: \(id))")
    }
}

/// 2. ViewModel (The Leaker)
@MainActor
class PurchaseViewModel: ObservableObject {
    private let manager: InventoryManager

    // 循環参照を作るためのクロージャ(リークの主犯)
    var leakClosure: (() -> Void)?

    init(manager: InventoryManager = .shared) {
        self.manager = manager
        print("Init VM: \(ObjectIdentifier(self))")
        // 1. 自分を強参照して循環参照させる(本来は[weak self])
        self.leakClosure = {
            self.doSomething()
        }
        // 2. 通知を監視する
        // メモリリークしているため、画面が消えてもこの監視は解除されず、イベントに反応し続ける
        self.addObserver()
    }

    deinit {
        print("Deinit VM: \(ObjectIdentifier(self))")
    }

    private func doSomething() { }

    private func addObserver() {
        NotificationCenter.default.addObserver(
            forName: .didTapPurchaseButton,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            guard let self = self else { return }
            Task {
                await self.purchase()
            }
        }
    }

    func purchase() async {
        let id = String(describing: ObjectIdentifier(self))
        print("Purchase Event Received: \(id)")
        await manager.purchase(from: id)
    }
}

/// 3. View(画面遷移用)
struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Text("メモリリーク実験")
                    .font(.title)

                NavigationLink("購入画面へ") {
                    PurchaseView()
                }
                .buttonStyle(.borderedProminent)

                Text("何回か画面遷移してから\nメモリリークの確認をする")
                    .font(.caption)
                    .multilineTextAlignment(.center)
                    .foregroundStyle(.gray)
            }
        }
    }
}

/// 4. View(ViewModel作成用)
struct PurchaseView: View {
    @StateObject private var viewModel = PurchaseViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("購入画面")
                .font(.largeTitle)

            // 現在のViewに紐づくViewModelのメモリアドレスを表示
            Text("VM ID: \(ObjectIdentifier(viewModel))")
                .font(.caption)
                .foregroundStyle(.gray)

            // ブロードキャストを利用した場合、画面から切り離されたViewModelにもイベントが飛ぶ
            Button("購入する") {
                NotificationCenter.default.post(name: .didTapPurchaseButton, object: nil)
            }
            .buttonStyle(.bordered)
        }
    }
}

// MARK: - Extensions
extension Notification.Name {
    static let didTapPurchaseButton = Notification.Name("didTapPurchaseButton")
}

#Preview {
    ContentView()
}

【補足】生成場所を特定する(Malloc Stack Logging)

メモリグラフだけでは「そのインスタンスがコードのどこで生成されたか」までは分かりません。
これを追跡したい場合、Scheme設定で Malloc Stack Logging を有効にする方法があります。

  1. Product > Scheme > Edit Scheme を開く
  2. Run > Diagnostics タブを選択
  3. Malloc Stack Logging にチェックを入れる
    malloc_stack_logging.png

この状態でメモリグラフを見ると、右側のインスペクタに Backtrace が表示され、クリックすると該当のコード行にジャンプできるはず。

memory_grapg_logging_inspector.png

しかし、今回のコードではメモリリークが発生しているものの、Backtraceに有用な情報は表示できませんでした。
システム内部のクロージャや非同期処理が深く絡む場合、このように Backtrace が追跡しきれないケースもあるようです。

3. Instruments (Allocations)

Instrumentsというパフォーマンス計測ツールを利用して、アプリの動作中のリソース使用量をリアルタイムで監視してみたいと思います。
Allocationsはその中でも、メモリの割り当て状況を分析し、不要なメモリ消費やメモリリークを特定するツールです。

  1. Xcodeで Product > Profile を選択
  2. Instrumentsのテンプレート選択画面で Allocations を選択
    スクリーンショット 2025-12-10 10.21.03.png
  3. 左上の録画ボタン(赤丸)を押してアプリを起動
    record.png
  4. アプリを操作した後、停止ボタン(■)を押して録画終了
    スクリーンショット 2025-12-10 10.39.54.png
  5. 画面下の「Input filter」の検索窓に調査したいクラス名(今回はPurchaseViewModel)を入力
    スクリーンショット 2025-12-10 10.24.14.png
    スクリーンショット 2025-12-10 10.25.34.png

アプリで「購入画面へ」→「戻る」を3回繰り返したところ、PurchaseViewModelのPersistent(生存数)が3となっていました。
こちらでも問題なくメモリリークしていましたね。

4. Instruments (Leaks)

Allocationsと並んで利用されることが多い、メモリリーク自動検知ツールの Leaks についても確認します。

Leaksはアプリ実行中に定期的にメモリスキャンを行い、「誰からも参照されなくなったが、解放されずに残っているメモリ領域」を自動的に検出するツールです。

  1. Xcodeで Product > Profile を選択
  2. Instrumentsのテンプレート選択画面で Leaks を選択
    スクリーンショット 2025-12-16 18.55.36.png
  3. 左上の録画ボタン(赤丸)を押してアプリを起動
    record.png
  4. メモリリークが検知されると、タイムライン上に「赤い✗マーク」が表示される
    スクリーンショット 2025-12-16 19.05.43.png
  5. アプリを操作した後、停止ボタン(■)を押して録画終了
    スクリーンショット 2025-12-10 10.39.54.png
  6. 画面下部の詳細ビューを Call Tree(呼び出し階層)に変更
  7. 下部の Call Tree ボタンから以下にチェックを入れる
    1. Invert Call Tree(呼び出し元の反転)
    2. Hide System Libraries(システムライブラリを隠す)

スクリーンショット 2025-12-16 19.23.13.png

今回の検証では、メモリリークの検知には成功したものの、
main(アプリのエントリーポイント)に集約されて表示されてしまい、具体的なクラス名の特定までは至りませんでした。

こういう場合「Call Tree」ではなく「Cycles & Roots」を使うと、循環参照のループ構(A -> B -> A)が可視化され、特定できる場合があります。

スクリーンショット 2025-12-16 19.46.56.png

ちなみに今回は余計によくわからなくなりました。私の負けです。

このように Leaks で詳細な追跡が難しい場合は、別の手段を活用した方が良さそうです。

5. Instruments(Signposter)

コード内に計測用のログ(OSSignposter API)を仕込むことで、任意の処理区間(Interval)にフォーカスして計測することができます。
「重い画像処理」「API通信」「DB書き込み」などを可視化し、正確な実行時間を計測できます。

Allocationsとの使い分けイメージ
Allocations:メモリ使用量の増減という「結果」をグラフで見る
Signposter:その瞬間にアプリが何をしていたかという「原因」を可視化する

Signposterを利用する場合は、Signposterのインスタンスを作成するようにコードの変更が必要です。
なお OSSignposter は iOS 15.0+ / macOS 12.0+ から利用可能です。

Debug Memory Graphの確認に利用したサンプルコードのInventoryManagerクラスを、以下のように変更しました。

import os

actor InventoryManager {
    static let shared = InventoryManager()
    
    // Signposterのインスタンス作成
    // subsystemの部分はアプリのBundleIDなど任意でOK
    private let signposter = OSSignposter(subsystem: "check_memoryleak", category: "Inventory")

    func purchase(from id: String) async {
        // 1. 計測開始(StateIDを発行することで、処理の「開始」と「終了」を紐づける)
        let state = signposter.beginInterval("PurchaseProcess", id: signposter.makeSignpostID())
        
        print("Actor: 在庫引き落とし処理開始 (Request: \(id))")
        try? await Task.sleep(for: .seconds(1))
        print("Actor: 処理完了 (Request: \(id))")
        
        // 2. 計測終了(開始時のStateを渡すことで区間を記録)
        signposter.endInterval("PurchaseProcess", state)
    }
}
  1. Xcodeで Product > Profile を選択
  2. Instrumentsのテンプレート選択画面で Logging を選択
    スクリーンショット 2025-12-10 10.21.17.png
  3. 左上の録画ボタン(赤丸)を押してアプリを起動
    record.png
  4. アプリを操作した後、停止ボタン(■)を押して録画終了
    スクリーンショット 2025-12-10 10.39.54.png
  5. os_signpostタイムライン上の Inventory を確認する
    スクリーンショット 2025-12-10 10.28.16.png
    スクリーンショット 2025-12-10 10.28.32.png

今回は、3回目に購入画面に遷移したタイミングで購入ボタンをタップしました。
PurchaseProcessが3つ縦に並んで表示されていますね。

1回のボタンで複数回処理が走っている = メモリリークが発生していることがわかりました。
よしよし。駄目そうですね。

個人的なメモリ監視ツール使い分け

本記事ではバグが発生した際の「調査(デバッグ)」に焦点を当てて、Xcode標準ツールによる解析方法を紹介しました。
そもそもバグを生まないための「予防(再発防止)」としては、XCTestのXCTAssertNilによるテストコードや、LifetimeTrackerのような監視用のライブラリ導入が有効です。

主観も大分含みますが、代表的な手法を表にまとめてみました。

方法 分類 Good Bad
Static Analyzer 静的解析 ・ビルド前に論理ミスを検知可能 ・循環参照のような実行時の状態依存バグは検知できない
ObjectIdentifier ログ ・ログだけで手軽にinit/deinitを確認できる ・実装コストが必要
・「誰が参照を握っているか」は分からない
Debug Memory Graph 標準ツール ・循環参照のペアが視覚的に分かる
・Xcodeからボタン1つで起動できる
・時系列でのインスタンス増減は不明
Instruments
(Allocations)
標準ツール ・「生存数」の推移を時系列で追える ・「誰が参照を握っているか」は見にくい
・Instrumentsを起動する手間がかかる
Instruments
(Leaks)
標準ツール ・リークの検知に優れる
・リーク発生を「赤バツ」で自動警告
・「誰が参照を握っているか」は見にくい
・Instrumentsを起動する手間がかかる
Instruments
(Signposter)
標準ツール ・「ゾンビが動いている(処理重複)」実害が見える ・実装コストが必要
・直接的なメモリリーク検知ツールではない
・Instrumentsを起動する手間がかかる
XCTest テストコード ・CIで自動検知できる(再発防止) ・テストコードを書くコストがかかる
・View階層が絡むリークは検知しにくい
OSS
(LifetimeTrackerなど)
ライブラリ ・CIで自動検知したり、常時監視できる ・コード記述やライブラリ管理など、導入コストがかかる

コード変更無しで気軽に確認できるので、自分はDebug Memory Graphをよく使っています。

「原因特定・デバッグ」を行いたい場合は、すぐに詳細な参照関係を見ることができるXcode標準ツール(Memory Graph, Instruments)が最適です。
一方で「再発防止・自動化」を狙いたい場合は、継続的に監視できるXCTestや、LifetimeTrackerなどのライブラリ導入が力を発揮します。

まずは標準ツールを利用し、プロジェクトの規模や運用方針に合わせて自動化やライブラリによる堅牢化を検討してみてください。
開発プロセスに適した方法でメモリリークの発生を確認し、アプリのパフォーマンスを向上させていきましょう!

宣伝

DONUTS では新卒中途問わず積極的に採用活動を行っています。

ジョブカン事業部の求人はこちらです。

主に参考にした記事

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?