PowerShell 的並行處理 ── ForEach-Object -Parallel 與工作(Job)的選用之道

· · PowerShell, Windows, 並行處理, 效能改善, 自動化, 維運改善, 腳本, 工作

「對 200 台電腦執行 ping 存活確認的指令碼,跑完一輪要 15 分鐘」「共用資料夾底下 10 萬個檔案的雜湊值計算算不完」── PowerShell 自動化發展到一定程度後,一定會撞上處理時間的瓶頸。而這類處理,大多是等待時間占了主導。CPU 明明是閒置的,卻因為要一件一件依序等待網路回應或磁碟 I/O 而變慢。這正是並行化該登場的地方。

PowerShell 7 開始可以使用 ForEach-Object -Parallel,讓並行處理的門檻大幅降低。不過,若隨便並行化,不但不會變快,反而會變慢,甚至連結果都會壞掉,這也是它麻煩的地方。「更新共用變數後值不見了」「輸出順序每次都不一樣」「找不到呼叫端定義的函式」,這些都是在不了解並行處理機制的情況下使用時的典型症狀。

本文將整理 PowerShell 中可用的並行化手段,包含 ForEach-Object -Parallel 的用法與陷阱、與 Start-ThreadJob / Start-Job 的選用方式,以及「不該並行化的情況」,以便在實務中做出判斷。

1. 先講結論

  • ForEach-Object -Parallel 是 PowerShell 7.0 新增的功能。Windows PowerShell 5.1 中並不存在。1
  • 每個指令碼區塊都在不同的 Runspace(執行環境)中執行。呼叫端的變數與函式並不會直接可見。變數要透過 $using: 範圍修飾詞傳遞。1
  • -ThrottleLimit 的預設值是 5。PowerShell 7.1 以後,Runspace 集區會被重複使用,ThrottleLimit 即為該集區的大小。若想每次都建立新的,則使用 -UseNewRunspace1
  • $using: 傳遞的是「參照」。只讀取的話是安全的,但若要更新,就必須使用 System.Collections.Concurrent 中的執行緒安全型別。一般的 Hashtable 或 List 若同時更新會壞掉。1
  • 並行化並不是「一定會變快」的手段。官方文件也明確指出,將瑣碎的處理並行化,可能會比平常慢上許多。真正有效的是等待時間長的處理,以及在多核心上有意義的計算處理。1
  • 輸出與錯誤的順序都不固定。寫入錯誤資料流的順序也是隨機的,指令碼區塊內的終止錯誤只會停止該次疊代,並以 PSTaskException 回報。1
  • 可用 -AsJob 以工作(Job)的形式送出。不過 -ThrottleLimit 是每一個工作的並行數,若建立多個工作,同時執行數就會相乘。1
  • 輕量的並行處理用 Start-ThreadJob,需要隔離的則用 Start-Job。Start-Job 因為在不同的處理程序中執行,額外負擔較大,結果會經過序列化,回傳時會失去方法。23
  • 若要對多台 Windows 執行同樣的處理,Invoke-Command 隱含的並行執行是最快的捷徑。它預設可同時執行至 32 台。4

2. 並行化的四種選擇

2.1 前提知識 ── Runspace(執行空間)是什麼

本文中會反覆出現「Runspace」這個詞,先在這裡說明清楚。Runspace 就是 PowerShell 用來執行指令碼的「執行環境」本身。變數、函式、已載入的模組、目前的工作目錄等整套工作階段狀態,都屬於這個 Runspace。

若以與處理程序、執行緒的關係來說明,如下表所示。

層級 是什麼 在本文中的意義
處理程序 從作業系統角度看到的執行單位(1 個 pwsh.exe) 1 個處理程序中可以擁有多個 Runspace
執行緒 實際執行程式碼的執行流程 Runspace 是在執行緒之上運作的
Runspace PowerShell 狀態(變數、函式、模組)的容器 一旦分開,變數與函式都不會共用

用一個形象的說法來理解,大致相當於「可以在 1 個處理程序中,建立多個各自擁有獨立變數與函式的 PowerShell 工作階段」。ForEach-Object -Parallel 就是準備多個這樣的 Runspace,並讓各個指令碼區塊分別在不同的執行緒中執行的機制。1

本文中幾乎所有麻煩事,都是從這裡衍生出來的。呼叫端的變數與函式屬於呼叫端的 Runspace,因此從其他 Runspace 是看不到的。所以變數必須透過 $using: 明確傳遞,函式則必須模組化後在各個 Runspace 中載入。另一方面,因為處理程序是同一個,記憶體中物件的實體是可以共用的。這一點會在後面變成執行緒安全性的問題反噬回來。

「看不到,卻又是共用的」── 這種乍看矛盾的性質,正是在不理解 Runspace 就貿然並行化時,會出事故的原因。

2.2 四種手段

先建立一份整體地圖。在 PowerShell 中「同時執行」的手段大致有四種。

手段 執行單位 可用版本 適合的用途
ForEach-Object -Parallel 執行緒(Runspace) PowerShell 7.0+1 對集合中的每個元素套用相同處理。第一候選
Start-ThreadJob 執行緒(Runspace) PowerShell 7 內建 / 5.1 需從 Gallery 安裝2 把少量處理丟出去,之後再回收
Start-Job 不同的處理程序 5.1、7 皆為標準3 需要隔離,或想在不同處理程序中執行的處理
Invoke-Command -ComputerName 遠端的各台電腦 5.1、7 皆為標準4 多台 Windows 分派相同處理

實務上的選用很單純。若要對大量目標套用相同處理,用 ForEach-Object -Parallel;若目標是「多台遠端電腦」,優先選遠端執行。後者對指定的多台電腦,預設最多可同時執行 32 台,不需要自己撰寫並行化邏輯。4 遠端執行本身的設定,請參閱「PowerShell Remoting(WinRM)入門 ── 一次管理多台 Windows」。

Start-Job 之所以較重,是因為背景工作會以不同的處理程序啟動。除了處理程序啟動的成本之外,結果會經過序列化才傳回,因此收到的物件會是不具方法的「反序列化複製品」。3 相對地,Start-ThreadJob 是在同一個處理程序內的執行緒中執行,因此輕量許多。2

3. ForEach-Object -Parallel 的基本用法

最基本的形式如下。$_ 代表目前的輸入物件,外部變數則要加上 $using: 才能參照。1

$timeout = 2   # 呼叫端的變數

$result = $computers | ForEach-Object -Parallel {
    $name = $_
    # 外部變數若不加上 $using: 就看不到
    $ok = Test-Connection -TargetName $name -Count 1 -TimeoutSeconds $using:timeout -Quiet

    # 基本做法不是更新用來彙總的共用變數,而是「輸出」結果
    [pscustomobject]@{
        Computer = $name
        Alive    = $ok
        CheckedAt = Get-Date
    }
} -ThrottleLimit 20

$result | Where-Object { -not $_.Alive } | Format-Table

這裡有一項該掌握的設計原則。不是更新共用變數,而是讓每個指令碼區塊「輸出」結果,由呼叫端統一接收。只要維持這種形式,執行緒安全性的問題原本就不會發生。並行處理中會出問題的程式碼,通常都是想要改寫共用狀態。

若無論如何都想彙整到共用集合中,就要使用執行緒安全的型別。1

# 官方文件示範的模式:ConcurrentDictionary 可以安全地被多個執行緒同時更新
$safeDict = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()

Get-Process | ForEach-Object -Parallel {
    $dict = $using:safeDict
    # TryAdd() 會傳回布林值。若不捨棄,True/False 會混入輸出中
    $null = $dict.TryAdd($_.ProcessName, $_)
}

# 【危險】把一般的 Hashtable 或 List<T> 透過 $using: 傳遞後再更新是不安全的
# $shared = @{}          # 同時更新會破壞內部結構,導致例外或值遺失

這裡先把 $using: 的意義說清楚。官方文件對 ForEach-Object -Parallel 明確指出,「Using: 修飾詞會將變數的參照,從呼叫該 Cmdlet 的執行緒,傳遞給每個正在執行的指令碼區塊所在的執行緒」。傳過去的是指向同一個處理程序內、同一個物件的參照。正因如此,若只是讀取不會改變的內容就是安全的,但若要改寫狀態,就需要執行緒安全的型別。1

而且,這種「傳遞參照」的性質,僅限於在同一個處理程序內執行的並行化。Start-Job 是在不同的處理程序中執行、Invoke-Command -ComputerName 則是在不同的機器上執行,因此透過 $using: 傳遞的值會被序列化後以複製品的形式送達。在對方那一側改寫也不會反映回呼叫端,回傳的物件同樣是不具方法的反序列化複製品。3 雖然寫法同樣是 $using:,但意義並不相同,請不要把 ForEach-Object -Parallel 的感覺直接套用到 Start-Job 上。

4. 五個陷阱

(1) 看不到呼叫端定義的函式

由於並行執行的指令碼區塊是在不同的 Runspace 中執行,呼叫端定義的函式無法直接呼叫。正規做法是把共用處理模組化,並在指令碼區塊開頭匯入。

$items | ForEach-Object -Parallel {
    Import-Module 'D:\Scripts\Modules\KsOps' -ErrorAction Stop   # 在各個 Runspace 中載入
    Convert-KsRecord -Input $_
} -ThrottleLimit 8

不過在各個 Runspace 中載入模組的成本,會隨並行數量一起累加。在使用較重的模組時,即使提高並行度也會遇到瓶頸,主因往往就在這裡。模組化的做法整理在「PowerShell 指令碼的引數設計與模組化 ── 從「能動的指令碼」到「能交給別人的指令碼」」中。

(2) 由於 Runspace 會被重複使用,狀態可能被延續

PowerShell 7.0 中,每次疊代都會建立新的 Runspace,但7.1 以後預設會從 Runspace 集區中重複使用1 這在效能上是很大的改善,但也意味著某次疊代中設定的環境變數、目前工作目錄的變更、已載入模組的狀態,都可能被使用同一個 Runspace 的後續疊代看到。若需要疊代之間完全獨立,則指定 -UseNewRunspace(相對地會變慢)。1

(3) 輸出與錯誤的順序都不保證

並行執行的順序是不確定的。寫入錯誤資料流的順序也是隨機的,警告、詳細資訊、資訊資料流同樣如此。1

1..5 | ForEach-Object -Parallel {
    if ($_ -eq 3) { throw "Terminating Error: $_" }   # 只有這次疊代會停止
    "Output: $_"
}
# Output: 1 / Output: 4 / Output: 2 / Output: 5 (順序不定),不會出現 Output: 3

指令碼區塊內的終止錯誤只會結束該次疊代,其他並行執行則會繼續進行。錯誤會以 FullyQualifiedErrorIdPSTaskException 的 ErrorRecord 寫入錯誤資料流。這並不是「只要有一件失敗就全部停止」的行為,因此必須自行彙總全部項目的成敗結果1

$results = $files | ForEach-Object -Parallel {
    # 在 catch 區塊中 $_ 會變成 ErrorRecord,
    # 因此輸入物件務必先另存到一個變數再使用
    $file = $_

    try {
        $hash = Get-FileHash -Path $file.FullName -Algorithm SHA256 -ErrorAction Stop
        [pscustomobject]@{ Path = $file.FullName; Hash = $hash.Hash; Error = $null }
    }
    catch {
        # 失敗也以「輸出」的形式回傳,交由呼叫端分類
        [pscustomobject]@{ Path = $file.FullName; Hash = $null; Error = $_.Exception.Message }
    }
} -ThrottleLimit 8

$failed = $results | Where-Object Error
if ($failed) { Write-Warning "有 $($failed.Count) 件失敗" }

(4) 無法使用 PipelineVariable

通用參數 -PipelineVariable,即使加上 $using:,在並行情境中也不受支援。1 這是從循序處理移植過來時容易踩到的地方。

(5) 進度顯示與記錄檔會混雜在一起

若同時從多個執行緒寫入 Write-Progress 或自訂記錄,行內容會混在一起,檔案也可能發生衝突。比起在指令碼區塊內直接附加寫入檔案,更安全的做法是以結果物件的形式回傳,由呼叫端在單一位置統一寫入。輸出資料流的設計方式,在「別再用 Write-Host ── PowerShell 的輸出資料流與記錄設計」中有說明。

5. ThrottleLimit 的決定方法

-ThrottleLimit 是同時執行的指令碼區塊數量,預設為 5。1 7.1 以後,這個值就是Runspace 集區的大小本身。1 用圖表示其運作方式如下。

每完成一筆就重複使用同一個 RunspaceRunspace 集區 ThrottleLimit = 5Runspace 1Runspace 2Runspace 3Runspace 4Runspace 5輸入 200筆等候佇列等待有空位釋出輸出順序不定

重點在於,面對 200 筆輸入,並不會建立 200 個 Runspace,而是重複使用依 -ThrottleLimit 數量所準備的 Runspace。由此可以導出兩件事。其一,-ThrottleLimit 提高得越多,記憶體與初始化成本也會增加。其二,正因為是重複使用,前一次疊代的狀態有可能殘留下來(第 4 章 (2))。

決定方式的參考標準,依處理的性質而不同。

處理性質 參考值 理由
網路等待(連線確認、API 呼叫) 可以大於核心數(從 20〜50 左右開始嘗試) CPU 幾乎是閒置的,限制在對方那一側
磁碟 I/O 等待 從 8〜16 左右開始嘗試 調得太高會增加隨機存取,反而造成反效果。SSD/HDD 差異很大
CPU 計算(雜湊、壓縮、轉換) 大約邏輯核心數 超過這個數字只是浪費在內容切換上
對方是業務伺服器・API 以對方的容許量為上限 超過速率限制或同時連線上限,就會變成造成故障的一方

最後一行在實務上最為重要。為了讓自己的指令碼變快而把業務伺服器弄當機,是本末倒置,因此在面對公司內部 API 或檔案伺服器時,自己這邊的並行度要在「對方能承受的範圍」內決定。應對 API 速率限制的方法,請參閱「用 PowerShell 串接 REST API ── Invoke-RestMethod 的實務」。

使用 -AsJob 時的注意事項,官方文件同樣有明確說明。ThrottleLimit 是每一次 ForEach-Object -Parallel 的限制,若建立 10 個工作,就會有「10 × ThrottleLimit」同時執行。1

還有一點,即使同樣叫做 -ThrottleLimit,不同指令所計算的對象也不一樣。若混淆會導致並行度的估算出錯,這裡把本文出現的兩者並列說明。

指令 -ThrottleLimit 計算的對象 預設值
ForEach-Object -Parallel 同時執行的指令碼區塊數量(= Runspace 集區的大小) 51
Invoke-Command -ComputerName 同時連線的電腦台數 324

也就是說,Invoke-Command -ThrottleLimit 32 指的是「同時連線 32 台」,而不是「每台以 32 個並行處理」。若把兩者組合使用(在遠端執行 ForEach-Object -Parallel),也請注意台數 × 並行度會是對方那一側的總負載。

6. 不並行化反而更快的情況

官方文件寫得相當直白 ── 新的 Runspace 相較於循序處理有相當程度的額外負擔,若並行執行的指令碼只是瑣碎的處理,可能會比平常慢上許多,建議實際嘗試找出有效果的地方。1 官方範例中甚至有一個明確標註為「這是並行化的低效率使用範例」的樣本。

判斷標準很單純。

  • 每件的處理時間短(毫秒等級) → 不要並行化。字串處理、Hashtable 查詢之類的,循序處理反而更快
  • 件數很少(數十件) → 不要並行化。額外負擔相對較大
  • 每件有數百毫秒以上的等待 → 並行化有價值
  • 有需要長時間占用 CPU 的計算 → 多核心能發揮效果

而且一定要先測量再決定。用 Measure-Command 比較循序版與並行版,幾十秒內就能得到答案。測量的方法以及 PowerShell 指令碼加速的整體整理,收錄在「PowerShell 指令碼變慢時該檢查的地方 ── 陣列、管線、比對的訣竅」中。

# 用相同的輸入比較循序與並行(執行數次以觀察穩定的數值)
# 並行處理端在不同的 Runspace 中執行,呼叫端定義的函式看不到。
# 若不處理這一點就無法比較,所以要載入模組或直接寫出處理內容
$seq = Measure-Command {
    $items | ForEach-Object { Invoke-Work $_ }
}
$par = Measure-Command {
    $items | ForEach-Object -Parallel {
        Import-Module 'D:\Scripts\Modules\KsOps'   # ← 沒有這一行會發生找不到指令的錯誤
        Invoke-Work $_
    } -ThrottleLimit 16
}
'{0:N1} 秒 → {1:N1} 秒' -f $seq.TotalSeconds, $par.TotalSeconds

7. Start-ThreadJob 與 Start-Job 的選用之道

ForEach-Object -Parallel 適合「對大量輸入套用相同處理」的形式,但想同時執行性質不同的處理,之後再回收結果時,則適合用工作(Job)。

# 同時執行不同的處理,再統一等待(執行緒工作 = 輕量)
# 執行緒工作同樣在不同的 Runspace 中執行,呼叫端的函式看不到。
# 在每個工作的開頭載入模組(或寫成自我完結的指令碼區塊)
$jobs = @(
    Start-ThreadJob -Name 'AD 使用者盤點' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsAdUserReport
    }
    Start-ThreadJob -Name '檔案伺服器容量' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsShareUsage
    }
    Start-ThreadJob -Name '授權彙總' -ScriptBlock {
        Import-Module 'D:\Scripts\Modules\KsOps'; Get-KsLicenseSummary
    }
)

# 等待完成後回收結果。錯誤務必透過 -ErrorVariable 接收
$reports = $jobs | Receive-Job -Wait -ErrorAction SilentlyContinue -ErrorVariable jobErrors

# 狀態變成 Failed,只會發生在指令碼區塊因「終止錯誤」而中斷時。
# 若工作是以 Write-Error 這類非終止錯誤結束,狀態仍會維持 Completed,
# 若只看狀態,就會出現「明明有錯誤卻被當成成功」的情況
$failed = @($jobs | Where-Object { $_.State -eq 'Failed' })
$jobs | Remove-Job -Force

if ($failed.Count -gt 0 -or @($jobErrors).Count -gt 0) {
    throw "並行執行時發生錯誤: $(($jobErrors | ForEach-Object { $_.Exception.Message }) -join ' / ')"
}

這裡不使用 -AutoRemoveJob,是因為回收後工作會立刻消失,無法確認是哪一個失敗了。Receive-Job 的錯誤預設是非終止錯誤,若什麼都不寫,就會變成「明明有部分失敗,卻只傳回成功的部分,看起來像是全部完成」這種最麻煩的情況。在盤點或報表的自動化中,這種漏接會以數字錯誤的形式悄悄殘留下來。

選擇 Start-Job(處理程序隔離型)的情況如下。3

  • 不想牽連呼叫端處理程序的處理(例如呼叫可能當機的原生 DLL)
  • 需要不同處理程序環境的處理(想以不同的文化設定或環境變數執行)
  • 想把 32 位元 / 64 位元等執行環境本身分開的處理

反過來說,如果不符合上述情況,Start-Job 就會因為額外負擔與序列化的限制而處於不利地位。反序列化後的物件不具方法這一點,也會在後續處理中造成影響。3

8. 只有 Windows PowerShell 5.1 的環境中

5.1 中無法使用 -Parallel。現實可行的選項有三個。

  1. Start-ThreadJob(安裝 PowerShell Gallery 的 ThreadJob 模組)── 5.1 中也能使用輕量的並行處理。公司內部散發的做法請參閱「PowerShell 模組的內部散發與更新 ── PSResourceGet 與內部儲存庫2
  2. Invoke-Command -ComputerName ── 若目標是多台 Windows,光是這樣就能達成並行(預設同時 32 台)4
  3. 導入 PowerShell 7 ── 5.1 與 7 可以共存,只讓較重的處理用 7 執行,也是現實可行的選擇

第一項的安裝方式如下。在原生的 5.1 上直接執行 Install-Module 有時會失敗,因此一併說明兩個容易卡關的地方。

# 【1】PowerShell Gallery 要求 TLS 1.2 以上。
#      Windows PowerShell 5.1 預設並未啟用,
#      有時會因「基礎連線已關閉」等錯誤而失敗。
#      先只為這個工作階段啟用 TLS 1.2 後再執行
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# 【2】安裝。CurrentUser 的話不需要系統管理員權限
Install-Module ThreadJob -Scope CurrentUser

# 【3】確認
Import-Module ThreadJob
Get-Command -Module ThreadJob     # 出現 Start-ThreadJob 就代表安裝成功

容易卡關的地方有以下兩點。

  • TLS 1.2 ── PowerShell Gallery 自 2020 年 4 月起,以 TLS 1.2 以上的連線為前提。在 5.1 中有時需要上面那一行5
  • 儲存庫的信任與執行原則 ── PSGallery 預設會被視為「不受信任的儲存庫」,因此執行 Install-Module 時會要求確認。若要無人值守安裝,可加上 -Force;若要經常使用,則可用 Set-PSRepository -Name PSGallery -InstallationPolicy Trusted 先將其設為信任狀態。此外,若執行原則為 Restricted(Windows 用戶端的預設值),會在載入指令碼時卡住,因此需要相當於 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 的設定。請避免將其永久設為 Unrestricted6

最終往往是第三個選項最划算,本公司也建議「與其為了並行化寫複雜的 5.1 程式碼,不如只讓該處理在 7 上執行」。共存與移轉的思路,整理在「Windows PowerShell 5.1 與 PowerShell 7 的差異 ── 公司內部指令碼移轉的實務指南」中。

9. 實務定石(判斷表)

想做的事 選擇 補充
對多個目標套用相同處理(連線確認、雜湊計算、API 呼叫) ForEach-Object -Parallel PowerShell 7 專用。設計成以輸出回傳結果1
對多台 Windows 執行相同處理 Invoke-Command -ComputerName 預設同時 32 台。不需要自行撰寫並行化4
同時執行性質不同的處理 Start-ThreadJob 輕量。用 Receive-Job -Wait 回收2
需要處理程序隔離 Start-Job 較重。結果會經過反序列化3
想把結果彙整到一處 以輸出回傳 / ConcurrentDictionary 一般的 Hashtable、List 無法同時更新1
每件輕量、件數少 不要並行化 額外負擔反而會拖慢速度1
對方是業務伺服器・API 以對方為基準決定 ThrottleLimit 速率限制、同時連線上限才是實質上限
疊代之間必須獨立 -UseNewRunspace 避免因重複使用而延續狀態(會變慢)1

10. 總結

  • ForEach-Object -Parallel 是 PowerShell 7.0 以後的功能,各個指令碼區塊會在不同的 Runspace 中執行。呼叫端的變數用 $using:、函式則模組化後載入,是基本形式。
  • 不要更新共用變數,而是設計成每次疊代都輸出結果、由呼叫端彙整,這樣幾乎可以避開執行緒安全性的問題。若無論如何都要共用,就使用 Concurrent 系列的型別。
  • 預設的 ThrottleLimit 是 5。等待時間占主導的處理可以調大,CPU 處理大約是核心數,對象是外部系統的處理則以對方的容許量為上限。
  • 輸出與錯誤的順序都不固定,終止錯誤只會停止該次疊代。請自行彙總全部項目的成敗結果。
  • 並行化並非萬能。處理較輕或件數較少時,會因額外負擔而變慢。請務必先用 Measure-Command 與循序版比較後再採用。
  • 在 5.1 環境中用 Start-ThreadJob,遠端則用 Invoke-Command,若仍不足,現實可行的做法是考慮導入 PowerShell 7。

範例程式碼下載

本文所使用的程式碼,已整理成可直接執行的形式提供下載。內含 ForEach-Object -Parallel / Start-ThreadJob 的實務用範本。

下載範例程式碼(zip)

本文的範例,已在 PowerShell 7.6 上實際執行並驗證(8 個 Pester 測試)。執行 zip 中內含的 Invoke-SampleTests.ps1,您也能在自己的環境重現相同的驗證。

# 語法解析 + 靜態分析 + Pester 測試
./Invoke-SampleTests.ps1

設定值(路徑、伺服器名稱、租用戶 ID 等)僅為範例。請勿直接在正式環境中執行,請依自家環境調整後再使用。

相關文章

相關諮詢領域

合同會社小村軟體處理耗時運維指令碼的加速、包含並行處理在內的自動化設計審查,以及「並行化之後結果變得怪怪的」這類問題的調查。

參考連結

  1. Microsoft Learn,ForEach-Object。內容涉及:PowerShell 7.0 新增了 -Parallel 參數集、每個指令碼區塊都在新的 Runspace 中執行、透過 $using: 範圍修飾詞傳遞變數、-ThrottleLimit 的預設值為 5 且即為 Runspace 集區的大小、7.1 以後 Runspace 會被重複使用且可用 -UseNewRunspace 建立新的、-AsJob 與 -TimeoutSeconds 的行為、透過 $using: 傳遞的參照若要更新則需要 System.Collections.Concurrent 這類執行緒安全的型別、非終止錯誤的輸出順序不固定、終止錯誤只會結束個別的並行執行個體且 FullyQualifiedErrorId 會變成 PSTaskException、PipelineVariable 在並行情境中不受支援、新建 Runspace 的額外負擔較大以致瑣碎處理反而可能變慢、使用 -AsJob 時 ThrottleLimit 是每個工作的限制。  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26

  2. Microsoft Learn,Start-ThreadJob。內容涉及:Start-ThreadJob 並非在不同的處理程序、而是在同一個處理程序內的獨立執行緒中執行指令碼區塊,因此比 Start-Job 更輕量、可用標準的工作 Cmdlet(如 Receive-Job 等)操作、以及透過 -ThrottleLimit 控制同時執行數量。  2 3 4 5

  3. Microsoft Learn,about_Jobs。內容涉及:背景工作會在新的處理程序中以非同步方式執行指令、透過 Start-Job、Get-Job、Receive-Job、Wait-Job 操作工作、以及工作的結果會經過序列化後回傳。  2 3 4 5 6 7

  4. Microsoft Learn,Invoke-Command。內容涉及:可在 -ComputerName 中指定多台電腦以執行相同的指令、-ThrottleLimit 限制同時連線數且預設值為 32、以及透過 -AsJob 進行背景執行。  2 3 4 5 6

  5. Microsoft PowerShell Team Blog,PowerShell Gallery TLS Support。內容涉及:自 2020 年 4 月起 PowerShell Gallery 已將 TLS 1.2 設為預設,用戶端也必須以 TLS 1.2 以上的版本連線、以及 Windows PowerShell 5.1 中,建議先將 [Net.ServicePointManager]::SecurityProtocol 設為 Tls12 再連線的因應方式。 

  6. Microsoft Learn,about_Execution_Policies。內容涉及:執行原則是控制指令碼載入條件的機制、在所有範圍皆未設定時,有效原則在 Windows 用戶端為 Restricted、在 Windows Server 為 RemoteSigned、Restricted 狀態下無法執行包含 .ps1、.psm1 在內的指令碼檔案、以及可透過 -Scope 指定僅套用於目前的使用者或處理程序。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

ForEach-Object -Parallel 在 Windows PowerShell 5.1 中也能使用嗎?
不能。-Parallel 是 PowerShell 7.0 新增的參數集,5.1 中原本就不存在這個功能。若要在 5.1 中實現並行化,可行的方式是使用可從 PowerShell Gallery 安裝的 ThreadJob 模組中的 Start-ThreadJob、標準的 Start-Job(採處理程序隔離,較重),或是自行組建 RunspacePool。如果目的是加快公司內部指令碼的執行速度,與其在 5.1 上寫複雜的並行處理,不如導入 PowerShell 7 並使用 -Parallel,在維護性上也更有利。
明明並行化了,反而變慢了,這是為什麼?
因為並行執行的額外負擔比處理本身還要大。ForEach-Object -Parallel 會讓每個指令碼區塊在不同的 Runspace 中執行,相較於循序處理有相當程度的額外負擔,官方文件也明確指出「若並行執行的指令碼只是瑣碎的處理,可能會比平常慢上許多」。若每件處理只要幾毫秒就結束,或是件數只有數十件,通常不並行化反而比較快。真正能發揮效果的,是網路等待或檔案 I/O 等待時間長的處理,以及在多核心上有意義的計算處理。
可以從並行執行的指令碼區塊中,對透過 $using: 傳遞的變數寫入值嗎?
讀取所參照的值是安全的,但寫入則不安全,除非對象是執行緒安全的型別。$using: 是把變數的參照從呼叫端的執行緒傳遞給各個指令碼區塊所在執行緒的機制,因為會有多個執行緒同時存取,若更新一般的 Hashtable 或 List 就會壞掉。若想彙整統計結果,請使用 System.Collections.Concurrent 命名空間中 ConcurrentDictionary、ConcurrentBag 這類執行緒安全的型別,或者從一開始就設計成由每個指令碼區塊輸出值、再由呼叫端接收的形式。
ThrottleLimit 該設成多少才正確?
會依處理的性質而不同。預設值是 5。以網路等待或檔案 I/O 等待為主的處理(例如對伺服器的連線確認、API 呼叫等),即使設得比 CPU 核心數大,也能看到效果。另一方面,在會用盡 CPU 的計算處理中,一旦超過核心數左右,就只會因為競爭而變慢。若對方是業務伺服器或 API,除了自己這邊的考量之外,對方的同時連線上限與速率限制也會成為上限。建議的做法是先以預設值 5 測量,再嘗試加倍看看是否有效果,這樣比較安全。
無法在並行執行的指令碼區塊中,呼叫自己寫的函式。
因為並行執行的指令碼區塊,是在與呼叫端不同的 Runspace 中執行,呼叫端範圍中定義的函式或變數並不會直接可見。因應方式有兩種:一是把共用處理做成模組(.psm1),在指令碼區塊開頭執行 Import-Module;二是把函式定義以文字形式透過 $using: 傳遞,並在指令碼區塊內重新定義。從維護性的角度來看,建議採用前者。不過在各個 Runspace 中載入模組會有成本,若模組較重,即使提高並行度也會遇到瓶頸,請留意這一點。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽