概要
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
である UIViewController
の viewDidLoad
から 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
}
}
1
の incrementCounter
を介して呼び出される 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
なプロパティにはアクセスすることができます。これは、 Sendable
な let
は同時に複数のスレッドから触られても安全なためです。逆に、 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
-
nonisolated
なinit
でも 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)}
}
}
x
と y
が初期化されるまではそのままメインスレッドで実行されていますが、初期化されて以降は actor のコンテキストで実行されるためにスレッドが切り替わっていることがわかります。この振る舞いにより、例えば以下の 1
と 2
どちらから呼ばれる 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
と同じで、以下の例では self
を print
関数の引数に渡すことで、それ以降は 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になった時の動作は未定義なのでクラッシュが発生する可能性があるようです。