PowerShell脚本运行慢时该看哪里 ── 数组・管道・匹配的诀窍

· · PowerShell, Windows, 性能改善, 自动化, 脚本, 运维改善, 调优, 数据处理

「原本几分钟就能跑完的汇总脚本,随着数据增加变成了要跑三个小时」── 这是在 PowerShell 自动化已经普及的现场经常听到的说法。而在大多数情况下,变慢的原因不在于 PowerShell 本身的执行速度,而在于写法

PowerShell 是一门以易写性为优先的语言,因此存在不少「按照自然的写法写出来,结果计算量却恶化了」的模式。最具代表性的就是 $array += $item。看起来很自然,但内部每次都会执行整个数组的复制,随着件数增加会急剧变慢。是否了解这些常见的陷阱,决定了同一处理是需要几个小时,还是只需要几十秒。

本文将按影响大小的顺序,整理在看到运行缓慢的脚本时首先应该怀疑的地方。同时也会讲解正确的测量方法,以避免凭主观臆断进行改写。

本文适用的环境同时包括 Windows PowerShell 5.1 与 PowerShell 7。两者行为不同的地方(例如 Get-Content 的默认字符编码等),会在正文中逐处说明。本文的示例代码是在 PowerShell 7.6 中执行验证的。

1. 先说结论

  • 请不要使用 $array += $itemPowerShell 的数组是固定长度的,+= 每次都会创建新数组并复制全部元素。速度会按件数的平方比例变慢。1
  • 请改用 List[T],或者用变量接收循环整体的输出。后者是既符合 PowerShell 风格、又更快的写法。
  • 匹配・检索最有效的手段是哈希表化。双重循环的线性搜索(O(n×m))会变为按键引用(大致为 O(n+m))。2
  • 管道虽然方便,但每个对象都有相应的开销。在处理大量件数的内层循环中,foreach 语句往往更快,值得实测比较。3
  • 文件读取要根据目的分别使用。Get-Content -Raw 一次性读取,用 -ReadCount 分批读取。有些情况下逐行对象化会是瓶颈所在。4
  • Get-ChildItem 请使用 -Filter官方文档也明确指出,由于是在提供程序一侧进行筛选,比获取后再用 Where-Object 丢弃更高效。5
  • 字符串的拼接请使用 -joinStringBuilder$s += "..." 与数组变慢的原因相同。6
  • Format-* 只在屏幕显示的最后使用。如果在中途插入,不仅会破坏后续处理,还会产生无谓的格式化开销。7
  • 先测量,再修改。Measure-Command 会丢弃输出,因此不包含显示开销。不要使用首次执行的数值。8
  • 并行化放在最后。请先修正算法,再考虑并行化(参见「PowerShell 的并行处理」)。

2. 先测量 ── Measure-Command 的正确用法

凭猜测进行调优,大多会修改到没有效果的地方。首先要做的是测量。8

先做一个说明。本文代码示例中出现的 Invoke-KsAggregateConvertTo-KsRecordJoin-KsRecord,都是供读者替换为自己处理逻辑的临时函数名。它们并非 PowerShell 的标准 cmdlet,因此直接粘贴运行会因「无法识别该术语」而报错停止。请将其替换为你自己脚本中的函数名或处理逻辑来阅读。

# 第 1 次会包含模块加载与 JIT 编译的时间,因此舍弃
$null = Measure-Command { Invoke-KsAggregate }

# 第 2 次及以后测量数次,同时观察波动情况
1..3 | ForEach-Object {
    (Measure-Command { Invoke-KsAggregate }).TotalSeconds
}

Measure-Command 有两点需要注意。

  • 脚本块的输出会被丢弃。不包含屏幕上的格式化・渲染开销。如果实际体感很慢,有时元凶其实在显示这一侧
  • 要在相同条件下比较。文件缓存是否已经预热,会让包含 I/O 的处理的测量值产生很大差异

想要细分处理内部哪个环节慢时,请使用 Stopwatch

$sw = [System.Diagnostics.Stopwatch]::StartNew()
$rows = Import-Csv $csvPath
Write-Verbose "读取: $($sw.ElapsedMilliseconds) ms"; $sw.Restart()

$index = $master | Group-Object Code -AsHashTable -AsString
Write-Verbose "创建索引: $($sw.ElapsedMilliseconds) ms"; $sw.Restart()

$result = foreach ($r in $rows) { Join-KsRecord $r $index }
Write-Verbose "匹配: $($sw.ElapsedMilliseconds) ms"; $sw.Stop()

像这样输出各区间的耗时,就能立刻发现「原来读取占了 8 成」这样的事实。之所以使用 Write-Verbose,是为了让它在正常运行时不显示,只在排查时才可见(参见「PowerShell 的输出流与日志设计」)。

3. 最大的元凶 ── 数组的 +=

PowerShell 的数组是固定长度的。无法直接追加元素,+= 会被展开为「创建新数组、复制全部元素、在末尾追加新元素」的处理。1 也就是说,追加 n 件的循环整体上会执行与 n 的平方成比例的复制。

具体会增加多少,可以直接从写法算出来。追加 n 件时发生的元素复制总次数为 n(n-1)/2

追加的件数 += 产生的元素复制总次数 List[T].Add 的追加次数
1,000 件 约 50 万次 1,000 次
10,000 件 约 5,000 万次 10,000 次
100,000 件 约 50 亿次 100,000 次

这并非执行时间的实测值,而是由写法唯一确定的处理次数。每一件的实际耗时会因环境而异,因此自己环境下的秒数,请用配套 zip 中的基准测试来测量。不过,「件数增加 10 倍时次数会增加到 100 倍」这种增长方式,与环境无关,始终相同。

# 【NG】件数增加时会急剧变慢
$result = @()
foreach ($row in $rows) {
    $result += ConvertTo-KsRecord $row     # 每次都会复制整个数组
}

# 【OK-1】使用 List[T](元素追加是常数时间)
$result = [System.Collections.Generic.List[object]]::new()
foreach ($row in $rows) {
    $result.Add((ConvertTo-KsRecord $row))
}

# 【OK-2】用变量接收循环整体的输出(符合 PowerShell 风格,且更快)
$result = foreach ($row in $rows) {
    ConvertTo-KsRecord $row                # 输出会被直接汇总
}

【OK-2】的写法,一旦习惯之后,是 PowerShell 中最自然的形式。foreach 语句、ForEach-Objectif 等的输出都可以直接汇总到变量中。这一特性需要与对「输出流」的理解配套掌握。

同样的道理也适用于字符串。字符串是不可变的(immutable),因此 $s += "line" 每次都会创建新字符串。

# 【NG】
$text = ''
foreach ($line in $lines) { $text += "$line`r`n" }

# 【OK-1】-join(最简洁)
$text = $lines -join "`r`n"

# 【OK-2】StringBuilder(适用于涉及条件分支的复杂拼装)
$sb = [System.Text.StringBuilder]::new()
foreach ($line in $lines) { [void]$sb.AppendLine($line) }
$text = $sb.ToString()

4. 停止使用双重循环 ── 匹配请用哈希表

接下来有效的是匹配(matching)。「针对订单数据的每一行,从主数据中查出商品名称」这一处理,如果按自然写法来写,会是这样。

# 【NG】订单 1 万件 × 主数据 1 万件 = 1 亿次比较
foreach ($order in $orders) {
    $item = $master | Where-Object { $_.Code -eq $order.Code }   # 每次都全量扫描
    $order | Add-Member NoteProperty ItemName $item.Name
}

把这个改成哈希表后,比较次数会大幅减少。2

# 【OK】只创建一次索引,之后按键引用
$index = $master | Group-Object -Property Code -AsHashTable -AsString

$result = foreach ($order in $orders) {
    $hit = $index[$order.Code]        # 按键引用不依赖件数
    [pscustomobject]@{
        Code     = $order.Code
        Qty      = $order.Qty
        ItemName = if ($hit) { $hit[0].Name } else { $null }   # 也能检测出未登记的情况
    }
}

这里同样看一下次数。双重循环的比较次数是订单件数 × 主数据件数,而哈希表是「创建一次索引」+「逐件查询」,因此与合计件数成比例。

订单件数 × 主数据件数 双重循环的比较次数 哈希表的处理次数
1,000 × 1,000 100 万次 约 2,000 次
10,000 × 10,000 1 亿次 约 2 万次
100,000 × 100,000 100 亿次 约 20 万次

件数增加到 10 倍时,双重循环会变成 100 倍,哈希表则是 10 倍。件数越多,差距就越大,因此对于预计数据量会增加的处理,越值得优先修改。

Group-Object -AsHashTable 会把相同键的元素汇总为数组,因此值会是数组(对应上面的 $hit[0])。如果已经知道键是唯一的,也可以自己组装哈希表。

$index = @{}
foreach ($m in $master) { $index[$m.Code] = $m }    # 以键唯一为前提

如果只是反复判定「是否包含」,HashSet 会很有效。针对数组的 -contains-in 是线性搜索,判定次数一多就会成为瓶颈。

$known = [System.Collections.Generic.HashSet[string]]::new(
    [string[]]$master.Code, [System.StringComparer]::OrdinalIgnoreCase)

$unknown = $orders | Where-Object { -not $known.Contains($_.Code) }

CSV 之间匹配的实务模式,在「用 PowerShell 自动化 Excel・CSV 业务处理」中也有讲解。

5. 管道与 foreach 语句

管道是 PowerShell 的核心功能,但每个对象在 cmdlet 之间流转都有相应的处理开销。在数十万件规模的内层循环中,foreach 语句往往更快。3

# 管道: 易读,且逐条处理,不会积压中间结果
$rows | Where-Object { $_.Status -eq 'OK' } | ForEach-Object { $_.Amount } |
    Measure-Object -Sum

# foreach 语句: 每件的开销较小,大量件数时更快
$sum = 0
foreach ($r in $rows) { if ($r.Status -eq 'OK') { $sum += $r.Amount } }

关于内存有一点容易被误解,这里补充说明。foreach 语句会先对圆括号内的表达式求值,然后才进入循环,因此像 foreach ($line in (Get-Content $path)) 这样传入命令的执行结果时,此时全部内容就已经加载到内存中了。3 另一方面,如果传入的是延迟枚举的 IEnumerable,则会逐条枚举。因此即使是巨大文件,只要按下面的写法书写,就能在保持 foreach 语句形式的同时不占用内存来处理。

# 不把全部内容加载到内存,用 foreach 语句处理(延迟枚举)
foreach ($line in [System.IO.File]::ReadLines($path)) {
    if ($line.StartsWith('ERROR')) { $errors++ }
}

不过请注意,默认字符编码是不同的这一点。只传一个参数的 ReadLines($path) 会按 UTF-8 读取(如果有 BOM 则优先按 BOM)。而 Windows PowerShell 5.1 的 Get-Content,在没有 BOM 时会按当前的 ANSI 代码页(在日语环境下为 Shift_JIS)读取。也就是说,如果直接替换 Shift_JIS 的日志,日文会出现乱码。替换时请使用能够明确指定编码的重载。

# 读取 Shift_JIS(CP932)日志的情况
# PowerShell 6.2 及以后已经注册了代码页提供程序
# 因此可以直接调用 GetEncoding(932)(Windows PowerShell 5.1 中同样如此)
$enc = [System.Text.Encoding]::GetEncoding(932)
foreach ($line in [System.IO.File]::ReadLines($path, $enc)) {
    if ($line.StartsWith('ERROR')) { $errors++ }
}

另外,PowerShell 7 中 Get-Content 的默认编码是 UTF-8(无 BOM),因此只要在 7 中处理的是 UTF-8 文件,即便使用单参数的重载,结果也是一致的。替换之前,请务必确认目标文件的字符编码以及运行环境的版本。

判断的大致标准如下。

情况 选择
件数较少・优先可读性 管道
传入命令结果的巨大输入(Get-Content 等) 管道,或 [IO.File]::ReadLines + foreach 语句
对已经在内存中的数组循环数十万次 foreach 语句
对数组的简单筛选 .Where({...}) / .ForEach({...}) 方法1

.Where().ForEach() 是 PowerShell 4.0 中新增的数组方法,由于不经过管道,所以更轻量。1 不过前提是对象已经是内存中的集合。

6. 文件与目录的 I/O

读取要根据目的分别选用。Get-Content 默认会逐行生成对象,因此对巨大文件而言,这项开销会成为主导因素。4

Get-Content $path                      # 逐行(生成与行数相同数量的对象)
Get-Content $path -Raw                 # 把整体作为一个字符串一次性读取
Get-Content $path -ReadCount 1000      # 以每 1000 行为一个数组传出(减少对象生成)
[System.IO.File]::ReadLines($path)     # .NET。逐条枚举,最轻量(默认为 UTF-8)

目录遍历请使用 -Filter。官方文档明确指出「筛选器是由提供程序在获取对象时应用的,因此比其他参数更高效」。5

# 【NG】先获取全部,再丢弃
Get-ChildItem -Path $root -Recurse | Where-Object { $_.Extension -eq '.log' }

# 【OK】在提供程序一侧筛选
Get-ChildItem -Path $root -Recurse -File -Filter '*.log'

# 在数十万文件规模下如果仍然很慢,可以考虑使用 .NET 的枚举
[System.IO.Directory]::EnumerateFiles($root, '*.log', 'AllDirectories')

写入方面,循环内每次追加写入是典型的瓶颈。Add-Content 每次调用都会打开并关闭文件。

# 【NG】1 万行 = 1 万次打开/关闭
foreach ($r in $result) { Add-Content -Path $out -Value ($r -join ',') }

# 【OK-1】一次性汇总写入
$result | ForEach-Object { $_ -join ',' } | Set-Content -Path $out -Encoding utf8

# 【OK-2】如果需要逐次写出,让 StreamWriter 保持打开状态
$writer = [System.IO.StreamWriter]::new($out, $false, [System.Text.UTF8Encoding]::new($false))
try   { foreach ($r in $result) { $writer.WriteLine($r -join ',') } }
finally { $writer.Dispose() }

在跨网络的文件操作中,往返次数本身就会成为主导因素。UNC 路径特有的注意事项,请参见「网络共享・UNC 路径的陷阱」。

7. 容易被忽视的小成本

为了在纠结该从哪里下手时有个参考,下面附上效果大小的大致标准。这不是实测值,而是由「单次成本 × 执行次数」决定的数量级感觉。请对照自己脚本的件数来阅读。

  • Write-Progress 的更新频率。如果每次循环都更新进度,绘制开销有时会超过处理本身的耗时。可以每 100 件等间隔更新一次,或在无人值守执行时用 $ProgressPreference = 'SilentlyContinue' 关闭 ── 效果程度:大。每次绘制的开销较重,循环次数会直接影响结果。数万次以上的循环应优先检查这一点
  • 在中途插入 Format-Table / Format-List由于会被转换成用于格式化显示的对象,不仅会破坏后续处理,还是一项无谓的开销。显示应仅放在最后7 ── 效果程度:中。比起速度,「破坏后续处理」造成的实际损害更大,即便不是出于速度目的也值得修正
  • 循环内的不变处理。只需把 Get-Date 的格式字符串生成、正则表达式的编译、模块的重新加载等移到循环外部,有时就会很有效果 ── 效果程度:中~大。取决于被移出的那项处理单次的成本,如果混入了像模块重新加载这样开销较大的处理,效果会非常显著
  • 大量使用 Select-Object -Property由于会生成新的 PSCustomObject,在件数庞大时不可忽视。有时保留原始对象直到确实需要的阶段会更快 ── 效果程度:小~中。在数千件规模下感受不到,超过数万件之后开始起作用
  • 异常频发。大量依赖 try/catch 的设计(例如每次都对不存在的文件执行 Get-Item 并捕获异常)开销很高。请预先用 Test-Path 进行分支判断 ── 效果程度:大。异常的抛出与捕获远比普通的分支判断沉重,件数 × 异常发生率越大,影响就越占主导

8. 实务定式(判断表)

症状 怀疑之处 对策 详见
件数增加后突然变慢 $array += / $string += List[T]、用变量接收循环输出、-join1 第 3 章
两份数据的匹配迟迟无法完成 双重循环的线性搜索 哈希表 / Group-Object -AsHashTable2 第 4 章
巨大文件的读取很慢 Get-Content 的逐行对象生成 -Raw / -ReadCount / [File]::ReadLines4 第 6 章(字符编码的注意事项见第 5 章)
文件夹遍历很慢 Where-Object 进行后段筛选 Get-ChildItem -Filter,必要时用 EnumerateFiles5 第 6 章
输出文件的写入很慢 循环内的 Add-Content 一次性写入,或使用 StreamWriter6 第 6 章
CPU 空闲却迟迟不结束 网络・I/O 等待 该轮到并行化了 另一篇文章「并行处理
屏幕显示很慢 格式化处理・绘制 Format-* 只放在最后,禁用 $ProgressPreference7 第 7 章
不知道哪里慢 测量不足 Stopwatch 分区间计时,Measure-Command 取第 2 次及以后的值8 第 2 章

9. 总结

  • 首先要测量。请注意 Measure-Command 会丢弃输出、不包含显示开销,以及首次执行的数值不可用这两点。
  • $array += 会按件数的平方比例变慢。改用 List[T] 或用变量接收循环输出,是最优先要做的事。
  • 匹配的哈希表化是费效比最高的改进。发现双重循环时,请考虑能否建立索引。
  • 文件 I/O 要根据目的选择读写方式。通过 -Filter 在提供程序一侧筛选、一次性写入、StreamWriter 的使用场景划分,是基本要点。
  • 不在中途插入 Format-*、间隔更新进度、把循环内的不变处理移出去 ── 这些小的积累在件数多时也会起作用。
  • 并行化是最后的手段。请先修正算法,再将其应用于以等待时间为主导的处理。

示例代码下载

本文中涉及的代码,已整理成可以直接运行的形式发布。其中包含 3 种基准测试和计测辅助工具。

下载示例代码(zip)

本文的示例,已经在 PowerShell 7.6 中实际执行验证(Pester 8 项)。运行 zip 中包含的 Invoke-SampleTests.ps1,你也可以在自己的环境中重现同样的验证。

# 语法解析 + 静态分析 + Pester 测试
./Invoke-SampleTests.ps1

配置值(路径、服务器名、租户 ID 等)均为示例。请勿直接在生产环境中运行,请根据贵公司的环境自行替换后再使用。

相关文章

相关咨询领域

合同会社小村软件提供耗时过长的汇总・匹配处理的提速、能够承受数据量增长的处理架构改造,以及性能劣化原因调查等服务。

参考链接

  1. Microsoft Learn,about_Arrays。关于 PowerShell 的数组是固定大小的,+= 运算符的行为是创建新数组并复制现有全部元素后再追加新元素,反复进行元素的追加・删除时适合使用集合类型(如 List)等内容,以及 PowerShell 4.0 中新增的 .Where() 和 .ForEach() 方法。  2 3 4 5

  2. Microsoft Learn,Group-Object。关于通过 -AsHashTable 可以把分组结果以哈希表形式返回,通过 -AsString 可以把键作为字符串处理,以及返回的哈希表中每个值都是各分组元素组成的数组。  2 3

  3. Microsoft Learn,about_Foreach。关于 foreach 语句会先对圆括号内的表达式求值再开始迭代,因此传入命令的执行结果时,其结果会被整体保留在内存中,而 ForEach-Object cmdlet 则是从管道逐条接收输入,以及两者的使用场景划分。作为延迟枚举的示例,File.ReadLines 方法说明了无需读取整个文件即可逐行枚举(与 ReadAllLines 的区别)。  2 3

  4. Microsoft Learn,Get-Content。关于默认情况下会按换行符逐行地以对象形式返回,通过 -Raw 可以把整个文件作为一个字符串一次性读取,通过 -ReadCount 可以按指定行数汇总后送入管道等内容。  2 3

  5. Microsoft Learn,Get-ChildItem。关于 -Filter 是由提供程序在获取对象时应用的,因此比获取后再筛选的其他参数更高效,以及与 -File、-Recurse 组合使用等内容。  2 3

  6. Microsoft Learn,StringBuilder 类。关于 String 是不可变的,因此每次连接都会生成新实例,而 StringBuilder 则是在可变的缓冲区上组装字符串。同时说明了StreamWriter 类在保持流打开的状态下逐次写入的行为。  2

  7. Microsoft Learn,Format-Table。关于 Format 系列 cmdlet 会生成用于显示的格式化对象,因此不适合再通过管道传给后续命令,应当放在管道的最后使用。  2 3

  8. Microsoft Learn,Measure-Command。关于会测量脚本块或命令的执行时间并返回 TimeSpan,测量对象本身的输出不会包含在结果中等内容。同时说明了通过Stopwatch 类进行区间计时。  2 3

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

为什么 `$array += $item` 这种写法会很慢?
这是因为 PowerShell 的数组是固定长度的,无法追加元素。`+=` 运算符会被展开为「创建新数组、复制现有全部元素、在末尾追加」这样的处理。每追加一件就要执行一次全量复制,因此追加 n 件时计算量会按 n 的平方比例增长。100 件的话感觉不出来,但超过 1 万件就会慢到能明显感觉出来,10 万件的话就无法实用了。解决办法是改用 System.Collections.Generic.List[T] 的 Add,或者改成用变量接收循环整体输出的写法。
用 Measure-Command 测量出的结果,和实际的执行时间对不上。
这是因为 Measure-Command 只测量脚本块本身的执行时间,其输出会被丢弃。而在实际执行中,还会加上把结果整理并显示到屏幕上的开销(格式化处理),以及在控制台上的绘制时间。另外,首次执行中包含模块加载与 JIT 编译的时间,因此第一次的测量值大多不可信。请遵守两点:对同一处理执行数次并查看第 2 次以后的数值,比较对象要在相同条件下测量。
两个 CSV 的匹配迟迟无法完成,应该改哪里?
很可能是内层循环中每次都在用 Where-Object 进行线性搜索。如果两边各有 1 万件,比较次数就会达到 1 亿次。把其中一方转换成哈希表(或用 Group-Object -AsHashTable)改为按键引用的形式,比较次数就能降到与合计件数大致成比例的程度。件数越多效果越大,是费效比最高的改进。
听说直接调用 .NET 的 API 会比 cmdlet 更快,是否应该总是这样做?
不,这需要区分使用场景。.NET 的 API(例如 System.IO.File 的 ReadLines 或 EnumerateFiles)确实速度更快,但会失去 PowerShell 的提供程序功能、通配符、相对路径解析等便利性,代码也会变得难以阅读。请先测量,只替换实际确认为瓶颈的地方。把使用范围限定在数十万文件的遍历、巨大文件的读取等明确有效的场景中,才是实务上的做法。
把处理并行化就能变快吗?
如果是以等待时间为主导的处理,并行化会有效果,但按顺序来说它应该放在最后。请先做减少无谓处理的工作(缩小获取件数、把不变的处理从循环中移出去、把匹配哈希表化)。如果算法本身仍是 O(n^2),即使并行化,最多也只能按核心数的倍数变快。反过来,如果对单件很轻量的处理进行并行化,有时反而会因为开销而变慢。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表