Skip to content

Instantly share code, notes, and snippets.

@rjmholt
Last active June 16, 2022 05:36
Show Gist options
  • Save rjmholt/02fe49189540acf0d2650f571f5176db to your computer and use it in GitHub Desktop.
Save rjmholt/02fe49189540acf0d2650f571f5176db to your computer and use it in GitHub Desktop.
Examples of how to run PowerShell with a runspace pool and async Tasks
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Reflection;
using System.Threading.Tasks;
/// <summary>
/// Encapsulates a simple way to run PowerShell commands and scripts
/// through a runspace pool without needing to expose that runspace pool.
///
/// Each instance of this class will have its own runspace pool,
/// and any PowerShell commands run through it will run in that runspace pool.
/// This guarantees a maximum footprint and parallelism level for PowerShell commands.
/// It also means you have a single place to decide your runspace/thread semantics,
/// since you can reconfigure your runspace pool easily.
///
/// This class has a lot of methods that are just examples
/// of how to use it in specific cases and how to use the PowerShell/PSCommand APIs.
/// If you're implementing this in your own code,
/// consider either providing only the generic methods that accept a PSCommand
/// or only providing concrete methods that implement required functionality for your application.
/// Ideally you should do both: implement the generic task runner,
/// and then use it within your domain-oriented class that performs concrete operations.
/// </summary>
public class PowerShellTaskRunner
{
public static PowerShellTaskRunner Create()
{
// Use the ISS to set up the starting state you want
// for runspaces in the runspace pool
var iss = InitialSessionState.CreateDefault();
// This call takes an array of module paths or names
// if you want to import multiple modules, put them into an array and call this API once
iss.ImportPSModule(new [] { s_pathToModuleToLoad });
// Mysteriously there's no overload here that allows you to set both the ISS
// and the min/max runspace count without also providing a host.
// But the default host is the internal DefaultHost.
// So the best way to set the things we want without needing to provide a host is like this:
RunspacePool rsPool = RunspaceFactory.CreateRunspacePool(iss);
rsPool.SetMinRunspaces(1);
rsPool.SetMaxRunspaces(5);
return new PowerShellTaskRunner(rsPool);
}
// A common requirement is to load some module that ships with the current one,
// or just a module from somewhere on the machine.
// The most efficient (and debuggable) way to do that is by absolute path.
// That's easy if it's in a well-known location,
// but here's a way to get it relative to your module DLL's location.
private static readonly string s_pathToModuleToLoad = Path.GetFullPath(
Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
"..",
"..",
"Modules",
"MyCoolModule"));
private readonly RunspacePool _runspacePool;
protected PowerShellTaskRunner(RunspacePool runspacePool)
{
_runspacePool = runspacePool;
}
// This method runs "Write-Host -Object 'Hello!' synchronously in a runspace in the runspace pool
// (This won't actually do anything, since DefaultHost doesn't write anywhere)
/// <summary>
/// Runs:
/// Write-Host -Object 'Hello!'
///
/// Synchronous -- so the caller must wait for the command to complete.
///
/// (This command doesn't actually do anything other than use CPU,
/// since DefaultHost doesn't write anywhere.)
/// </summary>
public void RunDefaultCommand()
{
using (PowerShell pwsh = GetPowerShellInstance())
{
pwsh.AddCommand("Write-Host")
.AddParameter("Object", "Hello!")
.Invoke();
}
}
/// <summary>
/// Synchronously runs:
/// Get-Module -ListAvailable | Out-String
/// </summary>
/// <returns>Lines of the formatted output of Get-Module -ListAvailable</returns>
public Collection<string> RunGetModuleListCommand()
{
using (PowerShell pwsh = GetPowerShellInstance())
{
// Note that AddCommand()...AddCommand() works like piping the first command to the second
return pwsh
.AddCommand("Get-Module")
.AddParameter("ListAvailable")
.AddCommand("Out-String")
.Invoke<string>();
// Invoke<T>() will give you back a strongly typed collection
// which is usually preferable in C#
}
}
/// <summary>
/// Runs:
/// Get-Module
///
/// This is run asynchronously from the perspective of the caller,
/// so the caller can call this method, and then await the Task later.
/// The actual computation will run synchronously on the Task threadpool,
/// but it effectively must run synchronously *somewhere*.
/// </summary>
/// <returns>The modules loaded in the runspace used by the runspace pool.</returns>
public Task<Collection<PSModuleInfo>> RunGetModuleAsync()
{
return Task.Run(() =>
{
using (PowerShell pwsh = GetPowerShellInstance())
{
return pwsh.AddCommand("Get-Module")
.Invoke<PSModuleInfo>();
}
});
}
/// <summary>
/// Runs:
/// Get-Module
///
/// This is run asynchronously for the caller,
/// but internally also uses PowerShell's own asynchronous API.
/// In this example we use .NET's own transformer method,
/// which takes care of some things for us,
/// but PowerShell's API makes it a bit cumbersome.
/// </summary>
/// <remarks>
/// Also have a read of https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/interop-with-other-asynchronous-patterns-and-types.
/// </remarks>
/// <returns></returns>
public Task<Collection<PSModuleInfo>> RunGetModuleAsync_Alternate1()
{
// Create our PowerShell instance, but remember we must dispose of it later
PowerShell pwsh = GetPowerShellInstance();
var settings = new PSInvocationSettings
{
// Add settings here
};
// Use .NET's helpful method for adapting older APM APIs to newer TAP ones
return Task.Factory.FromAsync(
(callback, state) => pwsh.BeginInvoke(new PSDataCollection<PSObject>(), settings, callback, state),
pwsh.EndInvoke,
state: null)
.ContinueWith(runTask =>
{
// Dispose of PowerShell now
pwsh.Dispose();
// Because of PowerShell's old old BeginInvoke/EndInvoke overloads return unhelpful types,
// we're forced to do a conversion here
var results = new List<PSModuleInfo>();
foreach (PSObject obj in runTask.Result)
{
results.Add((PSModuleInfo)obj.BaseObject);
}
return new Collection<PSModuleInfo>(results);
});
}
/// <summary>
/// Runs:
/// Get-Module
///
/// This is run asynchronously for the caller,
/// but also uses PowerShell's own async APIs internally, as before.
/// This time though, we don't use .NET's transformer method,
/// and do things ourselves.
/// This means no ContinueWith complexity,
/// but instead means we must implement the async callback
/// and TaskCompletionSource ourselves.
/// </summary>
/// <remarks>
/// Again look at https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/interop-with-other-asynchronous-patterns-and-types.
/// </remarks>
/// <returns></returns>
public Task<Collection<PSModuleInfo>> RunGetModuleAsync_Alternate2()
{
var completionSource = new TaskCompletionSource<Collection<PSModuleInfo>>();
PowerShell pwsh = GetPowerShellInstance();
var settings = new PSInvocationSettings
{
// Any settings go here
};
// Kick off the async call with a callback
pwsh.BeginInvoke(new PSDataCollection<PSObject>(), settings, (IAsyncResult iar) =>
{
try
{
// End the call here.
// This synchronously awaits the result on whichever thread is running the callback.
// But as above, we have to do this *somewhere*.
PSDataCollection<PSObject> results = pwsh.EndInvoke(iar);
// Now transform the results
var list = new List<PSModuleInfo>();
foreach (PSObject result in results)
{
list.Add((PSModuleInfo)result.BaseObject);
}
var collection = new Collection<PSModuleInfo>(list);
// Actually set the task result here
completionSource.TrySetResult(collection);
}
// Handle cancellation and exceptions here
catch (OperationCanceledException)
{
completionSource.TrySetCanceled();
}
catch (Exception e)
{
completionSource.TrySetException(e);
}
// We ensure we always dispose of the PowerShell instance
finally
{
pwsh.Dispose();
}
}, state: null);
return completionSource.Task;
}
/// <summary>
/// Runs the given PSCommand synchronously in our runspace pool.
///
/// This example separates out the command definition from the invocation
/// by taking the command in the form of a PSCommand instead.
///
/// Note that there are some issues with PSCommand:
/// - There's no CommandInfo overload, so you can't use a cmdlet type directly without reflection
/// - There's an issue where the cloning of the PSCommand object isn't done properly: https://github.com/PowerShell/PowerShell/issues/12297
/// </summary>
/// <param name="psCommand"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public Collection<T> RunCommand<T>(PSCommand psCommand)
{
using (PowerShell pwsh = GetPowerShellInstance())
{
// Simply assign the command here
pwsh.Commands = psCommand;
// Then invoke
return pwsh.Invoke<T>();
}
}
/// <summary>
/// Runs the given PSCommand asynchronously against our runspace pool.
///
/// This example just demonstrates the same PSCommand concept,
/// but with an asynchronous call.
/// </summary>
/// <param name="psCommand"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public Task<Collection<T>> RunCommandAsync<T>(PSCommand psCommand)
{
return Task.Run(() =>
{
using (PowerShell pwsh = GetPowerShellInstance())
{
pwsh.Commands = psCommand;
return pwsh.Invoke<T>();
}
});
}
/// <summary>
/// Runs:
/// Get-Module -ListAvailable
///
/// This example uses the synchronous RunCommand() method we created,
/// just to demonstrate how to use a PSCommand object.
/// It works just like the PowerShell object,
/// but with no methods/properties to deal with the runtime,
/// like Invoke() or Streams.
/// </summary>
/// <returns></returns>
public Collection<PSModuleInfo> RunGetModuleList_Alternate1()
{
var command = new PSCommand()
.AddCommand("Get-Module")
.AddParameter("-ListAvailable");
return RunCommand<PSModuleInfo>(command);
}
/// <summary>
/// Deduplicate our creation of a PowerShell instance that uses our RunspacePool.
/// </summary>
/// <returns></returns>
private PowerShell GetPowerShellInstance()
{
// RunspaceMode.NewRunspace is the default but we're being explicit here
//
// Only use RunspaceMode.CurrentRunspace in single-threaded, synchronous scenarios!
// If you use RunspaceMode.CurrentRunspace with a threadpool,
// you'll start spinning up runspaces without realising.
// And if you create a PowerShell object on one thread with RunspaceMode.CurrentRunspace,
// and use it in another thread, you'll hit threading issues.
var pwsh = PowerShell.Create(RunspaceMode.NewRunspace);
pwsh.RunspacePool = _runspacePool;
return pwsh;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment