65.9K
CodeProject 正在变化。 阅读更多。
Home

从 C# 异步执行 PowerShell 脚本

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.81/5 (30投票s)

2007年4月14日

CPOL

5分钟阅读

viewsIcon

392462

downloadIcon

6147

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

Screenshot - AsyncPowerShell_scr.png

引言

之前的文章展示了如何从 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.WaitHandlepipeline.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。
    • 修复了损坏的链接。
© . All rights reserved.