从 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。
- 修复了损坏的链接。




