PowerShell 練習 - 平行作業
10 |
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,你必須提供完整一點的資訊,大家才能幫忙分析問題。最好附上能重現問題的一小段程式碼(愈短愈好),這樣比較有機會得到答案。