LoginSignup
25
15

More than 1 year has passed since last update.

[Swift] actor の init / deinit における decay を理解する

Last updated at Posted at 2022-08-07

概要

actor の init / deinit は初見では不可解に思えるような振る舞いをします。例えば、以下のコードでは2回 print(self.count) を呼んでいますが2つ目だけに警告が出ます。

actor Counter {
    var count: Int

    init(count: Int) {
        self.count = count

        print(self.count)
        print(self)
        print(self.count) // ❗️Cannot access property 'count' here in non-isolated initializer; this is an error in Swift 6
    }
}

この警告は actor が持つ可変な状態をデータ競合から守るために設定されている decay という仕組みに由来するものです。この記事では、上記の例も含めて actor の init / deinit の振る舞いを理解していきます。

記事中での動作検証は Xcode 14 Beta 4 で行っています。また、 将来的に Swift 6 でエラーになるコードに対して警告が出るように Strict Concurrency Check を Complete に設定しています。 Strict Concurrency Check の詳細については以下の記事を参照してください。

TL;DR

  • actor の sync な init の中では、 self を別の処理の引数に渡すなど特定の操作をした以降に non-isolated なコンテキストに切り替わる decay が発生する
  • async な init では decay が発生しない代わりに self のすべてのプロパティが初期化された時点で暗黙的に suspend し自身の actor コンテキストに切り替わる
  • actor の deinit では decay に加えて、decay が発生する前でも Sendable でないプロパティにはアクセスできないという制限が加わる

sync な init における decay

Swift の init は async にすることもできますが、まずは sync な init から見ていきます。例として以下の actor を考えます。

actor Counter {
    var count: Int

    init(count: Int) {
        self.count = count
    }

    func increment() {
        count += 1
    }
}

まずは、記事の冒頭で挙げた警告が出る理由を理解していきます。init に処理を書き加えると再現ができます。

actor Counter {
    var count: Int

    init(count: Int) {
        self.count = count

        print(self.count)
        print(self)
        print(self.count) // ❗️Cannot access property 'count' here in non-isolated initializer; this is an error in Swift 6
    }

    // ...
}

2回目の print(self.count) に対してのみ警告が出ていますが、これは sync な init の中で self に対して特定の操作をするとそこで self が non-isolated に変化するという性質のためです。 SE-0327 に従ってこの変化のことを decay と呼びます。
例えば関数の引数に self を渡すと decay が発生するので、それ以降は self は non-isolated になり、 sync な init からはアクセスできなくなるので上記の警告が出るというわけです。

このように関数の途中でいきなり self の性質が変わるのは不思議に思えるので、 decay がなぜ必要なのか考えていきます。
前提となる注意点として、actor の init は actor のコンテキストではなく呼び出し元のスレッドで実行されます。例えば、以下のように @MainActor である UIViewControllerviewDidLoad から Counter.init を呼ぶと、 init の中身はメインスレッドで実行されます。

final class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let counter = Counter(count: 0) // メインスレッドで実行される
    }
}

これは、同期的に実行されるsync な init ではコンテキストを切り替えたくてもそのタイミングがないことを考えると自然です。

以上を念頭に置いた上で、例えば以下のようなコードを書くとデータ競合が起き得ることがわかります。

func incrementCounter(_ counter: Counter) {
    Task.detached {
        await counter.increment()
    }
}

actor Counter {
    var count: Int
    
    init(count: Int) {
        self.count = count

		// 以下2つの呼び出しでデータ競合が発生し得る
        incrementCounter(self) // 1
        self.increment()       // 2
    }
    
    func increment() {
        count += 1
    }
}

1incrementCounter を介して呼び出される increment は外部から await で呼ばれているので actor のコンテキストで実行されるのに対して、 2 で直接呼び出される increment は呼び出し元のスレッドで実行されます。これにより同時に2つのスレッドが increment を呼ぶ可能性があり、ひいては count に同時に書き込んでデータ競合が起き得ます。
これは self を別の関数に渡すことにより init と並行して何らかの処理が self に対して実行されるかもしれないことに起因する問題なので、 self を別の関数に渡して以降は init の中で直接 self が触れないように制限すればデータ競合は起きなくなります。そのために、別の関数に渡した以降は self が non-isolated になるように実装されており、これが decay です。実際に上記のコードを Xcode 14 で書くと decay により 2 の箇所で Actor-isolated instance method 'increment()' can not be referenced from a non-isolated context; this is an error in Swift 6 という警告が出るようになり、データ競合の可能性にコンパイル時に気づけるようになっています。

注意点として、decay が発生しても let で宣言された Sendable なプロパティにはアクセスすることができます。これは、 Sendablelet は同時に複数のスレッドから触られても安全なためです。逆に、 Sendable でないプロパティや Sendable であっても var で定義されているプロパティについてはデータ競合が起き得るため、 decay により self が non-isolated になった状態でアクセスしようとすると警告が出ます。

actor MyActor {
    var nonSendableVar = NonSendableType()
    let nonSendableLet = NonSendableType()
    var sendableVar = SendableType()      
    let sendableLet = SendableType()

    
    init() {
        print(self) // decay によりこれ以降 self は non-isolated
        
        print(nonSendableVar) // ❗️Cannot access property 'nonSendableVar' here in non-isolated initializer; this is an error in Swift 6
        print(nonSendableLet) // ❗️Cannot access property 'nonSendableLet' here in non-isolated initializer; this is an error in Swift 6
        print(sendableVar)    // ❗️Cannot access property 'sendableVar' here in non-isolated initializer; this is an error in Swift 6
        print(sendableLet)    // ✅ OK
    }
}

ここまでは decay の原因として self を別の関数の引数に渡すことのみを挙げてきましたが、実際には以下の操作でも decay が起こります。これは、 self を別の関数の引数に渡すのと同じような原因により decay しないとデータ競合が発生し得るためです。

  • self のメソッドを呼び出す
  • self の compueted propery にアクセスする
  • self のプロパティの didSet や willSet などのオブザーバを発火させる
  • self をクロージャがキャプチャする
  • self をメモリに保存する

もちろん、上記の操作によって必ずしもデータ競合が発生し得る状況になるわけではありません。例えば、以下のコードではプロパティに対して値の読み込みしか行っていないのでデータ競合は発生しませんが、それでも decay は発生し警告が出ます。

actor Counter {
    var count: Int
    
    init(count: Int) {
        self.count = count
        print(self)
        print(self.count) // ❗️Cannot access property 'count' here in non-isolated initializer; this is an error in Swift 6
    }
}

これは、個々のケースについてコンパイラが self の使われ方を見てデータ競合が発生するかを判断するのがコストが高かったり、場合によっては不可能なために条件を満たしたら一括で decay するようになっているためです。

また、

  • global actor に isolated された init
  • nonisolatedinit

でも sync な init と同じ条件を満たすと decay が発生します。これらはいずれも actor のコンテキスト外で実行されるという共通点を持ち、同様の原因でデータ競合を引き起こすためです。

async な init におけるコンテキスト切り替え

async な init は sync な init とは別の振る舞いをします。sync な init で警告が出たコードをそのまま async な init に変更すると警告が出なくなります。

actor Counter {
    var count: Int

    init(count: Int) async {
        self.count = count

        print(self.count)
        print(self)
        print(self.count) // ✅ OK
    }
}

これは、 async な init ではすべてのプロパティが初期化された時点で暗黙のうちに actor のコンテキストに切り替わって実行されるためです。上記の print(self.count) は sync な init とは異なり actor のコンテキスト内で実行されているので、同期的にアクセスしても何も問題ないというわけです。

コンテキストの切り替えが実際に発生していることを確認するため、複数のプロパティを持つ actor の init をメインスレッドから実行してみます。

actor Point {
    var x: Int
    var y: Int
    
    init(x: Int, y: Int) async {
        print(Thread.current.description) // <_NSMainThread: 0x600001704080>{number = 1, name = main}
        self.x = x
        print(Thread.current.description) // <_NSMainThread: 0x600001704080>{number = 1, name = main}
        self.y = y 
        // ここですべてのプロパティが初期化されたのでスレッドが切り替わる

        print(Thread.current.description) // <NSThread: 0x600001700240>{number = 2, name = (null)}
    }
}

xy が初期化されるまではそのままメインスレッドで実行されていますが、初期化されて以降は actor のコンテキストで実行されるためにスレッドが切り替わっていることがわかります。この振る舞いにより、例えば以下の 12 どちらから呼ばれる increment も actor のコンテキスト上で実行されることからデータ競合が起こることはありません。

func incrementCounter(_ counter: Counter) {
    Task.detached {
        await counter.increment()
    }
}

actor Counter {
    var count: Int
    
    init(count: Int) async {
        self.count = count

        incrementCounter(self) // 1
        self.increment()       // 2
    }
    
    func increment() {
        count += 1
    }
}

つまり、sync な init ではデータ競合を decay により回避しているのに対し、 async な init では自動的に actor のコンテキストに切り替えることで回避しているということです。これは、async な init では suspend してコンテキストを切り替えることが可能であるために取れる方法です。

コンテキスト切り替えが暗黙的に行われる理由

コンテキスト切り替えは通常 await を挟んで開発者が明確に認識できる形で発生するので、 actor の async init ではこれが暗黙のうちに行われるのは特徴的です。 SE-0327 ではその理由として以下が挙げられています。

  • すべてのプロパティが初期化されるタイミングは、 init の引数が増えたりデフォルト引数が設定されたりなどの変更によって変わってしまうので、もしコンテキスト切り替えのタイミングを await などでマークしないといけない場合はそのたびにコードの書き換えが発生してわずらわしい
  • そもそもプロパティ初期化でコンテキストが切り替わるというのは実装の詳細であって、開発者が意識する必要はない
  • actor の処理の中で await が挟まる場合は actor reentrancy が発生し得るので注意する必要があり、それが明示的に await を書く理由にもなっているが、 init の場合はプロパティの初期化が行われるまでは init 以外が actor インスタンスにアクセスすることができないので reentrancy が発生し得ない

actor reentrancy について、よければ以下の記事も参照してみてください。

プロパティが初期化されるまでの self の弱い isolation

ここまで考えると逆にすべてのプロパティが初期化されるまでは actor のコンテキスト外で実行されているのに同期的にプロパティを設定できるのが不思議に思えてきます。

actor Point {
    var x: Int
    var y: Int
    
    init(x: Int, y: Int) async {
        self.x = x // actor のコンテキスト外なのに同期的に値を設定できている
        self.y = y
    }
}

これは、すべてのプロパティを初期化するまでは init 以外から self を触ることができないため、実質的に self が isolated されているためです。 SE-0327 ではこのことを

weaker form of isolation that relies on having exclusive access to the reference self

と表現しています。実際、すべてのプロパティの初期化前に self にアクセスしようとすると以下のようにエラーになります。

actor Point {
    var x: Int
    var y: Int
    
    init(x: Int, y: Int) async {
        self.x = x
        doSomething(self) // ❗️'self' used in method call 'doSomething' before all stored properties are initialized
                          // self.y が未設定なので init 以外からは `self` を使えない
        self.y = y
    }
}

init しか self に触ることができないならば当然データ競合の心配もないため、文法上 actor のプロパティをコンテキスト外から同期的に設定できるということです。逆に、これが許されていない場合は actor のインスタンスをコンテキスト外から初期化できなくなってしまいます。

sync な init で decay が発生するまでは同期的にプロパティが設定できるのも同じ理由です。

deinit における decay

ここまで sync / async それぞれの init について書きましたが、 actor の deinit にも注意すべき点があるので見ていきます。

deinit における decay

deinit は参照型の reference count が0になった時に任意のスレッドから呼ばれます。これは actor であっても同じなので、actor の deinit は actor のコンテキスト外で実行されるということになります。
コンテキスト外で実行されるということは sync な init と同様にデータ競合が起こり得るということなので deinit でもこれを回避するための decay が発生します。decay の条件も init と同じで、以下の例では selfprint 関数の引数に渡すことで、それ以降は self が non-isolated になっています。

actor Counter {
    var count: Int
    
    deinit {
        print(self.count)
        print(self)
        print(self.count) // ❗️Cannot access property 'count' here in deinitializer; this is an error in Swift 6
    }
}

逆に、 self を関数の引数として渡すなどして decay の条件を満たすまでは self にアクセスできるのは deinit を呼んだスレッドだけなので self は isolated されていて安全に同期的な操作をすることができます。

deinit における Sendable でないプロパティへのアクセス制限

decay に加えて、 deinit では decay が発生する前であっても Sendable でないプロパティにはアクセスできないという制限が加わります。

actor MyActor {
    var nonSendableVar = NonSendableType()
    let nonSendableLet = NonSendableType()
    var sendableVar = SendableType()
    let sendableLet = SendableType()
    
    init() {
        print(nonSendableVar) // ✅ OK
        print(nonSendableLet) // ✅ OK
        print(sendableVar)
        print(sendableLet)
        
        print(self)
        
        print(nonSendableVar) // ❗️Cannot access property 'nonSendableVar' here in non-isolated initializer; this is an error in Swift 6
        print(nonSendableLet) // ❗️Cannot access property 'nonSendableLet' here in non-isolated initializer; this is an error in Swift 6
        print(sendableVar)    // ❗️Cannot access property 'sendableVar' here in non-isolated initializer; this is an error in Swift 6
        print(sendableLet)
    }
    
    deinit {
        // init と異なり decay 前の non-Sendable なプロパティへのアクセスもできないようになっている
        print(nonSendableVar) // ❗️Cannot access property 'nonSendableVar' with a non-sendable type 'NonSendableType' from non-isolated deinit; this is an error in Swift 6
        print(nonSendableLet) // ❗️Cannot access property 'nonSendableLet' with a non-sendable type 'NonSendableType' from non-isolated deinit; this is an error in Swift 6
        print(sendableVar)
        print(sendableLet)
        
        print(self)
        
        print(nonSendableVar) // ❗️Cannot access property 'nonSendableVar' here in non-isolated initializer; this is an error in Swift 6
        print(nonSendableLet) // ❗️Cannot access property 'nonSendableLet' here in non-isolated initializer; this is an error in Swift 6
        print(sendableVar)    // ❗️Cannot access property 'sendableVar' here in non-isolated initializer; this is an error in Swift 6
        print(sendableLet)
    }
}

前提として、 global actor はすべてのインスタンスの処理が1つの executor で行われるため複数のインスタンスの間で Sendable でないプロパティを安全に共有することができます。 deinit は actor のコンテキスト外で実行されるにも関わらず decay の前はプロパティに同期的にアクセスできてしまうので、複数のスレッドからその Sendable でないプロパティにアクセスを行うことでデータ競合が発生する可能性があります。言い換えると、global actor で Sendable でないプロパティが共有できるのは、同じ global actor に従う複数のインスタンスの処理が同時に実行されないという前提があるからですが、 deinit ではこれが崩れるということです。

例えば以下のような実装により deinit でデータ競合が発生することがあります。

class NonSendableCounter {
    var count: Int = 0
    func increment() { count += 1 }
}

@MainActor
final class Container {
    let counter: NonSendableCounter
    
    init() {
        self.counter = NonSendableCounter()
    }
    
    init(sharingCounterWith other: Container) {
        // self と other はいずれも MainActor であるため同時に処理が実行されることはなく、
        // Sendable なプロパティであっても(deinit のことを考えなければ)安全に共有できる
        self.counter = other.counter
    }
    
    deinit {
		// deinit は actor のコンテキスト外で実行されるので self と other の deinit が同時に実行され得る
		// 同時に実行された場合、データ競合が発生する可能性がある
        counter.increment()
    }
}

人工的なコードですが、上記の Container を以下のように操作することで実際にデータ競合が発生することが確認できました。

var container1: Container? = .init()
var container2: Container? = .init(sharingCounterWith: container1!)
let container3: Container = .init(sharingCounterWith: container1!)

// counter1 と counter2 の deinit が同時に実行される可能性がある
let t1 = Task.detached { container1 = nil }
let t2 = Task.detached { container2 = nil }

try await (t1.value, t2.value)

print(container3.counter.count) // 1 or 2

deinit の中で Sendable でないプロパティが触れない制限を回避するために Task を起動する方法を思いつくかもしれませんが、これは避けた方がいいでしょう。

actor MyActor {
    var nonSendableVar = NonSendableType()
    
    deinit {
        Task.detached {
            await print(self.nonSendableVar)
        }
    }
}

deinit は reference count が0になった時に呼ばれますが、このように deinit の中でさらに self の参照を増やすと reference count が1に増えてしまい、再度0になった時の動作は未定義なのでクラッシュが発生する可能性があるようです。

参考

25
15
1

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
25
15