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):
- "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
- Odd output like the below, specifically all the "write-host :" lines.
CodePudding user response:
Can't give you a proper answer as to why this is happening but clearly, it's a very bad idea to pass in a reference object and use it without thread safety, here is proof that 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)
}
CodePudding user response:
Santiago Squarzon has provided the crucial pointer:
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*
$funcDefString = ${function:Write-HelloWorld}.ToString()
$numbers | ForEach-Object -Parallel {
# Redefine the Write-HelloWorld in this function
# using the *string* representation of its body.
${function:Write-HelloWorld} = $using:funcDefString
foreach($letter in $using:letters) {
Write-HelloWorld -number "$_ $letter"
}
}
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), 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.
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.