非同期のトラブル一覧
- 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 も actor | UIスレッド制御も実は actor の仕組み | SwiftUIのViewModelやUI更新 |
class を使うなら? | 自前で DispatchQueue や NSLock を管理する必要あり | パフォーマンスチューニング時(でも難易度高い⚠️) |
@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も反応しなくなるやつ - ユーザーからすると「アプリがバグってる」状態、開発者からするとデッドロックに近い地獄
ハングのよくある原因
- 自分自身を
await
してる(デッドロック)- あるタスクが自分の完了を待っちゃうケース
- → 永遠に終わらない
- MainActor をブロックする処理
- メインスレッドで重い処理(ループ、同期I/O)をやってる
- → UI更新が詰まってフリーズ
- 子タスク間で相互待ち
- 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.detached や MainActor.run を使って逃がす | メインスレッドをブロックしないのが鉄則。UI更新以外は極力MainActorから外す |
相互待ち(タスク同士で依存) | TaskGroupでまとめて管理 | 個別に待ち合わず、グループで完了を待つ設計にする |
共通ポイント | – | 「処理が終わらない」「UI固まる」はハングの典型。Instrumentsで検知しつつ設計を見直すべし |
非同期テスト(XCTest)
✅ ベストプラクティス
- async/await 対応のテストメソッドが使える:
func testFoo() async throws
- UI関連(@MainActorをテストするなら、テスト関数にも
@MainActor
を付与 orawait 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更新 | テスト関数に @MainActor | Publishing from background を防ぐ |
キャンセル | Task を保持して cancel() → CancellationError を検証 | ロングタスクをモックで作る |
ネットワーク | URLProtocol で完全スタブ | 外部依存を切って安定化 |
コールバックAPI | XCTestExpectation / 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も
(テストコード書く経験が少ないならXCTestはまだまだ未熟、、、)
↓ 次回
