powershellforeachparallel-processingscriptblockforeach-object

PowerShell Make ForEach Loop Parallel


This is working code:

$ids = 1..9 
$status  = [PSCustomObject[]]::new(10)
foreach ($id in $ids)
{ 
   $uriStr      = "http://192.168." + [String]$id + ".51/status"
   $uri         = [System.Uri] $uriStr
   $status[$id] = try {Invoke-RestMethod -Uri $uri -TimeOut 30}catch{}
}
$status

I would like to execute the ForEach loop in parallel to explore performance improvements.

First thing I tried (turned out naive) is to simply introduce the -parallel parameter

$ids = 1..9 
$status  = [PSCustomObject[]]::new(10)
foreach -parallel ($id in $ids)
{ 
   $uriStr      = "http://192.168." + [String]$id + ".51/status"
   $uri         = [System.Uri] $uriStr
   $status[$id] = try {Invoke-RestMethod -Uri $uri -TimeOut 30}catch{}
}
$status

This results in the following error, suggesting this feature is still under consideration of development as of Powershell 7.3.9:

ParserError: 
Line |
   3 |  foreach -parallel ($id in $ids)
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | The foreach '-parallel' parameter is reserved for future use.

I say naive because the documentation says the parallel parameter is only valid in a workflow. However, when I try it I get an error saying workflow is no longer supported.

workflow helloworld {Write-Host "Hello World"}
ParserError: 
Line |
   1 |  workflow helloworld {Write-Host "Hello World"}
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Workflow is not supported in PowerShell 6+.

Then I tried various combinations from various references (Good Example), which advise about ForEach being fundamentally different from from ForEach-Object, which supports parallel, like so (basically piping the ids in):

$ids = 1..9 
$status  = [PSCustomObject[]]::new(10)
$ids | ForEach-Object -Parallel 
{ 
   $uriStr      = "http://192.168." + [String]$_ + ".51/status"
   $uri         = [System.Uri] $uriStr
   $status[$_] = try {Invoke-RestMethod -Uri $uri -TimeOut 30}catch{}
}
$status

This generates the following error:

ForEach-Object: 
Line |
   3 |  $ids | foreach-object -parallel
     |                        ~~~~~~~~~
     | Missing an argument for parameter 'Parallel'. Specify a parameter of type
     | 'System.Management.Automation.ScriptBlock' and try again.

   $uriStr      = "http://192.168." + [String]$_ + ".51/status"
   $uri         = [System.Uri] $uriStr
   $status[$i_] = try {Invoke-RestMethod -Uri $uri -TimeOut 30}catch{}

But, after trying various script block semantics, here is the best I could do (basically apply :using to status variable that is outside the script block):

$ids = 1..9 
$status  = [PSCustomObject[]]::new(10)
$myScriptBlock = 
{ 
   $uriStr      = "http://192.168." + [String]$_ + ".51/status"
   $uri         = [System.Uri] $uriStr
   {$using:status}[$_] = try {Invoke-RestMethod -Uri $uri -TimeOut 30}catch{}
}
$ids | foreach-object -parallel $myScriptBlock
$status

Error, again: Unable to index into Scriptblock

Line |
   4 |  … ng:status}[$_] = try {Invoke-RestMethod -Uri $uri -TimeOut 30}catch{}
     |                          ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Unable to index into an object of type "System.Management.Automation.ScriptBlock".
InvalidOperation: 

There are couple of other worthy to mention errors - if not applying the :using qualifier, get error

"cannot index into null array"

this basically means the $status variable is unrecognizable in the foreach or script block.

All other ways to express the :using qualifier are rejected with errors like

"assignment expression invalid" "use {}..."

so have been omitted for brevity and, better flow in problem statement. Lastly, here is a reference on SciptBlocks, for Powershell 7.3+ which have also been considered without much progress.


Solution

  • The following should work as intended (see the NOTE source-code comment below):

    $ids = 1..9 
    $status  = [PSCustomObject[]]::new(10)
    $ids | ForEach-Object -Parallel {  # NOTE: Opening { MUST be here.
       $uri = [System.Uri] "http://192.168.$_.51/status"
       # NOTE: (...) is required around the $using: reference.
       ($using:status)[$_] = try { Invoke-RestMethod -Uri $uri -TimeOut 30 } catch {}
    }
    $status
    

    Note: Since $_ is used as the array index ([$_]), the results for your 9 input IDs are stored in the array elements starting with the second one (whose index is 1), meaning that $status[0] will remain $null. Perhaps you meant to use 0..9.


    [1] Somewhat confusingly, ForEach-Object has an alias also named foreach. It is the syntactic context (the parsing mode) that determines in a given statement whether foreach refers to the foreach (language) statement or the ForEach-Object cmdlet; e.g. foreach ($i in 1..3) { $i } (statement) vs. 1..3 | foreach { $_ } (cmdlet).

    [2] However, if an expression is syntactically complete on a given line, PowerShell also stops parsing, which amounts to a notable pitfall with ., the member-access operator: Unlike in C#, for instance, . must be placed on the same line as the object / expression it is applied to. E.g. 'foo'.<newline> Length works, but 'foo'<newline> .Length does not. Additionally, the . must immediately follow the target object / expression even on a single line (e.g. 'foo' .Length breaks too)

    [3] Due to PowerShell's unified handling of list-like collections and scalars (single objects) - see this answer - indexing into a script block technically works with getting a value: indices [0] and [-1] return the scalar itself (e.g. $var = 42; $var[0]), all other indices return $null by default, but cause an error if Set-StrictMode -Version 3 or higher is in effect; however, an attempt to assign a value categorically fails (e.g. $var = 42; $var[0] = 43)