共用方式為


在多個線程中使用 ForEach-Object -Parallel 撰寫進度

從 PowerShell 7.0 開始,可以使用 ForEach-Object Cmdlet 中的 Parallel 參數,同時在多個線程中工作。 不過,監視這些線程的進度可能是一項挑戰。 一般而言,您可以使用 Write-Progress監視進程的進度。 不過,由於 PowerShell 在使用 Parallel時會為每個線程使用不同的 Runspace,因此向主機回報進度並不像正常使用 Write-Progress時那樣簡單明瞭。

使用同步哈希表來追蹤進度

從多個線程撰寫進度時,追蹤會變得困難,因為在PowerShell中執行平行進程時,每個進程都有自己的 Runspace。 若要解決此問題,您可以使用 同步化哈希表。 同步哈希表是線程安全的數據結構,可同時由多個線程修改,而不會擲回錯誤。

設定

這種方法的其中一個缺點是,它需要一個有點複雜設定,以確保一切執行時不會發生錯誤。

$dataset = @(
    @{
        Id   = 1
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 2
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 3
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 4
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 5
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
)

# Create a hashtable for process.
# Keys should be ID's of the processes
$origin = @{}
$dataset | ForEach-Object {$origin.($_.Id) = @{}}

# Create synced hashtable
$sync = [System.Collections.Hashtable]::Synchronized($origin)

本節會建立三個不同的數據結構,以供三個不同的用途使用。

$dataSet 變數會存儲一個包含多個哈希表的陣列,用來協調後續步驟,且不會被修改。 如果在逐一查看集合時修改物件集合,PowerShell 會擲回錯誤。 您必須將物件集合保留在迴圈中,與正在修改的物件分開。 每個哈希表中 Id 索引鍵是模擬進程的標識碼。 Wait 索引鍵會模擬所追蹤之每個模擬程式的工作負載。

$origin 變數會儲存巢狀哈希表,其中每個索引鍵都是模擬進程標識碼的其中一個。 然後,它會用來凍結儲存在 $sync 變數中的同步哈希表。 $sync 變數負責將進度回報回父 Runspace,以顯示進度。

執行進程

本節會執行多線程進程,並建立一些用來顯示進度的輸出。

$job = $dataset | ForEach-Object -ThrottleLimit 3 -AsJob -Parallel {
    $syncCopy = $Using:sync
    $process = $syncCopy.$($PSItem.Id)

    $process.Id = $PSItem.Id
    $process.Activity = "Id $($PSItem.Id) starting"
    $process.Status = "Processing"

    # Fake workload start up that takes x amount of time to complete
    Start-Sleep -Milliseconds ($PSItem.Wait*5)

    # Process. update activity
    $process.Activity = "Id $($PSItem.Id) processing"
    foreach ($percent in 1..100)
    {
        # Update process on status
        $process.Status = "Handling $percent/100"
        $process.PercentComplete = (($percent / 100) * 100)

        # Fake workload that takes x amount of time to complete
        Start-Sleep -Milliseconds $PSItem.Wait
    }

    # Mark process as completed
    $process.Completed = $true
}

模擬過程會傳送至 ForEach-Object,並啟動為任務。 ThrottleLimit 設定為 3,以強調在佇列中執行多個程序。 作業會儲存在 $job 變數中,並允許我們知道所有進程稍後完成的時間。

使用 Using: 語句在 PowerShell 中參考父範圍變數時,您無法使用表達式讓它成為動態。 例如,如果您嘗試建立類似下列的 $process 變數,$process = $Using:sync.$($PSItem.Id),您會收到錯誤,指出您無法在該處使用表達式。 因此,我們會建立 $syncCopy 變數,以便參考和修改 $sync 變數,而不會造成失敗的風險。

接下來,我們會建置一個雜湊表,以 $process 變數來參考同步的雜湊表索引鍵,從而表示目前在迴圈中的流程進度。 活動鍵狀態索引鍵 作為參數供 Write-Progress 使用,以便在下一節中顯示指定模擬過程的狀態。

foreach 迴圈只是模擬進程運作的一種方式,並根據 $dataSetWait 屬性隨機化,以使用毫秒設定 Start-Sleep。 您計算程序進度的方式可能會有所不同。

顯示多個進程的進度

現在模擬進程會以作業的形式執行,我們可以開始將進程進度寫入 PowerShell 視窗。

while($job.State -eq 'Running')
{
    $sync.Keys | ForEach-Object {
        # If key is not defined, ignore
        if(![string]::IsNullOrEmpty($sync.$_.Keys))
        {
            # Create parameter hashtable to splat
            $param = $sync.$_

            # Execute Write-Progress
            Write-Progress @param
        }
    }

    # Wait to refresh to not overload gui
    Start-Sleep -Seconds 0.1
}

$job 變數包含父 作業,而且每個模擬進程都有子 作業。 當任何子作業仍在執行時,父作業 狀態 仍會維持「執行中」。 這可讓我們使用 while 循環,持續更新每個進程的進度,直到所有進程都完成為止。

在 while 循環內,我們會遍歷 $sync 變數中的每個鍵。 由於這是同步哈希表,因此會持續更新,但仍可存取,且不會發生任何錯誤。

有一項檢查可確保所報告的進程實際上是使用 IsNullOrEmpty() 方法執行。 如果進程尚未啟動,迴圈將不會報告此進程,並會跳過到下一個,直到遇到一個已啟動的進程為止。 如果程序已啟動,則會使用當前鍵中的哈希表來展開參數到 Write-Progress

完整範例

# Example workload
$dataset = @(
    @{
        Id   = 1
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 2
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 3
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 4
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
    @{
        Id   = 5
        Wait = 3..10 | Get-Random | ForEach-Object {$_*100}
    }
)

# Create a hashtable for process.
# Keys should be ID's of the processes
$origin = @{}
$dataset | ForEach-Object {$origin.($_.Id) = @{}}

# Create synced hashtable
$sync = [System.Collections.Hashtable]::Synchronized($origin)

$job = $dataset | ForEach-Object -ThrottleLimit 3 -AsJob -Parallel {
    $syncCopy = $Using:sync
    $process = $syncCopy.$($PSItem.Id)

    $process.Id = $PSItem.Id
    $process.Activity = "Id $($PSItem.Id) starting"
    $process.Status = "Processing"

    # Fake workload start up that takes x amount of time to complete
    Start-Sleep -Milliseconds ($PSItem.Wait*5)

    # Process. update activity
    $process.Activity = "Id $($PSItem.Id) processing"
    foreach ($percent in 1..100)
    {
        # Update process on status
        $process.Status = "Handling $percent/100"
        $process.PercentComplete = (($percent / 100) * 100)

        # Fake workload that takes x amount of time to complete
        Start-Sleep -Milliseconds $PSItem.Wait
    }

    # Mark process as completed
    $process.Completed = $true
}

while($job.State -eq 'Running')
{
    $sync.Keys | ForEach-Object {
        # If key is not defined, ignore
        if(![string]::IsNullOrEmpty($sync.$_.Keys))
        {
            # Create parameter hashtable to splat
            $param = $sync.$_

            # Execute Write-Progress
            Write-Progress @param
        }
    }

    # Wait to refresh to not overload gui
    Start-Sleep -Seconds 0.1
}