Swift 5.5から登場したActorについて
サンプルコード
https://github.com/SatoTakeshiX/first-step-swift-concurrency/tree/main/try-concurrency.playground
対応するサンプルコードにはページ名を記載します。
検証環境
- Xcode 13.2.1
- Swift 5.5
データ競合
マルチスレッドプログラミングにおいて、重要な問題はデータ競合(data race)をいかに防ぐかです。複数のスレッドから一つのデータにアクセスした場合、あるスレッドがデータを更新するとデータが不整合を起こしてしまう可能性があります。デバックが非常に難しくやっかいなバグをになることが多いです。
データ競合がどういうものかをコードで解説します。
例えばゲームの点数を管理するScore
という型をクラスで定義します。
// Page: 3-1-data-race
class Score {
var logs: [Int] = []
private(set) var highScore: Int = 0
func update(with score: Int) {
logs.append(score)
if score > highScore { // ①
highScore = score // ②
}
}
}
update
メソッドでは渡されたスコアをlogs
に追加し、最高得点よりも多かったらそのスコアでhighScore
プロパティを更新するというシンプルな処理になっています。
複数スレッドからupdate
メソッドを実行して何が起こるかをみてみます。
// Page: 3-1-data-race
let score = Score()
DispatchQueue.global(qos: .default).async {
score.update(with: 100) // ③
print(score.highScore) // ④
}
DispatchQueue.global(qos: .default).async {
score.update(with: 110) // ⑤
print(score.highScore) // ⑥
}
期待する出力は順不同で100, 110が出力されることです。
③と④はそれぞれ別スレッドで同じscore
インスタンスに対してメソッドを実行します。
データ競合がなければ、それぞれ渡した点数がhighScore
として出力されるはずです。
ところが、Swift Playgroundで何回か実行すると、どちらも100になる場合や、110になる場合があります。
これはupdate
メソッド中で、①scoreが最高点数かを判断する行から②highScoreを更新する行に処理が渡る間にデータの不整合が起こるからです。
例えば、以下の順番で処理が進むとどちらの出力も100になります。
- ③が①を通過
- ⑤が①と②を通過し、highScoreが110になる
- ③が②を通過し、highScoreが100になる
- ④が通過し、100を出力
- ⑥が通過し、100を出力
シリアルディスパッチキューでデータ競合を解決
従来ではデータ競合を防ぐ方法としてスレッドをロックしたり、DispatchQueue
のシリアルキューを導入することでデータ競合を防いでいました。
ロックは一般的に扱いが難しいです。メインスレッドをロックすればUI操作が止まり、デットロックの危険があります。
ここではDispatchQueue
のシリアルキューを使った解決方法の例をみてみましょう。
// Page: 3-2-serial-dispatch-queue
class Score {
private let serialQueue = DispatchQueue(label: "serial-dispatch-queue")
var logs: [Int] = []
private(set) var highScore: Int = 0
func update(with score: Int, completion : @escaping ((Int) -> ())) {
serialQueue.async { [weak self] in
guard let self = self else { return }
self.logs.append(score)
if score > self.highScore {
self.highScore = score
}
completion(self.highScore)
}
}
}
シリアルキューはキューに入れられたタスクを順に実行し、一度に一つのタスクしか実行されません。
Score
にシリアルキューをもたせ、update
メソッドの処理をキューに入れることで、プロパティ更新中に他の処理が実行されるのを防ぎます。
結果は非同期的に得られるのでcompletion
ハンドラーを引数に追加しています。
改良版Score
を複数スレッドで実行してみます。
// Page: 3-2-serial-dispatch-queue
let score = Score()
DispatchQueue.global(qos: .default).async {
score.update(with: 100) { highScore in
print(highScore)
}
}
DispatchQueue.global(qos: .default).async {
score.update(with: 110) { highScore in
print(score.highScore)
}
}
結果は必ず100,110となります。データ競合がなくなりました。
Actorでデータ競合を守る
Swift 5.5からSwift Concurrencyが導入され、データ競合を守る新しい型が導入されました。それがactor
です。
actor
で作られたインスタンスは、同時に一つの処理のみがそのデータにアクセスできます。これにより、複数タスクがアクセスする場合でも安全にデータを扱えます。
これをActor隔離(Actor isolated)と呼びます。
さらに、データ競合が起きるようなコードがあればSwiftはコンパイル時にエラーを出し、プログラマーに修正を促します。
actor
の一種として異なるインスタンスが同じスレッドで実行される、Global Actorもあります。具体例としてメインスレッドで実行されるMainActorがありますが、解説は別の機会にします。
Score
をactor
を使って再実装します。
// Page: 3-3-actor
actor Score {
var logs: [Int] = []
private(set) var highScore: Int = 0
func update(with score: Int) {
logs.append(score)
if score > highScore {
highScore = score
}
}
}
3-1-data-raceで実装したScore
のclass
をactor
に変えただけです。
これだけで、データ競合を防ぐことができます。
さっそくScore
のインスタンスを同時にアクセスするコードを実行しましょう。
3-1-data-raceと違って複数同時アクセスにはTask.detached
を使います。
これはSwift Concurrencyで非同期処理を行うコンテキストを提供するものです。
let score = Score()
Task.detached {
await score.update(with: 100)
print(await score.highScore)
}
Task.detached {
await score.update(with: 110)
print(await score.highScore)
}
actor
のメソッドやプロパティに外からアクセスするにはawait
が必要です。
結果は必ず100, 110になります。class
をactor
に変えるだけでデータ競合がなくなりました。
Actorの文法
actor
はclass
と同じく参照型のインスタンスを作ります。actor
は外から共有して利用することが目的だからです。
さきほども言いましたが、同時に一つのタスクしかアクセスされない型を表します。
class
、struct
、enum
と同じ機能を提供します。例えばプロパティやメソッドの定義、イニシャライザー、添字、そしてプロトコルの適合などです。
ただし、actor
はclass
とは違って継承はできません。
次のようなコードを書いてもエラーになります。
// 3-4-actor-syntax
actor A {}
actor B: A {} // Actor types do not support inheritance
actor
はインスタンスごとにActor隔離として他のプログラムから守られています。
actor
外からアクセスする際はプロパティやメソッドの前にはawait
がつけます。
これはコンパイルに他のタスクがアクセスしている場合にプログラムを中断するし、そのタスクが終わるまで待つことを伝えるためです。
試しにactor
のメソッドの前にawait
を削除するとコンパイルエラーになります。
// Page: 3-3-actor
Task.detached {
score.update(with: 100) // Expression is 'async' but is not marked with 'await'
また、await
をつけたからといってactor
のデータを外から更新はできません。
// Page: 3-4-actor-syntax
actor A {
var number: Int = 0
}
let a = A()
Task.detached {
await a.number = 1 // Actor-isolated property 'number' can not be mutated from a Sendable closure
}
一方で、actor
内でのプロパティ更新にはawait
はいりません。
Actorが他のコードから隔離されているので、Actor内では自由に自身のプロパティにアクセスしたり、更新しても問題ないからです。
// Page: 3-3-actor
actor Score {
var logs: [Int] = []
private(set) var highScore: Int = 0
func update(with score: Int) {
logs.append(score) // データを更新
if score > highScore {
highScore = score // データを更新
}
}
}
nonisolatedでactor隔離をやめる
actor
はデフォルトで他のプログラムから隔離されており、外からのアクセスにはawait
が必要です。ですが、それでは都合が悪い場合が出てきます。
例えばHashable
プロトコルの適合です。
Hashable
プロトコルに適合にはhash(into:)
メソッドを実装しなければいけません。
しかしactor
のメソッドは外からawait
をつけて実行しなければいけないのでそのままではコンパイルエラーになります。
// Page: 3-4-actor-syntax
actor B: Hashable {
static func == (lhs: B, rhs: B) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) { // Actor-isolated instance method 'hash(into:)' cannot be used to satisfy a protocol requirement
hasher.combine(id)
}
let id: UUID = UUID()
private(set) var number = 0
func increace() {
number += 1
}
}
このような場合、hash(into:)
メソッドの前にnonisolated
というキーワードをつけることで解決できます。
// Page: 3-4-actor-syntax
actor B: Hashable {
...
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
nonisolated
は文字通りActor分離をやめることを表すキーワードです。これをつけることでactor
の外ではawait
なしで実行できます。
// Page: 3-4-actor-syntax
let b = B()
let dic = [b: "xxx"]
nonisolated
はメソッドの他にプロパティにもつけることができます。
ただし、書き込み可能なデータやその操作に対してnonisolated
をつけることはできません。
もしつけるとコンパイルエラーになります。
actor B: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(number) // Actor-isolated property 'number' can not be referenced from a non-isolated context
}
private(set) var number = 0
}
上記の例ではnumber
プロパティが書き込み可能なのにも関わらずnonisolated
内でアクセスをしたためエラーになりました。書き込みできるデータはデータ競合が起きる可能性があるのでコンパイルが守っているのです。
nonisolated
でエラーになるかどうかはSendable
プロトコルに適合しているかどうかによります。Sendable
プロトコルについてはまた次回解説します。
awaitと状態変化
メソッド内でawait
を使う場合はawait
の前後でactor
の状態が変化するので注意が必要です。
3-3-actorで実装したScore
を改良し、最高得点をサーバーに問い合わせるようにします。
次のようなコードを書きます。
// Page: 3-5-actor-await
actor Score {
var localLogs: [Int] = []
private(set) var highScore: Int = 0
func update(with score: Int) async {
localLogs.append(score) // ①
highScore = await requestHighScore(with: score) // ②
}
func requestHighScore(with score: Int) async -> Int {
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000) // 2秒待つ
return score
}
}
requestHighScore
メソッドはサーバーに点数を送るとサーバーが集計した自分の最高得点が得られると想定するメソッドですが、実際には2秒間処理を止め引数のscore
を返すだけのメソッドです。
2つのタスクを非同期で実行させます。
// Page: 3-5-actor-await
let score = Score()
Task.detached {
await score.update(with: 100) // ③
print(await score.localLogs) // ③'
print(await score.highScore) // ③''
}
Task.detached {
await score.update(with: 110) // ④
print(await score.localLogs) // ④'
print(await score.highScore) // ④''
}
結果はこうなります。
[100, 110]
100
[100, 110]
110
どちらもlocalLogs
の出力は[100, 110]
と同じ結果になりました。
処理の流れを見ていきましょう。
- ③が実行。③が①を通る
- ③が②を通る。
await requestHighScore
で処理が中断される - ④が実行。④が①を通る
- ④が②を通る。
await requestHighScore
で処理が中断される - ③の
await requestHighScore
が再開され、highScore
が更新される。 - ③'、③''が実行
- ④の
await requestHighScore
が再開され、highScore
が更新される。 - ④'、④''が実行
並行処理なので、1と4は順番入れ替わる可能性はあります。
それでは、続いて、localLogs
の更新をawait
の後に実装すると出力結果はどうなるでしょうか?
func update(with score: Int) async {
highScore = await requestHighScore(with: score) // ②
localLogs.append(score) // ①
}
結果はこうなります。
[100]
100
[100, 110]
110
先ほどと異なり、③'と④'のlocalLogs
にはそれぞれ別になっています。
どうして結果が異なったのでしょうか?
処理の流れはこうです。
- ③が実行。③が②を通る。
await requestHighScore
で処理が中断される - ④が実行。④が②を通る。
await requestHighScore
で処理が中断される - ③の
await requestHighScore
が再開され、highScore
が更新される - ③が①を通る(localLogsをappendする)
- ③'、③''が実行
- ④の
await requestHighScore
が再開され、highScore
が更新される。 - ④が①を通る(localLogsをappendする)
- ④'、④''が実行
ただし並行処理なので1と2は実行順が入れ替わる可能性はあります。
await
キーワードをつけると、プログラムが中断し他の処理が実行されることを意味します。
await
前後でプロパティを更新すると、結果が異なる場合があるので注意が必要です。
処理をわかりやすくする方法の一つとして、プロパティの更新は一つのメソッドに分ける方法があります。
// Page: 3-5-actor-await
actor ScoreV2 {
var localLogs: [Int] = []
private(set) var highScore: Int = 0
func update(with score: Int) async {
let highScore = await requestHighScore(with: score)
update(highScore: highScore, score: score)
}
// 同期的にプロパティを更新する
private func update(highScore: Int, score: Int) {
self.highScore = highScore
localLogs.append(score)
}
}
上記のScoreV2
ではプロパティの更新はupdate(highScore: score:)
メソッドにまとめています。actor
内の同期的なメソッド、つまりasync
がつかないメソッドはActor隔離で守られているので、安全にデータを更新できます。
続いてawaitでプログラム状態が変わる例をもう一つみてみましょう。
URLから画像をリクエストするImageDownloader
を作ります。
ただし、同じURLで画像のリクエストを何度もしたくないのでキャッシュで保存します。
// Page: 3-5-actor-await
actor ImageDownloader {
private var cached: [String: String] = [:]
func image(from url: String) async -> String {
// キャシュがあればそれを使う
if cached.keys.contains(url) {
return cached[url]!
}
// ダウンロード
let image = await downloadImage(from: url) // ①
// キャッシュに保存
cached[url] = image // ②
return cached[url]!
}
func downloadImage(from url: String) async -> String {
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000) // 2秒待つ
switch url {
case "monster":
// サーバー側でリソースが変わったことを表すため、ランダムで返す
return Bool.random() ? "👾" : "🎃"
default:
return ""
}
}
}
downloadImage
はサーバーに画像をリクエストを想定するメソッドです。サーバー側のリソースが変わることを表すため、ランダムで👾か🎃の絵文字を返しています。
一見問題なさそうですが、場合によっては不具合の可能性を秘めるコードです。
ではこれを実行します。
// Page: 3-5-actor-await
let imageDownloader = ImageDownloader()
Task.detached {
let image = await imageDownloader.image(from: "monster") // ③
print(image) // ③'
}
Task.detached {
let image = await imageDownloader.image(from: "monster") // ④
print(image) // ④'
}
すると時々👾と🎃がどちらも出力される場合がありました。
👾
🎃
これはバグの可能性があります。キャッシュはクリアするまで同じURLなら同じ画像(ここでは絵文字)が出力されてほしいですが、異なる結果になりました。
処理の流れを見てみましょう。
- ③が実行され、①を通り、
await downloadImage(from: url)
で処理が中断される - ④が実行され、①を通り、
await downloadImage(from: url)
で処理が中断される - ③の①が再開され、👾または🎃が
image
に代入される - ③の②が通り、キャッシュが更新される
- ④の①が再開され、👾または🎃が
image
に代入される - ④の②が通り、キャッシュが更新される
データ競合という意味では不具合はないですが、キャッシュという意味では潜在的な不具合を秘めています。
コードを改良しましょう。awaitの後でキャッシュを調べ同じURLがあれば更新しないようにします。
// Page: 3-5-actor-await
func image(from url: String) async -> String {
if cached.keys.contains(url) {
return cached[url]!
}
let image = await downloadImage(from: url)
if !cached.keys.contains(url) { // 追加
cached[url] = image
}
return cached[url]!
}
今度は、👾のみの場合、または🎃の場合で出力されるようになります。
このように、awaitの前後ではプログラムの状態が変わります。actor
を使えばデータ競合は起きなくなりますが、await前後でプロパティを更新する場合は注意が必要です。
今回提示した解決策は次の2つです。
- 更新メソッドを切り分ける
- awaitの後でデータを処理する
awaitの後はプログラムが中断することを踏まえ、actorの状態を更新しましょう。
まとめ
Actorという新しい型について解説しました。
データ競合という並行プログラミングにおいて厄介な問題をスマートに解決する新しい型です。
Actor隔離のおかげで複数タスクから同時にデータを読み込んでもデータを守れるコードを簡単に実装できます。
ただし、Actor内のメソッドでawaitを呼び出すときは注意が必要です。
プログラムの中断と再開でデータの状態が変わるので、それに対応するコードを書かないと思わぬ不具合に繋がります。
今回の記事ではではSendableとMainActorの解説はしませんでした。
SendableはActorの外に渡せるデータかどうかをコンパイラが判断するもので、MainActorはメインスレッドで実行される特別なactorです。
次の機会にこの2つをそれぞれ解説しますのでお楽しみに。
参考文献
- Concurrency — The Swift Programming Language (Swift 5.5)
- swift-evolution/0313-actor-isolation-control.md at main · apple/swift-evolution
- Protect mutable state with Swift actors - WWDC21
- Swift Concurrency チートシート
- Concurrent vs Serial DispatchQueue: Concurrency in Swift explained
宣伝
BOOTHより、同人版「Swift Concurrency入門」発売中です。
Swift Concurrencyを網羅的に学べ、さらに既存アプリへの適応方法も解説しています。
日本語で体系的に学べる解説本は他になかなかありません。
1章、2章が立ち読みできるおためし版もありますので、ぜひチェックしてください!