同僚と一緒に導入を行ったSwift Concurrencyについて
2023/09/13 田中幸一タイミーiOSアプリへのSwift Concurrency導入までの軌跡@_tanakoo
View Slide
田中 幸一(@_tanakoo)● 株式会社タイミー● プロダクト本部● ワーキングリレーションチーム● iOSエンジニアイチオシのiOSDCのトーク「複雑さに立ち向かうためのコードリーディング入門」by shiz自己紹介
目次● タイミーについて● Swift Concurrencyのおさらい● Swift Concurrencyの導入理由● 開発とリリースの方針● 起きた問題と対策● まとめ
1 タイミーについて
5
6
募集人数の推移7※1:2022年4Qと2021年4Qの比較コロナ禍においても、過去に例を見ない程の加速的高成長を実現。
タイミーの実績スキマバイトNo.1※2022年8月時点 ※1 [調査方法]デスクリサーチ及びヒアリング調査 [調査期間]2021年2月8日~22日 [調査概要]スキマバイトアプリサービスの実態調査 [調査対象]2020年12月までにサービスを開始しているスキマバイトアプリ10サービス [調査実施]株式会社ショッパーズアイ ※2 [出典]AppStoreライフスタイルカテゴリーランキング(2021年5月時点)8累計求人案件数 ・ダウンロード数※1 ※2導入事業者数46,000企業ワーカー数500万人
2 Swift Concurrencyのおさらい
Swift ConcurrencyとはSwift 5.5から導入された非同期処理や並行処理を安全かつ簡潔に書ける仕組み● async/await● Actor● Structured Concurrency● etc..ここではasync/awaitとActorだけを簡単におさらい
async/awaitとは● 非同期処理を同期処理と同じ見た目で簡潔に書ける仕組み● これによりコールバック地獄を回避できたり様々な利点がある
従来の非同期処理の例func downloadData(from url: URL, completion: @escaping (Result) -> Void) {URLSession.shared.dataTask(with: url) { data, response, error inif let error = error {completion(.failure(error))return}if let response = response as? HTTPURLResponse {guard response.statusCode == 200 else {completion(.failure(ResponseError(statusCode: response.statusCode)))return}}completion(.success(data!))}.resume()}
従来の非同期処理の例func downloadData(from url: URL, completion: @escaping (Result) -> Void) {URLSession.shared.dataTask(with: url) { data, response, error inif let error = error {completion(.failure(error))return}if let response = response as? HTTPURLResponse {guard response.statusCode == 200 else {completion(.failure(ResponseError(statusCode: response.statusCode)))return}}completion(.success(data!))}.resume()}処理のたびにネストが増える完了処理を呼び忘れる可能性がある
async/awaitで書き直すとfunc downloadData(from url: URL) async throws -> Data {let (data, response) = try await URLSession.shared.data(from: url)if let response = response as? HTTPURLResponse {guard response.statusCode == 200 else {throw ResponseError(statusCode: response.statusCode)}}return data}
async/awaitで書き直すとfunc downloadData(from url: URL) async throws -> Data {let (data, response) = try await URLSession.shared.data(from: url)if let response = response as? HTTPURLResponse {guard response.statusCode == 200 else {throw ResponseError(statusCode: response.statusCode)}}return data}asyncキーワードで非同期関数であることを明示
async/awaitで書き直すとfunc downloadData(from url: URL) async throws -> Data {let (data, response) = try await URLSession.shared.data(from: url)if let response = response as? HTTPURLResponse {guard response.statusCode == 200 else {throw ResponseError(statusCode: response.statusCode)}}return data}エラーを返す場合 throwsを付与成功結果は単純に返り値になる
async/awaitで書き直すとfunc downloadData(from url: URL) async throws -> Data {let (data, response) = try await URLSession.shared.data(from: url)if let response = response as? HTTPURLResponse {guard response.statusCode == 200 else {throw ResponseError(statusCode: response.statusCode)}}return data}potential suspension pointsであり、関数が停止する可能性がある。停止した場合、スレッドは開放され、別の仕事を行うことができる。
Actorとは● 並行処理におけるデータ競合やデッドロックを防ぐ仕組み● Actorはキューのようなものを持っていて処理を順番に実行する● これにより処理が同時に1つしか実行されないことを保証(Actor Isolation)
データ競合の例final class Counter {private var count: Int = 0func increment() -> Int {count += 1return count}}
データ競合の例let counter: Counter = .init()DispatchQueue.global().async {print(counter.increment())}DispatchQueue.global().async {print(counter.increment())}1,2 、 2,1 の結果を期待。しかし、両方の実行結果が2になりうる
データ競合の例final class Counter {private var count: Int = 0func increment() -> Int {count += 1return count}}1回目の処理でreturnする直前に、2回目の処理が呼ばれることで両方2となる
従来の排他制御の例final class Counter {private let queue: DispatchQueue = .init(label: "Counter")private var count: Int = 0func increment(completion: @escaping (Int) -> Void) {queue.async { [self] incount += 1completion(count)}}}
従来の排他制御の例final class Counter {private let queue: DispatchQueue = .init(label: "Counter")private var count: Int = 0func increment(completion: @escaping (Int) -> Void) {queue.async { [self] incount += 1completion(count)}}}クリティカルセクションを明示的に専用の実行キューで守る必要があった。
Actorで書き直すとactor Counter {private var count: Int = 0func increment() -> Int {count += 1return count}}
Actorで書き直すとlet counter: Counter = .init()Task.detached {print(await counter.increment())}Task.detached {print(await counter.increment())} 1,2、あるいは2,1と必ず出力される
3 Swift Concurrencyの導入理由
Swift Concurrencyの導入理由● トークンのリフレッシュ処理がRxSwiftで実装されており複雑化していた● トークン取得の排他制御に漏れがありエッジケースで問題が起きていた● そもそも標準APIで良いところはRxSwift剥がしていきたい
4 開発とリリースの方針
開発の方針● 初手のスコープを小さく保つ○ 外部ライブラリにAPIリクエストを投げる部分○ トークンリフレッシュ部分● 新しい技術をペアで開発して知見を広げる○ 同僚が前半、後半を自分が引き継いで実装
リリースの方針● 影響を局所化する○ 1部の通信から徐々に全体に適用○ 段階リリースの活用○ bugが修正されているXcode14.3.1を使ってリリースする● ロールバックしやすくする○ Remote ConfigでFeature Flagを用意
● 23.07.11 フィーチャーフラグをOFF● 23.08.01 一部の通信だけフラグをON● 23.08.07 全ての通信のフラグをON● 23.08.15 フィーチャーフラグを削除※ 現行アプリは日付でバージョニングしています。リリースされたバージョンの具体
5 起きた問題と対策
トークンリフレッシュ中にトークンのreadが走った際に古いトークンを取得してしまうことがあった。原因swiftのActor内でawaitを呼んだ際のreentrancy(再入可能性)を考慮できていなかった。(writeは実行中のtaskを待機していたがreadの考慮が漏れていた)開発中に起きた問題1public actor AuthManager {func token() async -> Token? {// awaitでサスペンドされるとresume前にここが呼ばれる可能性がある}func refreshToken() async -> Token? {// この処理の中で awaitがあった}
対策private var refreshTask: Task?func token() async -> Token? {if let refreshTask {return await refreshTask.value}...}トークンのread時も実行中のタスクの結果を使うように変更した。
トークンのリフレッシュ中にタブを切り替えるとセッションが切れる問題が起きていた。原因API通信クラスがActorを保持しており、アプリはタブ毎にAPI通信クラスを生成している。そのため、タブの数だけActorが生まれており、Actor1のリフレッシュ中にActor2(Actor1とは異なるインスタンス)が古い値を見ていた。開発中に起きた問題2
Actorをシングルトンにして対処余談当初はGlobalActorで実装していたが、コードレビューで意図の理解しづらさなどの指摘を受けてシンプルにシングルトン化した。(@MainActorのようにattributeも利用していなかったので今考えても使い方を間違っていたと感じています)対策
一部の通信をSwift Concurrencyを使うようにした。この段階では問題は起きなかった。初回リリース
一部の画面でクラッシュが起きた。原因await後にView側でUIを操作するが、ワーカースレッドからの操作になっていて、スレッド違反でクラッシュしていた。全体リリースの開発を始めたところ..
原因func request(_ request: T) -> Single where T: RequestDefinition {return Single.create { single inlet task = Task {do {let result = try await self._requestAsync(request)single(.success(result))} catch {single(.failure(error))}}return Disposables.create {task.cancel()}}}
原因func request(_ request: T) -> Single where T: RequestDefinition {return Single.create { single inlet task = Task {do {let result = try await self._requestAsync(request)single(.success(result))} catch {single(.failure(error))}}return Disposables.create {task.cancel()}}}ここまではメインスレッドだったが ..
原因func request(_ request: T) -> Single where T: RequestDefinition {return Single.create { single inlet task = Task {do {let result = try await self._requestAsync(request)single(.success(result))} catch {single(.failure(error))}}return Disposables.create {task.cancel()}}}Taskのclosure内の先頭でワーカースレッドになっていた。(Actor Contextの引き継ぎは静的にコンパイル時に解決されるため、明示的な@MainActorなどの付与がないと引き継がれない)
callbackを @MainActor にいれることにした。1API毎Actor hoppingが発生するがオーバーヘッドは小さいと判断して、MainActorでラップした。対策
しかしクラッシュが発生してしまった● クラッシュ検知15分後にはFeature FlagをOFF & 段階リリースを中止● 同じようなrequest関数が他にも存在、MainActorでのラップが漏れていた● 最終的にクラッシュは修正、フィーチャーフラグも削除済み全体リリース!
6 まとめ
Swift Concurrency導入で起きた良いこと● トークン管理がより安全になった● RxSwiftのコードが一部なくなった● 通信処理の見直しによりリトライの過不足がなくなった● チームに新しい技術の知見が広がった
残っている課題● Swift Concurrencyという新たな複雑性が導入された○ チームでの学習が引き続き必要● Domainモジュール側にRxが残っている● アプリケーション全体にSwift Concurrencyを適用する○ 通信の表層ではまだRxSwiftのSingleを返している● Strict Concurrency Checkingの有効化● まだまだリファクタの余地がある
まとめ● 新しい技術は小さくロールバックできるようにリリースしよう● 新しい技術はみんなでPRを作る or ペアプロで知見を共有しよう● Actorの中でawaitする場合のreentrancyな性質に注意● Actor Contextの引き継ぎは静的(コンパイル時)に解決される