Powershell:为什么在使用 ForEach-Object -Parallel 后我的变量为空?

5

我正在尝试使用ForEach-Object -Parallel从多个服务器收集数据。我使用的变量在循环内被填充,但是当循环结束时,该变量为空。

$DBDetails = "SELECT @@VERSION"

$VMs = ("vm1", "vm2", "vm3", "vm4", "vm5", "vm6", "vm7")
$DBInventory = @()

$scriptBlock = {
    $vm = $_
    $result = Invoke-Sqlcmd -ServerInstance $vm -Query $using:DBDetails
    $DBInventory += $result
    Write-Host "Added $($result.Count) rows from $($vm)"
}

$VMs | ForEach-Object -Parallel $scriptBlock
Write-Host "Number of elements in DBInventory: $($DBInventory.Count)"

我期望最后一行返回在前一行执行的循环中收集的元素数量。应该有总共7个元素,但是我一个都没有。

我的结果看起来像这样:

Added 1 rows from vm1
Added 1 rows from vm2
Added 1 rows from vm3
Added 1 rows from vm4
Added 1 rows from vm5
Added 1 rows from vm6
Added 1 rows from vm7
Number of elements in DBInventory: 0
2个回答

3

ForEach-Object -Parallel 会在一个单独的运行空间中执行循环体,这意味着您无法直接访问调用范围中定义的变量。

为了解决这个问题,请对代码进行两个更改:

  • 使用除可调整大小数组之外的集合类型(下面我使用了通用的 [List[psobject]]
  • 使用 using: 范围修饰符从调用者的作用域引用变量,并将其分配给块内的本地变量

结果本地变量将引用相同的列表对象,通过其方法(Add()Remove()AddRange() 等)对该列表所做的更改将反映在任何其他引用它的地方(包括来自调用范围的原始 $DBInventory 变量)。

$DBDetails = "SELECT @@VERSION"

$VMs = ("vm1", "vm2", "vm3", "vm4", "vm5", "vm6", "vm7")
$DBInventory = [System.Collections.Generic.List[psobject]]::new()

$scriptBlock = {
    $vm = $_
    $inventory = $using:DBInventory
    
    $result = Invoke-Sqlcmd -ServerInstance $vm -Query $using:DBDetails
    $inventory.AddRange([psobject[]]$result)
    Write-Host "Added $($result.Count) rows from $($vm)"
}

$VMs | ForEach-Object -Parallel $scriptBlock
Write-Host "Number of elements in DBInventory: $($DBInventory.Count)"

mklement0所指出的, [List[psobject]]不安全的线程 - 对于生产代码,您肯定需要选择一种是线程安全的集合类型,例如[System.Collections.Concurrent.ConcurrenBag[psobject]] - 本质上是一个无序列表:
$DBInventory = [System.Collections.Concurrent.ConcurrentBag[psobject]]::new()

请注意,ConcurrentBag 类型并不像其名称所示那样保留插入顺序。如果这是一个问题,您可能需要考虑使用[ConcurrentDictionary[string,psobject[]]] - 这样您就可以将查询结果与原始输入字符串绑定:

$DBInventory = [System.Collections.Concurrent.ConcurrentDictionary[string,psobject[]]]::new()

由于另一个线程可能(假设)在您调用Add()之后为相同的键添加了一个条目,因此ConcurrentDictionary类型要求我们稍微与常规字典或哈希表不同地使用它:

$scriptBlock = {
    $vm = $_
    $inventory = $using:DBInventory
    
    $result = Invoke-Sqlcmd -ServerInstance $vm -Query $using:DBDetails
    $adder = $updater = { return Write-Output $result -NoEnumerate }
    $inventory.AddOrUpdate($vm, $adder, $updater)
    Write-Host "Added $($result.Count) rows from $($vm)"
}

在这里,如果键不存在(否则将运行$updater),并且结果将被分配为条目值,则并发字典将代表我们执行$adder函数。

您随后可以以与哈希表相同的方式访问条目值:

$DBInventory[$vms[-1]] # returns array containing the query results from the last VM in the list

3

太长不看

  • 使用$using:作用域引用调用者作用域中定义的变量,就像你已经部分地做到了。

  • 不能直接修改调用者作用域中的变量$using:DBInventory += $result无法工作),但您不需要这样做:让PowerShell为您收集输出对象到一个数组中

$DBDetails = "SELECT @@VERSION"

$VMs = ("vm1", "vm2", "vm3", "vm4", "vm5", "vm6", "vm7")
 = @()

$scriptBlock = {
    $vm = $_
    $result = Invoke-Sqlcmd -ServerInstance $vm -Query $using:DBDetails
    Write-Host "Outputting $($result.Count) rows from $($vm)"
    $result # Simply output the objects 
}

# Let PowerShell collect all output objects from the ForEach-Object -Parallel call 
# in an array.
# Note: The [array] type constraint ensures that $DBInventory is an array
#       even if there happens to be only *one* output object.
[array] $DBInventory = $VMs | ForEach-Object -Parallel $scriptBlock

Write-Host "Number of elements in DBInventory: $($DBInventory.Count)"

$DBInventory 将包含一个常规的 PowerShell 数组([object[]])。


背景信息:

  • 你的代码已经部分地表明了你需要在运行于不同运行空间(例如由ForEach-Object -Parallel创建的线程)中的脚本块内使用$using:作用域来引用调用方作用域的变量值

    • 这原则上也适用于你的调用方$DBInventory变量,但是:
      • $using:引用是对变量值的引用,而不是对变量本身的引用,因此你不能对$using:引用进行赋值操作
      • 也就是说,$using:DBInventory += $result无法工作,更不用说使用+=“增长”数组的效率低下了——请参见this answer
  • 虽然你可以$DBInventory初始化为一个高效可扩展的列表类型,但是,考虑到你正在使用基于线程的并行性通过ForEach-Object -Parallel,你必须确保以线程安全的方式增长它

    • 值得注意的是,常用的列表类型[System.Collections.Generic.List[object]]System.Collections.ArrayList不是线程安全的。

    • 你必须要么:

      • 在你的脚本块中添加手动同步代码,使用.NET API,这是非常复杂的。
      • 选择一个不同的并发(线程安全)列表类型(没有内置的通用列表)
      • 使用线程安全的包装器,例如$DBInventory = [System.Collections.ArrayList]::Synchronized([System.Collections.Generic.List[object]] @()),它返回一个非泛型[System.Collections.IList]实现。
        然而,请注意,对于具有值类型元素的通用列表,这可能效率低下,不提供用于有效附加多个元素的.AddRange()方法,并且其.Add()方法返回一个(通常不需要的)值,你必须使用$null = ($using:DBInventory).Add(...)来丢弃它。
    • 请注意,通过$using:增长列表之所以可以工作——与通过+=不同——是因为你通过对象方法.Add().AddRange())添加元素,该对象是被引用的变量的。也就是说,你直接修改了变量,而不是变量本身(这是不支持的)。

  • 幸运的是,有一个更简单的解决方案:依靠PowerShell的能力自动收集管道发出的所有输出对象到一个数组中,这比手动增长列表更简洁、更高效,也适用于ForEach-Object -Parallel,如上所示——请参见{{link


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