何も考えず言語モードを 5 から 6 にしてビルドしたらエラー欄が真っ赤になってひえってなった人こんにちは。
ZYYX の幻獣枠その1、後藤です。(その2の人もなんか記事書きましょう)
社内ではモバイルアプリの開発メインで活動しています。
関数型言語、強い静的型付け言語が大好きなので、モバイル系で仕事にすんなり使えるものの中では Swift が一番のお気に入り。
社内勉強会向けに書いていたものを、どうせだから記事にしろと言われたのでそのまま貼ります。
本当は実際に Swift 6 対応した際の差分なんかを見せる予定もあったんですが、実案件のプロジェクトをベースにしているため外には出せません。悪しからず。
多少なりと Swift に触れたことがある(できれば Swift Concurrency にも)人をターゲットとしています。
Swift 6 対応への心構え
isolation
を意識する
isolation
とは?
- 直訳すれば『分離』や『隔離』などで、つまりは隔離された実行空間である
- 実際にどういうものかというと概念としてはスレッドみたいなものと思えば良いはず
- 平行して走るプログラムの流れであり
- 互いにデータをやり取りする際には気をつけないと変なことが起きるよというもの
- 現状の Swift において
isolation
はほぼactor
とイコールであると思って良い-
DispatchQueue
なんかも含まれるはずだけど混ぜると危険なのでひとまず置いておく
-
actor
とは?
- それぞれが個別の
isolation
(隔離された実行空間)を持っていて内部状態は常にその中でしか変更ができない - つまり別の
isolation
から直接中身に影響を及ぼすことはできない
globalActor
とは?
- みんなが使える共有された
actor
- デフォルトで存在するのは
MainActor
のみ-
MainActor
が持つisolation
はメインスレッドである - さまざまなしがらみにより
DispatchQueue.main
と実質的に同じものである(はず)
-
- 定義すると
@MainActor
のような属性として使えるようになる - この属性をつけた
class
はそのglobalActor
をisolation
として持つactor
のように振る舞う
Swift 6 における isolation
-
class
やstruct
はデフォルトではnonisolated
(isolation
不明)である -
actor
はそれぞれが個別のisolation
である -
globalActor
の属性で修飾された要素はそのglobalActor
がisolation
である-
globalActor
の指定はclass
などの型単位だけでなくextension
やproperty
,func
ごとに指定することもできる
-
-
nonisolated
ではない状態(いずれかのisolation
に属している)をisolated(any)
と表現する場合あり
そして、ある isolation
(nonisolated
含む) から別の isolation
にアクセスするにはいろいろな制限がある。
何はなくとも Sendable
Sendable
とは?
- 訳すと『送付可能』と言ったところか
- 要するに
isolation
間で送ることができる値であることを示すマーカーである - 逆に言うと、
Sendable
ではない値はisolation
間で(直接には)やりとりできない
Sendable
ではない値がやりとりできないようになっている理由
- Swift 6 で導入された Strict Concurrency Checking が目指すものは Data Race が存在しないようにすること
- Data Race (データ競合)とは制御されていない平行処理によってデータの状態が予期できない不安定な状態になること
- スレッドプログラミングで取り沙汰される Race Condition と概ね同じものと思って良い
- Race Condition が発生しているとデータの状態が不安定になり予測できない(再現性の低い)厄介なバグの元になる
- 最悪クラッシュやセキュリティホールの原因になり得る
- Data Race が起こらないデータを
Sendable
とマーキングすることで、データが不安定にならないことを保証する - 『データが不安定』って?
- 同じデータを全く同じ流れで処理しているのになぜか結果が違うと言う状況
- 一つのオブジェクトを複数のスレッドで変更している場合などに起きやすい
- ある配列の中身を書き換えていってる時に、同時に別のスレッドからも似たようなことをすると結果がぐちゃぐちゃになったり
- Data Race を起こさないためには常にどこか一つの isolation でしか変更がされないことを保証する
- いくつかアプローチはあるがスレッドプログラミングでは一つのスレッドからしかアクセスできないような排他制御機能を使う
- 一つのオブジェクトが複数の
isolation
で平行して変更がされたとしても、それが全く同時に行われることは無くなる - そうなっていれば、仮に一見不可解な動きが見えたとしても、実行される順番を考慮すれば動作を予測することができる
Sendable
の要件
Sendable
であるための概念的な要件は次のようになる。
- 値の変更が不可能である
- 値の変更が可能であるが、何らかの方法(排他制御)で Data Race が起きないようになっている
より具体的な要件を示すと以下のようになる。
-
actor
である-
actor
はそれぞれプライベートなisolation
を持っていてその中でしか内部状態を変更できない -
actor
は自動でActor
プロトコルを実装していて、Actor
はSendable
を含むので明示的にSendable
であることを宣言する必要はない
-
-
Sendable
な値のみで構成されたstruct
である-
struct
は値型(概念的にはコピーによって渡されるもの)であるため本質的に変更不可である -
var
プロパティがあったとして、それを使った場合に元の値が変更されるわけではない(新しいコピーが作られる) - 自動で
Sendable
のチェックが行われるので明示的に宣言する必要は通常ない
-
- 全ての
case
がSendable
な値のみで構成されたenum
である-
enum
は不変値なので中身が全てSendable
であればSendable
になれる - 自動で
Sendable
のチェックが行われるので明示的に宣言する必要は通常ない
-
- 次の要件全てを満たす
class
である-
final
である- 継承可能になっていると継承先が非
Sendable
であった場合でも言語仕様上区別がつかなくなるため -
Sendable
なクラスを継承したら子クラスも必ずSendable
にしなければいけないルールにすれば良いのではと思えなくもないが…- 親クラスの中身がわかっていないと
Sendable
を保証できない可能性がある - それに親クラスを 後から
Sendable
にしたりした時に子クラスが全部壊れるとか悪夢でしかない
- 親クラスの中身がわかっていないと
- 継承可能になっていると継承先が非
- 親クラスを持たない、もしくは、
NSObject
の直接のサブクラスである- 親クラスが
Sendable
であることを保証できないため -
Sendable
なクラスを継承すれば良いのではとも思えるがfinal
であることが要件であるためそもそも継承できるようなクラスが存在し得ない
- 親クラスが
- 全ての Stored Property が
Sendable
である - 変更可能な Stored Property を持たない
-
- これまでのいずれも満たさない場合でも、型全体が
globalActor
で修飾されている- 個別のプロパティやメソッドではなく、型自体が
globalActor
修飾されると、ほぼactor
と同等の扱いになる -
globalActor
で修飾されたクラスは継承先も同じisolation
に属するようになる- つまり、例えば
UIViewController
を継承したクラスなどは全てMainActor
がisolation
である
- つまり、例えば
- 個別のプロパティやメソッドではなく、型自体が
Swift 5.x -> 6.0
これまで述べたとおり、Swift 6 では isolation
と Sendable
を強く意識する必要がある。
そして、それらが正しく扱われていない場合、警告どころかエラーになりコンパイル自体できない。
言語モードは 5 のまま Upcoming Features で 6 相当の設定をオンにして対応していくのが無難(エラーの代わりに警告になる)
ref. Swift 6 Migration Strategy
既存のコードを初めて 6 に移行しようとした際に発生するエラーの典型的なパターンを挙げてみる。
static
な Stored Property はほぼ全滅
-
static
プロパティは最初にアクセスされた時に初期化されるルールになっているため、そのままでは初期化処理が行われるisolation
が特定できない- 逆に言えば
isolation
が特定できれば良いので、対応の方針としては次のようになるだろう -
class
をactor
に変える-
actor
がisolation
なのはインスタンス化された状態の話なのでstatic
はisolation
が特定できない気がする - のだが、おそらく何か特別な扱いをされているっぽい
- どんな文脈からも
await
も無しでアクセスが可能 - 既存クラスを変更してしまうと影響箇所が大きくなりすぎる可能性がある
- プロパティだけ新規の
actor
に切り出すような形の方が修正量は少なくなる
- プロパティだけ新規の
-
- 型自体、もしくはプロパティに globalActor を指定する
- プロパティの isolation が globalActor によって特定されるので、初期化処理が行われる isolation も決まる
- 古いプロジェクトで
await
が使えないコンテキストが大多数、かつ、複数のisolation
からアクセスする必要があるような場合はちょっと使いにくい
-
actor
にすることはできないがnonisolated
な状態(どのisolation
からでもアクセス可能)にしたい- 保持する値の型は
Sendable
であること -
immutable
(let
での定義)であること
- 保持する値の型は
- 逆に言えば
- これから新たに定義するなら適当に
actor
を作ってそこに持たせるのが楽そう- ただまあ、そもそもあんまりそういった広範囲で共用するようなものは Swift 6 とか関係なしになるべく避けようよ
- testable じゃないとか色々
- ただまあ、そもそもあんまりそういった広範囲で共用するようなものは Swift 6 とか関係なしになるべく避けようよ
lazy var
もだいたい全滅
- 実質的には
immutable
でもself
やその他、後にならないと使えるようにならない要素を使って初期化する必要があるような場合によく使っていた- 他にも、最初に1回だけ処理をさせたいようなパターンなんかにも使われたりする
- ダメな理由は
static
プロパティと同様で、そのままだと初期化処理のisolation
が特定できないため- よって、
actor
やglobalActor
でisolation
を固定してやれば良い -
var
(mutable
)であるためstatic
プロパティのようにnonisolated
な状態にすることはできない- 自己責任で行う方法はあるがひとまずここでは説明しない
- よって、
自作の Error
準拠型
-
protocol Error
はSendable
を含んでいるため、構成要素まで全てがSendable
でなくてはいけない- 原因となったオブジェクトをそのまま持たせたりする場合があるが、それが非
Sendable
なためにエラー - とりあえず後でどうにかするという想定で雑に
Any
型で保持していたりすると(Any
はSendable
にはならないので)エラー
- 原因となったオブジェクトをそのまま持たせたりする場合があるが、それが非
- 一応
actor
化したりglobalActor
をつけることで回避できるが扱いがめんどくさくなりそうなので疑問-
nonisolated
のままSendable
に準拠するように直す方がおそらく良い気がする
-
awakeFromNib
でエラー
-
awakeFromNib
はNSObject
のメソッドでnonisolated
である - なのに通常これが使われる(オーバーライドされる)のは
@MainActor
がついたクラスを継承している場合である - 当然ほぼ全ての要素は
isolated(MainActor)
の状態なので、nonisolted
なコンテキストから直接isolated
な要素に触ろうとしてエラーになる - 特殊なことをやっていない限り
UIKit
の要素のawakeFromNib
がメインスレッド外で呼ばれることはないはず- であれば
MainActor.assumeIsolated
を使っておけば大丈夫 - これは現在の
isolation
がMainActor
であると仮定して処理を強制的に行うためのメソッド- メインスレッド外で実行された場合にはここでクラッシュする
- であれば
既存プロジェクトを Swift 6 対応した結果得た感触について
大きな一つの指針としては次のようになると思われる。
あらゆる要素を可能な限り Sendable
にする
-
Task
内(async/await
が使えるコンテキスト)中にSendable
ではない要素が出てくるといちいち詰まる -
@escaping
なブロック(クロージャ)でSendable
ではないものをキャプチャしようとするとエラーになる
などなど、 Sendable
ではない要素は Swift 6 の世界においては非常に扱いにくいものになっている。
逆に言うと、あらゆるものが Sendable
であればほとんどの場面で面倒なことを考えずともコードが書けるし、かつ、Data Race の可能性を極小化した状態を Swift 言語自体が保証してくれる。
Sendable
にするには、すでに書いた要件を満たしていけば良い
つまりは以下のとおり。
-
isolated
で良いなら-
actor
にする -
globalActor
を指定したclass
にする
-
-
nonisolated
にする必要があるなら-
struct
やenum
にする -
final
にするなどの全ての条件を満たしたclass
にする
-
それを踏まえつつ、既存プロジェクトを(手間をなるべく小さく) Swift 6 対応するためのアプローチとしては次のようになると思われる。
-
Sendable
にするためにclass
をactor
に置き換えるのは使う側への影響が大きくなるので避ける- 使用する側のコードを
Task
化しないと何も書けない - すでに
Task
化されていても使用箇所全てにawait
を追加していかないといけない - (新規で機能を追加するような場合はバンバン
actor
使うと良い)
- 使用する側のコードを
-
class
をマジメに(nonisolated
な)Sendable
にするのは最低限に- 元々
mutable
な Stored Property を持たない不変なclass
で何も継承していないなどのラッキーなケースであればアリ -
class
にしてる時点であまりそう言うことは無さそうだけど - (実装の共通化は継承ではなくミックスインでやる時代なので新規に設計する時はそのようにしておけば
Sendable
にしやすいと思う)
- 元々
-
class
には基本的にglobalActor
を指定する- 同じ
globalActor
に属しているものはTask
とかawait
とか無しでアクセスできるので@MainActor
なものとの連携が多いもの(ViewModelとか)は@MainActor
にしておくとコードの修正量が減る - メインスレッドで実行されると困るもの(重たい処理とか)は自前の
globalActor
定義を追加してそちらを使う- これによって
@MainActor
な要素とはisolation
境界ができてしまいTask
やawait
の追加が必要になるが、それは仕方ないのでがんばって直す
- これによって
- 同じ
それでもどうしても Sendable
にできない要素は残るが、それはどうすべきか
- 外部のライブラリで提供されていて Swift 6 に対応していないもの
- システムライブラリで提供されているが
Sendable
ではないもの
こういったものについては以下のようなアプローチをした。
- ソースコードやマニュアルから十分スレッドセーフのための手当てがされていると判断できる場合
- 空の
extension
を作成し@unchecked Sendable
を付加- その旨のコメントも添えて
@unchecked
をつけるとコンパイラによるSendable
安全性のチェックが行われなくなるので、説明無しに安易につけてはいけない
- 空の
- スレッドセーフではない、あるいは、判断がつかない場合
- 自前で排他制御を行う
- プライベートなプロパティに値を保持し、常に排他制御内でしかアクセスできないように仕掛けを入れる
非 Sendable
な要素についてはこれだけでは対処できない場合もある。例えば Realm
など
-
Realm
クラスのインスタンスと、それから生成される各種オブジェクト類はRealm
が生成されたスレッド以外から触ることができない -
Realm
のインスタンス化からそれ以降の全ての操作が同じスレッド(isolation
)で行われるように制御する必要がある - 既存コードとの兼ね合いなどから、専用の
globalActor
を用意してRealm
に関する操作が常にそこの中で行われるようにした- 新規に設計するなら
actor
に閉じ込めるようにすると楽だろう - 最近のバージョンでは特定の
actor
と連動する形式の API が追加されているのでそれらを使うと良い
- 新規に設計するなら
isolation
の問題としては RxSwift
が結構悩ましい問題を孕んでいて困る
-
RxSwift
では登録したObserver
等が実行されるスレッド(isolation
)をobserve(on:)
やsubscribe(on:)
などで指定することができるが、これが Swift 6 の isolation check と非常に相性が悪い- Swift 6 のルールではクロージャは作成した場所の
isolation
を引き継ぐ - つまり
isolated(MainActor)
な場所でsubscribe
で登録したクロージャはメインスレッドで実行されることが期待されるのだけど、Rx 側の動きによっては別のスレッドで実行される可能性が多分にある- そうなった場合はクラッシュ
- Swift 6 のルールではクロージャは作成した場所の
-
RxSwift
側が十分に Swift 6 に配慮したものを出してくれるのが理想だけど…- そうなるまでの間はどうするか
-
subscribe
に関しては必ずobserve(on: MainScheduler.instance)
とかしてメインスレッドで実行されるようにしつつ、渡すブロックを@MainActor
で修飾しておくことで安定はしそう-
@MainActor
のコンテキストでsubscribe
する場合は『ブロックを@MainActor
で修飾』は不要(勝手にそうなる) -
subscribe
は値を返す必要もないので重たい処理をメインスレッド外に逃したい場合は単にTask
を生成すれば良い
-
-
map
とかfilter
に関しても先にobserve(on: MainScheduler.instance)
しておくと安心かもしれないが、クロージャを@Sendable
で修飾しておけばそれで良いかも- こちらの提案 が取り込まれれば気にしなくて良くなりそう?
- 新規プロジェクトではそもそも
RxSwift
を使わない選択もありだと思う-
AsyncStream
などでRx
のような機能はほとんど賄えるはず
-
- とはいえ
RxSwift
にどっぷり浸かっている既存プロジェクトでRx
を排除するのは容易ではない-
RxSwift
が常にisolated(any)
かつasync
なクロージャ(ブロック)を受け取るような API 構成になってくれれば使う側は余計なこと考えなくて良くなりそうな気がするが- そんな定義がうまいことできるのかはちょっとよくわからない
-
まとめっぽいもの
- Swift 6 に対応していこう
- Swift 6 のルールに従うことで Data Race を防ぐために今まで自前でやっていた色々なことが自然に実現できるようになる
- まじめにやろうとすればするほどひたすらめんどくさくなるので、これまではある程度妥協していた部分もあるはず
- ルールに従うのは、一時的には縛りがキツくなって動きにくいと感じられるかもしれないけど慣れるよ、というか慣れた方が良いよ
- メインスレッドでしか触っちゃいけないもの(特にUI系)をうっかりダメな場所で触ろうとしちゃってもコンパイラが教えてくれる
- 初心者に対して口を酸っぱくして説明しなくても良い
- Swift 6 のルールに従うことで Data Race を防ぐために今まで自前でやっていた色々なことが自然に実現できるようになる
- Swift に限らず他の言語でも
-
isolation
を意識しよう- 現代の GUI アプリケーションにおいて平行プログラミングは切っても切り離せない要素
- 名前はともかく
isolation
に相当する概念はどの言語にもある(thread
,actor
,coroutine
,future
, etc) - それらを常に意識し、Swift 6 で怒られるようなことは他の言語でも(たとえエラーにはならなくとも)行わないようにすることでより良いコードが書ける可能性がある
-
Sendable
を意識しよう-
Sendable
の概念的要件(不変である、もしくは、排他制御に守られている)を満たすを意識することで Data Race の無い、結果が安定したコードを書ける - 特に『不変』な値を多用し、『可変』な箇所は最小限に切り出すことで testability も上がる
-
-