C# Parallel.For 結果順序不是排程器替你保證的。當每個輸入都應該對應一筆輸出,正確做法是先建立穩定的索引對應,再讓每次迭代只寫入自己的結果槽位。迴圈完成後,才把完整結果交給下一個處理階段。這篇文章把「不會寫壞集合」、「資料身分正確」與「整批成功完成」拆成三個可測試的契約,避免把偶爾看起來正確的結果誤認為設計保證。

適用情境是固定筆數、互不相依的本機計算,例如將合成商品代碼轉成計算結果,或把一組測試輸入投影為輸出陣列。本文不處理遠端呼叫、非同步工作排程或資料庫寫入;測試只使用記憶體中的合成整數,不會連接外部系統。

為什麼替 List.Add 加鎖仍然不等於順序正確?

加鎖只能讓共用集合的寫入受到保護,不能讓完成順序自動變成輸入順序。若下游依索引配對資料,必須在工作開始前建立身分與槽位的對應,不能等所有工作結束後才猜測每筆結果屬於誰。

Microsoft 的 List 文件明確說明,執行個體成員不保證執行緒安全;需要在讀寫期間自行同步。多條工作同時呼叫同一個清單的 Add,風險因此不只是不整齊,還包括集合內容不正確。本文不透過故意製造競爭來示範資料損壞,因為這種反例可能在某次執行恰好沒有出錯,無法成為穩定的驗收條件。

就算把 Add 放在同一把鎖內,取得鎖的先後仍未必符合輸入排列。假設輸入依序是代碼三十、十、二十,最早完成的可能是任何一筆。安全的容器解決的是容器狀態,輸出契約解決的是資料意義,兩者不能互相替代。相同道理也適用於並行集合:佇列的入列順序不等於原始輸入順序,袋狀集合更不應被當成有序輸出。

因此,程式碼審查時不要只問「有沒有鎖」。先問下游使用這份集合的方式:是不是依索引配對另一個陣列?是不是拿最後一筆代表最大代碼?是不是認為筆數相同就代表每個身分都存在?這些問題通常比選擇哪一種容器更早決定正確的解法。

固定長度陣列如何建立單一寫入者契約?

固定長度陣列可以讓每個輸入索引對應唯一的輸出槽位,前提是迭代之間不重複寫入同一位置,也不修改共用的可變子物件。必須等同步迴圈正常完成後再讀取整批結果,才算完成這份契約。

具體做法是先將輸入固定為快照,配置相同長度的 results,再以 Parallel.For 的索引計算 results[i]。這裡需要保證的是每個位置的所有權,而不是要求工作按照索引順序執行。索引零可以晚於索引十完成,但兩者最後仍會回到各自的位置。若輸入長度未知、每筆輸入可能產出多筆資料,就不宜直接套用一輸入一槽位的結構。

「不同槽位」也不是全面性的安全保證。若所有槽位裡面裝的是同一個可變物件,工作仍可能透過該物件修改同一份狀態。本文的測試使用整數與值型別元組,刻意把共享參照排除。實際設計若包含巢狀清單,建議讓每次迭代建立自己的結果物件,再由循序階段組合;不要把同一個可修改的內部清單分配給所有結果。

另一個常見錯誤是結果陣列已經存在,就允許畫面或其他工作提前讀取。配置完成與計算完成是兩回事。本文採用區域候選陣列,只有同步迴圈成功返回而且完成條件成立,才交付參照。若未來加入 Break 或 Stop,則必須重新定義部分完成的資料格式,不能把它當成正常完整輸出。

保留輸入順序,不代表已經完成業務排序

索引寫回保留的是輸入快照的排列,而不是代碼大小或時間先後。若讀者需要另一種順序,應在計算完成後明確指定排序鍵,並保留資料身分,避免把最後一筆誤解成最大值或最新值。

本文用輸入代碼 30、10、20 作為刻意不排序的測試資料。依索引寫回後,輸出代碼仍是 30、10、20;每筆結果都同時保存 Id 與其平方值。接著另建以 Id 排序的陣列,順序才變成 10、20、30。原陣列最後一筆與排序後最後一筆並不相同,這正是測試要保留的差異,而不是要修掉的錯誤。

建議在介面契約中寫清楚「保留輸入順序」或「依指定鍵排序」,不要只寫「結果有序」。若多筆資料的排序鍵相同,還需要決定次要排序鍵與同鍵時的處理規則。只拿計算值回傳卻捨棄身分,會讓後續排查更困難;在範例中保留 Id,是為了可以直接驗證 Id 與 Value 的對應,而不是單純核對總筆數。

這個設計也能幫助跨模組協作:輸入提供者負責定義快照與索引,計算模組負責一筆輸入一筆結果,下游負責另外要求的排序或彙整。當責任分開,變更排序需求就不需要同時改動平行計算的工作排程,測試也能各自表達失敗原因。

例外與巢狀平行化應該如何處理?

同步平行迴圈失敗時,呼叫端應接住可觀察的例外並保留失敗狀態,不能把部分陣列當成完整結果。平行化層級則先選一層作為基準,再以量測決定是否增加,而不是假設巢狀平行一定更快。

Parallel.For 文件列出 AggregateException 作為迭代例外的彙整形式。測試在固定索引丟出合成例外,呼叫端使用 Flatten 檢查原因,並確認交付變數仍為空值。測試沒有假設哪幾個其他索引會先完成,也不檢查精確的部分筆數,因為那些細節受到排程影響。若錯誤原因不存在、例外未被觀察,或部分結果被交出去,都應視為測試失敗。

Microsoft 的平行化陷阱文件提醒避免過度平行化,尤其是巢狀迴圈;但它也列出內層工作很長、運算昂貴或處理器資源足夠等例外。故本文不把「只能一層」寫成語言規則。建議先建立循序基準與單層平行版本,再考慮有明確理由的替代方案。本文設定 MaxDegreeOfParallelism 為 2,目的只是限制這個小型測試,不代表最合適的設定,也不保證每次都會同時有兩個工作執行。

來源筆記提及特定專案的計算耗時與修正成果,本文沒有沿用那些數字,也沒有把它們寫成 Jerry 的個人成績。本文驗證的是資料契約與錯誤邊界,不是效能優勢。短小的整數平方運算甚至可能不值得平行化;若要提出速度結論,必須另做包含工作量、執行次數與資源條件的量測。

本機驗證與重現:三個可觀察的測試

以下測試把槽位完整性、資料身分與失敗邊界分開檢查,任何斷言不成立都會以未處理例外結束。請固定工具版本並使用獨立測試資料夾,只處理範例的合成資料,不要套入既有應用程式。

前置條件:安裝 .NET SDK 10.0.400 與 Microsoft.NETCore.App 10.0.11;使用可執行 dotnet 的終端。下列指令可在 Bash 或 PowerShell 執行。先檢查版本清單,缺少指定版本時停止,不要默默切換到另一個版本。本範例沒有額外 NuGet 套件,也不需要網路服務。

  1. 執行下列準備指令建立全新的 ParallelOrderLab 資料夾;如果資料夾已經存在,改用另一個未使用名稱,避免覆寫既有檔案。以文字編輯器在資料夾內建立下方 global.json,再執行建立專案的指令。
dotnet --list-sdks
dotnet --list-runtimes
mkdir ParallelOrderLab
cd ParallelOrderLab

將以下內容存為 global.json:

{"sdk":{"version":"10.0.400","rollForward":"disable"}}
dotnet --version
dotnet new console --framework net10.0 --no-restore
  1. 把新專案的 ParallelOrderLab.csproj 改為下方內容,把 Program.cs 完整替換為後面的測試程式。此步驟只修改剛建立的測試專案。SDK 不允許自動改版,執行階段也固定為 10.0.11。

  
    Exe
    net10.0
    enable
    enable
    10.0.11
    Disable
  

using System.Runtime.InteropServices;

static void Require(bool condition, string message)
{
    if (!condition) throw new InvalidOperationException(message);
}

Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
var mode = args.Length == 1 ? args[0] : "all";
Require(new[] { "all", "slots", "identity", "failure" }.Contains(mode), "Unknown check");
var options = new ParallelOptions { MaxDegreeOfParallelism = 2 };
var checks = 0;

if (mode is "all" or "slots")
{
    foreach (var count in new[] { 0, 1, 64 })
    {
        var results = new int[count];
        var visits = new int[count];
        var loop = Parallel.For(0, count, options, i =>
        {
            results[i] = checked((i + 1) * (i + 1));
            Interlocked.Increment(ref visits[i]);
        });
        Require(loop.IsCompleted, "Loop not complete");
        Require(results.Length == count, "Wrong length");
        for (var i = 0; i 
    {
        var id = input[i];
        results[i] = (id, checked(id * id));
    });
    Require(loop.IsCompleted, "Loop not complete");
    Require(results.Select(r => r.Id).SequenceEqual(input), "Input order lost");
    Require(results.All(r => r.Value == r.Id * r.Id), "Identity/value mismatch");
    var byId = results.OrderBy(r => r.Id).ToArray();
    Require(byId.Select(r => r.Id).SequenceEqual(new[] { 10, 20, 30 }), "Sort failed");
    Require(results[^1].Id == 20 && byId[^1].Id == 30, "Last has wrong meaning");
    Console.WriteLine("PASS identity: input order differs from explicit ID order");
    checks++;
}

if (mode is "all" or "failure")
{
    int[]? published = null;
    var observed = false;
    try
    {
        var candidate = new int[16];
        var loop = Parallel.For(0, candidate.Length, options, i =>
        {
            if (i == 5) throw new InvalidOperationException("Synthetic fixture failure");
            candidate[i] = i + 1;
        });
        Require(loop.IsCompleted, "Incomplete candidate");
        published = candidate;
    }
    catch (AggregateException ex)
    {
        observed = ex.Flatten().InnerExceptions.Any(e =>
            e is InvalidOperationException && e.Message == "Synthetic fixture failure");
    }
    Require(observed, "Expected loop exception was not observed");
    Require(published is null, "Partial result escaped");
    Console.WriteLine("PASS failure: aggregate observed; partial result withheld");
    checks++;
}
Console.WriteLine($"PASS total checks: {checks}");
  1. 依序執行下列指令。slots 檢查空集合、單筆與固定批次的每個槽位;identity 檢查輸入排列與明確排序的差別;failure 檢查合成例外被觀察且部分結果沒有交付。任何命令非零結束、缺少對應 PASS 或出現版本不符,皆判定失敗。
dotnet run --configuration Release -- slots
dotnet run --configuration Release -- identity
dotnet run --configuration Release -- failure
dotnet run --configuration Release -- all

排程環境已實際執行 all,使用 SDK 10.0.400 與執行階段 .NET 10.0.11,結束碼為 0。以下是實際擷取的輸出,不是預測結果;三個個別模式也於同一環境另外執行查核。

Runtime: .NET 10.0.11
PASS slots: empty, single, and 64-item inputs
PASS identity: input order differs from explicit ID order
PASS failure: aggregate observed; partial result withheld
PASS total checks: 3

檢查清單:slots 必須回報三種輸入規模通過,且每個槽位只記錄一次寫入;identity 必須同時通過身分配對與兩種末筆語意的斷言;failure 必須找到合成例外,且未交付候選陣列。這些結果只證明本文範例在指定環境通過,不宣稱所有平行程式都已安全。

技術查核、適用邊界與結論

本篇核心結論是用輸入索引管理輸出所有權,並將整批完成視為資料交付的必要條件。官方文件提供集合與迴圈行為的依據,本機測試補上可重現的資料契約,兩者都不構成效能保證。

文章取材自 T 槽開發筆記的索引寫回與巢狀平行化觀察,並完整讀取來源。只保留可通用的設計概念,未帶入專案識別、環境細節或既有耗時數字。原筆記把部分注意事項寫成強烈通則,這裡依官方文件補上適用條件:排序修正不等於容器同步、不同槽位不等於共用子物件安全、巢狀平行化並非一律錯誤。

發布前建議再檢查輸入是否固定、每個槽位是否只有一位寫入者、輸出是否保留身分、失敗是否阻擋完整結果交付,以及排序語意是否明寫。這五個問題能把「看起來跑完」變成明確的可驗收契約。需要限制共享資源的非同步工作,應另用適合的併發控制設計,不要將本文的小型同步計算範例直接延伸過去。

已成功開啟並查核的官方資料: