クラウドMacでSwift actorの再入とawaitをまたぐクォータ予約を検証する
クラウドMac上の2つのエクスポート要求が、同じactorのチェックを通ったのに、どちらも最後の1枠を取得してしまう。原因は必ずしもデータ競合ではありません。最初の要求が空きを確認して非同期の準備処理を待っている間に、2つ目の要求が入り込むことがあります。検証すべきなのは、actorが変数の読み書きを直列化できるかではなく、awaitをまたいでも業務上の制約が守られるかです。
まず容量の不変条件を明確にする
一時作業領域を同時に使用できるタスクが最大1つのエクスポートサービスを考えます。使用枠には「準備中」と「実行中」の両方を含め、準備が完了したタスクだけを数えてはいけません。そうしないと、時間のかかる準備処理が制限をすり抜ける抜け道になります。
危険なのは、「空きを確認する、準備を待つ、使用数を増やす」という順序です。待機中にactorは別の呼び出しを処理できるため、チェック時の結果が、実行再開時の状態を表すとは限りません。正しくは、中断点のない区間でチェックと予約を済ませてから、非同期処理を実行します。
actorの隔離が保護するのは状態へのアクセスであり、非同期メソッド全体が中断不能なトランザクションになるわけではありません。
awaitごとに、「再開したときも、前提としている条件は成立しているか」を確認する必要があります。
この例では、空きがなければ即座に拒否し、待ち行列は設けません。キューイング、公平性、タイムアウトは別途定義すべき契約であり、呼び出し側の無制限なリトライで補うものではありません。
トークンで予約の所有権を表す
クラウドMacでActorQuotaという名前のmacOSプロジェクトを作成し、XCTestのユニットテストターゲットを追加します。テストファイルの既定のサンプルを、次のコードに置き換えてください。ここで検証するのは共通のSwift並行処理ロジックなので、iOSシミュレータを起動する必要はありません。
import Foundation
import XCTest
enum QuotaError: Error {
case full
}
actor ReservationPool {
private let capacity: Int
private var active: Set<UUID> = []
init(capacity: Int) {
precondition(capacity > 0)
self.capacity = capacity
}
var count: Int { active.count }
func run(
_ operation: @Sendable () async throws -> Void
) async throws {
try Task.checkCancellation()
guard active.count < capacity else {
throw QuotaError.full
}
let token = UUID()
active.insert(token)
defer { active.remove(token) }
try await operation()
try Task.checkCancellation()
}
}
チェックと挿入の間にはawaitがないため、その途中に別の呼び出しが割り込むことはありません。各呼び出しは自分専用のトークンを持ち、終了時には自分の予約だけを解放します。これにより、失敗時の分岐で別の呼び出しの使用枠を誤って減らすことを防げます。
deferは正常終了とエラーのスローの両方に対応しますが、あらゆるリソース管理を代替できるわけではありません。渡す処理は、実際の作業が終わるまで戻ってはいけません。現在のライフサイクルから独立したタスクを起動してすぐに戻る実装では、やはり枠が早く解放されてしまいます。呼び出しの契約も、実装と併せてレビューする必要があります。
sleep任せにせず、再入の順序を固定する
最初の処理に、テスト側で制御できる停止点を設けます。2回目の呼び出しは、予約が完了してから解放されるまでの間に行う必要があります。固定時間のsleepでは実行の進み具合を推測することしかできず、マシンの負荷が変わると偶然テストが通るようになります。
続けて、次の使い切りのテスト用フィクスチャとテストクラスを同じファイルに追加します。このフィクスチャは、待機を始める前に開くこともできます。通知が先に届いたために永久に待ち続ける事態を避けるためです。
actor PauseGate {
private var opened = false
private var waiter: CheckedContinuation<Void, Never>?
func pause() async {
if opened { return }
await withCheckedContinuation { continuation in
waiter = continuation
}
}
func open() {
opened = true
waiter?.resume()
waiter = nil
}
}
final class ReservationTests: XCTestCase, @unchecked Sendable {
func testRejectsOverlapAndReleases() async throws {
let pool = ReservationPool(capacity: 1)
let gate = PauseGate()
let entered = expectation(description: "Operation entered")
let first = Task {
try await pool.run {
entered.fulfill()
await gate.pause()
}
}
await fulfillment(of: [entered], timeout: 2.0)
let during = await pool.count
XCTAssertEqual(during, 1)
do {
try await pool.run {}
XCTFail("Overlapping operation was accepted")
} catch QuotaError.full {
}
await gate.open()
try await first.value
let after = await pool.count
XCTAssertEqual(after, 0)
try await pool.run {}
}
}
Expectationが満たされた時点で、最初の呼び出しは処理本体に入っており、予約は確実に存在します。2秒という値は、フィクスチャに到達するまで待つタイムアウトの上限であって、性能基準ではありません。このフィクスチャが対応する待機者は1つだけです。そのまま本番環境のブロードキャスト用コンポーネントとして使わないでください。
失敗とキャンセルを分けて検証する
エラーをスローするテストケースを追加します。処理からテスト専用のエラーを即座にスローし、捕捉して型を確認したうえで、countを読み取ります。値はゼロでなければならず、次の呼び出しも成功する必要があります。エラーが発生したことだけをアサートすると、「業務処理は失敗したが、予約枠が解放されない」という不具合を見落とします。
キャンセルのテストでも同じ停止点を使います。最初の処理が本体に入るのを待ってからfirst.cancel()を呼び出し、その時点で使用数を読み取ります。値はまだ1のままであるべきです。その後、フィクスチャを開いてタスクの結果を待つと、CancellationErrorが返り、最終的な使用数はゼロになる必要があります。
これは意図的に保守的なセマンティクスです。キャンセルは協調的な通知を送るだけで、この例の一時停止用フィクスチャがキャンセルによって自動的に再開することはありません。実際の処理が終了する前に枠を解放すると、新しいタスクと古いタスクが重なって実行されてしまいます。
実際のエクスポートAPIがキャンセルをサポートしている場合は、キャンセル通知を渡し、終了が確認できるまで待つ必要があります。すでに取り消せない副作用が発生している場合、末尾のキャンセルチェックでも結果を元には戻せません。コミットポイントと結果の照会方法を別途定義する必要があり、「キャンセルエラーをスローした」を「何も起きなかった」と解釈してはいけません。
ビルドに組み込み、反例も残す
プロジェクトのルートディレクトリで次のコマンドを実行します。プロジェクト名と共有Schemeは先ほどの作成手順に合わせ、テストクラスはActorQuotaTestsターゲットに配置してください。
set -euo pipefail
xcodebuild \
-project ActorQuota.xcodeproj \
-scheme ActorQuota \
-destination 'platform=macOS' \
-only-testing:ActorQuotaTests/ReservationTests \
test
まず、正しい実装でテストが通った結果を残します。そのうえで、一時ブランチ上で意図的に予約を非同期処理の後へ移動してください。重複実行を検証するテストは失敗するはずです。それでも通るなら、チェックから枠の使用開始までの隙間を、テストが実際には捉えられていません。
最後に確認するのは4点です。待機前に予約が存在すること、拒否された呼び出しが使用数を変えないこと、エラー時には自分のトークンだけを解放すること、キャンセル後も実際の処理が終了してから枠を返すことです。さらに容量を2に変更し、2つの処理を停止させた状態で3つ目を拒否させます。これにより、制限が単なる真偽値のスイッチとして固定されていないことも確認できます。
このテスト一式は、リソースプールの実装と一緒にコミットしてください。今後、非同期の準備処理をリファクタリングする際にも、「ここはactorを使っているから安全」という一言より、実際の容量制限を確実に守ってくれます。
よくある質問
actorで状態を隔離しても容量超過が起こるのはなぜですか?
awaitで実行を譲ると別の呼び出しが進めます。容量を確認した直後に予約を記録しなければ、複数の呼び出しが同じ空きを利用できます。
キャンセル時は予約をすぐ解放すべきですか?
実際の処理が資源を使い終えた場合に限ります。キャンセル通知だけでは処理停止を保証しないため、処理が戻るまで予約を維持します。
ワークフローに専用Macを追加
macOSをそのまま使って、ビルドやテスト、検証を実行できます。構成と利用期間を比較して、用途に合った物理マシンをお選びください。