Last active
May 21, 2024 12:53
-
-
Save PanosGreg/5995b6daca9e5bc95b690e71bb41c39f to your computer and use it in GitHub Desktop.
ForEach Parallel wrapper with multi-threaded Progress Bars
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Invoke-ForEachParallel { | |
<# | |
.SYNOPSIS | |
This is a wrapper around foreach-parallel from PS v7, but with progress bars. | |
.DESCRIPTION | |
This is a wrapper around foreach-parallel from PS v7, but with progress bars. | |
You can run a scriptblock against an array of objects, just like foreach, | |
but it also shows multi-threaded progress bars, one for every thread. | |
The end-user can also use a custom automatic variable, $ProgressStatus, | |
which updates the progress message. | |
.EXAMPLE | |
$test = 'test123' | |
$list = Get-Service | Select-Object -First 15 | |
Invoke-ForEachParallel -InputObject $list -ScriptBlock { | |
$ProgressStatus = 'Initializing...' | |
Start-Sleep -Milliseconds (Get-Random -Minimum 1000 -Maximum 2000) | |
$ProgressStatus = 'Enumerating...' | |
[pscustomobject] @{ | |
Name = $_.Name | |
Status = $_.Status | |
Test = $using:test | |
} | |
Start-Sleep -Milliseconds (Get-Random -Minimum 1000 -Maximum 2000) | |
} -ThrottleLimit 5 -ActivityProperty Name | |
# this will spin up 5 parallel threads for the user's scriptblock | |
# it uses the automatic variable $ProgressStatus to show progress messages | |
# it also uses the ActivityProperty to define the activity label | |
# if the user does not provide an ActivityProperty, then a default label will be shown | |
# finally, in the scriptblock we also have a $using: variable to pass ad-hoc data inside. | |
.NOTES | |
The initial idea and code comes from: | |
https://learn.microsoft.com/en-us/powershell/scripting/learn/deep-dives/write-progress-across-multiple-threads | |
#> | |
[cmdletbinding()] | |
param ( | |
[Parameter(Mandatory,Position=0)] | |
$InputObject, | |
[Parameter(Mandatory,Position=1)] | |
[scriptblock]$ScriptBlock, | |
[Parameter(Position=2)] | |
[string]$ActivityProperty, | |
[int]$ThrottleLimit = 10 | |
) | |
#requires -Version 7.0 | |
# Note: The foreach -Parallel parameter is available on PS 7+ | |
# we need to check that the ActivityProperty is actually a property of the InputObject | |
if ($PSBoundParameters.ContainsKey('ActivityProperty')) { | |
$HasProperty = ($InputObject | Get-Member -MemberType Properties).Name -contains $ActivityProperty | |
if (-not $HasProperty) { | |
Write-Warning "The property $ActivityProperty was not found in the given input object" | |
return | |
} | |
} | |
else {$HasProperty = $false} # <-- that means we need to add a default activity label of our own | |
# Progress Bar with ForEach Parallel, related variables and setup | |
$ProgressParams = [System.Collections.Concurrent.ConcurrentDictionary[int,hashtable]]::new() | |
$ProgressIDNum = 0 | |
$InputListWithID = $InputObject | foreach { | |
$ProgressIDNum++ | |
[void]$ProgressParams.TryAdd($ProgressIDNum,@{}) | |
if ($HasProperty) {$Label = $_.$ActivityProperty} | |
else {$Label = "Thread #$ProgressIDNum"} | |
[pscustomobject] @{ | |
ProgressID = $ProgressIDNum | |
ActivityLabel = $Label | |
UserObject = $_ | |
} | |
} | |
$ActivityLength = ($InputListWithID.ActivityLabel | Measure-Object -Property Length -Maximum).Maximum | |
$ParallelBlock = { | |
# Progress Bar related variables | |
$_HashCopy = $using:ProgressParams # <-- copy of the Concurrent Dictionary [int,hashtable] | |
$_progress = $_HashCopy.$($_.ProgressID) # <-- $progress is a hashtable | |
$_Padding = $using:ActivityLength - $_.ActivityLabel.Length | |
$_progress.Id = $_.ProgressID | |
$_progress.Activity = "[{0}{1}]" -f $_.ActivityLabel,(' '*$_Padding) | |
# the $ProgressStatus custom automatic variable that updates the progress message | |
$_BreakAction = {$global:_progress.Status = (Get-Variable ProgressStatus).Value} | |
Set-PSBreakpoint -Variable ProgressStatus -Action $_BreakAction -Mode Write | Out-Null | |
[string]$ProgressStatus = 'Processing...' # <-- that's the default progress message | |
# user's scriptblock | |
$PSItem = $PSItem.UserObject # <-- set the current item to the user's object | |
## the user can use the automatic variable $ProgressStatus to show progress messages | |
## we need to place the user's code as-is, else any $using variables won't be respected | |
'@USERCODE@' | |
# in the end, mark progress as completed | |
$_progress.Completed = $true | |
} | |
# build the parallel scriptblock | |
$SB = [System.Text.StringBuilder]::new($ParallelBlock.ToString()) | |
[void]$SB.Replace("'@USERCODE@'",$ScriptBlock.ToString()) | |
$NewBlock = [scriptblock]::Create($SB.ToString()) | |
# run the command with multi-threading and progress bars | |
$params = @{ | |
Parallel = $NewBlock | |
ThrottleLimit = $ThrottleLimit | |
Verbose = $true | |
AsJob = $true | |
} | |
$Job = $InputListWithID | ForEach-Object @params | |
while ($Job.State -eq 'Running') { | |
$ProgressParams.Keys | foreach { | |
if (([array]$ProgressParams.$_.Keys).Count -ge 1) { | |
$params = $ProgressParams.$_ | |
Write-Progress @params | |
} | |
} | |
# Wait to refresh to not overload gui | |
Start-Sleep -Milliseconds 100 | |
} | |
# show the results | |
$Job | Receive-Job -Verbose -AutoRemoveJob -Wait | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment