$30 off During Our Annual Pro Sale. View Details »

タイミーiOSアプリへの Swift Concurrency 導入までの軌跡

tanako
September 13, 2023

タイミーiOSアプリへの Swift Concurrency 導入までの軌跡

同僚と一緒に導入を行ったSwift Concurrencyについて

tanako

September 13, 2023
Tweet

More Decks by tanako

Other Decks in Programming

Transcript

  1. 2023/09/13 田中幸一
    タイミーiOSアプリへの
    Swift Concurrency導入までの軌跡
    @_tanakoo

    View Slide

  2. 田中 幸一(@_tanakoo)
    ● 株式会社タイミー
    ● プロダクト本部
    ● ワーキングリレーションチーム
    ● iOSエンジニア
    イチオシのiOSDCのトーク
    「複雑さに立ち向かうためのコードリーディ
    ング入門」by shiz
    自己紹介

    View Slide

  3. 目次
    ● タイミーについて
    ● Swift Concurrencyのおさらい
    ● Swift Concurrencyの導入理由
    ● 開発とリリースの方針
    ● 起きた問題と対策
    ● まとめ

    View Slide

  4. 1 タイミーについて

    View Slide

  5. 5

    View Slide

  6. 6

    View Slide

  7. 募集人数の推移
    7
    ※1:2022年4Qと2021年4Qの比較
    コロナ禍においても、
    過去に例を見ない程の
    加速的高成長を実現。

    View Slide

  8. タイミーの実績
    スキマ
    バイト
    No.1
    ※2022年8月時点 ※1 [調査方法]デスクリサーチ及びヒアリング調査 [調査期間]2021年2月8日~22日 [調査概要]スキマバイトアプリ
    サービスの実態調査 [調査対象]2020年12月までにサービスを開始しているスキマバイトアプリ10サービス [調査実施]株式会社ショッ
    パーズアイ ※2 [出典]AppStoreライフスタイルカテゴリーランキング(2021年5月時点)
    8
    累計求人案件数 ・ダウンロード数
    ※1 ※2
    導入事業者数
    46,000企業
    ワーカー数
    500万人

    View Slide

  9. 2 Swift Concurrencyの
    おさらい

    View Slide

  10. Swift Concurrencyとは
    Swift 5.5から導入された非同期処理や並行処理を安全かつ簡潔に書ける仕組み
    ● async/await
    ● Actor
    ● Structured Concurrency
    ● etc..
    ここではasync/awaitとActorだけを簡単におさらい

    View Slide

  11. async/awaitとは
    ● 非同期処理を同期処理と同じ見た目で簡潔に書ける仕組み
    ● これによりコールバック地獄を回避できたり様々な利点がある

    View Slide

  12. 従来の非同期処理の例
    func downloadData(from url: URL, completion: @escaping (Result) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
    if 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()
    }

    View Slide

  13. 従来の非同期処理の例
    func downloadData(from url: URL, completion: @escaping (Result) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
    if 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()
    }
    処理のたびにネストが増える
    完了処理を呼び忘れる可能性がある

    View Slide

  14. 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
    }

    View Slide

  15. 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キーワードで非同期関数であること
    を明示

    View Slide

  16. 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を付与
    成功結果は単純に返り値になる

    View Slide

  17. 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であり、
    関数が停止する可能性がある。
    停止した場合、スレッドは開放され、
    別の仕事を行うことができる。

    View Slide

  18. Actorとは
    ● 並行処理におけるデータ競合やデッドロックを防ぐ仕組み
    ● Actorはキューのようなものを持っていて処理を順番に実行する
    ● これにより処理が同時に1つしか実行されないことを保証(Actor Isolation)

    View Slide

  19. データ競合の例
    final class Counter {
    private var count: Int = 0
    func increment() -> Int {
    count += 1
    return count
    }
    }

    View Slide

  20. データ競合の例
    let counter: Counter = .init()
    DispatchQueue.global().async {
    print(counter.increment())
    }
    DispatchQueue.global().async {
    print(counter.increment())
    }
    1,2 、 2,1 の結果を期待。
    しかし、両方の実行結果が2になりうる

    View Slide

  21. データ競合の例
    final class Counter {
    private var count: Int = 0
    func increment() -> Int {
    count += 1
    return count
    }
    }
    1回目の処理でreturnする直前に、
    2回目の処理が呼ばれることで両方
    2となる

    View Slide

  22. 従来の排他制御の例
    final class Counter {
    private let queue: DispatchQueue = .init(label: "Counter")
    private var count: Int = 0
    func increment(completion: @escaping (Int) -> Void) {
    queue.async { [self] in
    count += 1
    completion(count)
    }
    }
    }

    View Slide

  23. 従来の排他制御の例
    final class Counter {
    private let queue: DispatchQueue = .init(label: "Counter")
    private var count: Int = 0
    func increment(completion: @escaping (Int) -> Void) {
    queue.async { [self] in
    count += 1
    completion(count)
    }
    }
    }
    クリティカルセクションを明示的に専用の実
    行キューで守る必要があった。

    View Slide

  24. Actorで書き直すと
    actor Counter {
    private var count: Int = 0
    func increment() -> Int {
    count += 1
    return count
    }
    }

    View Slide

  25. Actorで書き直すと
    let counter: Counter = .init()
    Task.detached {
    print(await counter.increment())
    }
    Task.detached {
    print(await counter.increment())
    } 1,2、あるいは2,1と必ず出力される

    View Slide

  26. 3 Swift Concurrencyの
    導入理由

    View Slide

  27. Swift Concurrencyの導入理由
    ● トークンのリフレッシュ処理がRxSwiftで実装されており複雑化していた
    ● トークン取得の排他制御に漏れがありエッジケースで問題が起きていた
    ● そもそも標準APIで良いところはRxSwift剥がしていきたい

    View Slide

  28. 4 開発とリリースの方針

    View Slide

  29. 開発の方針
    ● 初手のスコープを小さく保つ
    ○ 外部ライブラリにAPIリクエストを投げる部分
    ○ トークンリフレッシュ部分
    ● 新しい技術をペアで開発して知見を広げる
    ○ 同僚が前半、後半を自分が引き継いで実装

    View Slide

  30. リリースの方針
    ● 影響を局所化する
    ○ 1部の通信から徐々に全体に適用
    ○ 段階リリースの活用
    ○ bugが修正されているXcode14.3.1を使ってリリースする
    ● ロールバックしやすくする
    ○ Remote ConfigでFeature Flagを用意

    View Slide

  31. ● 23.07.11 フィーチャーフラグをOFF
    ● 23.08.01 一部の通信だけフラグをON
    ● 23.08.07 全ての通信のフラグをON
    ● 23.08.15 フィーチャーフラグを削除
    ※ 現行アプリは日付でバージョニングしています。
    リリースされたバージョンの具体

    View Slide

  32. 5 起きた問題と対策

    View Slide

  33. トークンリフレッシュ中にトークンのreadが走った際に古いトークンを取得してしまうこと
    があった。
    原因
    swiftのActor内でawaitを呼んだ際のreentrancy(再入可能性)を考慮できていなかっ
    た。(writeは実行中のtaskを待機していたがreadの考慮が漏れていた)
    開発中に起きた問題1
    public actor AuthManager {
    func token() async -> Token? {
    // awaitでサスペンドされるとresume前にここが呼ばれる可能性がある
    }
    func refreshToken() async -> Token? {
    // この処理の中で awaitがあった
    }

    View Slide

  34. 対策
    private var refreshTask: Task?
    func token() async -> Token? {
    if let refreshTask {
    return await refreshTask.value
    }
    ...
    }
    トークンのread時も実行中のタスクの結果を使うように変更した。

    View Slide

  35. トークンのリフレッシュ中にタブを切り替えるとセッションが切れ
    る問題が起きていた。
    原因
    API通信クラスがActorを保持しており、アプリはタブ毎にAPI通
    信クラスを生成している。そのため、タブの数だけActorが生ま
    れており、Actor1のリフレッシュ中にActor2(Actor1とは異なる
    インスタンス)が古い値を見ていた。
    開発中に起きた問題2

    View Slide

  36. Actorをシングルトンにして対処
    余談
    当初はGlobalActorで実装していたが、コードレビューで意図の理解しづらさなどの指摘
    を受けてシンプルにシングルトン化した。(@MainActorのようにattributeも利用していな
    かったので今考えても使い方を間違っていたと感じています)
    対策

    View Slide

  37. 一部の通信をSwift Concurrencyを使うようにした。
    この段階では問題は起きなかった。
    初回リリース

    View Slide

  38. 一部の画面でクラッシュが起きた。
    原因
    await後にView側でUIを操作するが、ワーカースレッドからの操作になっていて、スレッ
    ド違反でクラッシュしていた。
    全体リリースの開発を始めたところ..

    View Slide

  39. 原因
    func request(_ request: T) -> Single where T: RequestDefinition {
    return Single.create { single in
    let task = Task {
    do {
    let result = try await self._requestAsync(request)
    single(.success(result))
    } catch {
    single(.failure(error))
    }
    }
    return Disposables.create {
    task.cancel()
    }
    }
    }

    View Slide

  40. 原因
    func request(_ request: T) -> Single where T: RequestDefinition {
    return Single.create { single in
    let task = Task {
    do {
    let result = try await self._requestAsync(request)
    single(.success(result))
    } catch {
    single(.failure(error))
    }
    }
    return Disposables.create {
    task.cancel()
    }
    }
    }
    ここまではメインスレッドだったが ..

    View Slide

  41. 原因
    func request(_ request: T) -> Single where T: RequestDefinition {
    return Single.create { single in
    let 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などの付与がないと引き継がれな
    い)

    View Slide

  42. callbackを @MainActor にいれることにした。
    1API毎Actor hoppingが発生するがオーバーヘッドは小さいと判断して、MainActorで
    ラップした。
    対策

    View Slide

  43. しかしクラッシュが発生してしまった
    ● クラッシュ検知15分後にはFeature FlagをOFF & 段階リリースを中止
    ● 同じようなrequest関数が他にも存在、MainActorでのラップが漏れていた
    ● 最終的にクラッシュは修正、フィーチャーフラグも削除済み
    全体リリース!

    View Slide

  44. 6 まとめ

    View Slide

  45. Swift Concurrency導入で起きた良いこと
    ● トークン管理がより安全になった
    ● RxSwiftのコードが一部なくなった
    ● 通信処理の見直しによりリトライの過不足がなくなった
    ● チームに新しい技術の知見が広がった

    View Slide

  46. 残っている課題
    ● Swift Concurrencyという新たな複雑性が導入された
    ○ チームでの学習が引き続き必要
    ● Domainモジュール側にRxが残っている
    ● アプリケーション全体にSwift Concurrencyを適用する
    ○ 通信の表層ではまだRxSwiftのSingleを返している
    ● Strict Concurrency Checkingの有効化
    ● まだまだリファクタの余地がある

    View Slide

  47. まとめ
    ● 新しい技術は小さくロールバックできるようにリリースしよう
    ● 新しい技術はみんなでPRを作る or ペアプロで知見を共有しよう
    ● Actorの中でawaitする場合のreentrancyな性質に注意
    ● Actor Contextの引き継ぎは静的(コンパイル時)に解決される

    View Slide