Swift のタスクグループを使って、完了順に競合する計算の結果を収集し、nil をスキップして、値が届いたら残りの子タスクをキャンセルします。この Mac のコマンドライン例では、合成タイルに使える長方形のレイアウトを見つけ、結果を実際に返せるタイミングを説明します。

最初の有用な結果の定義

小さなデスクトップレイアウトツールで、合成タイル 221 枚を長方形に並べる必要があるとします。複数のローカル計算が、それぞれ異なる行数の候補を検索します。自明でない因数であればどれでも構いません。13 行 17 列でも、17 行 13 列でも成立します。読み手のタスクでは、最初に完了した有効な計算結果を受け入れます。

これは完了順に関する契約です。最小の因数、最もバランスの取れた長方形、リストの先頭にある計算結果を選ぶものではありません。Apple のドキュメントによると、タスクグループから順次取得される結果は、追加された順序とは関係なく、完了順に届きます。TaskGroup.next(isolation:)

ヘルパーを書く前に、動作を定義します。

状況 必要な動作
計算が nil で完了する 他の計算の結果を待ち続ける。
計算が値を返して完了する その値を選び、残りの作業にキャンセルを要求する。
すべての計算が nil を返す nil を返す。
計算リストが空である nil を返す。
親タスクのキャンセルが検出される 結果の選択を止め、nil を返す。

最後の行は、この例における意図的な API 上の選択です。ここでは nil は、呼び出しで使用可能な値が得られなかったことを意味し、検索の失敗とキャンセルを区別しません。呼び出し元でその区別が必要なら、明示的な結果型か、エラーをスローする契約を使ってください。

Mac でローカルの完全な例を作る

Swift 6 以降のツールチェーンを使います。ターミナルで swift --version を実行して確認してください。Swift が使えない場合は、公式のSwift のインストール — macOSの手順に従ってください。このプログラムは Foundation と Swift の並行処理 API を使い、外部パッケージには依存しません。

次のコマンドで、新しい実行可能パッケージを作成します。これは Swift.org が文書化しているパッケージの作成と実行の手順に沿っています。コマンドラインツールの作成

mkdir FirstNonNilDemo
cd FirstNonNilDemo
swift package init --name FirstNonNilDemo --type executable

生成された実行可能ソースを、以下の完全なプログラムに置き換えます。生成されたレイアウトによっては、ファイル名が Sources/FirstNonNilDemo/FirstNonNilDemo.swift または Sources/main.swift になります。エントリポイントのファイル名を FirstNonNilDemo.swift にします。テンプレートが main.swift を作成した場合は、@main プログラムを追加する前にそのファイル名を変更してください。Swift のガイドによると、プログラムでは main.swift または @main エントリポイントのいずれかを使います。コマンドラインツールの作成

import Foundation

func firstNonNil<Value: Sendable>(
    _ calculations: [@Sendable () async -> Value?]
) async -> Value? {
    guard !Task.isCancelled else { return nil }

    return await withTaskGroup(
        of: Value?.self,
        returning: Value?.self
    ) { group in
        defer { group.cancelAll() }

        for calculation in calculations {
            let added = group.addTaskUnlessCancelled {
                guard !Task.isCancelled else { return nil }
                return await calculation()
            }
            guard added else { break }
        }

        for await candidate in group {
            guard !Task.isCancelled else { return nil }
            if let value = candidate {
                return value
            }
        }

        return nil
    }
}

func findFactor(total: Int, range: ClosedRange<Int>) -> Int? {
    guard total > 1 else { return nil }

    for rows in range {
        guard !Task.isCancelled else { return nil }
        guard rows > 1, rows < total else { continue }

        if total % rows == 0 {
            return rows
        }
    }

    return nil
}

@main
struct FirstNonNilDemo {
    static func main() async {
        let checks: [@Sendable () async -> Int?] = [
            { nil },
            { 42 }
        ]
        let checkResult = await firstNonNil(checks)
        precondition(checkResult == 42)

        let empty: [@Sendable () async -> Int?] = []
        let emptyResult = await firstNonNil(empty)
        precondition(emptyResult == nil)

        for rawText in [" 221\n", " 223 ", "tiles"] {
            let text = rawText.trimmingCharacters(
                in: .whitespacesAndNewlines
            )

            guard let total = Int(text), total > 1 else {
                print("Input must be a whole number greater than 1.")
                continue
            }

            let calculations: [@Sendable () async -> Int?] = [
                { findFactor(total: total, range: 2...5) },
                { findFactor(total: total, range: 6...16) },
                { findFactor(total: total, range: 17...30) }
            ]

            if let rows = await firstNonNil(calculations) {
                print("\(total) tiles: \(rows) rows x \(total / rows) columns")
            } else {
                print("\(total) tiles: no factor in the supplied ranges.")
            }
        }
    }
}

パッケージのディレクトリから実行します。

swift run FirstNonNilDemo

221 の場合は、221 tiles: 13 rows x 17 columns または 221 tiles: 17 rows x 13 columns のどちらでも構いません。223 の場合、プログラムは指定された範囲に因数がないと報告します。tiles というテキストは入力メッセージを表示します。これらは想定される結果であり、実行時に記録された出力ではありません。算術は別途確認しました。調査環境では Swift コンパイラを利用できなかったため、Swift プログラムは確認しましたが実行していません。

Foundation が処理するのは、前後の空白の除去だけです。trimmingCharacters(in:) メソッドは、両端から一致する文字を取り除きます。文字列の内部にある文字は取り除きません。そのため、221 は 221 になりますが、2 21 はこのプログラムでは無効なままです。trimmingCharacters(in:)

後続の結果を失わずに nil をスキップする仕組み

グループの子タスクの結果型は Value? です。そのため、各子タスクは使用可能な値か、通常の空の結果のいずれかを返します。for await ループは完了した子タスクの結果を受け取り、内側のオプショナルバインディングで非 nil の候補だけを選びます。

group.next() を直接使う場合、空には 2 種類あります。ドキュメントに記載された戻り値の型は ChildTaskResult? です。子タスクの結果がオプショナルなら、これは Value?? になります。外側のオプショナルは次の子タスクの結果が存在するかどうかを表し、内側のオプショナルはその子タスクが値を見つけたかどうかを表します。TaskGroup.next(isolation:)

この区別から、よくある間違いの理由が分かります。結果を 1 つだけ取得して、そのまま返してしまうことです。最初に完了した計算が nil を返しても、他の子タスクが有用な答えを持っている可能性があります。完全な例のループは、値を見つけるかグループの処理が終わるまで、結果を受け取り続けます。

このヘルパーは addTaskUnlessCancelled を使い、その戻り値の Boolean を確認します。Apple のドキュメントによると、グループがすでにキャンセルされている場合、このメソッドは子タスクの作成を行いません。この状態を検出した後も作業の追加を続けないよう、追加ループから抜けます。addTaskUnlessCancelled(priority:operation:)

計算は不変のタイル数をキャプチャし、答えをグループ経由で返します。共有の勝者変数は更新しません。汎用の結果型は Sendable に準拠する必要があり、これはドキュメントに記載されたタスクグループの制約に沿っています。withTaskGroup(of:returning:isolation:body:)

勝者を選んでもすぐには戻れない理由

グループのクロージャから値を返すと答えが選ばれますが、外側の withTaskGroup はすべての子タスクが終了するまで待機します。Apple のドキュメントには、キャンセルされた子タスクであっても、関数が戻る前に完了する必要があると明記されています。withTaskGroup(of:returning:isolation:body:)

defer ブロックは、クロージャが終了するたびに group.cancelAll() を呼び出します。これにより残りの計算にキャンセルを要求します。キャンセルは、任意の計算を中断するものではありません。子タスク側で協調して、早期に終了する必要があります。TaskGroup

この例では、findFactor が候補の除数ごとに Task.isCancelled を確認します。各作業単位は小さいため、停止できる機会が頻繁にあります。大きな計算では、意味のある範囲に区切った処理の合間に確認を入れてください。長時間にわたる分割できない処理の前に一度だけ確認しても、その処理は完了するまで実行され続けます。Swift の言語ガイドでは、キャンセルは協調的なものであり、対応方法の 1 つとして nil を返すことが説明されています。Concurrency

競合する計算が終わらず、キャンセルにも応答しない場合、この構造化ヘルパーは迅速な復帰を保証できません。各ワーカーの処理時間に上限を設けるか、キャンセルに対応させることが、保証を実現する方法です。競合するタイムアウト結果を追加しても、グループが子タスクの完了を待つ義務はなくなりません。

親タスクのキャンセルは、子タスクの追加前、子タスクの開始時、算術ループ内、各結果を受け入れる前に確認します。これらの確認は特定の時点におけるキャンセルを検出するものです。キャンセルが常に値より優先されるという原子的なルールを作るものではありません。最後の確認後にキャンセルが届くと、正常な戻りと同時に発生する可能性があります。

適切な選択ルールを選び、境界条件を確認する

この合成例は、判断に役立つテストになります。13 も 17 も、定められた契約を満たします。ツールで最小の行数が必要なら、どちらの結果も受け入れるのは誤りです。完了順による選択が役立つのは、受け入れ可能なすべての答えが、読み手のタスクにとって同等である場合に限られます。

呼び出し元が必要とするもの 適切な方法
計算の完了に合わせて得られる、許容可能な値 上記のタスクグループループを使う。
固定された優先順位リストの中で、最初に成功した計算 優先順位順に評価するか、インデックスを保持して、選択前に先行する候補を解決する。
最小または最高スコアの結果 関連する結果を比較してから選択する。
既知の複数の結果をまとめて必要とする async let を検討する。

Swift の言語ガイドでは、既知の子タスクを作成して後でその結果を必要とする場合に async let を、動的に追加する子タスクを明示的に制御する場合にタスクグループを紹介しています。名前付きの結果を任意の順序で await しても、完了順に選択するループにはなりません。Concurrency

プログラムを調整するときは、次の点を確認してください。

  • 空リストのアサーションを残します。nil を返す必要があります。
  • nil と 42 が混在するアサーションを残します。完了順に関係なく 42 を返す必要があります。
  • すべてが nil を返す計算をテストします。
  • 有効な答えが複数ある場合、特定の勝者ではなく、有効な集合に含まれることをアサートします。
  • すべてのワーカーについて、処理に上限があり、キャンセル確認があることを調べます。
  • 検索が失敗した場合は、指定された範囲内に限った結果として報告します。ほかの範囲にも答えがないと推測してはいけません。

3 つの小さな因数スキャンは動作を示すものであり、速度向上を示すものではありません。ここで例示した数値なら、逐次スキャンで十分です。許容可能な結果を受け入れることで実際のタスクに利点がある場合に、競合計算のパターンを使い、同時実行する作業量を決める前にその処理の所要時間を測定してください。

受け入れルールを明確にし、空の子タスク結果をスキップし、残りの作業を停止できるようにします。これにより Mac ツールの値に関する契約が予測可能になります。グループから最初に届いた有用な結果が返され、その後、子タスクが協調的に停止します。

参照元