これは株式会社TimeTree Advent Calendar 2024の16日目の記事です。
こんにちは。TimeTree の iOS エンジニア @Bax です。
Swift Concurrency が登場してから約 3 年が経とうとしています。TimeTree では新しくコードを書く際に積極的に Swift Concurrency を使用していますが、古いコードでは純粋な async/await
だけで対応することは難しく withCheckedContinuation
を使っている部分もまだ多くあります。今回は、業務の流れで withCheckedContinuation
を使わずに逐次処理(Sequential Task)を書く方法について調べてみました。
なぜ withCheckedContinuation
を使わないのか
まず初めに断っておきたいことがありますが withCheckedContinuation
を使うこと自体が悪いわけではありません。withCheckedContinuation
は Concurrency API の一部であり、従来の Completion Handler の形式を新しい Concurrency モデルに対応させるための有効な手段です。ただし、async/await
で直接操作可能な場合には、この限りではありません。
避けたい理由として、iOS 18 がリリースされて以降 withCheckedContinuation
を使用している部分でクラッシュが多発しており、Apple のフォーラムでも同様のクラッシュ報告が上がっています。「iOS 18 特有の不具合だ」と言ってしまえばそれまでの話ですが withCheckedContinuation
は低レベルのコードであり、クラッシュの原因を追跡しにくいという難点もあります。したがって withCheckedContinuation
を可能な限り使わず、より高レベルの Concurrency API のみを使用すべきだという判断に至りました。
どういった逐次処理をしたいか
おこなう逐次処理は以下のようなものです。
- ある画面を開くと、データ取得のためにリクエストを送信する
- リクエスト中に別の画面を開くと、前回のリクエストが終了してから、その画面のデータ取得をリクエストする
- さらに連続して画面を開くと、リクエストがキューに積まれ、画面を開いた順番通りにリクエストされる
- ただし、それぞれのリクエストの間隔を必ず 2 秒空ける
このような制約がある理由は、ユーザーの画面操作によってリクエストが頻発することが可能であり、それによるサーバーの負荷を避けるためです。そして、この処理を Concurrency で実行する場合、タスクは順番通りに積まれるため、リクエストは async/await
で実行するだけで済みそうですが、少し工夫が必要です。実際の例を見ていきたいと思います。
Taskを積み、逐次処理する
まずは逐次処理することに失敗する例です。
actor SequenceManager {
func request(url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
}
let manager = SequenceManager()
Task {
let data = try await manager.request(url: URL(string: "https://jsonplaceholder.typicode.com/todos/1")!)
print(String(data: data, encoding: .utf8) ?? "Invalid data")
}
Task {
let data = try await manager.request(url: URL(string: "https://jsonplaceholder.typicode.com/todos/2")!)
print(String(data: data, encoding: .utf8) ?? "Invalid data")
}
Task {
let data = try await manager.request(url: URL(string: "https://jsonplaceholder.typicode.com/todos/3")!)
print(String(data: data, encoding: .utf8) ?? "Invalid data")
}
このように書くと、実行するたびにレスポンスの順番が異なる結果が得られます。Concurrency に慣れている方はすぐにお気づきかもしれませんが、Task に積まれた時点でリクエストは並列に実行されるため、順番通りにリクエストが実行され、レスポンスが返されることは保証されません。
キューで管理
次は SequenceManager 内部でキュー管理をしてみます。実装ポイントとしては OperationQueue
などのキューや Timer
による遅延実行を使わないことです。これらはブロックで完了処理を呼ぶことになり最終的に DispatchQueue
や withCheckedContinuation
で処理を返すことになるためです。
actor SequenceManager {
private var requestQueue: [() async throws -> Void] = []
func enqueue(_ task: @escaping () async throws -> Void) async throws {
requestQueue.append(task)
try await processNext()
}
private func processNext() async throws {
guard !requestQueue.isEmpty else { return }
let nextTask = requestQueue.removeFirst()
do {
try await nextTask()
} catch {
print("Error processing request: \(error)")
}
try await Task.sleep(nanoseconds: 2000000000)
try await processNext()
}
func request(url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
}
let manager = SequenceManager()
Task {
try await manager.enqueue {
let data = try await manager.request(url: URL(string: "https://jsonplaceholder.typicode.com/todos/1")!)
print(String(data: data, encoding: .utf8) ?? "Invalid data")
}
}
Task {
try await manager.enqueue {
let data = try await manager.request(url: URL(string: "https://jsonplaceholder.typicode.com/todos/2")!)
print(String(data: data, encoding: .utf8) ?? "Invalid data")
}
}
Task {
try await manager.enqueue {
let data = try await manager.request(url: URL(string: "https://jsonplaceholder.typicode.com/todos/3")!)
print(String(data: data, encoding: .utf8) ?? "Invalid data")
}
}
一見うまくいきそうですが、こちらも失敗例です。実行してみると、3 つのリクエストが同時に実行され、前回のリクエストから 2 秒待機している様子もありません。await がサスペンションポイントとなり、並列に処理が行われます。また、Task.sleep
で処理を待っていますが、並列に待機するだけなので、実際にはこれも意味がありません。では、どのようにすれば良かったのでしょうか。
キューとフラグで管理
actor SequenceManager {
private var requestQueue: [() async throws -> Void] = []
private var isProcessing = false
func enqueue(_ task: @escaping () async throws -> Void) async throws {
requestQueue.append(task)
try await processNext()
}
private func processNext() async throws {
guard !isProcessing, !requestQueue.isEmpty else { return }
let nextTask = requestQueue.removeFirst()
isProcessing = true
do {
try await nextTask()
} catch {
print("Error processing request: \(error)")
}
try await Task.sleep(nanoseconds: 2000000000)
isProcessing = false
try await processNext()
}
func request(url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
}
キューの処理中は isProcessing
を true にして、キューの処理が終わるまで他の Task の処理が前へ進まないようにしておきます。ちなみに isProcessing は actor 内の変数であり、排他的に管理されるため Race Condition が発生する心配もありません。これで純粋な Concurrency API のみを使って逐次処理を実装することができました。
まとめ
この記事では純粋な Concurrency API のみを使って逐次処理をおこなう方法を解説しました。注意点はありますが、従来の OperationQueue などを用いなくてもキューの処理が書けることが分かったと思います。また Timer を使うことなく遅延実行もおこなえました。並列処理をしないと Concurrency のメリットを最大限に活かせないと思われがちですが、このような逐次処理をシンプルなコードで実現できる点も、Concurrency の魅力のひとつだと思います。ぜひ、何かの参考になれば幸いです。
TimeTreeの採用情報
TimeTreeでは共にミッションに挑戦する仲間を募集しています。
ご興味のある方はぜひ下記に目を通していただけるとうれしいです。
お会いできることを楽しみにしています!
ここまでご覧いただきありがとうございました。
それでは良いお年をお迎えください🎅