无法并行使用 foreach-object 开始作业

Cannot start job with foreach-object in parallel

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

$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 $_
}

当我运行它时,输出是

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            ...

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

我做错了什么?

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

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

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

  • 在 PowerShell 版本 6.x 及以下版本(包括 Windows PowerShell)中,甚至 当前位置(目录)也没有从调用者继承(默认为[Environment]::GetFolderPath('MyDocuments'));这已在 v7.0.

  • 中修复
  • 他们确实看到的调用会话状态的方面是调用进程的副本环境变量 .

  • 要使调用者会话中的变量值可用于后台作业,必须通过 $using:scope(参见 about_Remote_Variables)引用它们。

    • 请注意,对于字符串、原始类型(例如数字)和少数其他知名类型以外的值,这 可能会导致类型保真度下降 ,因为这些值是使用 PowerShell 基于 XML 的序列化和反序列化跨进程边界编组的;这种潜在的类型保真度损失也会影响作业的 输出 - 有关背景信息,请参阅
    • 使用 更快且占用资源更少的 线程 作业,通过 Start-ThreadJob, avoids this problem (although all the other limitations apply); Start-ThreadJob comes with PowerShell [Core] 6+ and can be installed on demand in Windows PowerShell (e.g., Install-Module -Scope CurrentUser ThreadJob) - see 获取背景信息。

重要每当您将作业用于自动化,例如在名为从 Windows 任务计划程序或在 CI / CD 的上下文中,请确保您 等待所有作业完成 后再退出script(通过 Receive-Job -Wait or Wait-Job), because a script invoked via PowerShell's CLI 退出整个 PowerShell 进程,kill 任何未完成的作业。

因此,除非命令MakeARestCall:

  • 恰好是一个脚本文件MakeARestCall.ps1)或者可执行文件MakeARestCall.exe) 位于 $env:Path

  • 中列出的目录之一
  • 恰好是一个模块中定义的函数,即自动加载,

您的 $doJob 脚本块将 在作业进程中执行时失败 ,因为既没有定义 MakeARestCall 函数也没有定义别名。

您的评论表明 MakeARestCall 确实是一个 函数 ,因此为了使您的代码正常工作,您必须 (重新)将函数定义为作业执行的脚本块的一部分$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

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

一种替代方法

制作 MakeARestCall 一个 script (MakeARestCall.ps1) 并通过其完整的 path 调用它安全。

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

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


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

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

本质上,它是一种使用线程作业 (Start-ThreadJob) 的更简单、流水线友好的方式,具有相同的性能和资源- 相对于 后台作业 的使用优势,以及直接报告线程输出的额外简单性 .

但是,上面关于后台作业讨论的缺乏状态共享也适用线程作业(即使他们 运行 在 相同的 进程中,他们在孤立的 PowerShell 运行spaces 中这样做), 因此 这里 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 $_
}

语法陷阱:-Parallel不是开关(flag类型参数),而是将脚本块带到运行并行作为它的论点;换句话说:-Parallel 必须直接放在脚本块之前。

以上直接从并行线程发出输出,当它到达时——但请注意,这意味着输出保证按输入顺序到达;也就是说,稍后创建的线程可能 returns 它的输出早于较早的线程。

一个简单的例子:

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

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

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

等待整个作业完成,通过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

[1] 或者,将您的 MakeARestCall 函数重新定义为 过滤函数 (Filter),隐含地在 管道 输入,通过 $_,因此您可以按原样将其定义用作 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