【SwiftUI】Actor

SwiftUI

Swift5.5(iOS15〜)でActorが追加されました。
Actorにより排他アクセスを行う事ができ、非同期でスレッドセーフな処理を行う際に役立ちます。

Actorを使用するにはasync/awaitが前提知識として必要です。
async/awaitについては以下の記事をご覧ください。

Actorとは

Actorは排他アクセスを行う為の機能です。
Actorを使用する事でSwiftが排他アクセスを保証してくれる為、
排他アクセスの為のロックを意識せずプログラミングできます。

ミューテックスやセマフォ、あるいはクリティカルセクションなどと言うと
ピンと来る方もいるかもしれません。
Actorはそれらを意識する事なくSwiftに任せてしまう機能です。

Actorの対象になったものは排他アクセスを担保する為の制約を受けます。
基本的に同一Actorからしかアクセス出来ません。
Actor外からアクセスする場合は非同期で呼ぶ必要があります。

Actorの分類について

本サイトでは大きく2つに分類します。

1つはクラスをActorとして宣言する方法です。
classの代わりにactorで宣言する事ができます。
こうする事でそのクラスは全て同一Actorに属することになります。

actor TestModel:ObservableObject{
    var count = 0
    
    func countUp(){
        count += 1
    }
}

※補足 上記はclass同様に扱える事を示すためObservableObjectを継承しているが、適切でないので@Publishedは付けていない。理由は後述。

もう1つは属性(attribute)として使用する方法です。
この方法ではマークされたものだけがActorに属することになります。
複数のクラスに同一のActorを指定したい場合などに良いでしょう。

class TestModel2:ObservableObject{
    @MainActor @Published var count = 0
    
    @MainActor
    func countUp() {
        count += 1
    }
}

また、この2つは併用する事ができます。
殆どの処理を同一Actorで行うが、一部は別Actorで処理したい場合に使用します。
描画処理はMainActor(メインスレッド)で行わないといけない制約がある為、
こういった場合に便利です。

actor TestModel3:ObservableObject{
    @MainActor @Published var count = 0
    var count2 = 0
    
    @MainActor
    func countUp(){
        count += 1
    }

    func count2Up(){
        count2 += 1
    }
}

actorの使用例

まずはactorで宣言した場合について見ていきましょう。

actor TestModel{
    var count = 0
    
    func countUp(){
        sleep(5) //追加
        count += 1
    }
}
struct ActorView: View {
        
    var testModel = TestModel()
    @State var text = "0"
    
    var body: some View {
        VStack {
            Text(self.text)
                .padding()
            Button("CountUp"){
                Task {
                    await testModel.countUp()
                    self.text = await String(testModel.count)
                }
            }
        }
    }
}

ボタンを押すと5秒後にカウントアップします。

TestModelはactorで指定しているのでメインスレッドから直接アクセスすることができません。
その為asyncを使用して非同期でアクセスしています。

これはメソッドやメンバへの書き込みだけでなくメンバの読み取りも該当します。
その為、countの値を読み取る場合もawaitを使用しています。

なお、この方法でactorではなくclassで宣言した場合は、
メソッドにasyncを指定しても同期処理されます。

Actor属性

Actorを属性(attribute)として使用する場合は自分で定義して使うことができます。

@globalActor
actor MyActor {
    static let shared = MyActor()
}

この様にActorを定義し使用する事ができます。
MyActorとしましたので@MyActorとして使用します。

@globalActorを使用した事でMyActorはGlobalActorプロトコルに準拠する必要があります。
staticな変数sharedを持っているのはその為です。

なお@globalActorを使用した時点で暗黙的にGlobalActorプロトコルが実装される為、
MyActor:GlobalActorの様に書く必要はありません。

またメインスレッド用に@MainActorが予め用意されています。
UI変更を伴うメソッドなどにはこのMainActorを使用しましょう。
さらにMainActorにはrunメソッドが用意されています。

await MainActor.run {
    //UI変更処理など
}

ざっくりDispatchQueue.main.asyncのasync/await版だという認識で良いと思います。

GlobalActorはプロトコルでありクラス継承では無いので、
自分で定義したActorにはrunメソッドは有りません。
ちなみにactorは継承が出来ません。

使用する際は@Stateの様に@ActorNameの様に書き、クラスや変数、メソッドをマークします。

class TestModel2:ObservableObject{
    @MainActor @Published var count = 0
    
    @MyActor
    func countUp() async {
        sleep(5)
        await MainActor.run{
            count += 1
        }
    }
}

Actorでマークされたものは排他アクセスを担保する為の制約を受けます。
基本的に同一Actorでマークされたメソッドからしか変数やメソッドにアクセス出来ません。
Actor外からメソッドを呼ぶ場合は非同期で呼ぶ必要があります。

Actorでマークしたクラスは、actorで宣言した場合とは異なる部分があります。
Actorでマークした場合はイニシャライザも非同期で呼ぶ必要があります。
actorで宣言した場合はイニシャライザは同期で呼ぶ事が出来ます。

@MyActor
class SampleClass2{
    var text = ""
}

Task{
    await SampleClass2()
}

Actor属性の使用例

@globalActor
actor MyActor:GlobalActor {
    static let shared = MyActor()
}

class TestModel2:ObservableObject{
    @MainActor @Published var count = 0
    
    @MyActor
    func countUp() async {
        sleep(5)
        await MainActor.run{
            count += 1
        }
    }
}
struct ActorMarkedView: View {
    
    @StateObject var testModel2 = TestModel2()
    
    var body: some View {
        VStack {
            Text(String(self.testModel2.count))
                .padding()
            Button("CountUp"){
                Task {
                    await testModel2.countUp()
                }
            }
        }
    }
}

この例では変数が@MainActorマークされている為、Viewから直接参照する事ができます。
countUpメソッドは@MyActorでマークされている為非同期で呼ぶ必要があります。

countUpメソッド内でcount変数を変更していますがActorが異なっています。
その為、変更時には非同期処理の必要があります。
変数が@MainActorなのでawait MainActor.run{}を使用して変更しています。

Actorの効果を試してみる

実機で試してください。
シミュレータを使用する想定と異なる動作になる可能性があります。

struct ActorMarkedView: View {
    
    @StateObject var testModel = TestModel3()
    
    var body: some View {
        VStack {
            Text(String(self.testModel.count))
                .padding()
            Button("CountUp(MyActor)"){
                Task {
                    await testModel.countUp()
                }
            }
            Button("CountUp(MyActor2)"){
                Task {
                    await testModel.countUp2()
                }
            }
        }
    }
}

@globalActor
actor MyActor:GlobalActor {
    static let shared = MyActor()
}

@globalActor
actor MyActor2:GlobalActor {
    static let shared = MyActor2()
}

class TestModel3:ObservableObject{
    @MainActor @Published var count = 0
    
    @MyActor
    func countUp() async {
        sleep(5)
        await MainActor.run{
            count += 1
        }
    }

    @MyActor2
    func countUp2() async {
        sleep(5)
        await MainActor.run{
            count += 1
        }
    }
}

今度は2つのActorを用意しました。

同じボタンを2度押すと同じActorの非同期処理が2度走ります。
排他処理となるため、1度目の処理が全て終わってから2度目の処理が始まります。
5秒のsleepの後に再度5秒のsleepが入るので全ての処理が終わるまでに約10秒かかります。

異なるボタンを押すと異なるActorの非同期処理がそれぞれ走ります。
こちらは並列処理される為sleepが同時に走っています。
その為5秒のsleepが同時に終了し順に数字が変化し、約5秒で全ての処理が終了します。

この様にActorを設定すると簡単に排他処理を行う事が出来ます。

注意点としては非同期メソッド内でさらに非同期処理を行なった場合です。

class TestModel3:ObservableObject{
    @MainActor @Published var count = 0
    
    @MyActor
    func countUp() async {
        await Task.sleep(5 * 1 * 1000 * 1000 * 1000)
        await MainActor.run{
            count += 1
        }
    }

    @MyActor2
    func countUp2() async {
        sleep(5)
        await MainActor.run{
            count += 1
        }
    }
}

countUpメソッドのsleepをawait Task.sleepにしました。
この場合はTask.sleepはさらに別のActorで処理されます。
その為、一時停止しているMyActorは手隙となり他の処理が行われます。

この例で言うと、2回countUpメソッドを実行すると、
Task.sleepを待っている間に2回目のTask.sleepを行います。
するとsleepだった時とは異なり、約5秒後に全ての処理を終えてしまいます。

コメント

  1. […] […]

タイトルとURLをコピーしました