Swift Actorsで並行処理における可変状態の保護
並行処理における命題
最近はCPUにマルチコアプロセッサを採用していることが多いので、マルチコアプロセッサにおいて同時にタスクを実行できる機能を並行処理とします。
シングルプロセッサの逐次処理とは違い、マルチコアプロセッサによる並行処理は便利な反面、以下の命題が出てきます。
データ競合の回避
データ競合は、2つのスレッドからアクセス(1つ以上の書き込みを含む)することで起きます。これをいかに防ぐかが並行処理プログラミングにおいて重要になってきます。
データ競合についてはとてもデバッグが難しいですが、swift 5.5
から導入されたactors
によって、言語レベルでデータ競合を防ぐための仕組みが備わりました。
この記事は、以下のWWDCセッションについてまとめたものになります。
今までの問題
今までのswift
では、以下のような問題がありました。
classの場合
class
は参照型です。
以下のようなコードを考えてみます。
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment())
}
Task.detached {
print(counter.increment())
}
Task.detachedはタスクを生成・実行する関数です。
この場合、タスクが順番に呼ばれることは非確定なので、出力結果として、[1,1], [1,2], [2,1], [2,2]が表示される可能性があり、データ競合が起きていると判断されます。
structの場合
struct
は値型です。swiftでは以下のようにして、classの場合と同じように書いてみます。
struct Counter {
var value = 0
mutating func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment())
}
Task.detached {
print(counter.increment())
}
このコードではコンパイラがエラーを出します。let
をvar
に変える必要があります。
var counter = Counter()
Task.detached {
print(counter.increment())
}
Task.detached {
print(counter.increment())
}
これもコンパイラがエラーを出します。これはTask.detached
での判定によるものです。これについては後述します。
Mutation of captured var 'counter' in concurrently-executing code
var counter = Counter()
Task.detached {
let counter = counter
print(counter.increment())
}
Task.detached {
let counter = counter
print(counter.increment())
}
これもclass
の場合のようにデータ競合が起きます。
この場合、タスクが順番に呼ばれることは非確定なので、出力結果として、[1,1], [1,2], [2,1], [2,2]が表示される可能性があり、データ競合が起きていると判断されます。
actorsの導入
swift 5.5
では、concurrency
機能の一部としてactors
があります。並列アクセスを起こさないためのswift言語の仕組みです。
actors
に関連する用語として
- actor
- actor reentrancy
- Sendable
- nonisolated
- MainActor
があります。
actor
actor
はclass
と同じ参照型です。
actor
で宣言された定義、その関数については必ずawait
を記述し、処理を待ちます。つまり他の処理から隔離されます。
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(await counter.increment())
}
Task.detached {
print(await counter.increment())
}
結果は[1,2], [2,1]のどちらかになり、データ競合を排除できました。
actor reentrancy
直訳すると、再入可能性
です。
以下のような場合を考えます。
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
cache[url] = image
return image
}
}
処理を順に追ってみます。
- ①ダウンロードをリクエスト(サーバの画像は1)
- ②ダウンロードをリクエスト(サーバの画像は2)
- ①ダウンロードが完了(画像は1)
- ②ダウンロードが完了(画像は2)
データの競合はおきませんが、cache[url]
の上書きが発生してバグとなり得ます。await
後に処理を再開する際に、プログラムの状態が変わったためです。
そこで、以下のようにします。
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
cache[url] = cache[url, default: image]
return image
}
}
ここでDictionary
のデフォルト用サブスクリプトを使っています。
重要な点としては、
awaitの後に確認する必要がある
ということです。
actor isolation
直訳すると、隔離性です。プロトコルなどとどう相互作用するかを見ていきます。
例として、Hashable
に適合してみます。
actor LibraryAccount {
let idNumber: Int
let booksOnLoan: [Book]
}
extension LibraryAccount: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(idNumber)
}
}
hash
が隔離されたactorの外からアクセスできてしまうため、エラーとなります。
Actor-isolated instance method 'hash(into:)' cannot be used to satisfy a protocol requirement
nonisolated
をつけると、非隔離となりアクセスできるようになります。
actor LibraryAccount {
let idNumber: Int
let booksOnLoan: [Book]
}
extension LibraryAccount: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(idNumber)
}
}
nonisolated
をつけると、actor外として扱われるので、let
のみアクセスできます。
var
はデータ競合を引き起こすため、アクセスできません。
Sendable
Sendable type are safe to share concurrently
Sendable
とは、同期的に共有できるタイプのことです。
- 値をある場所から別の場所にコピーし、両方の場所で変更しても互いに干渉しないこと
- 値型、actor、一部のclass(注意深く実装された場合)
Sendable protocol
例えば、
- Bool
- Int
- Float
- Double
などがあります。
カスタムのstruct
にも明示的につけることができる。Sendableではない場合はエラーが出る
struct Book: Sendable {
var title: String
var authors: [Author]
}
class Author {
var name: String = ""
}
上記の場合、以下のエラーが出ます。
Stored property 'authors' of 'Sendable'-conforming struct 'Book' has non-sendable type '[Author]'
Author
をstructに変えるとエラーは出なくなります。つまりSendableになります。
struct Author {
var name: String
}
@Sendable
@Sendable
は、関数をSendable
に適合しているかどうかチェックします。
- クロージャでキャプチャした変数が
Sendable
であるかどうかチェックする -
actor
で定義した関数にawait
がついているかチェックする
例えばTask.detachedの定義を見てみます。
@discardableResult static func detached(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success) -> Task<Success, Failure>
前述のstruct Counter
の場合でエラーが起きていたのは、この@Sendable
チェックによることが原因でした。
MainActor
@MainActor
で定義すると、以下のような特徴があります。
- UIスレッドで実行できる(DispatchQueue.mainのような感じ)
- var, func, classにつけることができる
- UIのアップデートを行う処理にはつけるべき
例えば、以下のように定義します。
@MainActor
class ViewModel: ObservableObject {
...
class
につけることで、全てのvar
の変更(ObservableObjectの場合は@Published
など), func
の処理をUIスレッドで実行するようになります。
サンプルプロジェクト
Discussion
自分も勘違いしていたのですが、おそらくactorのコードはactorの利点の説明になっていないと思います。
actorは
構造化されたタスク
のみ値の変更の保証がされているので、記事上にある構造化されていないタスク
はactorでは保証されていないはずです。(実行速度が速すぎて、毎回同じ結果になると思いますが、Task.sleep()
などをしてあげるとわかると思います。)構造化
1つのタスクにまとめることで値の保証がされるはずです。