[Xcode/Swift] GPT先生からSwift Concurrencyを学んだ④ (非同期トラブル、actorなど)

非同期のトラブル一覧

  • UIが更新されない
  • 謎のクラッシュ(でも再現しない)
  • Task.cancel()してるのに止まらない
  • 非同期テスト、通る時と通らない時がある

「地味にストレスな非同期トラブル」、全て原因が特定てきて、対策できる。

データレースとは?

データレース = 複数の並行処理が同時に同じ変数を読み書きして、予期しないバグが起きる現象

再現性が低い、クラッシュしないけど値がおかしい、ってやつはほとんどこれが原因。

actorとclassで実際に見比べる

⚠️ 普通の class でデータレースが起きる例:

final class Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

// 1,000回 increment() したはずなのに、最終的な value は900台〜980台になることも起こる
// 理由:同時にvalueを変更してる途中で値が競合して壊れる
func testRaceCondition() async {
    let counter = Counter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<1_000 {
            group.addTask {
                counter.increment()
            }
        }
    }

    print("Final value: \(counter.value)")
}

✅ actorを使用する例

actor SafeCounter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        return value
    }
}

// 常に1000になる、actor は内部の状態を1つのタスクが1回ずつしか触れないように直列化してくれてるから。
func testActorCounter() async {
    let counter = SafeCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<1_000 {
            group.addTask {
                await counter.increment()
            }
        }
    }

    let final = await counter.currentValue()
    print("Final value (actor): \(final)")
}

整理すると:

特徴説明実務での使いどころ
同時アクセスを制御複数タスクから同じデータを守ってくれるカウンター、キャッシュ、共有リストなど
await 必須メソッド呼び出し時に直列化のため await が必要非同期で安全に状態を扱いたいとき
MainActor も actorUIスレッド制御も実は actor の仕組みSwiftUIのViewModelやUI更新
class を使うなら?自前で DispatchQueueNSLock を管理する必要ありパフォーマンスチューニング時(でも難易度高い⚠️)

@MainActorでUIスレッド制御

❌ よくある落とし穴:UI更新はメインスレッドじゃないと落ちる

SwiftUIやUIKitのUIはメインスレッドでしか触ってはいけない。
でも非同期処理してると、別スレッドから値を書き換えてしまうことがある。

⚠️ 悪い例(クラッシュする可能性あり)

class UserViewModel: ObservableObject {
    @Published var username: String = ""

    func loadUser() async {
        let (data, _) = try! await URLSession.shared.data(
            from: URL(string: "https://jsonplaceholder.typicode.com/users/1")!
        )
        let user = try! JSONDecoder().decode(User.self, from: data)

        // ❌ バックグラウンドスレッドから直接UI更新
        // Publishing changes from background threads is not allowed警告がでる (おそらく)
        self.username = user.name
    }
}

✅ 正しい例:@MainActorでUI更新保証

// VM全体に、MainActorをつける、メソッド単位にも付けられる
@MainActor
class UserViewModel: ObservableObject {
    @Published var username: String = ""

    func loadUser() async {
        let (data, _) = try! await URLSession.shared.data(
            from: URL(string: "https://jsonplaceholder.typicode.com/users/1")!
        )
        let user = try! JSONDecoder().decode(User.self, from: data)

        // ✅ 必ずメインスレッドで実行される
        self.username = user.name
    }
}
  • ViewModel全体がUI専用なら @MainActor を class に付けるのが楽
  • 計算やネットワーク処理は別Taskで走らせて、UI更新部分だけ @MainActor にするのもOK
  • 「UIが更新されない…」「Publishing changes from background threads is not allowed」って出たらまず@MainActor漏れを疑ってみる

Task.cancel() だけでは止まらない?

❌ 落とし穴:Task.cancel() は“お願い”ベース

Swiftの Task.cancel() は「キャンセルしてくれ」ってリクエストを出すだけ。
実際に処理を止めるかどうかは、そのタスクが キャンセル状態をチェックしてるかにかかっている。

⚠️ 悪い例:キャンセルしても止まらない


func longRunningTask() async {
    for i in 1...5 {
        print("Step \(i)")
        try? await Task.sleep(nanoseconds: 1_000_000_000) // 1秒スリープ
    }
    print("Finished!")
}

func testCancel() {
    let task = Task {
        await longRunningTask()
    }

    // 2秒後にキャンセル
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        task.cancel()
        print("Cancel requested!")
    }
}

// Point: Cancel requested!」って出ても、その後も Step 3, 4, 5... が続く
// Why? → longRunningTask() 内で キャンセル確認してないから!

✅ 解決策:Task.checkCancellation() を使う

func cancellableTask() async throws {
    for i in 1...5 {
        try Task.checkCancellation() // ← キャンセルされたらここで即throw
        print("Step \(i)")
        try await Task.sleep(nanoseconds: 1_000_000_000)
    }
    print("Finished!")
}

func testCancel() {
    let task = Task {
        do {
            try await cancellableTask()
        } catch {
            print("Task was cancelled!")
        }
    }

    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        task.cancel()
        print("Cancel requested!")
    }
}
  • 2秒後に「Cancel requested!」
  • その直後に「Task was cancelled!」
  • 残りの処理は実行されない
方法役割実務での使いどころ
Task.cancel()タスクに「キャンセルしてね」とリクエストを送るViewが消えたときやボタン操作でキャンセル指示
Task.isCancelledタスクがキャンセルされてるかどうかを自分で判定するネットワークリクエストやループ処理中のチェック
Task.checkCancellation()キャンセルされてたら即 CancellationError を throw長い処理の途中で確実に止めたいとき
実務Tips– ネットワーク処理やループではキャンセル確認を挟むのが鉄則- Viewのライフサイクルに合わせたキャンセル制御も必須- 「止まらん」の正体は 自前チェック漏れ非同期処理を安全に扱うための必修パターン

ハング問題とその回避方法

そもそも「ハング」とは?

  • アプリが固まって進まない状態
  • クラッシュはしないけど await が返ってこない、処理が進まずUIも反応しなくなるやつ
  • ユーザーからすると「アプリがバグってる」状態、開発者からするとデッドロックに近い地獄

ハングのよくある原因

  1. 自分自身を await してる(デッドロック)
    • あるタスクが自分の完了を待っちゃうケース
    • → 永遠に終わらない
  2. MainActor をブロックする処理
    • メインスレッドで重い処理(ループ、同期I/O)をやってる
    • → UI更新が詰まってフリーズ
  3. 子タスク間で相互待ち
    • AタスクがBを待ち、BタスクがAを待つ
    • → 双方待機で永遠に進まない

⚠️ 悪い例:自分自身を待つ

actor DeadlockActor {
    func doSomething() async {
        // ❌ 自分のメソッドをawait → 永遠に返らない
        await doSomething()
    }
}

// 回避策: 自分自身を await しない or 必要なら「再帰」じゃなくてループ or 別タスクに分離する

⚠️ 悪い例:MainActorで重い処理

@MainActor
class ViewModel {
    func loadData() {
        // ❌ メインスレッドで重い処理、UIが固まる。ボタンも押せない。
        for _ in 0..<10_000_000 {
            _ = UUID().uuidString
        }
    }
}

// 回避策: 重い処理は別Taskに逃がす
@MainActor
class ViewModel {
    func loadData() {
        Task.detached {
            // ✅ バックグラウンドで実行
            for _ in 0..<10_000_000 {
                _ = UUID().uuidString
            }
            // ✅ 結果だけUIに戻す
            await MainActor.run {
                print("Finished heavy task")
            }
        }
    }
}

// 回避策2: TaskGroupで依存を整理
func fetchAll() async throws -> [String] {
    try await withThrowingTaskGroup(of: String.self) { group in
        group.addTask { "A" }
        group.addTask { "B" }
        
        var results: [String] = []
        for try await result in group {
            results.append(result)
        }
        return results
    }
}
ハング原因回避方法実務Tips
自分を await してる設計を見直す(ループ or 別Taskに分離)自分自身を待たない設計にする。怪しい挙動があったらまずここを疑え
MainActorで重い処理Task.detachedMainActor.run を使って逃がすメインスレッドをブロックしないのが鉄則。UI更新以外は極力MainActorから外す
相互待ち(タスク同士で依存)TaskGroupでまとめて管理個別に待ち合わず、グループで完了を待つ設計にする
共通ポイント「処理が終わらない」「UI固まる」はハングの典型。Instrumentsで検知しつつ設計を見直すべし

非同期テスト(XCTest)

✅ ベストプラクティス

  • async/await 対応のテストメソッドが使える:func testFoo() async throws
  • UI関連(@MainActorをテストするなら、テスト関数にも @MainActor を付与 or await MainActor.run { ... }
  • ネットワークは直叩きしないURLProtocol モックでスタブ化
  • expectationは基本不要(async/awaitで済む)。コールバック/Delegateのテストにだけ使う

モック:URLProtocol でネットワークをスタブ化

import Foundation

final class URLProtocolMock: URLProtocol {
    static var responseData: Data?
    static var responseError: Error?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        if let error = URLProtocolMock.responseError {
            client?.urlProtocol(self, didFailWithError: error)
        } else {
            let data = URLProtocolMock.responseData ?? Data()
            let response = HTTPURLResponse(url: request.url!,
                                           statusCode: 200,
                                           httpVersion: nil,
                                           headerFields: nil)!
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        }
    }

    override func stopLoading() {}
}

extension URLSessionConfiguration {
    static func mocked() -> URLSessionConfiguration {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [URLProtocolMock.self]
        return config
    }
}

テスト対象(例):UserService & ViewModel

import Foundation

struct User: Codable, Equatable { let id: Int; let name: String }

protocol UserServiceType {
    func fetchUser(id: Int) async throws -> User
}

final class UserService: UserServiceType {
    private let session: URLSession
    init(session: URLSession = .shared) { self.session = session }

    func fetchUser(id: Int) async throws -> User {
        let url = URL(string: "https://example.com/users/\(id)")!
        let (data, _) = try await session.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
}

@MainActor
final class UserViewModel: ObservableObject {
    @Published private(set) var name: String = ""
    private let service: UserServiceType

    init(service: UserServiceType) { self.service = service }

    func load(id: Int) async throws {
        let user = try await service.fetchUser(id: id)
        name = user.name // @MainActor なのでUI更新OK
    }
}

XCTest で書く(async/await 版)

import XCTest

final class UserTests: XCTestCase {

    private func makeSession(data: Data?, error: Error? = nil) -> URLSession {
        URLProtocolMock.responseData = data
        URLProtocolMock.responseError = error
        return URLSession(configuration: .mocked())
    }

    func test_fetchUser_success() async throws {
        let stub = User(id: 1, name: "Bob")
        let data = try JSONEncoder().encode(stub)
        let service = UserService(session: makeSession(data: data))

        let user = try await service.fetchUser(id: 1)

        XCTAssertEqual(user, stub)
    }

    func test_fetchUser_error() async {
        struct DummyError: Error {}
        let service = UserService(session: makeSession(data: nil, error: DummyError()))

        do {
            _ = try await service.fetchUser(id: 1)
            XCTFail("should throw")
        } catch {
            // OK
        }
    }

    @MainActor
    func test_viewModel_updatesOnMainActor() async throws {
        let stub = User(id: 1, name: "Tom")
        let data = try JSONEncoder().encode(stub)
        let vm = UserViewModel(service: UserService(session: makeSession(data: data)))

        try await vm.load(id: 1)

        XCTAssertEqual(vm.name, "Tom") // @Publishedの更新をメインで検証
    }

    func test_cancellation_propagates() async {
        // 長い処理をモック
        final class SlowService: UserServiceType {
            func fetchUser(id: Int) async throws -> User {
                try await Task.sleep(nanoseconds: 2_000_000_000)
                return User(id: id, name: "Slow")
            }
        }

        let vm = await MainActor.run { UserViewModel(service: SlowService()) }
        let task = Task {
            try await vm.load(id: 1)
        }

        task.cancel()
        do {
            try await task.value
            XCTFail("should be cancelled")
        } catch is CancellationError {
            // OK: キャンセルが伝搬
        } catch {
            XCTFail("unexpected error: \(error)")
        }
    }
}
目的推奨アプローチひと言メモ
成功パスasync/awaitで素直に XCTAssertまずは“ハッピーパス”を固定
エラーパスXCTAssertThrowsError or do-catch具体的なError型をチェック
UI更新テスト関数に @MainActorPublishing from background を防ぐ
キャンセルTask を保持して cancel()CancellationError を検証ロングタスクをモックで作る
ネットワークURLProtocolで完全スタブ外部依存を切って安定化
コールバックAPIXCTestExpectation / Nimbleの waitUntilレガシーAPIの橋渡し

まとめ

  • データレース
    • 普通の class だと同時アクセスで値が壊れる
    • actor を使えば安全にシリアライズされる
    • 共有状態はactorに任せ
  • @MainActor
    • UI更新はメインスレッドでしかできない
    • @MainActor を付ければ安心してUIに反映可能
    • 警告「Publishing changes from background threads is not allowed」の救世主
  • キャンセル問題
    • Task.cancel() は“お願い”ベース、勝手には止まらない
    • Task.isCancelled / Task.checkCancellation() を挟むことで途中で止められる
    • 「止まらん」の正体は チェック漏れ
  • ハング問題
    • 自分自身を await、MainActorで重い処理、相互待ち → フリーズの三大原因
    • 回避は「別タスクに逃がす」「TaskGroupで依存整理」
    • “非同期を同期っぽく書かない”のが鉄則
  • 非同期テスト
    • XCTestも async/await に完全対応
    • 成功/失敗/キャンセルを網羅してテスト可能
    • ViewModelのUI更新は @MainActor テストで保証
    • ネットワークは URLProtocol モックで外部依存ゼロに!

(テストコード書く経験が少ないならXCTestはまだまだ未熟、、、)

↓ 次回

[Xcode/Swift] GPT先生からSwift Concurrencyを学んだ⑤(設計 × Concurrency)