6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

何も考えず言語モードを 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 はその globalActorisolation として持つ actor のように振る舞う

Swift 6 における isolation

  • classstruct はデフォルトでは nonisolatedisolation 不明)である
  • actor はそれぞれが個別の isolation である
  • globalActor の属性で修飾された要素はその globalActorisolation である
    • globalActor の指定は class などの型単位だけでなく extensionproperty, 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 プロトコルを実装していて、ActorSendable を含むので明示的に Sendable であることを宣言する必要はない
  • Sendable な値のみで構成された struct である
    • struct は値型(概念的にはコピーによって渡されるもの)であるため本質的に変更不可である
    • var プロパティがあったとして、それを使った場合に元の値が変更されるわけではない(新しいコピーが作られる)
    • 自動で Sendable のチェックが行われるので明示的に宣言する必要は通常ない
  • 全ての caseSendable な値のみで構成された 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 を継承したクラスなどは全て MainActorisolation である

Swift 5.x -> 6.0

これまで述べたとおり、Swift 6 では isolationSendable を強く意識する必要がある。
そして、それらが正しく扱われていない場合、警告どころかエラーになりコンパイル自体できない。

言語モードは 5 のまま Upcoming Features で 6 相当の設定をオンにして対応していくのが無難(エラーの代わりに警告になる)

ref. Swift 6 Migration Strategy

既存のコードを初めて 6 に移行しようとした際に発生するエラーの典型的なパターンを挙げてみる。

static な Stored Property はほぼ全滅

  • static プロパティは最初にアクセスされた時に初期化されるルールになっているため、そのままでは初期化処理が行われる isolation が特定できない
    • 逆に言えば isolation が特定できれば良いので、対応の方針としては次のようになるだろう
    • classactor に変える
      • actorisolation なのはインスタンス化された状態の話なので staticisolation が特定できない気がする
      • のだが、おそらく何か特別な扱いをされているっぽい
      • どんな文脈からも await も無しでアクセスが可能
      • 既存クラスを変更してしまうと影響箇所が大きくなりすぎる可能性がある
        • プロパティだけ新規の actor に切り出すような形の方が修正量は少なくなる
    • 型自体、もしくはプロパティに globalActor を指定する
      • プロパティの isolation が globalActor によって特定されるので、初期化処理が行われる isolation も決まる
      • 古いプロジェクトで await が使えないコンテキストが大多数、かつ、複数の isolation からアクセスする必要があるような場合はちょっと使いにくい
    • actor にすることはできないが nonisolated な状態(どの isolation からでもアクセス可能)にしたい
      • 保持する値の型は Sendable であること
      • immutablelet での定義)であること
  • これから新たに定義するなら適当に actor を作ってそこに持たせるのが楽そう
    • ただまあ、そもそもあんまりそういった広範囲で共用するようなものは Swift 6 とか関係なしになるべく避けようよ
      • testable じゃないとか色々

lazy var もだいたい全滅

  • 実質的には immutable でも self やその他、後にならないと使えるようにならない要素を使って初期化する必要があるような場合によく使っていた
    • 他にも、最初に1回だけ処理をさせたいようなパターンなんかにも使われたりする
  • ダメな理由は static プロパティと同様で、そのままだと初期化処理の isolation が特定できないため
    • よって、 actorglobalActorisolation を固定してやれば良い
    • varmutable )であるため static プロパティのように nonisolated な状態にすることはできない
      • 自己責任で行う方法はあるがひとまずここでは説明しない

自作の Error 準拠型

  • protocol ErrorSendable を含んでいるため、構成要素まで全てが Sendable でなくてはいけない
    • 原因となったオブジェクトをそのまま持たせたりする場合があるが、それが非 Sendable なためにエラー
    • とりあえず後でどうにかするという想定で雑に Any 型で保持していたりすると(AnySendable にはならないので)エラー
  • 一応 actor 化したり globalActor をつけることで回避できるが扱いがめんどくさくなりそうなので疑問
    • nonisolated のまま Sendable に準拠するように直す方がおそらく良い気がする

awakeFromNib でエラー

  • awakeFromNibNSObject のメソッドで nonisolated である
  • なのに通常これが使われる(オーバーライドされる)のは @MainActor がついたクラスを継承している場合である
  • 当然ほぼ全ての要素は isolated(MainActor) の状態なので、 nonisolted なコンテキストから直接 isolated な要素に触ろうとしてエラーになる
  • 特殊なことをやっていない限り UIKit の要素の awakeFromNib がメインスレッド外で呼ばれることはないはず
    • であれば MainActor.assumeIsolated を使っておけば大丈夫
    • これは現在の isolationMainActor であると仮定して処理を強制的に行うためのメソッド
      • メインスレッド外で実行された場合にはここでクラッシュする

既存プロジェクトを Swift 6 対応した結果得た感触について

大きな一つの指針としては次のようになると思われる。

あらゆる要素を可能な限り Sendable にする

  • Task 内( async/await が使えるコンテキスト)中に Sendable ではない要素が出てくるといちいち詰まる
  • @escaping なブロック(クロージャ)で Sendable ではないものをキャプチャしようとするとエラーになる

などなど、 Sendable ではない要素は Swift 6 の世界においては非常に扱いにくいものになっている。
逆に言うと、あらゆるものが Sendable であればほとんどの場面で面倒なことを考えずともコードが書けるし、かつ、Data Race の可能性を極小化した状態を Swift 言語自体が保証してくれる。

Sendable にするには、すでに書いた要件を満たしていけば良い

つまりは以下のとおり。

  • isolated で良いなら
    • actor にする
    • globalActor を指定した class にする
  • nonisolated にする必要があるなら
    • structenum にする
    • final にするなどの全ての条件を満たした class にする

それを踏まえつつ、既存プロジェクトを(手間をなるべく小さく) Swift 6 対応するためのアプローチとしては次のようになると思われる。

  • Sendable にするために classactor に置き換えるのは使う側への影響が大きくなるので避ける
    • 使用する側のコードを Task 化しないと何も書けない
    • すでに Task 化されていても使用箇所全てに await を追加していかないといけない
    • (新規で機能を追加するような場合はバンバン actor 使うと良い)
  • class をマジメに(nonisolated な) Sendable にするのは最低限に
    • 元々 mutable な Stored Property を持たない不変な class で何も継承していないなどのラッキーなケースであればアリ
    • class にしてる時点であまりそう言うことは無さそうだけど
    • (実装の共通化は継承ではなくミックスインでやる時代なので新規に設計する時はそのようにしておけば Sendable にしやすいと思う)
  • class には基本的に globalActor を指定する
    • 同じ globalActor に属しているものは Task とか await とか無しでアクセスできるので @MainActor なものとの連携が多いもの(ViewModelとか)は @MainActor にしておくとコードの修正量が減る
    • メインスレッドで実行されると困るもの(重たい処理とか)は自前の globalActor 定義を追加してそちらを使う
      • これによって @MainActor な要素とは isolation 境界ができてしまい Taskawait の追加が必要になるが、それは仕方ないのでがんばって直す

それでもどうしても 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 側の動きによっては別のスレッドで実行される可能性が多分にある
      • そうなった場合はクラッシュ
  • 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 に限らず他の言語でも
    • isolation を意識しよう
      • 現代の GUI アプリケーションにおいて平行プログラミングは切っても切り離せない要素
      • 名前はともかく isolation に相当する概念はどの言語にもある( thread , actor , coroutine , future, etc)
      • それらを常に意識し、Swift 6 で怒られるようなことは他の言語でも(たとえエラーにはならなくとも)行わないようにすることでより良いコードが書ける可能性がある
    • Sendable を意識しよう
      • Sendable の概念的要件(不変である、もしくは、排他制御に守られている)を満たすを意識することで Data Race の無い、結果が安定したコードを書ける
      • 特に『不変』な値を多用し、『可変』な箇所は最小限に切り出すことで testability も上がる
6
3
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
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?