PowerShell 執行環境以單執行緒為主,優點是程式邏輯直覺、簡單,但遇上呼叫遠端服務的大量批次操作,性急如王藍田的我,自然無法忍受一堆作業乾等單一窗口消化,這種情境就是要開多執行緒萬箭齊發才爽多線並行才合理!

寫 C# 多執行緒程式我已駕輕就熟,有 Parallel.For、ThreadPool、自開 Thread 等好幾種選擇,但在 PowerShell 裡要怎麼做就得花點時間研究,以下是我的練習成果。

假設我要用 WebAPI 取得 Random.Next() 亂數,每次耗時 0-1 秒(用 System.Threading.Thread.Sleep(new Random().Next(1000)) 模擬),共要產生 100 個:(請不要問我哪有這麼廢的 WebAPI 玩法,這是範例這是範例)

$todo = 1..100 | ForEach-Object { "Job - $_" } 

$sw = New-Object System.Diagnostics.Stopwatch
$sw.Start()
# 循序執行
$todo | ForEach-Object {
    $randNum = (Invoke-WebRequest -Uri http://localhost/aspnet/getrandomnumber.aspx).Content
    Write-Host "$_ : $randNum"
}
$sw.Stop()
Write-Host "耗時$($sw.ElapsedMilliseconds.ToString('n0'))ms"

用 ForEach 跑迴圈,取 100 個亂數耗時 54 秒:(延遲 0-1 秒,平均 0.5 秒,合乎預期)

查到一篇很棒的介紹:PowerShell Multithreading: A Deep Dive,整理重點如下:

  • PowerShell 實現多緒執行最簡單的做法是使用 PSJob 背景執行作業,以 Start-Job 建立並執行,Wait-Job 等待作業執行完成,Receive-Job 取得作業傳回結果,概念還蠻簡單的。(甚至可以設成排程或由特定事件觸發)
  • 有一些 Cmdlet 提供 AsJob 可直接轉為背景作業執行,其中最好用的是 Invoke-Command,可傳入 ScriptBlock 以背景執行,甚至在遠端機器跑。
  • PSJob 好寫但笨重,Start-Job 建立有時需耗時 150ms。改用 Runspace 可提升效率,但寫起來較繁瑣,有個 PoshRSJob 模組能再簡化一些。如果在意效能,建議改用 Runspace。(我內心的 OS:若效能重要,何不用 C# 寫呢?)
  • PowerShell 7 推出了 ForEach-Object -Parallel,實現了 .NET Parallel.For 般的簡便做法,但要考慮執行主機是否能升級版本。(前幾天我就遇上主機是 PS 4.0 的狀況,鳴...)
  • PSJob 或 Runspace 間無法共享變數或其他資源、Logging 也變得困難,要花點心思設計。

最後,我試著用 PSJob 實做出八線平行作業版:

# 將待處理工作用 PSCustomObject 包成物件
# 包含參數及分組代碼及存放結果用的屬性
# 註: Class 要 PS 5.1+,為相容 PS 4.0 可取 PSCustomObject
$todo = 1..100 | ForEach-Object { 
    return [PSCustomObject]@{
        Name   = "Job - $_"
        GrpNo  = -1
        Result = ""
    }
} 

# 在 PowerShell 要搞非同步鎖定有點困難,就不搞待處理 Queue 或消費者生產者模型了
# 用以下函式事先將待處理陣列平分成多個
function SplitArray([object[]]$array, [int]$groupCount) {
    $arrayLength = $array.Length;
    # 注意:C# 整數相除結果為無條件捨去的整數,但 PowerShell 則含小數點
    $countPerGrp = [Math]::Floor($arrayLength / $groupCount)
    $remainder = $arrayLength % $groupCount
    $result = New-Object System.Collections.ArrayList
    $index = 0
    0..($groupCount - 1) | ForEach-Object {
        $takeCount = $countPerGrp
        if ($_ -le $remainder - 1) {
            # 除不盡的餘數由前面幾筆分攤
            $takeCount++
        }
        # 用 Skip + First 從第 index 取 takeCount 筆進行分組
        $subArray = @($array | Select-Object -Skip $index -First $takeCount)
        # 在待處理工作項目標註群組別(這個是實驗觀察用的,實務應用時不需要)
        $subArray.ForEach("GrpNo", $_)
        $index += $takeCount 
        $result.Add($subArray)
    } | Out-Null
    return $result
}

# 將待處理項目分成八組
$groups = SplitArray $todo 8

$sw = New-Object System.Diagnostics.Stopwatch
$sw.Start()
# 平行執行
$psJobPool = New-Object System.Collections.ArrayList
$groups | ForEach-Object {
    $psJob = Start-Job -ScriptBlock {
        param ([object[]]$array)
        $array | ForEach-Object {
            $randNum = (Invoke-WebRequest -Uri http://localhost/aspnet/getrandomnumber.aspx).Content
            $_.Result = $randNum
            # 填上執行結果後將整個 PSCustomObject 回傳
            return $_
        }
        # 眉角:下寫的寫法確保將整個 ArrayList 當成 ArgumentList 的一個參數
        #       而非將 ArrayList 轉成 ArgumentList
        #       參考:https://blog.darkthread.net/blog/psfaq-return-collection/
    } -ArgumentList @(, $_) 
    $psJobPool.Add($psJob) | Out-Null
}
# Wait-Job 可等待所有 PSJob 結束
$psJobPool | Wait-Job | Out-Null
$sw.Stop()
# Receive-Job 接收 PSJob 傳回結果
$result = $psJobPool | Receive-Job
$result | ForEach-Object {
    Write-Host "[$($_.GrpNo)] $($_.Name) : $($_.Result)"
}
Write-Host "耗時$($sw.ElapsedMilliseconds.ToString('n0'))ms"

裡面眉角不少,前陣子學會的陣列函式回傳集合知識也派上用場,細節我都寫在註解裡了。深深覺得 PowerShell 是個入門不難精通不易的語言,跟 JavaScript 有拼。

實測改為八緒並行,執行時間由 54 秒縮短至不到 12 秒,成功!

Practice of runing code parallelly with JSJobs in PowerShell.


Comments

# by HCC

WebAPI現法是什麼意思?

# by Jeffrey

by HCC, 是玩法打錯字的意思 Orz 謝謝指正

# by Ryan

請問,像這樣的產生檔案的指令 ( 1..8 | ForEach-Object { $out = New-Object byte[] 128MB; (New-Object Random).NextBytes($out); [IO.File]::WriteAllBytes("D:\Test Data\Test_$_.dummy", $out); Write-Host $_ -NoNewline } ) 要怎麼樣整合進去?? 或甚至我要多重迴圈去擴大製造檔案數量的規模要怎樣寫比較恰當??

# by Jeffrey

to Ryan, 實作有遭遇什麼問題嗎?(另外,你的案例我覺得效能瓶頸最後會落在 Disk IO 上)

# by Pierre

感謝分享,我改寫到其他應用,很有幫助。 但在分組時如果每組個數只有1(例如11個工作要分8組,最後5組每組的個數只有1個),會在 SplitArray 函式裡的 $subArray.ForEach("GrpNo", $_)發生問題: "Method invocation failed because [Selected.System.Management.Automation.PSCusto mObject] does not contain a method named 'ForEach'." 用gettype()追了一下,原來是只有一個時,$subarray的型別會被轉成PSCustomObject,而不是原來的system.object[],改善的方法,我是 把 $subArray = $array | Select-Object -Skip $index -First $takeCount 改成 [system.object[]]$subArray = $array | Select-Object -Skip $index -First $takeCount 就保證$subArray 永遠會是物件陣列(即使只有一個元素),就不會有前述狀況 ,以上參考。

# by Jeffrey

to Pierre,謝謝通報,我忘記處理單筆的狀況了。有個更簡單的改法,將 $array 改寫為 @($array) 就可以強迫單筆視為陣列處理,你可以試看看。

# by Pierre

感謝解惑,這方法確實比較精簡,實際測試後,應該是改成@( $array | Select-Object -Skip $index -First $takeCount) 才能強制讓$subarray收到陣列,如果只有@(array),那問題還是和之前相同。

# by Jeffrey

to Pierre,你說的對,應該要加在 $subArray 處,範例已修正,謝謝提醒。

# by CHG

執行範例程序等了好幾分鐘都不會結束,環境需要做什麼特別的設定嗎

# by Jeffrey

to CHG,你必須提供完整一點的資訊,大家才能幫忙分析問題。最好附上能重現問題的一小段程式碼(愈短愈好),這樣比較有機會得到答案。

Post a comment