从 C# 异步执行 PowerShell 脚本
如何从 C# 托管和异步运行 PowerShell 脚本。

引言
我之前的文章展示了如何从 C# 运行 PowerShell 脚本。但该实现有一个局限性,那就是它会同步执行脚本,直到脚本完成其工作才返回。这对于运行时间短的脚本来说是可以的,但如果你有运行时间长甚至永不结束的脚本,你就需要异步执行。本文将介绍如何做到这一点。
基本步骤
以下是异步运行 PowerShell 脚本的基本步骤:
- 通过调用
Runspace.CreatePipeline()
来创建一个Pipeline
实例。 - 使用
pipeline.Commands.AddScript()
将脚本传递给Pipeline
实例。 - 使用
pipeline.Input.Write()
向Pipeline
馈送输入对象。 - 通过调用
pipeline.Input.Close()
来关闭输入。 - 调用
pipeline.InvokeAsync()
;这将导致Pipeline
创建一个工作线程,在后台执行脚本。 - 开始读取
pipeline.Output
以获取脚本的结果,直到pipeline.Output.EndOfPipeline
变为true
。
有两种方法可以获得新输出数据可用性的通知:
- 第一种方法是使用类型为
System.Threading.WaitHandle
的pipeline.Output.WaitHandle
属性。此句柄可与System.Threading.WaitHandle
的各种static Wait***()
方法一起使用,以等待新数据到达。 - 第二种方法是,
pipeline.Output
有一个名为DataReady
的事件。通过订阅此事件,PowerShell 后台线程将在每次有新数据可用时调用您。
Output.WaitHandle 的问题
乍一看,Output.WaitHandle
似乎是检索脚本输出数据的一个不错的选择;它在生产者(PowerShell 线程)和消费者(输出读取线程)之间提供了完全的分离,与直接从 PowerShell 线程调用的 DataReady
事件不同。但有一个问题:如果消费者处理输出的速度不够快,生产者将继续全速生成数据,直到耗尽你**所有**内存,或者脚本结束。虽然看起来输出队列中提供了限制最大内存使用量的机制(有一个只读属性 MaxCapacity
),但我还没有找到实际设置此限制的方法。
你可能会想,为什么消费者处理输出的速度会太慢呢?嗯,如果你使用 PowerShell 脚本来自动化 C# 程序的一些方面,你很可能会想在用户界面上以某种方式显示脚本的输出。PowerShell 脚本的输出很容易就会超过任何 GUI 的刷新能力。
准备处理 DataReady
由于 DataReady
事件直接从 PowerShell 线程调用,它允许我们将 PowerShell 的处理速度减慢到刚好匹配消费者吞吐量的程度。当然,这会降低 PowerShell 脚本的速度,但我认为在这方面,这是两害相权取其轻。
PipelineExecutor
以上所有步骤都已封装在一个名为 PipelineExecutor
的辅助类中。此类可帮助您轻松地在后台运行 PowerShell 脚本,并提供接收脚本输出数据的事件。它还将数据“调用”到正确的线程,因此当事件到达时,您不再需要执行令人讨厌的“InvokeRequired
”例程来显示数据。以下是该类的 public
接口:
/// Class that assists in asynchronously executing
/// and retrieving the results of a powershell script pipeline.
public class PipelineExecutor
{
/// Gets the powershell Pipeline associated with this PipelineExecutor
public Pipeline Pipeline
{
get;
}
public delegate void DataReadyDelegate(PipelineExecutor sender,
ICollection<psobject> data);
public delegate void DataEndDelegate(PipelineExecutor sender);
public delegate void ErrorReadyDelegate(PipelineExecutor sender,
ICollection<object> data);
/// Occurs when there is new data available from the powershell script.
public event DataReadyDelegate OnDataReady;
/// Occurs when powershell script completed its execution.
public event DataEndDelegate OnDataEnd;
/// Occurs when there is error data available.
public event ErrorReadyDelegate OnErrorRead;
/// Constructor, creates a new PipelineExecutor for the given powershell script.
public PipelineExecutor
(Runspace runSpace, ISynchronizeInvoke invoker, string command);
/// Start executing the script in the background.
public void Start();
/// Stop executing the script.
public void Stop();
}
Using the Code
以下代码展示了如何使用 PipelineExecutor
创建和异步运行 PowerShell 脚本。
...
using System.Collections.ObjectModel;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using Codeproject.PowerShell
...
// create Powershell runspace
Runspace runSpace = RunspaceFactory.CreateRunspace();
// open it
runSpace.Open();
// create a new PipelineExecutor instance
// 'this' is the form that will show the output of the script.
// it is needed to marshal the script output data from the
// powershell thread to the UI thread
PipelineExecutor pipelineExecutor =
new PipelineExecutor(runSpace, this, textBoxScript.Text);
// listen for new data
pipelineExecutor.OnDataReady +=
new PipelineExecutor.DataReadyDelegate(pipelineExecutor_OnDataReady);
// listen for end of data
pipelineExecutor.OnDataEnd +=
new PipelineExecutor.DataEndDelegate(pipelineExecutor_OnDataEnd);
// listen for errors
pipelineExecutor.OnErrorReady +=
new PipelineExecutor.ErrorReadyDelegate(pipelineExecutor_OnErrorReady);
// launch the script
pipelineExecutor.Start();
终止脚本和清理
pipelineExecutor.OnDataReady -=
new PipelineExecutor.DataReadyDelegate(pipelineExecutor_OnDataReady);
pipelineExecutor.OnDataEnd -=
new PipelineExecutor.DataEndDelegate(pipelineExecutor_OnDataEnd);
pipelineExecutor.OnErrorReady -=
new PipelineExecutor.ErrorReadyDelegate(pipelineExecutor_OnErrorReady);
pipelineExecutor.Stop();
// close the powershell runspace
runSpace.Close();
请注意,要编译示例项目,您首先需要安装 PowerShell 和 Windows Server 2008 SDK。有关详细信息,请参阅我的上一篇文章。
错误处理
在执行 PowerShell 脚本期间,您可能会遇到两种错误:
- 在 PowerShell 脚本执行期间发生,但不会导致脚本终止的错误。
- 无效 PowerShell 语法导致的致命错误。
第一种错误将进入错误管道。您可以通过向 PipelineExecutor.OnErrorReady
添加事件处理程序来监听这些错误。
要检测和显示第二种错误(致命语法错误),您需要在 OnDataEnd
事件处理程序中检查 Pipeline.PipelineStateInfo.State
属性。如果此值设置为 PipelineState.Failed
,则 Pipeline.PipelineStateInfo.Reason
属性将包含一个异常对象,其中包含有关错误原因的详细信息。以下代码片段展示了在示例项目中如何做到这一点:
// OnDataEnd event handler
private void pipelineExecutor_OnDataEnd(PipelineExecutor sender)
{
if (sender.Pipeline.PipelineStateInfo.State == PipelineState.Failed)
{
AppendLine(string.Format("Error in script: {0}", sender.Pipeline.PipelineStateInfo.Reason));
}
else
{
AppendLine("Ready.");
}
}
如果您想看到错误处理的实际效果,请在示例项目中执行“错误处理演示”脚本。
关注点
对于那些对 PipelineExecutor
内部工作原理感兴趣的人,我想指出 private StoppableInvoke
方法。它会等待目标线程处理数据,从而实现对 PowerShell 脚本的节流效果。它还避免了如果我直接使用 ISynchronizeInvoke.Invoke
可能发生的潜在死锁问题,因为它可以通过 ManualResetEvent
来中断。这种模式在其他工作线程到 UI 的通知场景中可能很有用。
我还致力于通过将 StoppableInvoke
的 'BeginInvoke
' 和 'EndInvoke
' 阶段分离到后续的 DataReady
周期来提高脚本执行的性能,这效果非常好……但是最终的输出性能与消费者性能太接近了,导致用户界面出现延迟。我的理论是,.NET 倾向于优先处理 Invoke
消息,然后再处理 UI 更新消息。因此,如果你接近 100% 的消息循环性能,它就会耗尽 UI 更新。解决这个问题可能需要另写一篇文章。
历史
- 2007 年 4 月 15 日
- 首次发布
- 2008 年 8 月 29 日
- 添加了关于错误处理的段落。
- 将项目转换为 Visual Studio 2008。
- 修复了损坏的链接。