この記事はChatwork AdventCalendar 2021の18日目の記事です。
モバイルアプリケーション開発部の池田(Twitter: m_ike)です。普段はiOS担当でSwiftを書いています。
2021年も残すところあとわずかですが、皆さんの今年の重大ニュースはなんだったでしょうか?自分の場合は、気がつくとChatworkに入社していたことです。
さて、Swiftの一番のニュースとなると、やはりSwift 5.5で並行処理(非同期処理)をサポートするSwift Concurrencyが出たことだと思います。
特にその中でも注目されていたのはasync
/ await
ですが、この記事では個人的に一番すごい!となった「actorでデータ競合を防ぐ」という点を取り上げます。(ちなみに2番目にすごいと思ったのは構造化並行処理です)
データ競合とは
データ競合は、あるデータに対して複数同時にアクセスした時に起こります。Swiftの場合では、可変な変数(var
)へ複数のスレッドから同時に読み書きが発生した時にデータ競合が起こる可能性があります。
データ競合が起こると、データ(値)が不整合な状態となり、さまざまなバグの原因となります。しかも、データ競合は防ぐのも見つけるのも難しく大変です。
よくある例ですが、次のような単純なカウントをするクラスがあるとします。
class Counter { var value = 0 func increment() -> Int { value = value + 1 return value } }
このクラスは、パッと見た感じ、どこにもバグは無いように見えます。
事実、このコードを以下のように1万回実行すると最後に10000
が出力されるのでバグはありません。
let counter = Counter() for _ in 0..<10000 { print(counter.incement()) }
ところが、このコードをちょっと変更して以下のように並行処理にすると、、、
突然のデータ競合が起こります。
let counter = Counter() for _ in 0..<10000 { Task.detached { // 並行処理で実行するように追加 print(counter.incement()) } }
実際、このコードを実行してみると、最後に10000
より少ない数字が出力されると思います。さらに、何回か試すとその結果も一定ではなくバラついた数値となり、場合によっては正しい10000
となることもあります。
これがデータ競合の恐ろしいところです。なにせ通常の同期的に実行されている時には全く問題のないコードが、ちょっと並行的に実行されただけでいきなり不具合を起こすのです。しかも、結果が一定ではない=その時によって起こったり起こらなかったりするという不安定で再現性の低いバグになります。
また、コードからバグを調査するのも大変です。通常、カウント処理の結果がバグっているとなるとCounter
の中のincement
の実装部分を中心に調査すると思いますが、そこをいくら調査してもバグは見つかりません。今回の例では、Counter
とそれをループで実行する箇所を並べているので、カウント処理は問題なく呼び出し側の並行処理部分が怪しいと気づきやすいですが、実プロダクトで調査の場合だと、該当処理の呼び出し元を全部辿っていって、並行処理になっていないかどうか・・・といった調査が必要となります。
データ競合を防ぐには
では、このデータ競合を防ぐにはどうすれば良いのでしょうか?
今回のコードの場合だと、value = value + 1
の部分で、同時に複数のスレッドがvalue
にアクセスしているのがデータ競合の原因です。仮にスレッドAとB、両方がほぼ同時にアクセスしてデータ競合が起こったとすると、以下のような状況になります。
valueの値 | スレッドAの処理 | スレッドBの処理 |
---|---|---|
10 | valueの10 を読み取り |
|
10 | 10 + 1 の処理 |
valueの10 を読み取り |
11 | valueへ11 を書き込み |
10 + 1 の処理 |
11 | valueへ11 を書き込み |
本来ならvalue
が10で、そこから2回カウントするので、期待される正しい結果は12です。しかし、value
が10のタイミングで複数のスレッドが同時にアクセスしてしまった為、結果は11となってしまいました。
このデータ競合を防ぐには、変数への同時アクセスが起こらないようにすれば良いので、
- 並行処理をあきらめる
- アクセス部分だけ同期的に処理させる
のどちらかが解決策となります。
処理や仕様の見直しで1の方法が取れるとベストですが、意味もなく並行処理を使っていることはあまり無いので、大抵は2の方法を取ることになると思います。
iOSでよく使われるのは、GCDのシリアル(直列)のディスパッチキューを使う方法です。シリアルキューを使うことで、並列にアクセスしていた部分を直列、つまり同期的に順番にアクセスするよう変更することができます。
とはいえ、これで無事データ競合は回避できたとしても、一つ大きな問題が残ります。それは、データ競合が起こる可能性があるかどうかを、並行処理を入れる度に実装者が注意深く判断しないといけないという点です。
例えば、画像を一つずつダウンロードすると遅いので、複数同時ダウンロードにするといった仕様変更が入ると、ダウンロード部分の処理を全部調べてデータ競合が起こるか確認し、その箇所にピンポイントで同期する処理を入れないといけません。この時、immutableに書いてあるコードならまだ良いのですが、var
のプロパティ変数を使いまくっているコードだとダウンロード処理自体から書き直さないといけないこともあります。。。
Swift Concurrencyがある場合
この難しい問題をサクッとエレガントに解決してくれるのが、Swift Concurrencyで登場したactor
です。
actor
は常に1つのタスクだけが変数にアクセスできるよう制限する仕組みを持っているので、複数のタスク(スレッド)から同時に変数へアクセスするようなコードを書いてもデータ競合が起きません。
使い方もとてもスマートで、class
の代わりにactor
をつけるだけです。
先ほどのコードだと
actor Counter { var value = 0 func increment() -> Int { value = value + 1 return value } }
とするだけです。変更すると、以下のようにデータ競合する部分の呼び出し元にビルドエラーが出るようになります。
これを解消するには、
print(await counter.incement())
と書き換えます。
await
を追加したことから判るように、actor
は、もし複数のタスクから同時に呼び出されても、1つずつ順番にアクセスするよう処理中のタスク以外は待機させるという同期処理を自動で実行します。
実際に書き換えたコードを実行すると、最後に正しく10000
が出力されます。
つまり、actor
を使うと、
- データ競合が発生する部分をコンパイラ(Swift)が自動で判断してエラーになる
- データ競合を回避する為の同期処理を自分で実装しなくて良い
という非常にありがたいメリットがあります。
さらに地味にうれしいのは、actor
がついていれば、そのデータ型はスレッドセーフであることが判るという点です。これまでだと、コメントを読むか実装を読まないといけなかったのが、見ただけで判るようになります。また、常時コンパイラがチェックするので、スレッドセーフになるように頑張って作ったクラスが、ちょっとした改修で壊れてしまって気づかないうちにデータ競合のバグを生んでいた・・・といった悲劇もなくなります。
まとめ
actor
はとても便利で安全なコードを書けるので、新規でコードを書く時はもちろん、既存のコードにも積極的に使っていくのがオススメです。弊社のiOSアプリでも、ガシガシ使っていこう!リファクタしていこう!!ってなっています。
actor
を使うと、「クラッシュログでは、バックグラウンドでAPI叩いた時に変な値が入ってるみたいで、まれにアプリが落ちている。でも、全然再現しない・・・」といったバグが解消できるかもしれません。何より、並行処理を書く時の安心感がハンパ無いです!!
ついでに1年の締めくくり
今年の3月から入社して、あっという間に年末になりました。今年書いたコードを振り返ると、粛々と技術的負債の返済に取り組む、地ならしの1年だったなぁーって感じです。チームみんなの頑張りのおかげで負債の解消も進み、今回のSwift Concurrencyのような新しい技術やアーキテクチャを入れていく下準備はだいぶ出来てきたと思います。来年はアグレッシブに新しいことを取り入れていきたいですね!
が、しかし、やりたいことはいっぱいあるのに対し、人手が足りません。。。
特に、新しい技術を既存のプロダクトに取り入れるというチャレンジをしたい方、みんなで楽しい開発をしたい方、残業のない開発をしたい方は、ぜひぜひカジュアル面談へどうぞ!
カジュアル面談は以下のMeetyから申し込めます!
もちろん、話を聞いてみたいってだけでも大丈夫です。お待ちしております!