3

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

$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 服务。这意味着当我尝试并行运行该块时我的问题。

我究竟做错了什么?

4

1 回答 1

6

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

  • 他们看不到调用会话中定义的任何函数和别名,也看不到手动导入的模块,也看不到手动加载的 .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 -Wait或)之前等待所有作业完成Wait-Job,因为通过 PowerShell 的CLI调用的脚本会作为一个整体退出 PowerShell 进程,这会杀死任何未完成的作业。

因此,除非命令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一个脚本( ) 并通过其完整路径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 $_
}

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

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

一个简单的例子:

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

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

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

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

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),它隐式地对管道输入进行操作, 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
于 2020-03-13T11:09:04.880 回答