若要平行執行 CPU 密集型 Swift 計算,請為工作函式明確標示 @concurrent 邊界,並將彼此獨立的工作分配給子任務。這個 Mac 範例會讓介面更新留在 MainActor 上,並在不共用可變狀態的情況下合併合成結果。

為什麼新增任務後,計算仍可能序列執行

假設 Mac app 有一個 Calculate 按鈕,用來啟動大量本機計算。將計算包在 Task { ... } 中,會提供非同步進入點,但在主 actor 環境中建立的任務可能會繼承該隔離。裡面若有長時間的同步迴圈,仍會占用主 actor。Apple 在採用 Swift 並行功能中說明了這項差異。

加入任務群組也沒有解答一個重要問題:包含昂貴迴圈的函式究竟在哪裡執行?如果每個子任務都呼叫同一個 @MainActor 計算方法,該 actor 就會將計算序列化。正如 Swift 語言指南的並行功能章節所述,actor 一次只允許一個任務存取其隔離狀態。

對於 Swift 6.2 及更新版本,請將非同步計算進入點標示為 @concurrent。這會明確切換執行位置,使其離開呼叫端的 actor。啟用 NonisolatedNonsendingByDefault 功能後,一般的非隔離非同步函式反而會在呼叫端的 actor 上執行;舊版行為則不同。已實作的 SE-0461 提案說明了功能適用範圍與這個明確屬性。

兩項不同的設定能達成所需行為:工作函式的隔離設定允許它在 MainActor 之外執行,而多個子任務則提供可獨立排程的工作。執行環境會決定由哪些可用執行緒執行這些任務。任務數量和 @concurrent 都不保證特定數量的核心會同時執行。Apple 在採用 Swift 並行功能中說明了這種排程模型。

定義可正確分割的計算

以下範例讓 Mac 使用者啟動或取消合成計算,並顯示最後的檢查和。所有輸入都是常數或產生的整數。此計算具有以下明確的值契約:

  1. 將 0..<count 中的每個整數索引恰好處理一次。
  2. 將每個索引轉換為 UInt64,並依指定的回合數反覆套用 x = (x &* 1_664_525) &+ 1_013_904_223。
  3. 使用溢位環繞加法累加結果值。
  4. 空輸入回傳零;負數的 count 或 rounds 會被拒絕;非正數的 worker limit 也會被拒絕。
  5. 偵測到取消時丟出錯誤,而不是將不完整的檢查和當作完成結果呈現。

這裡刻意使用溢位環繞算術。Swift 一般的整數算術會在溢位時觸發陷阱;&+ 和 &* 運算子則明確支援溢位行為。請參閱進階運算子。

在此契約中,每個索引彼此獨立,溢位環繞加法會將部分總和以 2 的 64 次方為模數合併。因此,改變分組方式或完成順序都會保留相同的最終值。這個結論來自所選用的算術方式。浮點數計算需要自行訂定正確性規則,因為重新分組加法可能改變捨入結果。

分區使用除法和餘數:每個 worker 會分到相同的基本索引數量,前幾個 worker 則各多分到一個索引。以 count 為 17、worker 數量為 3 為例,範圍分別是 0..<6、6..<12 和 12..<17。這些範圍涵蓋所有輸入索引,包含餘數部分。

建立完整的 Mac 計算範例

使用 Swift 6.2 或更新版本的工具鏈,以及最低部署目標為 macOS 13 或更新版本的 macOS SwiftUI app target。以下兩段程式碼請放在同一個 Swift 檔案中,取代產生的 app 進入點和內容檢視。它們構成一個完整程式;target 中只保留一個 @main app 宣告。

計算命名空間明確標示為 nonisolated,因此其成員不會取得推斷而來的全域 actor 隔離。Swift 在 SE-0449中說明了這種型別層級的宣告。兩個非同步計算方法都使用 @concurrent 明確指定執行邊界。

import Foundation
import SwiftUI

nonisolated enum CalculationError: Error {
    case invalidInput
}

nonisolated enum SyntheticCalculator {
    @concurrent
    static func run(
        count: Int,
        rounds: Int,
        workerLimit: Int
    ) async throws -> UInt64 {
        guard count >= 0, rounds >= 0, workerLimit > 0 else {
            throw CalculationError.invalidInput
        }
        try Task.checkCancellation()
        guard count > 0 else { return 0 }

        let workers = min(count, workerLimit)
        let baseSize = count / workers
        let remainder = count % workers

        return try await withThrowingTaskGroup(
            of: UInt64.self,
            returning: UInt64.self
        ) { group in
            var lower = 0

            for worker in 0..<workers {
                try Task.checkCancellation()
                let size = baseSize + (worker < remainder ? 1 : 0)
                let start = lower
                let end = start + size
                lower = end

                group.addTask {
                    try await calculateChunk(
                        lower: start,
                        upper: end,
                        rounds: rounds
                    )
                }
            }

            var total: UInt64 = 0
            for try await partial in group {
                total = total &+ partial
            }
            try Task.checkCancellation()
            return total
        }
    }

    @concurrent
    private static func calculateChunk(
        lower: Int,
        upper: Int,
        rounds: Int
    ) async throws -> UInt64 {
        try Task.checkCancellation()
        var total: UInt64 = 0

        for index in lower..<upper {
            if index % 256 == 0 {
                try Task.checkCancellation()
            }
            var value = UInt64(index)
            for round in 0..<rounds {
                if round % 4096 == 0 {
                    try Task.checkCancellation()
                }
                value = (value &* 1_664_525) &+ 1_013_904_223
            }
            total = total &+ value
        }

        try Task.checkCancellation()
        return total
    }
}

每個子任務都會擷取不可變的範圍界限,並回傳一個整數。只有父任務會更新 total;子任務不會附加內容到共用集合,也不會修改介面狀態。任務群組支援子任務並行執行、要求結果符合 sendable,並將子任務的生命週期限制在群組範圍內。請參閱 Apple 的 TaskGroup 文件。

將以下介面程式碼附加到同一個檔案:

@main
@MainActor
struct ParallelCalculationApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var status = "Ready"
    @State private var work: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic Calculation")
                .font(.title2)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Calculate") {
                    startCalculation()
                }
                .disabled(work != nil)

                Button("Cancel") {
                    work?.cancel()
                }
                .disabled(work == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 180)
        .onDisappear {
            work?.cancel()
        }
    }

    private func startCalculation() {
        guard work == nil else { return }
        status = "Calculating…"
        let workerLimit = max(
            1,
            ProcessInfo.processInfo.activeProcessorCount
        )

        work = Task { @MainActor in
            defer { work = nil }
            do {
                let checksum = try await SyntheticCalculator.run(
                    count: 1_000_000,
                    rounds: 128,
                    workerLimit: workerLimit
                )
                try Task.checkCancellation()
                status = "Checksum: \(checksum)"
            } catch is CancellationError {
                status = "Cancelled"
            } catch {
                status = "Calculation failed: \(error)"
            }
        }
    }
}

介面會持有外層任務控制代碼,讓 Cancel 按鈕可以取消該任務。它的任務仍隔離在主 actor 上,會呼叫並行計算,並在等待結果後更新顯示的狀態。持有控制代碼也能在檢視消失時取消任務。Apple 的 Task 文件說明,丟棄控制代碼並不會取消任務。

工作數量限制以 Foundation 的 activeProcessorCount 為起點;它會回報電腦上可用的作用中處理器核心數。應將此數值視為待測量的初始設定,而非保證最佳的任務數量。其文件說明見 activeProcessorCount。

比較效能之前,先確認正確性

先使用較小的示範輸入:將呼叫改為 count: 17、rounds: 4 和 workerLimit: 3。預期的檢查和為 18149427060254280572。將 workerLimit 改為 1 再執行一次;結果必須相同。也請確認 count: 0 必須回傳零,以及小於 worker limit 的正數 count 仍會涵蓋每個索引。

示範檢查和與範圍涵蓋情況已使用一個簡單的 Python 程式重現溢位環繞算術並完成檢查。研究環境中沒有 Swift 編譯器,因此此 Swift 程式尚未在此處編譯或執行。

接著還原較大的輸入,使用 worker limit 為 1、2 和作用中處理器核心數的最佳化建置進行比較。保持 count 和 rounds 不變,保留產生的檢查和,並測量多次執行的結果。這是建議的測量程序;本文未宣稱任何執行時間或加速幅度。

單一 worker 的執行會使用相同的並行邊界與任務群組結構,因此可作為此實作的實用比較基準。較小的工作量可能不值得付出分區與排程的額外成本。如果實際計算中不同索引的成本差異很大,大小相同的範圍也可能導致某個 worker 較晚完成;下一步可試著使用較小的區塊,並限制同時執行中的任務數量。

若計算會產生陣列,請為每個結果附上區塊索引,並依輸入順序組合區塊。任務群組的子任務可能以任何順序執行,而群組會在結果完成時逐一提供。只有因為所選用的歸約方式與順序無關,檢查和才不需要考慮順序。請參閱 TaskGroup。

讓取消與隔離問題清楚可見

取消是合作式的。呼叫 cancel() 會標記任務;計算程式碼必須檢查這個狀態。兩個迴圈中的檢查都為工作函式提供了丟出錯誤的機會,包括單一索引需要執行許多回合的情況。這些檢查間隔是實作上的選擇,並非保證的取消延遲。Apple 在 Task中說明了這項責任。

對結果使用會丟出錯誤的迴圈也很重要。如果子任務丟出錯誤,且錯誤從群組主體向外傳播,其餘子任務就會被取消。群組仍會等待它們完成,因此仍需要在子任務中檢查取消狀態。Swift 標準函式庫的實作在 TaskGroup.swift中說明了這項行為。

如果介面仍然卡頓,請追查完整的昂貴呼叫路徑。檢查工作函式是否呼叫了主 actor 方法、存取了 actor 隔離屬性,或在進入並行邊界前執行大量準備工作。將必要的整數值傳入工作函式,並將長時間迴圈留在該邊界內。

如果編譯器不接受 @concurrent,請確認工具鏈版本。此屬性是在 Swift 6.2 中實作,並適用於非同步函式。對較舊的工具鏈,SE-0461記錄了較早的非隔離非同步執行模型;請使用符合該編譯器版本的文件,不要假設所有版本對 nonisolated async 的行為都相同。

對此 Mac 工作而言,實用的架構很精簡:主 actor 管理介面狀態,並行工作函式接收彼此獨立的值,而父任務則合併完整結果。調整區塊大小時請保留值契約,並在採用效能變更前確認檢查和相同且介面仍維持流暢。

來源