无法使用 foreach-object 并行启动作业

Man*_*anu 3 parallel-processing powershell start-job foreach-object

我准备了这个脚本来尝试使用不同的参数多次并行执行相同的函数:

$myparams = "A", "B","C", "D"

$doPlan = {
    Param([string] $myparam)
        echo "print $myparam"
        # MakeARestCall is a function calling a web service
        MakeARestCall -myparam $myparam
        echo "done"
}

$myparams | Foreach-Object { 
    Start-Job -ScriptBlock $doPlan  -ArgumentList $_
}
Run Code Online (Sandbox Code Playgroud)

当我运行它时,输出是

Id     Name            PSJobTypeName   State         HasMoreData     Location             Command                  
--     ----            -------------   -----         -----------     --------             -------                  
79     Job79           BackgroundJob   Running       True            localhost            ...                      
81     Job81           BackgroundJob   Running       True            localhost            ...                      
83     Job83           BackgroundJob   Running       True            localhost            ...                      
85     Job85           BackgroundJob   Running       True            localhost            ...
Run Code Online (Sandbox Code Playgroud)

但对块(然后对 Web 服务)的实际调用尚未完成。如果我删除 foreach-object 并将其替换为不带 Start-Job 的正常顺序 foreach 块,则可以正确调用 Web 服务。这意味着当我尝试并行运行该块时出现问题。

我究竟做错了什么?

mkl*_*nt0 10

后台作业在独立的子进程中运行,几乎不与调用者共享任何状态;具体来说:

  • 他们看不到调用会话中定义的任何函数和别名,也看不到手动导入的模块,也看不到手动加载的 .NET 程序集。

  • 他们不会加载(点源)您的$PROFILE文件,因此他们不会从那里看到任何定义。

  • 在 PowerShell 版本 6.x 及更低版本(包括 Windows PowerShell)中,甚至当前位置(目录)也不是从调用者继承的(默认为[Environment]::GetFolderPath('MyDocuments'));这已在 v7.0 中修复。

  • 他们看到的调用会话状态的唯一方面是调用进程的环境变量副本。

  • 为了使调用者会话中的变量值可供后台作业使用,必须通过$using:scope(请参阅about_Remote_Variables)来引用它们。

    • 请注意,对于字符串、基本类型(例如数字)和其他一些众所周知的类型以外的值,这可能会导致类型保真度的损失,因为这些值是使用 PowerShell 的基于 XML 的序列化跨进程边界进行编组的,并且反序列化;这种潜在的类型保真度损失也会影响作业的输出- 请参阅此答案以获取背景信息。
    • 使用速度更快且资源占用更少的线程作业 viaStart-ThreadJob可以避免此问题(尽管所有其他限制都适用);Start-ThreadJob附带 PowerShell [Core] 6+,并且可以在 Windows PowerShell 中按需安装(例如Install-Module -Scope CurrentUser ThreadJob) - 请参阅此答案以获取背景信息。

重要提示每当您使用作业进行自动化时,例如在从 Windows 任务计划程序调用的脚本中或在 CI/CD 上下文中,请确保在退出脚本之前等待所有作业完成(通过Receive-Job -WaitWait-Job),因为通过 PowerShell 的CLI调用的脚本会整体退出 PowerShell 进程,从而杀死所有未完成的作业。

因此,除非命令MakeARestCall

  • 恰好是位于下列目录之一的脚本文件( MakeARestCall.ps1) 或可执行文件( )MakeARestCall.exe$env:Path

  • 恰好是自动加载模块中定义的函数,

如果函数和别名都没有被定义,那么在作业进程中执行时你的$doJob脚本块将会失败。MakeARestCall

您的评论表明这MakeARestCall确实是一个function,因此为了使您的代码正常工作,您必须(重新)定义该函数作为作业执行的脚本块的一部分$doJob在您的情况下):

以下简化示例演示了该技术:

# Sample function that simply echoes its argument.
function MakeARestCall { param($MyParam) "MakeARestCall: $MyParam" }

'foo', 'bar' | ForEach-Object {
  # Note: If Start-ThreadJob is available, use it instead of Start-Job,
  #       for much better performance and resource efficiency.
  Start-Job -ArgumentList $_ { 

    Param([string] $myparam)

    # Redefine the function via its definition in the caller's scope.
    # $function:MakeARestCall returns MakeARestCall's function body
    # which $using: retrieves from the caller's scope, assigning to
    # it defines the function in the job's scope.
    $function:MakeARestCall = $using:function:MakeARestCall

    # Call the recreated MakeARestCall function with the parameter.
    MakeARestCall -MyParam $myparam
  }
} | Receive-Job -Wait -AutoRemove
Run Code Online (Sandbox Code Playgroud)

上述输出MakeARestCall: fooMakeARestCall: bar,表明(重新定义的)MakeARestCall函数已在作业进程中成功调用。

另一种方法

为了安全起见,创建MakeARestCall一个脚本( MakeARestCall.ps1) 并通过其完整路径调用它。

例如,如果您的脚本与调用脚本位于同一文件夹中,则将其调用为
& $using:PSScriptRoot\MakeARestCall.ps1 -MyParam $myParam

当然,如果您不介意重复函数定义或在后台作业的上下文中需要它,则可以简单地将函数定义直接嵌入到脚本块中。


更简单、更快的 PowerShell [Core] 7+ 替代方案,使用ForEach-Object -Parallel

PowerShell 7-Parallel引入的该参数在每个管道输入对象的单独运行空间(线程)中运行给定的脚本块。ForEach-Object

从本质上讲,它是一种更简单、管道友好的使用线程作业( ) 的方式,与后台作业Start-ThreadJob相比具有相同的性能和资源使用优势,并且还可以直接报告线程的输出,从而更加简单

然而,上面讨论的后台作业缺乏状态共享的情况也适用线程作业(即使它们在同一进程中运行,但它们在隔离的 PowerShell运行空间中运行),因此这里也MakARestCall必须(重新)定义该函数(或嵌入)在脚本块[1]内。

# Sample function that simply echoes its argument.
function MakeARestCall { param($MyParam) "MakeARestCall: $MyParam" }

# Get the function definition (body) *as a string*.
# This is necessary, because the ForEach-Object -Parallel explicitly
# disallows referencing *script block* values via $using:
$funcDef = $function:MakeARestCall.ToString()

'foo', 'bar' | ForEach-Object -Parallel {
  $function:MakeARestCall = $using:funcDef
  MakeARestCall -MyParam $_
}
Run Code Online (Sandbox Code Playgroud)

语法陷阱:-Parallel不是一个switch(标志型参数),而是将要并行运行的脚本块作为参数;换句话说:-Parallel必须直接放在脚本块之前。

上面的代码在并行线程到达时直接发出输出 - 但请注意,这意味着不保证输出按输入顺序到达;也就是说,稍后创建的线程可能会在较早的线程之前返回其输出。

一个简单的例子:

PS> 3, 1 | ForEach-Object -Parallel { Start-Sleep $_; "$_" }
1  # !! *Second* input's thread produced output *first*.
3
Run Code Online (Sandbox Code Playgroud)

为了按输入顺序显示输出- 这总是需要等待所有线程完成才能显示输出,您可以添加-AsJob开关

  • 然后返回一个轻量级(基于线程)的作业对象,而不是直接输出,该对象返回包含多个子作业的类型的单个作业,每个子作业对应一个并行运行空间(线程);您可以使用常用的 cmdlet 对其进行管理,并通过该属性访问各个子作业。PSTaskJob*-Job.ChildJobs

通过等待整个作业完成通过 then 接收其输出,然后按输入顺序Receive-Job显示它们:

PS> 3, 1 | ForEach-Object -AsJob -Parallel { Start-Sleep $_; "$_" } |
      Receive-Job -Wait -AutoRemove
3  # OK, first input's output shown first, due to having waited.
1
Run Code Online (Sandbox Code Playgroud)

[1] 或者,将您的MakeARestCall函数重新定义为过滤函数( Filter),它隐式地对管道输入 via进行操作$_,这样您就可以按原样使用其定义作为ForEach-Object -Parallel脚本块:

# Sample *filter* function that echoes the pipeline input it is given.
Filter MakeARestCall { "MakeARestCall: $_" }

# Pass the filter function's definition (which is a script block)
# directly to ForEach-Object -Parallel
'foo', 'bar' | ForEach-Object -Parallel $function:MakeARestCall
Run Code Online (Sandbox Code Playgroud)