本文目录
PowerShell性能优化系列文章
- PowerShell优化和性能测试
- 让你的PowerShell For循环提速四倍
- 优化PowerShell的性能和内存消耗
- PowerShell提速和多线程
- 优化PowerShell脚本的几个小技巧
- 轻量级的PowerShell性能测试
本篇文章本是源自PowerShell.Com上的一个教程视频,讲师为Dr. Tobias Weltner。有时间的朋友可以直接去看英文视频。我英文水平有限,还没到达单靠纯听力就能给视频加中文字幕的能力,所以就把原视频中的观点与例子分享出来。
概述
我们平时写脚本时,经常会提醒自己要多使用管道,要多使用流模式,少占内存,少占CPU。但是这篇文章会反其道而行之,少用管道,通过内存和CPU的占用来提高效率,也就是我们通常算法上说的用空间来换取时间。机器配置高,有的用,而不用就是浪费。
比如下面的场景:
- 写一个大文件可能需要3.6分钟,提高性能后,只需3秒钟。
- 读一个大文件可能需要77秒钟,提高性能后,只需2秒钟。
- 检查250台机器的是否在线,需要23.2分钟,提高性能后,只需26秒钟。
这一切性能的提升都是有偿的,需要你额外的投资。
投资更多内存
在PowerShell中推崇的管道主要是为了限制内存的使用量,让管道中的元素像流水线中的零件或者半成品一样,从车间一个一个穿过。但是管道并不是最快的,且看下面的随机数的例子。
随机数的例子
#这个很快 PS> 1..100 |Get-Random 90 #这个稍有延迟,可以容忍 PS> 1..10000 |Get-Random 4868 #这个慢的受不了,所以直接Ctrl-C取消。 PS> 1..1000000 |Get-Random
是不是就意味着1000000这么大的一个数组Get-Random天生就这么慢,非也。换个方法:
#这样做,快的一塌糊涂啊 PS> Get-Random -InputObject (1..1000000) 317486
原因是前者使用了管道,产生一条数据,流过一条数据。而后者是直接一次性产生全部数据,然后交给Get-Random,所以快。
写文件的例子
$all = @('Some Test' * 20)*1000000
$file = "$Env:TEMP\testfile.txt"
$all | Out-File $file
上面的脚本执行大概需要215 秒钟(3.6分钟)
换个方式,使用inputObject,仅用了101秒钟(1.8分钟)
Out-File $file -InputObject $all
是不是优化就止于此了呢,不,下面的结果会亮瞎我的眼睛啊,只需要2.5秒钟。
[Io.file]::WriteAllLines( $file, $all,[text.encoding]::Unicode)
真真没想到直接调用.NET方法差别会这么大,但是我用的是PowerShell,你给我整.NET方法,用键盘敲起来未免坑爹啊,那就试试Set-Content吧,只需要3.1秒钟。
Set-Content $file -Value $all -Encoding Unicode
与WriteAllLine相比,稍慢,也可以忍受。
通过这个写文件的例子对比不难发现,3.6分钟/3.1秒钟=70,,速度几乎提高了70倍啊。
总结一下,写文件时注意两点即可:
- 不要使用管道。
- 不要使用神马的Out-这样的命令(因为它会做格式化)
读文件的例子
对于我们刚才创建的文本文件,一般读取时,我们习惯使用Get-Content,需要77秒钟(1.3分钟)
Get-Content $file
而如果使用.NET方法,只需1.8秒钟。
[io.file]::ReadAllLines($file)
Get-Content为什么会这么慢,因为它要一行一行来读,并把读取的数据存储成数组,所以慢。但是Get-Content有一个参数ReadCount,把值设为0,一次性全部读取,只需2.2秒钟。
Get-Content $file -ReadCount 0
把文件读出来一般是需要处理的,如果这样肯定不是你预期的,因为只会输出一个X:
Get-Content $file -ReadCount 0 | foreach {"X" }
这样就对了,只需要2.7秒钟。
$text = Get-Content $file -ReadCount 0
foreach ($line in $text) {'X'}
千万不要多次一举,引入管道,得68秒钟啊:
$text = Get-Content $file -ReadCount 0 | foreach {"X" }
$text | ForEach-Object {'X'}
警告
- cmdlet明显非常慢
- .NET一些底层的方法相对较快
解药
- 不要轻易引入管道。
- 尽量使用传统的For或者foreach循环
如果你还不相信,请继续看例子。
多用循环,少用管道的例子
1..1000000 | ForEach-Object { "looping for the $_ Time"}
上面使用管道,执行时间为6.9秒钟。如果换成简单的For循环,只需要0.5秒钟,速度提高了14倍。
For ( $x=1; $x -le 100000;$x++)
{
"Looping for the $x. time"
}
再看一个抑制输出的例子,三个写法效果一样,速度相差几十倍。
#耗时0.1毫秒 'Hello' |out-null #耗时 0.002毫秒,速度提高了56倍 $null= 'Hello' #耗时0.0025毫秒,速度也很快 [void] 'Hello'
投资更多CPU
PowerShell默认是单线程的执行的,只能一行命令接着一行命令来执行。我们可以使用PowerShell的后台Job来提高效率:对于批量任务,启用多个后台任务去处理,使用wait-job等待所有的任务结束。
先看一个顺序执行的例子。
$start = Get-Date
$code1 = { Start-Sleep -Seconds 5; 'A' }
$code2 = { Start-Sleep -Seconds 6; 'B'}
$code3 = { Start-Sleep -Seconds 7; 'C'}
$result1,$result2,$result3= (& $code1),(& $code2),(& $code3)
$end =Get-Date
$timespan= $end - $start
$seconds = $timespan.TotalSeconds
Write-Host "总耗时 $seconds 秒."
Write-Host "三个脚本块总共延时 18 秒"
输出为(耗时18秒钟):
总耗时 18.0240865 秒. 三个脚本块总共延时 18 秒
同样的任务,使用后台Job多线程执行:
$start = Get-Date
$code1 = { Start-Sleep -Seconds 5; 'A' }
$code2 = { Start-Sleep -Seconds 6; 'B'}
$code3 = { Start-Sleep -Seconds 7; 'C'}
$job1 = Start-Job -ScriptBlock $code1
$job2 = Start-Job -ScriptBlock $code2
$job3 = Start-Job -ScriptBlock $code3
$alljobs = Wait-Job $job1,$job2,$job3
$result1,$result2,$result3 = Receive-Job $alljobs
$end =Get-Date
$timespan= $end - $start
$seconds = $timespan.TotalSeconds
Write-Host "总耗时 $seconds 秒."
Write-Host "三个脚本块总共延时 18 秒"
输出为(耗时10秒钟):
总耗时 10.3778469 秒. 三个脚本块总共延时 18 秒
效率提升很明显。
使用后台Job的开销
- 每一个新的任务执行时都会使用一个新PowerShell进程。(所以所谓的多线程并不是真正的多线程,而是工作在进程级别上)
- 每一个任务的结果需要序列化后,跨进程传递给调度的主进程。
- 没有节流机制(所以要注意控制后台Job的数量)。
计算后台Job的开销
使用这段示例脚本:
# (C) 2012 Dr. Tobias Weltner
# you may freely use this code for commercial or non-commercial purposes at your own risk
# as long as you credit its original author and keep this comment block.
# For PowerShell training or PowerShell support, feel free to contact tobias.weltner@email.de
$code = {
$begin = Get-Date
$result = Get-Process
$end = Get-Date
$begin
$end
# play here by reducing the returned data,
# i.e. use select-object to pick specific properties:
$result
}
$start = Get-Date
$job = Start-Job -ScriptBlock $code
$null = Wait-Job $job
$completed = Get-Date
$result = Receive-Job $job
$received = Get-Date
$spinup = $result[0]
$exit = $result[1]
$timeToLaunch = ($spinup - $start).TotalMilliseconds
$timeToExit = ($completed - $exit).TotalMilliseconds
$timeToRunCommand = ($exit - $spinup).TotalMilliseconds
$timeToReceive = ($received - $completed).TotalMilliseconds
'{0,-30} : {1,10:#,##0.00} ms' -f 'Time to set up background job', $timeToLaunch
'{0,-30} : {1,10:#,##0.00} ms' -f 'Time to run code', $timeToRunCommand
'{0,-30} : {1,10:#,##0.00} ms' -f 'Time to exit background job', $timeToExit
'{0,-30} : {1,10:#,##0.00} ms' -f 'Time to receive results', $timeToReceive
脚本会在后台的Job的启动,运行,结束和接受数据每个阶段设置时间戳,然后计算各个阶段耗费的时间。
第一次运行时,输出结果为:
Time to set up background job : 270.01 ms Time to run code : 10.00 ms Time to exit background job : 1,550.06 ms Time to receive results : 10.00 ms
主要的延迟在Job退出时,因为上面的Job返回了大量的数据,假如我们注释掉上面示例代码的第15行,再执行一遍:
Time to set up background job : 350.01 ms Time to run code : 10.00 ms Time to exit background job : 10.00 ms Time to receive results : 0.00 ms
执行效率明显提高,但是绝大多数的后台Job应当都会返回数据的,哪怕返回一点,所以我们把上面脚本的第15改成这样,再执行一遍:
$result | select-object Name,CPU
Time to set up background job : 420.02 ms Time to run code : 10.00 ms Time to exit background job : 100.01 ms Time to receive results : 0.00 ms
稍有延迟,可以忍受。
结论
通过这个例子主要是告诉大家,影响后台任务的关键因素是返回的数据量,如果没有特别的需求,尽量在后台Job中不返回数据,或者少返回数据。
从进程间迁移到进程内
正如后台Job为用户所诟病的那样,它不是真正的多线程,而是多进程。所以下面我们开始从进程间迁移到进程内,使用实至名归的PowerShell多线程,因为它会在PowerShel.exe内部创建一个新的线程。
使用进程内多线程的优点
- 不需要新的宿主进程
- 不需要序列化结果
- 线程内通信方便
- 运行空间池提供了自动内存节流
开启一个线程
先看一个简单的在PowerShell中开启一个同步线程的例子
PS> # Running New Thread Synchronously:
PS> $code = { Start-Sleep -Seconds 2; "Hello" }
PS> $newPowerShell = [PowerShell]::Create().AddScript($code)
PS> $newPowerShell.Invoke()
Hello
让线程异步运行
稍加改动,使用BeginInvoke()异步执行,使用EndInvoke()返回线程的数据:
$code = {Start-Sleep -Seconds 2; "Hello"}
$newPowerShell = [PowerShell]::Create().AddScript($code)
$handle = $newPowerShell.BeginInvoke()
while ($handle.IsCompleted -eq $false) {
Write-Host '.' -NoNewline
Start-Sleep -Milliseconds 500
}
Write-Host ''
$newPowerShell.EndInvoke($handle)
输出示例,先有原点的进度条
PS> ..... Hello
演示一个进度提示器
function Start-Progress {
param(
[ScriptBlock]
$code
)
$newPowerShell = [PowerShell]::Create().AddScript($code)
$handle = $newPowerShell.BeginInvoke()
while ($handle.IsCompleted -eq $false) {
Write-Host '.' -NoNewline
Start-Sleep -Milliseconds 500
}
Write-Host ''
$newPowerShell.EndInvoke($handle)
$newPowerShell.Runspace.Close()
$newPowerShell.Dispose()
}
记得要在运行空间使用结束后,调用Close和Dispose方法释放资源。
先显示进度信息,然后返回结果。
PS> Start-Progress -code {Get-HotFix}
..
Source Description HotFixID InstalledBy InstalledOn
------ ----------- -------- ----------- -----------
ETS-V-TEST-01 Update KB2899189_... NT AUTHORITY\SYSTEM 5/14/2014 12:00:00 AM
ETS-V-TEST-01 Update KB2919355 ETS-V-TEST-01\Adm... 3/18/2014 12:00:00 AM
ETS-V-TEST-01 Update KB2919442 ETS-V-TEST-01\Adm... 3/18/2014 12:00:00 AM
ETS-V-TEST-01 Security Update KB2920189 NT AUTHORITY\SYSTEM 5/14/2014 12:00:00 AM
ETS-V-TEST-01 Security Update KB2926765 NT AUTHORITY\SYSTEM 5/15/2014 12:00:00 AM
ETS-V-TEST-01 Security Update KB2931366 NT AUTHORITY\SYSTEM 5/14/2014 12:00:00 AM
这个执行起来太快了,换一个慢一点的命令,效果更明显。
PS> Start-Progress -code {Get-WmiObject -Class Win32_product}
................................................
IdentifyingNumber : {90150000-0015-0409-0000-0000000FF1CE}
Name : Microsoft Access MUI (English) 2013
Vendor : Microsoft Corporation
Version : 15.0.4569.1506
Caption : Microsoft Access MUI (English) 2013
IdentifyingNumber : {90150000-0115-0409-0000-0000000FF1CE}
Name : Microsoft Office Shared Setup Metadata MUI (English) 2013
Vendor : Microsoft Corporation
Version : 15.0.4569.1506
Caption : Microsoft Office Shared Setup Metadata MUI (English) 2013
演示定时炸弹
function Start-Timebomb {
param(
[Int32]
$Seconds,
[ScriptBlock]
$Action = { Stop-Process -Id $PID }
)
$Wait = "Start-Sleep -seconds $seconds"
$script:newPowerShell = [PowerShell]::Create().AddScript($Wait).AddScript($Action)
$handle = $newPowerShell.BeginInvoke()
Write-Warning "Timebomb is active and will go off in $Seconds seconds unless you call Stop-Timebomb before."
}
function Stop-Timebomb {
if ( $script:newPowerShell -ne $null) {
Write-Host 'Trying to stop timebomb...' -NoNewline
$script:newPowerShell.Stop()
$script:newPowerShell.Runspace.Close()
$script:newPowerShell.Dispose()
Remove-Variable newPowerShell -Scope script
Write-Host 'Done!'
} else {
Write-Warning 'No timebomb found.'
}
}
在控制台上开启了定时炸弹后,如果没有及时停止,倒计时结束后,控制台会自动关闭。
PS> Start-Timebomb -Seconds 10 WARNING: Timebomb is active and will go off in 10 seconds unless you call Stop-Timebomb before.
监控脚本的执行时间
如果你想让倒计时的信息显示在控制台的标题栏,只需要修改上面的脚本第10行,修改成:
$Wait = "1..$seconds | foreach-object {start-sleep -seconds 1; [console]::Title=""`$($Seconds-`$_) seconds remaining`"}"
监控脚本的运行内存
如果一个脚本运行时,占用的内存超过了限制,就自动终结掉这个进程。
function Start-TimebombMemory {
param(
[Int32]
$MemoryMB=30,
[ScriptBlock]
$Action = { Stop-Process -Id $PID }
)
$Wait = '
$initial = (Get-Process -Id $PID).WorkingSet
$threshold = (XXX * 1MB)
do {
$memory = ((Get-Process -Id $PID).WorkingSet - $initial)
Start-Sleep -Seconds 1
[system.Console]::Title = ("Current Memory Load: {0:0.00} MB. Threshold: XXX MB" -f ($memory/1MB))
} while ($memory -lt $threshold)
$message1 = "Shell is using {0:0.0} MB which is exceeding the threshold by {1:0.0} MB." -f ($memory/1MB), (($memory-$threshold)/1MB)
$message2 = "Shell will be aborted in 5 seconds. There is nothing you can do about it, sorry."
[System.Console]::WriteLine($message1)
[System.Console]::WriteLine($message2)
Start-Sleep -Seconds 5
' -replace 'XXX', $MemoryMB
$script:newPowerShellMB = [PowerShell]::Create().AddScript($Wait).AddScript($Action)
$handle = $newPowerShellMB.BeginInvoke()
Write-Warning "Timebomb is active and will go off when the shell uses more than $memoryMB MB - unless you call Stop-Timebomb before."
}
function Stop-TimebombMemory {
if ( $script:newPowerShellMB -ne $null) {
Write-Host 'Trying to stop timebomb...' -NoNewline
$script:newPowerShellMB.Stop()
$script:newPowerShellMB.Runspace.Close()
$script:newPowerShellMB.Dispose()
Remove-Variable newPowerShellMB -Scope script
Write-Host 'Done!'
} else {
Write-Warning 'No timebomb found.'
}
}
执行了Start-TimebombMemory后,会在PowerShell的控制台动态显示当前PowerShell进程占用的内存和阈值,如果内存占用超标,打印信息提示用户,并在5秒钟后自动关闭当前进程。
创建一个STA模式的线程
你写了一个函数,调用winform的OpenFileDialog来打开文件选择对话框。很不幸如果当前的控制台运行在MTA模式下,则对话框不能显示。所以为了增强兼容性,给你的函数单独指定一个线程运行,因为在运行空间中可以指定Apartment State。具体看下面的代码:
function Show-OpenFileDialog {
param(
[string]$Title='Select a file',
[string]$Path=$home,
[string]$Filter = "All Files (*.*)|*.*"
)
$code = {
param(
[string]$Title,
[string]$Path,
[string]$Filter = "All Files (*.*)|*.*"
)
Add-Type -AssemblyName System.Windows.Forms
$DialogOpen = New-Object System.Windows.Forms.OpenFileDialog
$DialogOpen.InitialDirectory = $Path
$DialogOpen.Filter = $Filter
$DialogOpen.Title = $Title
$Result = $DialogOpen.ShowDialog()
if ($Result -eq "OK")
{
$DialogOpen.FileName
}
}
$newRunspace = [RunSpaceFactory]::CreateRunspace()
$newRunspace.ApartmentState = 'MTA'
$newRunspace.Open()
$newPowerShell = [PowerShell]::Create()
$newPowerShell.Runspace = $newRunspace
[void]$newPowerShell.AddScript($code).AddArgument($Title).AddArgument($Path).AddArgument($Filter)
$newPowerShell.Invoke()
$newPowerShell.Runspace.Close()
$newPowerShell.Dispose()
}
多线程中的关键组件
- PowerShell:代表线程
- RunSpace:代表Powershell会话
- BeginInvoke():返回等待的句柄
- EndInvoke():返回结果对象
- MTA和STA模式可以完全控制
- 每次执行完毕后,记得释放RunSpace,销毁线程。
启用节流
- 创建一个RunSpace 池
- 使用RunSpace池代替Runspace
- 在池中控制活动的RunSpace个数
演示简单的运行空间池
限定活动的线程最多为5,这样当尝试开启40个线程时,并不是一下子开启,而是排队等候空闲的线程池,每次最多只能有5个活动的线程池。
$throttleLimit = 5
$iss = [system.management.automation.runspaces.initialsessionstate]::CreateDefault()
$Pool = [runspacefactory]::CreateRunspacePool(1, $throttleLimit, $iss, $Host)
$Pool.Open()
$ScriptBlock = {
param($id)
Start-Sleep -Seconds 2
[System.Console]::WriteLine("Done processing ID $id")
}
for ($x = 1; $x -le 40; $x++) {
$powershell = [powershell]::Create().AddScript($ScriptBlock).AddArgument($x)
$powershell.RunspacePool = $Pool
$handle = $powershell.BeginInvoke()
}
从多线程中接受数据
$throttleLimit = 4
$SessionState = [system.management.automation.runspaces.initialsessionstate]::CreateDefault()
$Pool = [runspacefactory]::CreateRunspacePool(1, $throttleLimit, $SessionState, $Host)
$Pool.Open()
$ScriptBlock = {
param($id)
Start-Sleep -Seconds 2
"Done processing ID $id"
}
$threads = @()
$handles = for ($x = 1; $x -le 40; $x++) {
$powershell = [powershell]::Create().AddScript($ScriptBlock).AddArgument($x)
$powershell.RunspacePool = $Pool
$powershell.BeginInvoke()
$threads += $powershell
}
do {
$i = 0
$done = $true
foreach ($handle in $handles) {
if ($handle -ne $null) {
if ($handle.IsCompleted) {
$threads[$i].EndInvoke($handle)
$threads[$i].Dispose()
$handles[$i] = $null
} else {
$done = $false
}
}
$i++
}
if (-not $done) { Start-Sleep -Milliseconds 500 }
} until ($done)
声明:本文所有观点,图片,示例脚本引用自 Dr. Tobias Weltner的视频教程。感谢 Dr. Tobias Weltner!
示例脚本出处:demofiles_multithreading
视频出处:Speeding Up PowerShell (网盘:密码: 7w21 )
请尊重原作者和编辑的辛勤劳动,欢迎转载,并注明出处!




广播: PowerShell优化和性能测试 | PowerShell 中文博客
广播: 让你的PowerShell For循环提速四倍 | PowerShell 中文博客
大赞!
非常棒,这篇文章今年看,依然让我受益匪浅
谢谢,非常有用
很有用哦!
PS C:\Users\******> $all = (‘Some Test’ * 5)*3
PS C:\Users\******> $all
Some TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome TestSome Test
PS C:\Users\******> $all = @(‘Some Test’ * 5)*3
PS C:\Users\******> $all
Some TestSome TestSome TestSome TestSome Test
Some TestSome TestSome TestSome TestSome Test
Some TestSome TestSome TestSome TestSome Test
↑原来带不带@效果是不一样的
Get-Content $file -ReadCount 0
这里面的 “-ReadCount 0” 并不好使,还是按行读的,跟没有这个parameter是一样的。