如何在 ForEach-Object -Parallel 中传递自定义函数

29

我找不到一种方法来传递函数,只有变量。

有没有办法在不将函数放在ForEach循环中的情况下解决这个问题?

function CustomFunction {
    Param (
        $A
    )
    Write-Host $A
}

$List = "Apple", "Banana", "Grape" 
$List | ForEach-Object -Parallel {
    Write-Host $using:CustomFunction $_
}

在此输入图片描述


2
要么将您的函数打包到一个模块中,要么在-Parallel块内(重新)定义它。 - Mathias R. Jessen
顺便提一下:Write-Host通常不是正确的工具,除非意图仅仅是写入到显示器上,绕过成功输出流以及将输出发送到其他命令、在变量中捕获它或将其重定向到文件的能力。要输出一个值,请直接使用它;例如,使用$value而不是Write-Host $value(或者使用Write-Output $value,但这很少需要)。另请参见:https://dev59.com/0Zvga4cB1Zd3GeqP9vTJ#50416448的底部部分。 - mklement0
5个回答

42

解决方案并不像人们希望的那样直截了当:

# Sample custom function.
function Get-Custom {
  Param ($A)
  "[$A]"
}

# Get the function's definition *as a string*
$funcDef = ${function:Get-Custom}.ToString()

"Apple", "Banana", "Grape"  | ForEach-Object -Parallel {
  # Define the function inside this thread...
  ${function:Get-Custom} = $using:funcDef
  # ... and call it.
  Get-Custom $_
}

注意: 此答案提供了一个类似的解决方案,用于在ForEach-Object -Parallel脚本块中使用调用者范围内的脚本块

  • 注意: 如果您的函数定义在已知模块自动加载功能的任一位置中的模块中,则您的函数调用将直接与ForEach-Object -Parallel一起工作,无需额外努力-但每个线程都会产生(隐式)导入模块的成本。

  • 上述方法是必要的,因为除了当前位置(工作目录)和环境变量(适用于整个进程)之外,ForEach-Object -Parallel创建的线程不会看到调用者的状态,特别是对于变量和函数(以及自定义PS驱动器和导入的模块)。

  • 截至PowerShell 7.2.x,正在讨论GitHub问题#12240的增强功能,以支持将调用者的状态按需复制到并行线程中,这将使调用者的函数自动可用。

请注意,通过字符串在每个线程中重新定义函数至关重要,因为尝试在没有辅助$funcDef变量的情况下进行操作,并尝试使用${function:Get-Custom} = ${using:function:Get-Custom}重新定义函数失败,因为${function:Get-Custom}是一个脚本块,而使用带有$using:范围限定符的脚本块被明确禁止,以避免跨线程(跨运行空间)问题。

  • 然而,${function:Get-Custom} = ${using:function:Get-Custom} 可以在 Start-Job 中使用;请参见此答案中的示例。

  • 它无法在 Start-ThreadJob 中工作,尽管语法上允许您执行 & ${using:function:Get-Custom} $_,因为 ${using:function:Get-Custom} 被保留为一个脚本块(与 Start-Job 不同,后者将其反序列化为字符串,这是令人惊讶的行为 - 请参见GitHub issue #11698),但实际上不能。也就是说,直接跨线程使用 [scriptblock] 实例会导致模糊的故障,这就是为什么 ForEach-Object -Parallel 在第一次使用时就防止了它。

  • 一个类似的漏洞,即在每个线程中使用从调用方范围中使用 Get-Command 获取的命令信息对象作为函数体,通过 $using: 范围传递:这也应该被阻止,但自 PowerShell 7.2.7 以来没有这样做 - 请参见 此帖子GitHub issue #16461

${function:Get-Custom}命名空间变量表示法 的实例,它允许您同时 获取 函数(其 主体 作为 [scriptblock] 实例)和 定义 它,通过分配一个 [scriptblock] 或包含函数主体的字符串。


非常感谢。这不是我所期望的更清晰的解决方案,但它能够工作。就性能而言,每次迭代基本上都会实例化一个新函数。这就像将函数插入到foreach中,但在视觉上更加清晰,对吧? - smark91
很高兴听到它对你有帮助,@smark91。如果您有一个预先存在的函数想要在ForEach-Object -Parallel块中使用,那么这种技术是非常有用的;直接插入函数定义可能更快,但我不确定在实践中是否有太大的区别。 - mklement0
3
这对于一次性操作非常好,但如果您导入了多个模块,定义了更多的函数,变量也不确定,基本上整个程序都像是一堆卡牌,那么这将会带来太多麻烦和错误。希望 PowerShell Core 团队能决定将运行空间复制作为一个选项。 - Max Cascone

5
我通过在循环中使用 include,将一整套自定义函数添加到 ps1 文件中的并行处理中。这样可以使得代码保持简洁、整洁。
ForEach-Object -Parallel {
    # Include custom functions inside parallel scope
    . $using:PSScriptRoot\CustomFunctions.ps1
    # Now you can reference any function defined in the file
    My-CustomFunction
    ....

这确实会产生开销,需要在每个并行进程中加载函数,但在我的情况下,与整体处理时间相比,这是微不足道的。


1

所以我发现了另一个小技巧,可能对那些试图动态添加函数的人很有用,特别是当你事先不知道它的名称时,比如当这些函数在数组中时。

# Store the current function list in a variable
$initialFunctions=Get-ChildItem Function:

# Source all .ps1 files in the current folder and all subfolders
Get-ChildItem . -Recurse | Where-Object { $_.Name -like '*.ps1' } |
     ForEach-Object { . "$($_.FullName)" }

# Get only the functions that were added above, and store them in an array
$functions = @()
Compare-Object $initialFunctions (Get-ChildItem Function:) -PassThru |
    ForEach-Object { $functions = @($functions) + @($_) }

1..3 | ForEach-Object -Parallel {
    # Pull the $functions array from the outer scope and set each function
    # to its definition
    $using:functions | ForEach-Object {
        Set-Content "Function:$($_.Name)" -Value $_.Definition
    }
    # Call one of the functions in the sourced .ps1 files by name
    SourcedFunction $_
}

这个主要的“技巧”是使用Set-ContentFunction:加上函数名称,因为PowerShell本质上将Function:的每个条目都视为路径。
当您考虑Get-PSDrive的输出时,这是有意义的。由于这些条目中的每一个都可以以相同的方式(即带有冒号)用作“驱动器”。

尽管如https://dev59.com/73sQtIcB2Jgan1znF8pK所述,这个方法很有前途,但它并不是线程安全的,并且可能会在运行时导致微妙或不太微妙的故障。为了使其稳健地工作,您需要将函数体“作为字符串”传递给并行运行空间。 - mklement0
我之前说错了:你确实在使用字符串,因为$_.Definition返回一个字符串。然而,这里有一个警告:使用[System.Management.Automation.FunctionInfo]实例(通过Get-ChildItem Function:Get-Command获得)本质上是有问题的,因为它们确实包含一个脚本块(.ScriptBlock),甚至可以直接被调用,使用&。这样的调用可能会导致状态损坏,就像GitHub问题#16461中所解释的那样,该问题主张通过$using:禁止使用FunctionInfo - undefined

1

我刚刚想到了另一种使用get-command的方式,可以与调用运算符一起使用。$a最终会成为一个FunctionInfo对象。

编辑:有人告诉我这不是线程安全的,但我不明白为什么。

function hi { 'hi' }
$a = get-command hi
1..3 | foreach -parallel { & $using:a }

hi
hi
hi

3
事实上,即使函数体不依赖于调用者的状态,它实际上确实存在线程安全问题,这可能会导致难以理解的故障 - 请参见此问题 - mklement0

0
这可能是一个更优雅的低代码选项,可以将一个函数放入Foreach-Object -Parallel块中:
$m = New-Module -Name MyFunctions -ScriptBlock {
    function Func-Timestamp {
        return [DateTime]::Now.ToString("HH:mm:ss.ff")
    }
}
    
$files | ForEach-Object -Parallel {
    import-module $using:m -DisableNameChecking
    [Console]::WriteLine("Here is the Time! $(Func-TimeStamp)")
}

你可以创建一个名为$m的模块变量,然后将其导入到Foreach-Object循环中。

这是一个诱人的选择,有时可能有效,但我怀疑它会遇到与简单选项(传递函数信息对象)相同的状态腐败问题,就像js2010的回答中所示。请参阅GitHub问题#16461 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接