Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save stzn/0b0fb443c9e8114eac9c30581de2aebd to your computer and use it in GitHub Desktop.
Save stzn/0b0fb443c9e8114eac9c30581de2aebd to your computer and use it in GitHub Desktop.
Incremental Adoptionの翻訳

原文: https://github.com/apple/swift-migration-guide/blob/main/Guide.docc/IncrementalAdoption.md

2024/7/6

ここまで反映: https://github.com/apple/swift-migration-guide/commit/98272b4e7ee450b78e382e08757df1ba56bddfc4

段階的な適用

段階的に Swift Concurrency をプロジェクトに導入する方法を学びましょう。

Swift 6 言語モードへのプロジェクトの移行は、たいてい、段階的に行われます。実際、多くのプロジェクトは、Swift 6 が利用可能になる前に、この移行プロセスを開始しました。あなたは、途中で出てくる問題に対処しながら、Concurrency を徐々に導入することができます。これにより、プロジェクト全体を混乱させることなく、段階的に移行を進めることができます。

Swift には、段階的に導入しやすくするための、多くの言語機能と標準ライブラリAPIがあります。

コールバックベースの関数をラップする

処理の完了時に1つの関数を受け入れ、呼び出すAPIは、Swift ではとてもよく使われるパターンです。そして、そのような関数の、async のコンテキストから直接できるバージョンを作れます。

func updateStyle(backgroundColor: ColorComponents, completionHandler: @escaping () -> Void) {
    // ...
}

これは、コールバックを使って、呼び出し側に処理の完了を知らせる関数の例です。

いつ、どのスレッドでコールバックが呼び出されるかは、ドキュメントを参照しなければ、呼び出し側にはわかりません。

この関数は、Continuation を使って、async バージョンにラップできます。

func updateStyle(backgroundColor: ColorComponents) async {
    await withCheckedContinuation { continuation in
        updateStyle(backgroundColor: backgroundColor) {
            continuation.resume()
        }
    }
}

async バージョンでは、コールバックのようにいつ、どこで呼び出されるかわからないといった曖昧さはありません。関数の処理が完了した後、残りの処理の実行は、常に開始時と同じコンテキストで再開されます。

await updateStyle(backgroundColor: color)
// スタイルが更新された

withCheckedContinuation 関数は、非同期ではないコードと非同期のコードのつなげるために存在する一連の標準ライブラリAPIの1つです。

プロジェクトに非同期コードを導入すると、データの隔離(isolation)チェック違反を引き起こす場合があります。これを理解し、対処するには、Crossing Isolation Boundariesを参照してください。

動的な隔離

アノテーションやその他の言語の構造を使って、プログラムが静的に隔離されていることを表現できれば、強力かつシンプルです。しかし、すべての依存関係を同時に更新せずに、静的な隔離を導入するのが難しい場合もあります。

動的な隔離は、データの隔離を記述するためのフォールバックとして使用できる、実行時のメカニズムを提供します。

動的な隔離は、Swift 6 のコンポーネントを、他のまだ Swift 6 に更新されていない別のコンポーネントと、(これらのコンポーネントが同じモジュール内にあったとしても)つなげるために不可欠なツールです。

内部のみにおける隔離

たとえば、プロジェクト内のある参照型を MainActor で静的に隔離するのが最適だと判断したとします。

@MainActor
class WindowStyler {
    private var backgroundColor: ColorComponents

    func applyStyle() {
        // ...
    }
}

この MainActor の隔離は論理的には正しいかもしれません。しかし、この型が、他のまだ移行していない場所で使用されていた場合、ここに静的な隔離を追加すると、多くの追加の変更が必要になる可能性があります。そこで、代替案としては、スコープをコントロールするために、動的な隔離を使う方法があります。

class WindowStyler {
    @MainActor
    private var backgroundColor: ColorComponents

    func applyStyle() {
        MainActor.assumeIsolated {
            // 他の `MainActor` に隔離された状態を使用し、やり取りする
        }
    }
}

ここでは、隔離はクラスの内部に制限されています(internalized)。これにより、変更はその型の中に局所化され、その型の呼び出し側に影響を与えることなく変更を加えられるのです。

しかし、この方法の大きな欠点は、型の真の隔離要件が見えないままであることです。
呼び出し側は、このパブリックAPIに基づいて変更すべきか、あるいはどのように変更すべきかを判断する方法がありません。 そのため、この手法は一時的な解決策としてのみ、そして他の選択肢がなかった場合にのみ使うべきです。

使う部分だけ隔離する(Usage-only Isolation)

型全体で隔離を行うのが現実的でない場合、代わりに、そのAPIを使う部分だけをカバーするように隔離できます。

これを行うには、まず型に静的な隔離を適用し、次にそれを使う任意の場所では、動的な隔離を使用します:

@MainActor
class WindowStyler {
    // ...
}

class UIStyler {
    @MainActor
    private let windowStyler: WindowStyler
    
    func applyStyle() {
        MainActor.assumeIsolated {
            windowStyler.applyStyle()
        }
    }
}

静的な隔離と動的な隔離を組み合わせることで、変更の範囲を段階的に保つための強力なツールになります。

明示的な MainActor コンテキスト

assumeIsolated は同期メソッドで、間違った想定をした場合に実行を止め、実行時の隔離情報を型システムへの戻すために存在します。MainActor 型には、非同期のコンテキストで隔離ドメインを手動で切り替えるために使用できるメソッドもあります。

// MainActor であるべきだが、まだ更新されていない型
class PersonalTransportation {
}

await MainActor.run {
    // ここで MainActor に隔離されている
    let transport = PersonalTransportation()
    
    // ...
}

静的な隔離により、コンパイラは、必要に応じて隔離ドメインを切り替えるプロセスを検証し、自動化できることを忘れないでください。MainActor.run は、静的な隔離と組み合わせて使用する場合でさえも、本当に必要な場面を見極めるのは難しいかもしれません。MainActor.run は移行時に有用ですが、システムの隔離要件を静的に表現する代わりとして使うべきではありません。最終的な目標はやはり@MainActorPersonalTransportationに適用することです。

アノテーションが付いていない場合

動的な隔離は実行時に隔離を表すためのツールを提供します。しかし、Concurrencyに適応していないモジュール内のプロパティを使う必要があることもあります。

アノテーションのない Sendable クロージャ

クロージャが Sendable であるかどうかは、コンパイラがその本文の隔離をどのように推論するかに依ります。実際に隔離の境界を越えているのにSendable アノテーションがないコールバッククロージャは、Swift Concurrencyの重要な不変条件に違反します。

// Swift 6より前のモジュール内で定義されている
extension JPKJetPack {
    // @Sendable がないことに注目
    static func jetPackConfiguration(_ callback: @escaping () -> Void) {
             // 隔離ドメインをまたがる可能性がある
    }
}

@MainActor
class PersonalTransportation {
    func configure() {
        JPKJetPack.jetPackConfiguration {
            // ここではMainActorに隔離されていると推論されてしまう
            self.applyConfiguration()
        }
    }

    func applyConfiguration() {
    }
}

jetPackConfiguration が別の分離ドメインでそのクロージャを呼び出すことができる場合、@Sendable とマークする必要があります。まだSwift6に移行していないモジュールが@Sendableを付けていない場合、アクターの推論が正しく機能しません。このコードは問題なくコンパイルできますが、実行時にクラッシュします。

これを回避するには、手動でクロージャに@Sendableのアノテーションを付けます。これにより、コンパイラはMainActorの隔離を推論しなくなります。コンパイラはアクターの隔離が変更される可能性があることを知っているため、呼び出し元では Task を使う必要があり、 Task の中で await する必要があります。

@MainActor
class PersonalTransportation {
    func configure() {
        JPKJetPack.jetPackConfiguration { @Sendable in
            // Sendableクロージャはアクターに隔離されていると推論せず
            // non-isolatedであるとみなす
            Task {
                await self.applyConfiguration()
            }
        }
    }

    func applyConfiguration() {
    }
}

後方互換性(Backwards Compatibility)

静的な隔離は型システムの一部であるため、パブリックAPIに影響を与えることを念頭に置くのが重要です。 ただし、既存のクライアントを壊すことなく、Swift 6 用にAPIを改良する方法で独自のモジュールを移行できます。

たとえば、WindowStylerがパブリックAPIだとします。 本当はWindowStylerMainActorに隔離するべきだと思っていますが、クライアントの後方互換性も確保したい状況です。

@preconcurrency @MainActor
public class WindowStyler {
    // ...
}

このように @preconcurrency を使用すると、クライアントのモジュールで、完全な(complete)チェックが有効になっていれば、MainActorに隔離されます。 これにより、Swift 6 をまだ導入し始めていないクライアントとのソース互換性を維持できます。

依存関係

多くの場合、依存関係としてインポートする必要があるモジュールを制御できません。 これらのモジュールがまだ Swift 6 へ移行していない場合、解決が困難または不可能なエラーが発生する可能性があります。

移行されていないコードを使用すると、さまざまな種類の問題が発生します。 @preconcurrency アノテーションは、こういった状況の多くで役に立ちます:

C/Objective-C

アノテーションを使用して、C および Objective-C API の Swift Concurrencyへのサポートを公開できます。 これは、Clang の [Swift Concurrency 固有のアノテーション][clang-annotations]を使うことで可能です。

__attribute__((swift_attr(“@Sendable”)))
__attribute__((swift_attr(“@_nonSendable”)))
__attribute__((swift_attr("nonisolated")))
__attribute__((swift_attr("@UIActor")))

__attribute__((swift_async(none)))
__attribute__((swift_async(not_swift_private, COMPLETION_BLOCK_INDEX))
__attribute__((swift_async(swift_private, COMPLETION_BLOCK_INDEX)))
__attribute__((__swift_async_name__(NAME)))
__attribute__((swift_async_error(none)))
__attribute__((__swift_attr__("@_unavailableFromAsync(message: \"" msg "\")")))

When working with a project that can import Foundation, the following annotation macros are available in NSObjCRuntime.h:

NS_SWIFT_SENDABLE
NS_SWIFT_NONSENDABLE
NS_SWIFT_NONISOLATED
NS_SWIFT_UI_ACTOR

NS_SWIFT_DISABLE_ASYNC
NS_SWIFT_ASYNC(COMPLETION_BLOCK_INDEX)
NS_REFINED_FOR_SWIFT_ASYNC(COMPLETION_BLOCK_INDEX)
NS_SWIFT_ASYNC_NAME
NS_SWIFT_ASYNC_NOTHROW
NS_SWIFT_UNAVAILABLE_FROM_ASYNC(msg)

Objective-Cライブラリで隔離アノテーションが欠落している場合の対処法

SDKと他のObjective-Cのライブラリは、Swift Concurrency を採用することで進歩していますが、ドキュメントでしか説明されていない契約(Contract)をコードに追加する作業が必要になることがよくあります。たとえば、Swift Concurrency が導入以前に、API は「これは常にメインスレッド上で呼び出される」というようなコメントによって、スレッドの動作を説明しなければならないことがよくありました。

Swift Concurrency は、これらのコードコメントを、コンパイラと実行時に強制される隔離チェックに変えることができます。

たとえば、架空の NSJetPack プロトコルは、一般的にメインスレッド上でそのデリゲートメソッドのすべてを呼び出すので、現在、MainActorに隔離されています。

ライブラリの作者は、NS_SWIFT_UI_ACTOR 属性を使用して、MainActor に隔離されていることを示すことができ、これは Swift で @MainActor を使用して型をアノテートすることと同じです:

NS_SWIFT_UI_ACTOR // SDKの作者は、最近の更新でMainActorのアノテーションを付けた
@protocol NSJetPack // fictional protocol
/* このジェットパックが高高度での飛行をサポートする場合はYESを返す!
 
 JetPackKit は、このメソッドをさまざまなタイミングで呼び出すが、常にメインスレッドで呼び出されるわけではない。たとえば...
*/
@property(readonly) BOOL supportsHighAltitude;

@end

このメソッドの隔離は、属している型のアノテーションのせいで誤って@MainActor と推論されてしまいました。(MainActorで 呼び出される場合と呼び出されない場合がありますという)スレッド戦略が異なることが具体的にドキュメントに記載されていますが、メソッドにこのセマンティクスに対するアノテーションを付けるのを忘れしまいました。

これは架空の JetPackKit ライブラリのアノテーションの問題です。具体的には、Swift に正しい期待される実行セマンティクスを知らせるために、メソッド上ninonisolatedが抜けています。

このライブラリを採用した Swift のコードは、次のようなものかもしれません:

@MainActor
final class MyJetPack: NSJetPack {
  override class var supportsHighAltitude: Bool { // Swift6では実行時クラッシュ
    true
  }
}

上記のコードは、実行時チェックでクラッシュします。このチェックは、Objective-C の Swift Concurrencyではない領域から Swift に移行するときに、MainActorで実際に実行されていることを確認するために行われます。

Swift 6 の機能では、このような問題を自動的に検出し、期待に反すると実行時にクラッシュします。このような問題を診断せずに放置すると、実際に検出が難しいデータ競合が発生し、Swift 6 のデータ競合の安全性に関する契約が破られる可能性があります。

このような問題には、次のようなバックトレースが出力されます。

* thread #5, queue = 'com.apple.root.default-qos', stop reason = EXC_BREAKPOINT (code=1, subcode=0x1004f8a5c)
  * frame #0: 0x00000001004..... libdispatch.dylib`_dispatch_assert_queue_fail + 120
    frame #1: 0x00000001004..... libdispatch.dylib`dispatch_assert_queue + 196
    frame #2: 0x0000000275b..... libswift_Concurrency.dylib`swift_task_isCurrentExecutorImpl(swift::SerialExecutorRef) + 280
    frame #3: 0x0000000275b..... libswift_Concurrency.dylib`Swift._checkExpectedExecutor(_filenameStart: Builtin.RawPointer, _filenameLength: Builtin.Word, _filenameIsASCII: Builtin.Int1, _line: Builtin.Word, _executor: Builtin.Executor) -> () + 60
    frame #4: 0x00000001089..... MyApp.debug.dylib`@objc static JetPack.supportsHighAltitude.getter at <compiler-generated>:0
    ...
    frame #10: 0x00000001005..... libdispatch.dylib`_dispatch_root_queue_drain + 404
    frame #11: 0x00000001005..... libdispatch.dylib`_dispatch_worker_thread2 + 188
    frame #12: 0x00000001005..... libsystem_pthread.dylib`_pthread_wqthread + 228

注記: このような問題に遭遇し、ドキュメントやAPIのアノテーションを調査することで、何か間違ったアノテーションがされていると判断した場合、問題の根本的な原因を解決する最善の方法は、その問題をライブラリのメンテナに報告することです。

ご覧のように、ランタイムは、メソッドの呼び出しにエクゼキュータチェックを注入し、(MainActor上で実行される)DispatchQueueのアサーションに失敗しています。これにより、見えづらくデバッグしにくいデータ競合を防ぐことができます。

この問題に対する長期的な正しい解決策は、ライブラリがメソッドのアノテーションを修正し、nonisolatedとマークすることです:

// APIを提供するライブラリ側の解決策:
@property(readonly) BOOL supportsHighAltitude NS_SWIFT_NONISOLATED;

ライブラリがアノテーションの問題を修正するまでは、次のように、正しく隔離されていないメソッドを使用して解決できます。

// Swift 6モードで実行したいクライアントコードを導入する場合の解決策:
@MainActor
final class MyJetPack: NSJetPack {
  // 正しい
  override nonisolated class var readyForTakeoff: Bool {
    true
  }
}

こうすると、Swift は、メソッドがMainActorの隔離を必要とするという誤った仮定をチェックしないようになります。

Dispatch

Dispatch や他の Concuency ライブラリで使い慣れている一部のパターンは、Swift のStructuerd Concurrency モデルの世界に適合させるために、形を変える必要があるかもしれません。

Limiting concurrency using Task Groups

処理すべき内容が膨大になることがあるかもしれません。

その場合、以下のように、「すべて」の作業項目をタスクグループにエンキューすることができます:

// WARNING: 無駄の可能性 -- おそらくこれは、何千ものタスクを同時に処理させることになるかもしれない(?!)

let lotsOfWork: [Work] = ...
await withTaskGroup(of: Something.self) { group in
  for work in lotsOfWork {
    // WARNING: これが数千個のタスクに及ぶ場合、
    // システムのコア数とデフォルトのグローバルエグゼキュータの構成に応じて、同時に実行されるタスクの数に制限があるため、
    // かなり時間が経ってからでないと実行されないタスクが多数作成される可能性がある
    group.addTask {
    //  which won't get to be executed until much later, as we have a global limit on
      await work.work()
    }
  }

  for await result in group {
    process(result) // 必要に応じて、結果を何らかの方法で処理する
  }
}

何百、何千というタスクを扱う可能性がある場合、それらを全部すぐにエンキューするのは無駄かもしれません。 (addTaskの中で)タスクを作成するためには、中断(suspend)して実行するために、タスク用にメモリを確保する必要があります。このメモリ量は個々でも考えればそれほど大きくありませんが、何千ものタスクを作成した場合、すぐに実行されるわけではなく、エクゼキュータが実行するまでただ待っているだけなので、かなりの量になる可能性があります。

このような状況に直面した場合、以下のように、タスクグループに同時に追加されるタスクの数を手動で調整するとよい場合があります:

let lotsOfWork: [Work] = ... 
let maxConcurrentWorkTasks = min(lotsOfWork.count, 10)
assert(maxConcurrentWorkTasks > 0)

await withTaskGroup(of: Something.self) { group in
    var submittedWork = 0
    for _ in 0..<maxConcurrentWorkTasks {
        group.addTask { // or 'addTaskUnlessCancelled'
            await lotsOfWork[submittedWork].work() 
        }
        submittedWork += 1
    }
    
    for await result in group {
        process(result) // process the result somehow, depends on your needs
    
        // 結果が返ってくるたびに、まだやるべき処理があるかどうかをチェックし、タスクを追加する
        if submittedWork < lotsOfWork.count, 
           let remainingWorkItem = lotsOfWork[submittedWork] {
            group.addTask { // or 'addTaskUnlessCancelled'
                await remainingWorkItem.work() 
            }  
            submittedWork += 1
        }
    }
}

「作業」タスクが長時間実行される同期コードを含んでいる場合、そのタスクを自ら中断し、他のタスクが実行できるようにするほうが合理的なこともあります。

struct Work {
  let dependency: Dependency
  func work() async {
    await dependency.fetch()
    // 長時間実行される同期コードの一部を実行する
    await Task.yield()  // 明示的に中断ポイントを入れる
    // 同期コードの続きを実行する
  }
}

明示的な中断ポイントを導入することは、このタスクの進捗と、プログラム内の他のタスクの作業の進捗のバランスをとるのに役立ちます。しかし、このタスクがシステムで最も優先順位が高い場合、エグゼキューターは、ただちに同じタスクの実行を再開します。そのため、明示的な中断ポイントは、必ずしもリソースの枯渇を避けられるわけではありません。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment