気づけなかったデータ競合を防ぐSwift Concurrencyの革新性とは

目次
1. 本記事の目的
DI部Web2課の中島です。
皆様、Swift Concurrencyというものをご存知でしょうか?
これは、Swift 5.5から導入された、非同期処理や並列処理を安全かつ簡潔に記述できるようにした新しい仕組みです。従来、クロージャを使って複雑に書いていた非同期処理を、同期的に書けるようになって可読性が向上しました。
また、データ競合が発生する可能性がある箇所をコンパイル時に検出できるようになり、スレッドセーフなコードをより安心して簡潔に書けるようになりました。
以前案件でSwiftのバージョンを5から6にアップデートしようとした際、コンパイルしてみるとビルドエラーが大量に発生してしまうことがありました。これは、Swift 6からstrict concurrency checking(厳密なSwift並行処理の安全性チェック)がデフォルトで有効になったことが原因です。このエラーを解消していくためにSwift Concurrencyについてキャッチアップする必要がありました。
本記事では、Swift Concurrencyによって、非同期処理がどのように安全に扱えるようになったかについて、キャッチアップした内容をまとめます。
2.データ競合について
2.1 データ競合とは
データ競合とは、複数スレッドが同時に同じメモリ領域にアクセスし、そのうち少なくとも1つが書き込みを行い、データの一貫性が無くなってしまう現象・状態です。
このような状態では、クラッシュや予期せぬ動作が発生する可能性があり、再現しにくい不具合を引き起こす原因となります。
2.2 データ競合を体験してみる
データ競合による不具合を実際に体験して、イメージを掴んでみたいと思います。
先ほど述べたようにデータ競合を発生させるためには、複数のスレッドが同時に同じメモリ領域へアクセスし、そのうち少なくとも1つが書き込みを行う必要があります。
今回は、同じ変数に対してインクリメント処理を並列実行し、データ競合を発生させてみます。
// カウンタークラス(データ競合が起きうる)
class Counter {
var value: Int
func increment() {
value += 1
}
}
<
let dispatchGroup = DispatchGroup()
let dispatchQueue = DispatchQueue(label: "queue", attributes: .concurrent) // 並列キュー
let counter = Counter()
// インクリメントを並列に10000回行う
for _ in 0..<10000 {
dispatchQueue.async(group: dispatchGroup) {
counter.increment()
}
}
// 全非同期処理完了後にカウント結果を表示
dispatchGroup.notify(queue: .main) {
print("value: \(counter.value)")
}
実行結果
value: 9997
valueは10000になるはずですが、9997になっており予期せぬ不具合が発生しています。実行するたびにvalueの値が変わってしまうことも確認できると思います。
ちなみに、100回のインクリメント回数だと、ほぼ毎回100という結果になりました。
この現象は、開発者を悩ませる典型的な問題の一つです。通常は正しく動作しているように見えても、ごくまれに不具合を引き起こすため、再現が難しくなります。
何が起きているのか
value
が1110
の状態で2回インクリメント処理されると、1112
になるはずですが、データ競合によって1111
になってしまう状況を図で表しました。
スレッド1でインクリメントした結果をvalue
へ書き込み完了する前に、スレッド2で読み込みが発生してしまったことで、1110
をインクリメントした1111
を再び書き込んだだけの状況になっています。
このような問題が何度か起きることで、上記のコードでは10000に達しない予期せぬ結果となっていたのです。
3. データ競合を防ぐ
今までの防止策
データ競合を防ぐために、排他制御(あるスレッドによってデータにアクセス中に、そのデータへの他のスレッドからのアクセスを制御すること)を行う必要があります。
では、具体的にどうすれば排他制御を実装できるのでしょうか。
先ほど「データ競合を体験してみる」で使用していた並列キューを、シリアルキューに変え、直列処理にすることで排他制御を実装する方法が簡単だと思います。
その他にも、NSLock
やDispatchSemaphore
を利用する方法があります。
例: NSLockを使用してデータ競合を防ぐ
let dispatchGroup = DispatchGroup()
let dispatchQueue = DispatchQueue(label: "queue", attributes: .concurrent) // 並列キュー
let counter = Counter()
+ let lock = NSLock()
// インクリメントを並列に10000回行う
for _ in 0..<10000 {
dispatchQueue.async(group: dispatchGroup) {
+ lock.lock()
counter.increment()
+ lock.unlock() // unlockされるまで、他のスレッドから値にアクセスされなくなる
}
}
// 全非同期処理完了後にカウント結果を表示
dispatchGroup.notify(queue: .main) {
print("value: \(counter.value)")
}
実行結果
value: 10000
これらの方法でデータ競合を防ぐことはできますが、以下のような問題点もあります。
- unlockを書き忘れたり、処理が途中で終了した場合、他のスレッドが永遠に待たされる可能性がある
- コードが複雑になりやすい
- 誤った使い方でデッドロックを引き起こすリスクがある
つまり、こうした排他制御の正しさや不具合の有無は、すべて開発者の責任であり、システム側で保証されるものではありません。
そこで、今回のメインテーマであるSwift Concurrencyでは、上記の問題を解決しつつどのようにデータ競合を防ぐのか見ていきたいと思います。
4. データ競合を守る新しい型 actor
4.1 actorとは
actor Counter {
var value = 0
func increment() {
value += 1
}
}
先ほどのCounterクラスのclass
と記述していた部分をactor
に変えました。
actorは内部の可変状態に対するアクセスを自動的に直列化してくれるため、明示的な排他制御を行わなくてもデータ競合が発生しないことが保証されています。
4.2 actorでデータ競合を防ぐ
データ競合が発生する例のコードをactorで修正すると以下になります。
let dispatchGroup = DispatchGroup()
let dispatchQueue = DispatchQueue(label: "queue", attributes: .concurrent) // 並列キュー
let counter = Counter()
// 10000回インクリメントを並列に行う
for _ in 0..<10000 {
dispatchQueue.async(group: dispatchGroup) {
Task {
await counter.increment()
}
}
}
// 全ての非同期処理完了後にカウント結果を表示
dispatchGroup.notify(queue: .main) {
Task {
print("value: \(await counter.value)")
}
}
actor Counter {
var value = 0
func increment() {
value += 1
}
}
実行結果
value: 10000
Task、awaitの記述が必要になりました。
外部からCounterへアクセスするには、Taskとawaitで非同期的に呼び出す必要があります。これによりactor(Counter)内部で、データ競合が発生しないようないい感じのタイミングで読み書きしてくれます。
一方で、actor内のコードでは、同じactor内部からであればawaitなしで同期的に呼び出すことができる、という特徴があります。
actor Counter {
var value = 0
func increment() { // asyncで定義しておらず、同期関数のように見える
value += 1 // awaitは不要
}
}
実は SwiftUIでの開発において、このような actorの特徴を無意識のうちに活用しているケースがあります。それを次に見ていきましょう。
4.3 SwiftUIでactorを既に使用していた
バックグラウンドスレッドからUIに関する操作を行うとコンパイルエラーが出ることは、何度も経験したことがあるはずです。
DispatchQueue.main.async {
// UIに関する操作
}
バックグラウンドスレッドや複数スレッドから並列でUIを操作すると、画面が固まったり描画の競合やクラッシュなどの不具合が起きる可能性があります。それを防ぐために、メインキューに処理を任せる、という非同期処理を書いていました。
Swift Concurrencyでは、以下のように書くことができます。
Task { @MainActor in
// UIに関する操作
}
// run関数はメインスレッドで動くことが保証される
@MainActor
func run() {
}
// Counterクラスのプロパティの読み書き、メソッドの呼び出し全てメインスレッドで動くことが保証される
@MainActor
class Counter {
var value = 0
func increment() {
value += 1
}
}
@MainActorと明示することで、メインスレッドで動くことが保証されます。
SwiftUIのView側では、「メインスレッドで処理を実行できているか」を気にせずに同期的に書いていました。
import SwiftUI
struct CounterView: View {
@State var count = 0
var body: some View {
Text("count: \(count)")
Button("button") {
increment()
}
}
func increment() {
count += 1 // メインキューに入れずともViewに関して直接操作できる
}
}
Viewに関する操作をメインキューに入れずに同期的に直接扱うことができています。
これは、先ほど述べた「同じactor内部からであればawaitなしで同期的に呼び出すことができる」特徴と同じですね。
CounterViewはViewプロトコルに準拠していますが、このViewプロトコルを見てみると、MainActorであることが分かります。
@MainActor @preconcurrency public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor @preconcurrency var body: Self.Body { get }
}
CounterViewはMainActorというメインスレッドで動くことが保証されたactor上で動作しており、@Stateやbodyの更新処理などがメインスレッドで安全に行えるようになっています。
次に、データ競合を防ぐSwift ConcurrencyのIsolation Domainについて見ていきます。
5. Isolation Domainとは
Isolation Domain(隔離領域)とは、actorのように、値や処理が排他的に管理されているスレッドセーフな領域のことです。
明示的に定義していない場合でも、すべての関数と変数の宣言にはIsolation domainが存在しており、Isolation domainには以下の3種類があります。
- Non-isolated(非隔離)
- Isolated to an actor value(actorによる隔離)
- Isolated to a global actor(Global Actorによる隔離。MainActorはこの1つ)
以下は、各Isolation domainと、その境界線(Isolation Boundary)を視覚的に表しました。
The critical feature of an isolation domain is the safety it provides. Mutable state can only be accessed from one isolation domain at a time. You can pass mutable state from one isolation domain to another, but you can never access that state concurrently from a different domain. This guarantee is validated by the compiler.
(Migrating to Swift 6より引用)
分離ドメインの重要な特徴は、それが提供する安全性です。可変状態は、一度に1つの分離ドメインからのみアクセスできます。可変状態をある分離ドメインから別の分離ドメインに渡すことはできますが、異なるドメインから同時にその状態にアクセスすることはできません。この保証はコンパイラによって検証されます。
つまり、「この値はこのIsolation domainでしか直接アクセスできない」という制限を設けることで、そのドメイン内では排他制御されていることが保証されます。
これにより、「同じ可変状態に対して複数のIsolation domainから同時にアクセスすることは起こらない」とコンパイラが判断できるため、コンパイル時に安全性を検証できるようになっています。
6. Sendableとは
Isolation domainによって領域内の可変状態は保護されていますが、実際にアプリケーション開発を行う際は各Isolation domain間でデータのやり取りを行う必要が発生します。
その際、Isolation Boundaryを超えてデータのやり取りを行うことになりますが、そこでデータ競合が起きる可能性がまだ残っています。
それを防ぐ仕組みがSendableです。
Sendableとは、Isolation Boundaryを超えても安全であることをコンパイラに保証するための保証書のようなものです。
より厳密に言うと、並行にアクセスされても安全な型だけが準拠できるプロトコルがSendableです。
6.1 Sendableの例
//値型
struct User: Sendable {
let name: String
var email: String
}
enum UserRole: Sendable {
case guest
case member
case admin
}
structやenumは値型であり、代入や引数渡しの際に常にコピーされて渡されるので、別々のIsolation domainから同時に同じ値を書き換えるリスクがありません。よって、並行にアクセスされても安全な型と言えるのでSendableです。
ちなみに、Sendableに格納されているすべてのプロパティがSendableである場合、Sendableに準拠させなくても暗黙的にSendableとして扱われます。
つまり、上記のstructやenumはSendableに明示的に準拠させなくても自動的にSendableとして扱われます。
しかしこれは、internalで定義した場合のみであり、publicやopenだと明示的にSendableに準拠させる必要があるので注意です。
6.2 Non-Sendableの例
//参照型なのでNon-Sendable
class User {
let name: String
var email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
}
//値型ではあるが、Non-Sendableなプロパティ(Setting)を持つのでNon-Sendable
struct User {
let name: String
var setting: Setting
}
class Setting {
var language: String = "ja"
}
値型に対して参照型は、代入や引数渡しの際に参照先のインスタンスを共有することになるので、別々のIsolation domainから同時に同じ値を書き換えるリスクがあります。よって、並行にアクセスされても安全な型と言えないのでNon-Sendableとなります。
実際にSendableに準拠させようとすると、以下のようなコンパイルエラーになります。
ActorBからActorAにIsolation boundaryを越えてNon-SendableなUserクラスを渡そうとしてみると、こちらもコンパイルエラーになってくれます。
このように、同じ可変状態に対して、異なるIsolation domainから同時にアクセスされる可能性があることを、コンパイラが実行前に注意してくれることが分かります。
6.3 actorは参照型だけどSendable?
actorも、Isolation boundaryを超えることができます。
actorは、値型ではなく参照型です。参照型なのになぜIsolation boundaryを超えられるのか?と思いましたが、ドキュメントには以下のように書かれています。
Actors are not value types, but because they protect all of their state in their own isolation domain, they are inherently safe to pass across boundaries. This makes all actor types implicitly Sendable, even if their properties are not Sendable themselves.
actor Island {
var flock: [Chicken] // non-Sendable
var food: [Pineapple] // Sendable
}(Migrating to Swift 6より引用)
アクターは値型ではありませんが、自身の状態すべてを独自の隔離ドメインで保護しているため、境界(スレッドやタスク)を越えて渡しても本質的に安全です。 そのため、アクター型はすべて暗黙的に Sendable であり、たとえそのプロパティが Sendable でなくても構いません。
これで納得しました。
7. まとめ
Swift Concurrencyで導入されたactorによって、その中の状態はIsolation domainにより保護され、スレッドセーフに扱えるようになりました。
さらに Sendableプロトコルによって、actorやTask間でデータを受け渡す際に、それがスレッド間で安全かどうかを型チェックで検証できるようになりました。
データ競合というのは従来、開発者が気づきにくく避けがたい問題でした。Swift Concurrencyはデータアクセスの制約や安全性を「型」という仕組みに落とし込むことで、コンパイル時に検出・防止できるようになった非常に革新的な技術だと感じています。
Swift Concurrencyは奥が深く、学ぶべきことも多い分野です。Swift 6への移行を進める中で、私自身も多くのコンパイルエラーに向き合ってきました。同じように移行作業に取り組まれている方、キャッチアップを考えている方へ、本記事が少しでも参考になれば嬉しいです。
最後までご覧いただき、ありがとうございました。
8. 参考記事
アジアクエスト株式会社では一緒に働いていただける方を募集しています。
興味のある方は以下のURLを御覧ください。