2

I've encountered this issue in a longer script and have simplified here to show the minimal code required to reproduce it (I think). It outputs numbers followed by letters: 1 a 1 b 1 c... 2 a 2 b 2 c... all the way to "500 z"

Function Write-HelloWorld
{
    Param($number)
    write-host -Object $number
}

$numbers = 1..500
$letters = "a".."z"
$Function = get-command Write-HelloWorld 
$numbers | ForEach-Object -Parallel {
    ${function:Write-HelloWorld} = $using:Function
    foreach($letter in $using:letters) {
        Write-HelloWorld -number "$_ $letter"
    }
}

I'm seeing 2 types of sporadically (not every time I run it):

  1. "The term 'write-host' is not recognized as a name of a cmdlet, function, script file, or executable program." As understand it, write-host should always be available. Adding the line "Import-Module Microsoft.PowerShell.Utility" just before the call to write-host didn't help
  2. Odd output like the below, specifically all the "write-host :" lines.

enter image description here

Bobby Dore
  • 499
  • 5
  • 13
  • 1
    Does this solve the problem? `$Function = (Get-Command Write-HelloWorld).Definition` – Santiago Squarzon Oct 31 '22 at 00:46
  • 1
    Whoo! Yes! Thanks @SantiagoSquarzon. Could you share why? – Bobby Dore Oct 31 '22 at 01:34
  • Not sure why but a clear indication is that you're attempting to pass and re-use a reference object with increased complexity for your function definition rather than a simple string (the `.Definition` property value) – Santiago Squarzon Oct 31 '22 at 01:41
  • Needles to say, even if my above comment was not the actual cause of your issue, there is no need to pass in a `FunctionInfo` instance to your runspaces when the same can be accomplished and clearly without failures with a simple string definition – Santiago Squarzon Oct 31 '22 at 01:45

2 Answers2

1

Santiago Squarzon's helpful answer demonstrates the problem with your approach well and links to a GitHub issue explaining the underlying problem (runspace affinity); however, that demonstration isn't the right solution (it wasn't meant to be), as it uses explicit synchronization to allow only one thread at a time to call the function, which negates the benefits of parallelism.

As for a solution:

You must pass a string representation of your Write-HelloWorld's function body to the ForEach-Object -Parallel call:

Function Write-HelloWorld
{
    Param($number)
    write-host -Object $number
}

$numbers = 1..500
$letters = "a".."z"
# Get the body of the Write-HelloWorld function *as a string*
# Alternative, as suggested by @Santiago:
#   $funcDefString = (Get-Command -Type Function Write-HelloWorld).Definition
$funcDefString = ${function:Write-HelloWorld}.ToString()
$numbers | ForEach-Object -Parallel {
  # Redefine the Write-HelloWorld function in this thread,
  # using the *string* representation of its body.
  ${function:Write-HelloWorld} = $using:funcDefString
    foreach($letter in $using:letters) {
        Write-HelloWorld -number "$_ $letter"
    }
}

${function:Write-HelloWorld} is an instance of namespace variable notation, which allows you to both get a function (its body as a [scriptblock] instance) and to set (define) it, by assigning either a [scriptblock] or a string containing the function body.

By passing a string, the function is recreated in the context of each thread, which avoids cross-thread issues that can arise when you pass a [System.Management.Automation.FunctionInfo] instance, as output by Get-Command, which contains a [scriptblock] that is bound to the runspace in which it was defined (i.e., the caller's; that script block is said to have affinity with the caller's runspace), and calling this bound [scriptblock] instance from other threads (runspaces) isn't safe.

By contrast, by redefining the function in each thread, via a string, a thread-specific [scriptblock] instance bound to that thread is created, which can safely be called.

In fact, you appear to have found a loophole, given that when you attempt to use a [scriptblock] instance directly with the $using: scope, the command by design breaks with an explicit error message:

A ForEach-Object -Parallel using variable cannot be a script block. 
Passed-in script block variables are not supported with ForEach-Object -Parallel,  
and can result in undefined behavior

In other words: PowerShell shouldn't even let you do what you attempted to do, but unfortunately does, as of PowerShell Core 7.2.7, resulting in the obscure failures you saw - see GitHub issue #16461.


Potential future improvement:

  • An enhancement is being discussed in GitHub issue #12240 to support copying the caller's state to the parallel threads on demand, which would automatically make the caller's functions available, without the need for manual redefinition.
mklement0
  • 382,024
  • 64
  • 607
  • 775
1

Note, this answer is meant to prove a point but does not provide the correct solution to the problem.

See mklement0's helpful answer for the proper way to solve this by simply passing the function's definition as string to the runspaces. See also GitHub Issue #4003 for more details.


It's a very bad idea to pass in a reference object and use it without thread safety, here is proof that by simply adding thread safety to your code the problem is solved:

function Write-HelloWorld {
    param($number)
    Write-Host -Object $number
}

$numbers  = 1..500
$letters  = "a".."z"
$Function = Get-Command Write-HelloWorld

$numbers | ForEach-Object -Parallel {
    $refObj = $using:Function

    [System.Threading.Monitor]::Enter($refObj)

    ${function:Write-HelloWorld} = $using:Function
    foreach($letter in $using:letters) {
        Write-HelloWorld -number "$_ $letter"
    }

    [System.Threading.Monitor]::Exit($refObj)
}

To be precise, this issue is related to Runspace Affinity, all Runspaces are trying to send the invocation back to the origin Runspace thread hence poor PowerShell collapses.

Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37