STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

潜在的なデータ競合をなくすための取り組み

こんにちは、STORES 決済 でiOSアプリを開発している @nekowenです。

マルチスレッドプログラミングは難しいと言われますが、その理由の1つとして、データ競合(data race)があります。

データ競合は複数のスレッドが同じ共有データに同時にアクセスし、少なくとも1つが書き込みを行う場合に発生します。これは主に予期せぬ動作やクラッシュを引き起こす厄介なバグの原因となります。

そのため、データ競合を起こさないよう NSLockDispatchQueue を用いた排他処理が必要となってきますが、これは実装や考慮漏れを引き起こすリスクがあります。

一例として、STORES 決済 でも開発中のバージョンでデータ競合に遭遇しました。

決済のステータスをアプリ上に表示するのにDelegate メソッドから受け取ったメッセージを利用しますが、 Delegate メソッドそのものが意図せずバックグラウンドスレッドで実行されていたことに気づかず、アプリのクラッシュを引き起こしていました。

状況を再現すると以下の疑似コードのような形になります。

class PaymentOperation {
    // 他のスレッドからアクセスされる可能性のある共有データ
    var message: String = ""
    
    // バックグラウンドスレッドから同時に複数回呼ばれるDelegateメソッド
    func didReceivedData(_ message: String) {
        self.message = message // 💥 ここでデータ競合が発生
    }
}

func process() {
     // バックグラウンドスレッドから didReceivedData が呼ばれる
    DispatchQueue.global().async {
        self.paymentOperation.didReceivedData("...")
    }
}

幸いなことにこの問題は QA で気づくことができましたが、発生頻度が低確率、かつ特定条件で発生するものだったため、問題の特定まで時間を要しました。

こうした問題を防ぐには、データ競合の発生を検知し、データ競合をなくす仕組みにしていく必要があります。

Thread Sanitizer を用いたデータ競合の検知

潜在的なデータ競合を検知する方法として、Xcode に組み込まれている Thread Sanitizer が強力です。これは、実行時にデータ競合を検出し、問題の箇所を正確に警告してくれます。

まずは Thread Sanitizer を有効化します。

Xcode のスキーム設定を開き、 Run > Diagnostics > Thread Sanitizer にチェックを入れます。

有効化したら、アプリを実行し、データ競合が起こり得る操作をします。

ここでは、先ほどの疑似コードが実行してみると、Xcodeが警告を表示します

WARNING: ThreadSanitizer: data race (pid=19748)
  Write of size 8 at 0x000107b257b0 by thread T1:
    #0 SampleDataRace.PaymentOperation.message.setter : Swift.String <null> (SampleDataRace.debug.dylib:arm64+0x2ad4)
    #1 closure #1 @Sendable () -> () in SampleDataRace.PaymentOperation.start() -> () <null> (SampleDataRace.debug.dylib:arm64+0x2fcc)
    #2 partial apply forwarder for closure #1 @Sendable () -> () in SampleDataRace.PaymentOperation.start() -> () <null> (SampleDataRace.debug.dylib:arm64+0x33c4)
    #3 reabstraction thunk helper from @escaping @callee_guaranteed @Sendable () -> () to @escaping @callee_unowned @convention(block) @Sendable () -> () <null> (SampleDataRace.debug.dylib:arm64+0x3054)
    #4 __tsan::invoke_and_release_block(void*) <null> (libclang_rt.tsan_iossim_dynamic.dylib:arm64+0x7f97c)
    #5 _dispatch_client_callout <null> (libdispatch.dylib:arm64+0x1d794)

  Previous read of size 8 at 0x000107b257b0 by main thread:
    #0 SampleDataRace.PaymentOperation.message.getter : Swift.String <null> (SampleDataRace.debug.dylib:arm64+0x2a2c)
  ...

Thread Sanitizer の限界

Thread Sanitizer は非常に有用ですが、万能ではありません。

  • シミュレータでの実行が基本: 決済端末とのペアリングや、接続といった実機でしか確認できない処理のデータ競合は検知できません。
  • 実行ベースの検知: ツールが検知できるのは、テスト中に「実際に発生した」データ競合だけです。網羅的なテストは難しく、特定の条件下でしか発生しない問題を見逃す可能性があります。

Strict Concurrency に適応し、Swift6 モードへ切り替える

データ競合に対する根本的な解決策は、Swift 6モード へ切り替えることです。 これによりデータ隔離のチェックが入るようになるため、コンパイル時にエラーとして検出できるようになります。

先ほどのコードを Swift 6 モードでコンパイルすると、実行する前に以下のようなエラーが表示されます。

func process() {
     // ❌ コンパイルエラー
     // Main actor-isolated property 'paymentOperation' can not be referenced from a Sendable closure
    DispatchQueue.global().async {
        self.paymentOperation.didReceivedData("...")
    }
}

Main Actor に隔離されている paymentOperation が、どのスレッドから実行されるかわからないクロージャから呼ばれたためビルドエラーとなっています。

このように Swift6 モードに対応していくことで、実装者が考慮すべき点やミスを確実に減らし、コンパイラに安全性を保証させることができます。

STORES 決済 では、現在この Swift 6 モードへの移行を計画的に進めています。この点についてまた何かアップデートがあればブログ記事にてお伝えしようと思います。


最後に

データ競合は、再現性が低くデバッグが困難なため、アプリの安定性を脅かす深刻な問題です。

本記事で紹介した Thread Sanitizer による検知や、Swift 6 の Strict Concurrency の導入は、こうした問題を未然に防ぎ、アプリの信頼性を高めるために不可欠です。

STORES ではモバイルエンジニアを募集しています。 カジュアル面談はもちろん、定期的に Beer Bash を開催していますので少しでも興味を持ってくださった方、ぜひ参加をお願いします。

jobs.st.inc

coubic.com