PowerShell 脚本性能注意事项

直接利用 .NET 并避免使用管道的 PowerShell 脚本的速度要快于惯用 PowerShell。 惯用 PowerShell 使用 cmdlet 和 PowerShell 函数并且常使用管道,仅在必要时才会使用 .NET。

注意

此处介绍的许多方法并不是惯用的 PowerShell 做法,可能会降低 PowerShell 脚本的可读性。 建议脚本作者使用惯用 PowerShell 做法,除非因性能决定不这样做。

取消显示输出

有多种方法可避免将对象写入管道。

  • 分配或文件重定向到 $null
  • 强制转换为 [void]
  • 通过管道传递到 Out-Null

分配到 $null、强制转换为 [void] 和文件重定向到 $null 的速度几乎相同。 但是,在一个大型循环中调用 Out-Null 可能会慢很多,尤其在 PowerShell 5.1 中也是如此。

$tests = @{
    'Assign to $null' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            $null = $arraylist.Add($i)
        }
    }
    'Cast to [void]' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            [void] $arraylist.Add($i)
        }
    }
    'Redirect to $null' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            $arraylist.Add($i) > $null
        }
    }
    'Pipe to Out-Null' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            $arraylist.Add($i) | Out-Null
        }
    }
}

10kb, 50kb, 100kb | ForEach-Object {
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = (Measure-Command { & $test.Value $_ }).TotalMilliseconds

        [pscustomobject]@{
            Iterations        = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

这些测试在 PowerShell 7.3.4 中的 Windows 11 计算机上运行。 结果如下所示:

Iterations Test              TotalMilliseconds RelativeSpeed
---------- ----              ----------------- -------------
     10240 Assign to $null               36.74 1x
     10240 Redirect to $null             55.84 1.52x
     10240 Cast to [void]                62.96 1.71x
     10240 Pipe to Out-Null              81.65 2.22x
     51200 Assign to $null              193.92 1x
     51200 Cast to [void]               200.77 1.04x
     51200 Redirect to $null            219.69 1.13x
     51200 Pipe to Out-Null             329.62 1.7x
    102400 Redirect to $null            386.08 1x
    102400 Assign to $null              392.13 1.02x
    102400 Cast to [void]               405.24 1.05x
    102400 Pipe to Out-Null             572.94 1.48x

时间和相对速度可能因硬件、PowerShell 版本和系统上的当前工作负载而异。

数组加法

通常通过结合使用加法运算符和数组来生成项列表:

$results = @()
$results += Get-Something
$results += Get-SomethingElse
$results

数组相加可能效率不高,因为数组的大小固定。 每次相加数组都会创建一个足以容纳左操作数和右操作数的所有元素的新数组。 系统将这两个操作数的元素复制到新数组中。 对于小型集合,这种开销可能不重要。 大型集合的性能可能会受到影响。

有两种替代方法。 如果实际上不需要数组,请考虑改用类型化泛型列表 ([List<T>]):

$results = [System.Collections.Generic.List[object]]::new()
$results.AddRange((Get-Something))
$results.AddRange((Get-SomethingElse))
$results

使用数组相加对性能的影响会随着集合的大小和数字相加而呈指数级增长。 此代码比较了向数组显式赋值、使用数组添加以及在 [List<T>] 对象上使用 Add(T) 方法。 它将显式分配定义为性能基线。

$tests = @{
    'PowerShell Explicit Assignment' = {
        param($count)

        $result = foreach($i in 1..$count) {
            $i
        }
    }
    '.Add(T) to List<T>' = {
        param($count)

        $result = [Collections.Generic.List[int]]::new()
        foreach($i in 1..$count) {
            $result.Add($i)
        }
    }
    '+= Operator to Array' = {
        param($count)

        $result = @()
        foreach($i in 1..$count) {
            $result += $i
        }
    }
}

5kb, 10kb, 100kb | ForEach-Object {
    $groupResult = foreach($test in $tests.GetEnumerator()) {
        $ms = (Measure-Command { & $test.Value -Count $_ }).TotalMilliseconds

        [pscustomobject]@{
            CollectionSize    = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

这些测试在 PowerShell 7.3.4 中的 Windows 11 计算机上运行。

CollectionSize Test                           TotalMilliseconds RelativeSpeed
-------------- ----                           ----------------- -------------
          5120 PowerShell Explicit Assignment             26.65 1x
          5120 .Add(T) to List<T>                        110.98 4.16x
          5120 += Operator to Array                      402.91 15.12x
         10240 PowerShell Explicit Assignment              0.49 1x
         10240 .Add(T) to List<T>                        137.67 280.96x
         10240 += Operator to Array                     1678.13 3424.76x
        102400 PowerShell Explicit Assignment             11.18 1x
        102400 .Add(T) to List<T>                       1384.03 123.8x
        102400 += Operator to Array                   201991.06 18067.18x

在处理大型集合时,向数组添加的速度比向 List<T> 添加要慢得多。

使用 [List<T>] 对象时,需要创建特定类型(例如 [String][Int])的列表。 将不同类型的对象添加到列表时,它们将强制转换为指定类型。 如果无法将其强制转换为指定类型,则方法将引发异常。

$intList = [System.Collections.Generic.List[int]]::new()
$intList.Add(1)
$intList.Add('2')
$intList.Add(3.0)
$intList.Add('Four')
$intList
MethodException:
Line |
   5 |  $intList.Add('Four')
     |  ~~~~~~~~~~~~~~~~~~~~
     | Cannot convert argument "item", with value: "Four", for "Add" to type
     "System.Int32": "Cannot convert value "Four" to type "System.Int32".
     Error: "The input string 'Four' was not in a correct format.""

1
2
3

如果需要列表是不同类型对象的集合,请使用 [Object] 作为列表类型创建列表。 可以枚举集合,检查其中对象的类型。

$objectList = [System.Collections.Generic.List[object]]::new()
$objectList.Add(1)
$objectList.Add('2')
$objectList.Add(3.0)
$objectList | ForEach-Object { "$_ is $($_.GetType())" }
1 is int
2 is string
3 is double

如果确实需要数组,可以在列表上调用 ToArray() 方法,也可以让 PowerShell 为你创建数组:

$results = @(
    Get-Something
    Get-SomethingElse
)

在此示例中,PowerShell 会创建一个 [ArrayList] 来保存写入管道内数组表达式中的结果。 在分配到 $results 之前,PowerShell 会将 [ArrayList] 转换为 [Object[]]

字符串加法

字符串是不可变的。 每次相加字符串实际上会创建一个足以容纳左操作数和右操作数的内容的新字符串,然后系统将这两个操作数的元素复制到新字符串中。 对于小型字符串,此开销可能并不重要。 对于大型字符串,这可能会影响性能和内存消耗。

至少有两种选择:

  • -join 运算符连接字符串
  • .NET [StringBuilder] 类提供可变字符串

以下示例比较这三种生成字符串的方法的性能。

$tests = @{
    'StringBuilder' = {
        $sb = [System.Text.StringBuilder]::new()
        foreach ($i in 0..$args[0]) {
            $sb = $sb.AppendLine("Iteration $i")
        }
        $sb.ToString()
    }
    'Join operator' = {
        $string = @(
            foreach ($i in 0..$args[0]) {
                "Iteration $i"
            }
        ) -join "`n"
        $string
    }
    'Addition Assignment +=' = {
        $string = ''
        foreach ($i in 0..$args[0]) {
            $string += "Iteration $i`n"
        }
        $string
    }
}

10kb, 50kb, 100kb | ForEach-Object {
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = (Measure-Command { & $test.Value $_ }).TotalMilliseconds

        [pscustomobject]@{
            Iterations        = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

这些测试在 PowerShell 7.4.2 中的 Windows 11 计算机上运行。 输出显示 -join 运算符速度最快,接着是 [StringBuilder] 类。

Iterations Test                   TotalMilliseconds RelativeSpeed
---------- ----                   ----------------- -------------
     10240 Join operator                      14.75 1x
     10240 StringBuilder                      62.44 4.23x
     10240 Addition Assignment +=            619.64 42.01x
     51200 Join operator                      43.15 1x
     51200 StringBuilder                     304.32 7.05x
     51200 Addition Assignment +=          14225.13 329.67x
    102400 Join operator                      85.62 1x
    102400 StringBuilder                     499.12 5.83x
    102400 Addition Assignment +=          67640.79 790.01x

时间和相对速度可能因硬件、PowerShell 版本和系统上的当前工作负载而异。

处理大型文件

在 PowerShell 中处理文件的惯用方法可能如下所示:

Get-Content $path | Where-Object Length -GT 10

这可能比直接使用 .NET API 要慢一个数量级。 例如,可以使用 .NET [StreamReader] 类:

try {
    $reader = [System.IO.StreamReader]::new($path)
    while (-not $reader.EndOfStream) {
        $line = $reader.ReadLine()
        if ($line.Length -gt 10) {
            $line
        }
    }
}
finally {
    if ($reader) {
        $reader.Dispose()
    }
}

也可以使用 [System.IO.File]ReadLines 方法,它包装了 StreamReader,简化了读取过程:

foreach ($line in [System.IO.File]::ReadLines($path)) {
    if ($line.Length -gt 10) {
        $line
    }
}

在大型集合中按属性查找条目

通常需要使用共享属性来标识不同集合中的同一记录,例如使用名称从一个列表中检索 ID,从另一个列表中检索电子邮件。 循环访问第一个列表以查找第二个集合中的匹配记录速度较慢。 具体而言,重复筛选第二个集合会产生较大的开销。

给定两个集合,一个具有 ID 和 Name,另一个具有 Name 和 Email:

$Employees = 1..10000 | ForEach-Object {
    [PSCustomObject]@{
        Id   = $_
        Name = "Name$_"
    }
}

$Accounts = 2500..7500 | ForEach-Object {
    [PSCustomObject]@{
        Name  = "Name$_"
        Email = "Name$_@fabrikam.com"
    }
}

协调这些集合以返回具有 ID、Name 和 Email 属性的对象列表的常用方法可能如下所示:

$Results = $Employees | ForEach-Object -Process {
    $Employee = $_

    $Account = $Accounts | Where-Object -FilterScript {
        $_.Name -eq $Employee.Name
    }

    [pscustomobject]@{
        Id    = $Employee.Id
        Name  = $Employee.Name
        Email = $Account.Email
    }
}

但是,该实现必须针对 $Employee 集合中的每个项筛选一次 $Accounts 集合中的所有 5000 个项。 这可能需要几分钟,即使对于此单值查找也是如此。

相反,你可以创建一个哈希表,将共享的 Name 属性作为键,将匹配的帐户作为值

$LookupHash = @{}
foreach ($Account in $Accounts) {
    $LookupHash[$Account.Name] = $Account
}

在哈希表中查找键比按属性值筛选集合要快得多。 PowerShell 可以检查是否定义了键并使用其值,而不是检查集合中的每个项。

$Results = $Employees | ForEach-Object -Process {
    $Email = $LookupHash[$_.Name].Email
    [pscustomobject]@{
        Id    = $_.Id
        Name  = $_.Name
        Email = $Email
    }
}

这要快得多。 虽然循环筛选器需要几分钟才能完成,但哈希查找所需的时间不到一秒。

请谨慎使用 Write-Host

仅当需要将格式化文本写入主机控制台,而不是将对象写入 Success 管道时,才应使用 Write-Host 命令

对于特定主机(例如 pwsh.exepowershell.exepowershell_ise.exe),Write-Host 可能比 [Console]::WriteLine() 要慢一个数量级。 然而,[Console]::WriteLine() 不保证在所有主机中都能正常工作。 此外,使用 [Console]::WriteLine() 编写的输出不会写入由 Start-Transcript 启动的脚本。

JIT 编译

PowerShell 将脚本代码编译为已解释的字节码。 从 PowerShell 3 开始,对于在循环中重复执行的代码,PowerShell 可以通过实时 (JIT) 将代码编译为本机代码来提高性能。

指令少于 300 条的循环有资格进行 JIT 编译。 大于此循环的循环成本太高,难以编译。 当循环执行了 16 次后,脚本在后台进行 JIT 编译。 当 JIT 编译完成时,执行转移到已编译的代码。

避免重复调用函数

调用函数是一项成本高昂的操作。 如果在长时间运行的紧密循环中调用函数,请考虑将循环移动到函数内部。

请开考虑以下示例:

$tests = @{
    'Simple for-loop'       = {
        param([int] $RepeatCount, [random] $RanGen)

        for ($i = 0; $i -lt $RepeatCount; $i++) {
            $null = $RanGen.Next()
        }
    }
    'Wrapped in a function' = {
        param([int] $RepeatCount, [random] $RanGen)

        function Get-RandomNumberCore {
            param ($rng)

            $rng.Next()
        }

        for ($i = 0; $i -lt $RepeatCount; $i++) {
            $null = Get-RandomNumberCore -rng $RanGen
        }
    }
    'for-loop in a function' = {
        param([int] $RepeatCount, [random] $RanGen)

        function Get-RandomNumberAll {
            param ($rng, $count)

            for ($i = 0; $i -lt $count; $i++) {
                $null = $rng.Next()
            }
        }

        Get-RandomNumberAll -rng $RanGen -count $RepeatCount
    }
}

5kb, 10kb, 100kb | ForEach-Object {
    $rng = [random]::new()
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = Measure-Command { & $test.Value -RepeatCount $_ -RanGen $rng }

        [pscustomobject]@{
            CollectionSize    = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms.TotalMilliseconds,2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

基本 for 循环示例是性能的基线。 第二个示例将随机数生成器包装在一个在紧密循环中调用的函数中。 第三个示例在函数内部移动循环。 该函数仅被调用了一次,但代码仍会生成相同数量的随机数。 请注意每个示例的执行时间差异。

CollectionSize Test                   TotalMilliseconds RelativeSpeed
-------------- ----                   ----------------- -------------
          5120 for-loop in a function              9.62 1x
          5120 Simple for-loop                    10.55 1.1x
          5120 Wrapped in a function              62.39 6.49x
         10240 Simple for-loop                    17.79 1x
         10240 for-loop in a function             18.48 1.04x
         10240 Wrapped in a function             127.39 7.16x
        102400 for-loop in a function            179.19 1x
        102400 Simple for-loop                   181.58 1.01x
        102400 Wrapped in a function            1155.57 6.45x

避免包装 cmdlet 管道

大多数 cmdlet 都是为管道实现的,管道是一个有顺序的语法和过程。 例如:

cmdlet1 | cmdlet2 | cmdlet3

初始化新管道的成本可能很高,因此应避免将 cmdlet 管道包装到另一个现有的管道中。

请考虑以下示例。 Input.csv 文件包含 2100 行。 Export-Csv 命令包装在 ForEach-Object 管道内。 在 ForEach-Object 循环的每次迭代中都会调用 Export-Csv cmdlet。

$measure = Measure-Command -Expression {
    Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 1 } -Process {
        [PSCustomObject]@{
            Id   = $Id
            Name = $_.opened_by
        } | Export-Csv .\Output1.csv -Append
    }
}

'Wrapped = {0:N2} ms' -f $measure.TotalMilliseconds
Wrapped = 15,968.78 ms

对于下一个示例,Export-Csv 命令已移到 ForEach-Object 管道之外。 在这种情况下,Export-Csv 仅调用一次,但仍会处理所有从 ForEach-Object 传出的对象。

$measure = Measure-Command -Expression {
    Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 2 } -Process {
        [PSCustomObject]@{
            Id   = $Id
            Name = $_.opened_by
        }
    } | Export-Csv .\Output2.csv
}

'Unwrapped = {0:N2} ms' -f $measure.TotalMilliseconds
Unwrapped = 42.92 ms

未包装的示例要快 372 倍。 另请注意,第一个实现需要 Append 参数,后续实现不需要此参数。

使用 OrderedDictionary 动态创建新对象

在某些情况下,我们可能需要根据某些输入动态创建对象,这也许是创建新的 PSObject,然后使用 Add-Member cmdlet 添加新属性的最常用方法。 使用此技术的小集合的性能成本可能微不足道,但对于大型集合来说,它可能会变得非常明显。 在这种情况下,建议的方法是使用 [OrderedDictionary],然后使用 [pscustomobject] 类型加速器将其转换为 PSObject。 有关详细信息,请参阅 about_Hash_Tables 的“创建有序字典”部分

假设在变量 $json 中存储了以下 API 响应。

{
  "tables": [
    {
      "name": "PrimaryResult",
      "columns": [
        { "name": "Type", "type": "string" },
        { "name": "TenantId", "type": "string" },
        { "name": "count_", "type": "long" }
      ],
      "rows": [
        [ "Usage", "63613592-b6f7-4c3d-a390-22ba13102111", "1" ],
        [ "Usage", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "1" ],
        [ "BillingFact", "63613592-b6f7-4c3d-a390-22ba13102111", "1" ],
        [ "BillingFact", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "1" ],
        [ "Operation", "63613592-b6f7-4c3d-a390-22ba13102111", "7" ],
        [ "Operation", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "5" ]
      ]
    }
  ]
}

现在,假设你想要将此数据导出到 CSV。 首先,需要创建新对象并使用 Add-Member cmdlet 添加属性和值。

$data = $json | ConvertFrom-Json
$columns = $data.tables.columns
$result = foreach ($row in $data.tables.rows) {
    $obj = [psobject]::new()
    $index = 0

    foreach ($column in $columns) {
        $obj | Add-Member -MemberType NoteProperty -Name $column.name -Value $row[$index++]
    }

    $obj
}

使用 OrderedDictionary,可以将代码转换为:

$data = $json | ConvertFrom-Json
$columns = $data.tables.columns
$result = foreach ($row in $data.tables.rows) {
    $obj = [ordered]@{}
    $index = 0

    foreach ($column in $columns) {
        $obj[$column.name] = $row[$index++]
    }

    [pscustomobject] $obj
}

在这两种情况下,$result 输出相同:

Type        TenantId                             count_
----        --------                             ------
Usage       63613592-b6f7-4c3d-a390-22ba13102111 1
Usage       d436f322-a9f4-4aad-9a7d-271fbf66001c 1
BillingFact 63613592-b6f7-4c3d-a390-22ba13102111 1
BillingFact d436f322-a9f4-4aad-9a7d-271fbf66001c 1
Operation   63613592-b6f7-4c3d-a390-22ba13102111 7
Operation   d436f322-a9f4-4aad-9a7d-271fbf66001c 5

随着对象和成员属性数量的增加,后一种方法的效率呈指数级增长。

以下是创建具有 5 个属性的对象的 3 种技术的性能比较:

$tests = @{
    '[ordered] into [pscustomobject] cast' = {
        param([int] $iterations, [string[]] $props)

        foreach ($i in 1..$iterations) {
            $obj = [ordered]@{}
            foreach ($prop in $props) {
                $obj[$prop] = $i
            }
            [pscustomobject] $obj
        }
    }
    'Add-Member'                           = {
        param([int] $iterations, [string[]] $props)

        foreach ($i in 1..$iterations) {
            $obj = [psobject]::new()
            foreach ($prop in $props) {
                $obj | Add-Member -MemberType NoteProperty -Name $prop -Value $i
            }
            $obj
        }
    }
    'PSObject.Properties.Add'              = {
        param([int] $iterations, [string[]] $props)

        # this is how, behind the scenes, `Add-Member` attaches
        # new properties to our PSObject.
        # Worth having it here for performance comparison

        foreach ($i in 1..$iterations) {
            $obj = [psobject]::new()
            foreach ($prop in $props) {
                $obj.PSObject.Properties.Add(
                    [psnoteproperty]::new($prop, $i))
            }
            $obj
        }
    }
}

$properties = 'Prop1', 'Prop2', 'Prop3', 'Prop4', 'Prop5'

1kb, 10kb, 100kb | ForEach-Object {
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = Measure-Command { & $test.Value -iterations $_ -props $properties }

        [pscustomobject]@{
            Iterations        = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms.TotalMilliseconds, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

以下是结果:

Iterations Test                                 TotalMilliseconds RelativeSpeed
---------- ----                                 ----------------- -------------
      1024 [ordered] into [pscustomobject] cast             22.00 1x
      1024 PSObject.Properties.Add                         153.17 6.96x
      1024 Add-Member                                      261.96 11.91x
     10240 [ordered] into [pscustomobject] cast             65.24 1x
     10240 PSObject.Properties.Add                        1293.07 19.82x
     10240 Add-Member                                     2203.03 33.77x
    102400 [ordered] into [pscustomobject] cast            639.83 1x
    102400 PSObject.Properties.Add                       13914.67 21.75x
    102400 Add-Member                                    23496.08 36.72x