1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Swift】Concurrencyの基本

Posted at

SwiftのConcurrencyを勉強していると、async/awaitTaskMainActoractorSendable...等々、たくさんの単語が出てきます。
ここでは、そうした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

※実際、この関数はasyncTaskともにつけなくてもこの順番で実行されます。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は以下のいずれかの条件に当てはまるものが準拠できます。

  1. 値型であること
  2. 参照型だが、可変変数がないこと
  3. 参照型だが、内部の変数のアクセスコントロールが適切になされていること
  4. @Sendableがついた関数やクロージャ

また、structclassなどの各場合においてSendableに準拠するための条件が上記公式ドキュメントに詳しく載っています。詳しくはそちらを参照ください。
Sendableに準拠していると、内部状態の変更が行えない、またはactorのメソッドを通じてしか変更ができなくなります。
このプロトコルによって、さまざまな場所で非同期処理によって変数の変更を行っても競合や不整合が起こらず、安全に値を扱うことができるようになります。

まとめ

  • Swift Concurrencyは時間がかかる処理を複数のスレッドで同時に実行する手法
  • Taskは非同期で実行する処理のまとまりで、内部の処理は処理が完了するか、またはcancel()が呼ばれるまで継続する。
  • async/awaitにより前の処理が終わった後に次の処理が始まることが保証される。これらの処理は必ず、Task内で呼ばれる。
    • プロパティもasync/awaitで非同期取得することができる。
  • actorはデータに関する競合を防ぐために存在するclassのようなもので、async/awaitでメソッドを呼び出すことで一箇所のみから内部状態を更新することが保証される。
  • Sendableは一箇所のみから更新されることが保証されており、データ競合が起こらない値が準拠しているprotocol。structclassなどさまざまな場合によってその準拠条件が異なる。

最後に

こちらは私が書籍で学んだ学習内容をアウトプットしたものです。
わかりにくい点、間違っている点等ございましたら是非ご指摘お願いいたします。

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?