SwiftのConcurrencyを勉強していると、async/await
、Task
、MainActor
、actor
、Sendable
...等々、たくさんの単語が出てきます。
ここでは、そうしたConcurrencyの基本について解説していきます。
Concurrencyの概要
Swiftにおける処理は基本、コードが上から順に実行されていきます。こうした同期的処理は、main threadという単一のスレッドで実行されていきます。
しかし、APIへのデータフェッチなど、完了までに時間がかかる処理があると実行が止まってしまうため、UXとして非常に悪くなってしまいます。
そのため、複数のスレッドで同時に実行し、時間がかかる処理はmain thread以外で処理することでこうした問題を解決する為に開発されたのが、今回紹介するSwift Concurrencyです。
Concurrencyの基本
Task
非同期で実行する処理のまとまりです。Task内で定義された処理はいずれかのthread内で実行され、Task
内の処理が完了するか、またはcancel()
が呼ばれるまで継続します。
以下のように、時間がかかる処理であってもmain threadで行われる処理の遅延等の影響は無くなります。
func process1() {
Task {
var count = 0
print("process1 started")
for _ in 0..<10000 {
count += 1
}
print("process1 ended")
}
}
print("main thread print start")
process1()
print("main thread print end")
// main thread print start
// main thread print end
// process1 started
// process1 ended
また、cancel()
が呼ばれた際に処理を止めたい場合などは、Task.checkCancellation()
を呼ぶことによって、cancel()
が呼ばれた際に該当箇所でエラーが吐き出され、処理が止まるようになります。
async/await
しかし、Task
中の処理は並行処理であり、Task
内に書かれた順番に関わらず同時に実行されるため、以下のような場合に順番がバラバラになります。
func process1() {
Task {
var count = 0
print("process1 started")
for _ in 0..<10000 {
count += 1
}
print("process1 ended")
}
}
func process2() {
Task {
var count = 0
print("process2 started")
for _ in 0..<1000 {
count += 1
}
print("process2 ended")
}
}
print("main thread print start")
process1()
process2()
print("main thread print end")
// main thread print start
// main thread print end
// process1 started
// process2 started
// process2 ended
// process1 ended
このように、非同期処理の中でも「前の処理が終わるまで次の処理を実行したくない」場合があると思います。これを解決するのがasync/await
です。
以下のように、関数をasync
にし、また呼び出し部分全体をTask
で囲ったのちに関数にawait
を付与すると、以下のように上から順に処理が開始、終了されるようになります。
Task {
print("main thread print start")
await process1()
await process2()
print("main thread print end")
}
// main thread print start
// process1 started
// process1 ended
// process2 started
// process2 ended
// main thread print end
※実際、この関数はasync
、Task
ともにつけなくてもこの順番で実行されます。for
ループが同期処理である為です。ここでは説明のためにあえてasync
を付与しています。
全てのasync/await
の処理は必ず、何らかのTask
の中で実行されます。
Async Property
上記は関数の話でしたが、例えばプロパティを非同期で取得したい場合はどのようにすれば良いのでしょうか。
実は、以下のようにgetterにasync
をつけることによって、async
関数同様に値を取得できるようになります。
func getDataAsync() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "Data"
}
struct DataPool {
var data: String {
get async throws {
return try await getDataAsync()
}
}
}
let dataPool = DataPool()
print(try await dataPool.data)
// Data
Actor
概要
しかし、Concurrencyでは並行で処理を実行するというその性質上、いくつかのリスクも抱えています。
例えば、二箇所で同じ値を同時に書き換えした場合、予期せぬ変化、状態が生じてしまうリスクを孕んでいます。
そのような複数のスレッドから同時に変更が加わってしまう、いわゆるデータ競合状態を防ぐために登場したのが、今回紹介するactorになります。
具体例
例えば、銀行にお金を預けたり引き出したりする場合を考えます。
class ClassBank {
private var current = 0
func add(_ amount: Int) {
current += amount
}
func remove(_ amount: Int) {
if current > 0 {
current -= amount
}
}
var currentSum: Int {
return current
}
}
DispatchQueue.global(qos: .default).async {
for _ in 0..<100 {
ABank.add(100)
}
}
DispatchQueue.global(qos: .default).async {
for _ in 0..<100 {
ABank.remove(100)
}
}
Task {
try await Task.sleep(for: .seconds(1))
print(ABank.currentSum)
// 結果はばらつく
}
上記のコードの場合、100
の金額に対して100回add
を呼び100回remove
を呼んでいるので合計金額は0
になるはずです。
しかし、変更を加える順番は保証されておらず、currentSum
の結果がばらつくことから一定の割合でremove
処理が失敗しているのがわかります。
そこで以下のように書くことで、actor
がデータ競合を解決してくれます。
actor ActorBank {
private var current = 0
func add(_ amount: Int) {
current += amount
}
func remove(_ amount: Int) {
if current > 0 {
current -= amount
}
}
var currentSum: Int {
return current
}
}
let BBank = ActorBank()
Task {
for _ in 0..<100 {
await BBank.add(100)
await BBank.remove(100)
}
}
Task {
try await Task.sleep(for: .seconds(1))
print(await BBank.currentSum) // 0
}
actor
のメソッドはawait
をつけることでのみ呼ぶことができるので、確実にadd
が呼ばれた後にremove
が呼ばれる、ということが保証されます。
そのため、結果は必ず0
になります。
actor isolation
actor
のプロパティは一箇所からのアクセスしか受け付けておらず、プロパティへアクセスする際にもawait
をつける必要があります。
このようにactor
のプロパティへのアクセスが一箇所からに制限されていることをactor isolationと呼びます。
しかし、await
する必要がないプロパティ(例えば、定数や定数と連動するcomputed propertyなど、変化しないことが保証されているもの)もあります。
そうしたものにはnonisolated
修飾子をつけてあげることで、await
がなくともそのプロパティにアクセスできるようになります。
let BBank = ActorBank(name: "BBank")
actor ActorBank {
private var current = 0
let name: String
init(name: String) {
self.name = name
}
func add(_ amount: Int) {
current += amount
}
func remove(_ amount: Int) {
if current > 0 {
current -= amount
}
}
var currentSum: Int {
return current
}
nonisolated var bankName: String {
return name
}
}
print(BBank.bankName) // BBank
Sendable
そして、actor
のように、数箇所からの変更が加わらないことが保証されている値が準拠するprotocolが存在します。それがSendable
です。
Sendable
は以下のいずれかの条件に当てはまるものが準拠できます。
- 値型であること
- 参照型だが、可変変数がないこと
- 参照型だが、内部の変数のアクセスコントロールが適切になされていること
-
@Sendable
がついた関数やクロージャ
また、struct
やclass
などの各場合においてSendable
に準拠するための条件が上記公式ドキュメントに詳しく載っています。詳しくはそちらを参照ください。
Sendableに準拠していると、内部状態の変更が行えない、またはactor
のメソッドを通じてしか変更ができなくなります。
このプロトコルによって、さまざまな場所で非同期処理によって変数の変更を行っても競合や不整合が起こらず、安全に値を扱うことができるようになります。
まとめ
- Swift Concurrencyは時間がかかる処理を複数のスレッドで同時に実行する手法
-
Task
は非同期で実行する処理のまとまりで、内部の処理は処理が完了するか、またはcancel()
が呼ばれるまで継続する。 -
async/await
により前の処理が終わった後に次の処理が始まることが保証される。これらの処理は必ず、Task
内で呼ばれる。- プロパティも
async/await
で非同期取得することができる。
- プロパティも
-
actor
はデータに関する競合を防ぐために存在するclassのようなもので、async/await
でメソッドを呼び出すことで一箇所のみから内部状態を更新することが保証される。 -
Sendable
は一箇所のみから更新されることが保証されており、データ競合が起こらない値が準拠しているprotocol。struct
やclass
などさまざまな場合によってその準拠条件が異なる。
最後に
こちらは私が書籍で学んだ学習内容をアウトプットしたものです。
わかりにくい点、間違っている点等ございましたら是非ご指摘お願いいたします。