Как запустить PowerShell из C# (CSharp) и получить результат в виде объектов
· Обновлено: · Го Комура · C#, CSharp, PowerShell, Windows, .NET, Автоматизация, Повторное использование существующих наработок
История изменений (2 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Русский перевод приведён к полной версии японского оригинала: добавлены различия для .NET Framework, асинхронная отмена через CancellationToken и BeginStop, параллельный запуск через RunspacePool и карта знаний.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619881)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Как запустить PowerShell из C# (CSharp) и получить результат в виде объектов. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/06/08/001-csharp-run-powershell-receive-objects/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21619881
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21619882
Ситуации, когда нужно запустить PowerShell из C#, часто встречаются в бизнес-приложениях и внутренних утилитах. Например:
- получить список служб Windows;
- изучить процессы или журнал событий;
- вызвать существующий скрипт PowerShell из C#-приложения;
- выполнить команду PowerShell из небольшого GUI-инструмента для администраторов;
- постепенно переносить существующие автоматизированные наработки на PowerShell в .NET-приложение.
Если нужен просто запуск, работает и такой способ: запустить powershell.exe или pwsh.exe как внешний процесс и прочитать стандартный вывод как строку. Но при этом теряется то, что делает PowerShell удобным, — конвейер объектов.
Результат работы PowerShell по своей сути не просто текст. Результат Get-Process — это объекты процессов, а результат Get-Service — объекты служб. Если сторона C# может получить эту структуру в сохранённом виде, парсинг строк становится не нужен, и обработка заметно надёжнее.
В этой статье разбираем основы: как запустить PowerShell из C# и получить результат в виде PSObject.
Код из статьи опубликован на GitHub как полный набор примеров, готовый к сборке и запуску: библиотека с оболочкой выполнения и логикой преобразования, консольная демонстрация каждого раздела и юнит-тесты, которые проверяют получение PSObject и обработку ошибок.
csharp-run-powershell-receive-objects - komurasoft-blog-samples (GitHub)
1. Используем PowerShell SDK, а не запуск внешнего процесса
Способов вызвать PowerShell из C# в целом два.
| Способ | Особенности | Когда подходит |
|---|---|---|
Запустить powershell.exe / pwsh.exe через ProcessStartInfo |
Стандартный вывод и стандартная ошибка читаются как строки | Простой запуск существующих batch-сценариев, обработка, где нужно только оставить лог |
Использовать System.Management.Automation.PowerShell |
Результат можно получить как PSObject |
Обработка результата на стороне C#, управляющие утилиты, бизнес-приложения |
В этой статье речь о втором подходе. System.Management.Automation.PowerShell позволяет собрать и выполнить конвейер PowerShell прямо из кода C#. Важно, что возвращаемое значение — не строка, а по сути Collection<PSObject>.
Иными словами, логика такая:
Выполняем команду PowerShell
↓
Получаем результат как коллекцию PSObject
↓
Извлекаем значения через BaseObject или Properties
↓
При необходимости преобразуем в DTO / record / class C#
Ключевая идея — с самого начала работать с выводом PowerShell как с объектами, а не разбирать его как строки.
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 26, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Требования к окружению
В этой статье в качестве примера используется консольное приложение .NET 8. PowerShell SDK для разных версий рассчитан на разные версии .NET, поэтому пакет выбирают под целевой фреймворк проекта.
По состоянию на июнь 2026 года удобно ориентироваться, например, так:
| Целевой фреймворк C#-приложения | Пример версии PowerShell SDK | Примечание |
|---|---|---|
| .NET 8 | Microsoft.PowerShell.SDK серии 7.4 |
Удобно использовать в приложениях .NET 8 |
| .NET 10 | Microsoft.PowerShell.SDK серии 7.6 |
Вариант, если нужен более новый PowerShell SDK |
| .NET Framework | Microsoft.PowerShell.5.1.ReferenceAssemblies |
Для Windows PowerShell 5.1; для новой разработки стоит уточнить требования |
Здесь в качестве примера для .NET 8 используем Microsoft.PowerShell.SDK версии 7.4.16.
dotnet new console -n PowerShellObjectSample
cd PowerShellObjectSample
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16
Файл .csproj при этом выглядит, например, так:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.SDK" Version="7.4.16" />
</ItemGroup>
</Project>
Версию рекомендуется фиксировать. PowerShell SDK удобен, но зависит от среды выполнения приложения, целевой версии .NET и совместимости модулей PowerShell. Для бизнес-приложений безопаснее явно зафиксировать версию, которую вы проверили, чем просто взять «последнюю, которая заработала в среде разработки».
Что меняется, если писать под .NET Framework
Иногда объект сопровождения — приложение Windows Forms или WPF на .NET Framework, и из него нужно вызывать PowerShell. Примеры кода в этой статье рассчитаны на современный .NET, но запись на стороне C# почти та же. Отличаются следующие пункты.
| Аспект | Современный .NET + Microsoft.PowerShell.SDK |
.NET Framework + Microsoft.PowerShell.5.1.ReferenceAssemblies |
|---|---|---|
| Какой PowerShell выполняется | PowerShell 7.x, входящий в пакет | Windows PowerShell 5.1, входящий в состав Windows |
| Роль пакета NuGet | Содержит реализацию | Только ссылочные сборки; исполняемые сборки берутся у ОС |
| Доступный синтаксис и командлеты | В пределах PowerShell 7.x | В пределах 5.1. ForEach-Object -Parallel, ??, тернарный оператор и подобное недоступны |
| Откуда ищутся модули | $env:PSModulePath PowerShell 7 |
$env:PSModulePath Windows PowerShell |
| Асинхронное выполнение | Можно использовать InvokeAsync |
BeginInvoke / EndInvoke либо обернуть Invoke() в Task.Run |
| Запись на стороне C# | PowerShell.Create(), AddCommand, AddParameter, Invoke(), Collection<PSObject> |
Та же |
| Размер дистрибутива | Большой: в комплект входит весь SDK | Маленький: используется то, что уже есть в ОС |
.csproj выглядит, например, так:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.5.1.ReferenceAssemblies" Version="1.0.0" />
</ItemGroup>
</Project>
На практике сильнее всего бьёт то, что выполняется именно Windows PowerShell 5.1. Если вы берёте существующий скрипт PowerShell, а он написан под PowerShell 7, в 5.1 он может упасть с синтаксической ошибкой. Обратный случай тоже бывает: этот путь выбирают именно потому, что нужен старый модуль, который работает только в Windows PowerShell 5.1.
Код из глав 3–12 этой статьи InvokeAsync не использует, поэтому его можно перенести на сторону .NET Framework как есть. Асинхронность отдельно разбираем в главе 13.
3. Минимальный код: запускаем PowerShell и получаем PSObject
Для начала получим через PowerShell процесс самого текущего C#-приложения.
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Management.Automation;
int currentProcessId = Environment.ProcessId;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> results = ps
.AddCommand("Get-Process")
.AddParameter("Id", currentProcessId)
.Invoke();
foreach (PSObject item in results)
{
Console.WriteLine($"PSObject type: {item.GetType().FullName}");
Console.WriteLine($"BaseObject type: {item.BaseObject.GetType().FullName}");
if (item.BaseObject is Process process)
{
Console.WriteLine($"Id: {process.Id}");
Console.WriteLine($"Name: {process.ProcessName}");
Console.WriteLine($"Memory: {process.WorkingSet64:N0} bytes");
}
}
Здесь важны три момента: PowerShell.Create() создаёт объект выполнения PowerShell; AddCommand("Get-Process") и AddParameter("Id", currentProcessId) собирают команду и её параметры; а возвращаемое значение Invoke() — это Collection<PSObject>.
PSObject — обёртка вокруг значения, которое выводит PowerShell. Чтобы увидеть внутри исходный .NET-объект, смотрят на BaseObject. В этом примере содержимое результата Get-Process можно извлечь как System.Diagnostics.Process.
4. Когда использовать BaseObject, а когда Properties
При работе с результатом PowerShell в C# первое, с чем возникают сомнения, — вот эти два варианта:
item.BaseObject
item.Properties["Name"]?.Value
Ориентир для выбора такой:
| Способ извлечения | Когда используется |
|---|---|
BaseObject |
Когда нужен исходный .NET-объект, возвращённый PowerShell, как есть |
Properties["..."] |
Когда нужно извлечь столбцы, сформированные через Select-Object или [pscustomobject] |
Если выполнить такую команду, как Get-Process, напрямую, в BaseObject может оказаться исходный .NET-объект. Если же на стороне PowerShell столбцы оформили через Select-Object, результат чаще возвращается как пользовательский объект PowerShell. Тогда естественнее извлекать значение по имени столбца из Properties.
5. Читаем в C# результат Select-Object
На практике редко нужны все свойства, которые возвращает PowerShell. Чтобы передать на сторону C# только нужные столбцы, в конвейере PowerShell используют Select-Object.
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> rows = ps
.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddParameter("Property", "CPU")
.AddParameter("Descending", true)
.AddCommand("Select-Object")
.AddParameter("First", 10)
.AddParameter("Property", new[] { "Name", "Id", "CPU", "WorkingSet" })
.Invoke();
foreach (PSObject row in rows)
{
string name = Convert.ToString(row.Properties["Name"]?.Value, CultureInfo.InvariantCulture) ?? "";
int id = Convert.ToInt32(row.Properties["Id"]?.Value, CultureInfo.InvariantCulture);
double? cpu = row.Properties["CPU"]?.Value is null
? null
: Convert.ToDouble(row.Properties["CPU"]!.Value, CultureInfo.InvariantCulture);
long workingSet = Convert.ToInt64(row.Properties["WorkingSet"]?.Value, CultureInfo.InvariantCulture);
Console.WriteLine($"{id}: {name}, CPU={cpu}, WorkingSet={workingSet:N0}");
}
Этот код соответствует следующему конвейеру в PowerShell:
Get-Process |
Sort-Object -Property CPU -Descending |
Select-Object -First 10 -Property Name, Id, CPU, WorkingSet
Со стороны C# последовательные вызовы AddCommand строят конвейер PowerShell:
.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddCommand("Select-Object")
При такой записи вывод каждой предыдущей команды передаётся в следующую.
После того как Select-Object сузил столбцы, значения извлекают по имени столбца, например row.Properties["Name"]?.Value.
6. Преобразуем в record C#
Если передавать PSObject по всему приложению как есть, последующий код слишком зависит от PowerShell. Для отображения на экране или бизнес-логики удобнее преобразовать результат в тип на стороне C#.
Например, информацию о процессе преобразуем в такой record:
public sealed record ProcessSummary(
string Name,
int Id,
double? Cpu,
long WorkingSet);
Если вынести логику преобразования отдельно, код становится нагляднее:
using System.Globalization;
using System.Management.Automation;
static ProcessSummary ToProcessSummary(PSObject row)
{
string name = GetString(row, "Name");
int id = GetInt32(row, "Id");
double? cpu = GetNullableDouble(row, "CPU");
long workingSet = GetInt64(row, "WorkingSet");
return new ProcessSummary(name, id, cpu, workingSet);
}
static string GetString(PSObject row, string propertyName)
{
return Convert.ToString(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture) ?? "";
}
static int GetInt32(PSObject row, string propertyName)
{
return Convert.ToInt32(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}
static long GetInt64(PSObject row, string propertyName)
{
return Convert.ToInt64(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}
static double? GetNullableDouble(PSObject row, string propertyName)
{
object? value = row.Properties[propertyName]?.Value;
return value is null ? null : Convert.ToDouble(value, CultureInfo.InvariantCulture);
}
Со стороны вызова это выглядит так:
List<ProcessSummary> processes = rows
.Select(ToProcessSummary)
.ToList();
foreach (ProcessSummary process in processes)
{
Console.WriteLine($"{process.Id}: {process.Name}");
}
PSObject обрабатывают на границе с PowerShell, а внутри приложения преобразуют в обычные типы C#, такие как ProcessSummary.
Если заложить это разделение заранее, изменение команды PowerShell в будущем затронет меньшую область кода.
7. Если возвращать PSCustomObject, со стороны C# работать проще
Когда на стороне PowerShell нужно вернуть сразу несколько значений, удобно использовать [pscustomobject].
using System.Collections.ObjectModel;
using System.Management.Automation;
string script = @"
[pscustomobject]@{
MachineName = [System.Environment]::MachineName
PowerShellVersion = $PSVersionTable.PSVersion.ToString()
CurrentDirectory = (Get-Location).Path
}
";
using PowerShell ps = PowerShell.Create();
Collection<PSObject> rows = ps
.AddScript(script, useLocalScope: true)
.Invoke();
foreach (PSObject row in rows)
{
Console.WriteLine($"MachineName: {row.Properties["MachineName"]?.Value}");
Console.WriteLine($"PowerShell: {row.Properties["PowerShellVersion"]?.Value}");
Console.WriteLine($"Directory: {row.Properties["CurrentDirectory"]?.Value}");
}
Если скрипт PowerShell в конце возвращает [pscustomobject], на стороне C# значения можно извлекать по имени из Properties. Это заметно безопаснее, чем возвращать сложную строку и разбивать её на части в C#.
Пример, которого стоит избегать, — такой вывод:
"$MachineName,$PowerShellVersion,$CurrentDirectory"
Этот способ выглядит простым, но ломается, как только в значении появляется запятая или перенос строки.
PowerShell возвращает объекты, а C# читает их как свойства. При такой форме легче справляться с добавлением новых столбцов.
8. Не встраивайте пользовательский ввод напрямую в AddScript
Даже при использовании PowerShell SDK собирать скрипт через конкатенацию строк опасно. Например, кода вроде этого стоит избегать:
// Пример, которого стоит избегать
string userInputPath = GetPathFromUser();
string script = $"Get-ChildItem -Path '{userInputPath}'";
using PowerShell ps = PowerShell.Create();
ps.AddScript(script).Invoke();
При такой записи пользовательский ввод может быть интерпретирован как код PowerShell. Когда нужно передать значение в команду PowerShell, по возможности используйте AddCommand и AddParameter.
string userInputPath = GetPathFromUser();
using PowerShell ps = PowerShell.Create();
Collection<PSObject> files = ps
.AddCommand("Get-ChildItem")
.AddParameter("Path", userInputPath)
.AddParameter("File", true)
.Invoke();
Значение, переданное через AddParameter, обрабатывается не как строка кода, соединённая конкатенацией, а как значение параметра.
На практике разумно придерживаться такого разделения:
| Способ записи | Когда используется |
|---|---|
AddCommand / AddParameter |
Когда нужно безопасно собрать команду на стороне C# |
AddScript |
Для выполнения фиксированного короткого скрипта или загрузки уже существующего скрипта |
AddScript со строковой конкатенацией |
В принципе стоит избегать; если всё же используете — тщательно проверяйте и экранируйте входные значения |
Встраивание PowerShell в C# даёт приложению сильные операции. Вместе с удобством нужно соблюдать одну границу: не превращать пользовательский ввод напрямую в скрипт.
9. Format-Table нужен только для финального отображения на экране. Перед передачей в C# его не используют
Если результат PowerShell нужно получить в C# как объект, Format-Table и Format-List, как правило, не используют.
Например, такой PowerShell удобен, когда человек смотрит на экран:
Get-Service | Format-Table Name, Status
Но если применить Format-Table до передачи результата в C#, вместо объектов служб вы получите информацию для форматирования отображения. Если результат нужен для обработки в C#, используйте Select-Object.
Get-Service | Select-Object Name, Status
Со стороны C# это выглядит так:
using PowerShell ps = PowerShell.Create();
Collection<PSObject> services = ps
.AddCommand("Get-Service")
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "Status" })
.Invoke();
Идея простая:
Нужна только читаемость на экране → Format-Table / Format-List
Нужна дальнейшая обработка в C# → Select-Object / PSCustomObject
Это справедливо и при использовании PowerShell самого по себе, но особенно важно становится при интеграции с C#.
10. Получаем ошибки
В PowerShell вывод и ошибки — это отдельные потоки. Если смотреть только на возвращаемое значение Invoke(), ошибки легко упустить. Базовая форма такая:
using System.Management.Automation;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> output = ps
.AddCommand("Get-Item")
.AddParameter("Path", @"C:\no-such-file.txt")
.Invoke();
if (ps.HadErrors)
{
foreach (ErrorRecord error in ps.Streams.Error)
{
Console.WriteLine($"Error: {error.Exception.Message}");
Console.WriteLine($"Category: {error.CategoryInfo.Category}");
Console.WriteLine($"Target: {error.TargetObject}");
}
}
У командлетов PowerShell есть ошибки, которые останавливают выполнение, и ошибки, при которых выполнение продолжается. Если на стороне C# нужно обрабатывать их как исключения, можно указать Stop для ErrorAction.
using System.Management.Automation;
try
{
using PowerShell ps = PowerShell.Create();
Collection<PSObject> output = ps
.AddCommand("Get-Item")
.AddParameter("Path", @"C:\no-such-file.txt")
.AddParameter("ErrorAction", "Stop")
.Invoke();
}
catch (RuntimeException ex)
{
Console.WriteLine($"PowerShell failed: {ex.Message}");
}
Что лучше, зависит от характера приложения. Для управляющей утилиты, где нужно вывести список даже при частичных сбоях, удобнее собирать поток ошибок и показывать его на экране. Если же при сбое нужно остановить всю обработку целиком, понятнее обрабатывать это как исключение через ErrorAction Stop.
11. Создаём небольшую обёртку для выполнения
В приложении, которое многократно вызывает PowerShell, повторение одной и той же обработки ошибок каждый раз захламляет код. Удобно подготовить простую обёртку.
using System.Management.Automation;
public sealed record PowerShellRunResult(
IReadOnlyList<PSObject> Output,
IReadOnlyList<ErrorRecord> Errors);
public static class PowerShellRunner
{
public static PowerShellRunResult Run(Action<PowerShell> build)
{
using PowerShell ps = PowerShell.Create();
build(ps);
List<PSObject> output;
try
{
output = ps.Invoke().ToList();
}
catch (RuntimeException ex)
{
throw new InvalidOperationException($"PowerShell execution failed: {ex.Message}", ex);
}
return new PowerShellRunResult(
Output: output,
Errors: ps.Streams.Error.ToList());
}
}
Со стороны вызова остаётся сосредоточиться только на сборке команды.
PowerShellRunResult result = PowerShellRunner.Run(ps => ps
.AddCommand("Get-Service")
.AddCommand("Where-Object")
.AddParameter("Property", "Status")
.AddParameter("EQ", "Running")
.AddCommand("Select-Object")
.AddParameter("First", 10)
.AddParameter("Property", new[] { "Name", "DisplayName", "Status" }));
foreach (PSObject row in result.Output)
{
Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}
foreach (ErrorRecord error in result.Errors)
{
Console.Error.WriteLine(error.Exception.Message);
}
Впрочем, сборка специфичного для PowerShell условия, как Where-Object в этом примере, из C# иногда получается не очень читаемой. Простые команды и параметры можно собирать через AddCommand / AddParameter, но для сложных фильтров и агрегаций иногда читабельнее подготовить фиксированный скрипт PowerShell. Даже в этом случае правило не соединять внешний ввод напрямую со строкой скрипта не меняется.
12. Сложную обработку оформляем в объекты на стороне PowerShell
При сочетании C# и PowerShell проектировать проще, если заранее разделить, кто за что отвечает.
Рекомендуем такое разделение ролей:
| Отвечает | Что делает |
|---|---|
| PowerShell | Операции, близкие к Windows и её модулям, существующие скрипты, выполнение административных команд |
| C# | UI, проверка ввода, преобразование типов, бизнес-логика, сохранение, интеграция с API |
На стороне PowerShell итоговый вывод оформляют в [pscustomobject].
Get-Service |
Where-Object Status -eq 'Running' |
Select-Object Name, DisplayName, Status
Либо создают [pscustomobject] явно:
$services = Get-Service | Where-Object Status -eq 'Running'
[pscustomobject]@{
Count = $services.Count
Names = $services.Name
}
На стороне C# читают свойства полученного PSObject и преобразуют их в собственные типы приложения.
При такой форме детали реализации PowerShell не просачиваются в C# сверх необходимого.
13. Частые практические нюансы
Когда C# запускает PowerShell, недостаточно того, чтобы код просто работал. На практике стоит заранее проверить следующие моменты.
Права выполняющего пользователя
PowerShell работает с правами пользователя, от имени которого запущено C#-приложение. Команды, требующие прав администратора, завершатся ошибкой при запуске от обычного пользователя. При работе со службами, журналом событий, сертификатами, реестром, Hyper-V, модулями администрирования Microsoft 365 и подобным нужно заранее продумать разграничение прав.
Различия между 32-битным и 64-битным процессом
В Windows видимость реестра и модулей может отличаться между 32-битным и 64-битным процессом. Если вы создаёте инструмент для администрирования Windows, изначально ориентироваться на выполнение в x64 — это способ уменьшить число проблем.
Наличие нужных модулей в среде выполнения
Установка PowerShell SDK в C#-приложение не означает, что автоматически подтянутся все модули PowerShell. Например, при использовании модуля управления конкретным продуктом или внутрикорпоративного модуля нужно проверить, есть ли этот модуль в среде выполнения и из какого пути он будет загружен.
В GUI-приложении не блокируйте UI-поток
Если запускать PowerShell из WinForms или WPF и выполнять тяжёлую операцию прямо в UI-потоке, окно зависнет. В этом случае выполнение выносят в фоновую обработку, а интерфейс обновляют после завершения.
В PowerShell SDK есть API асинхронного выполнения — им и стоит пользоваться. PowerShell.InvokeAsync возвращает Task<PSDataCollection<PSObject>>, поэтому его можно сразу await.
Сначала вынесите вызов PowerShell в метод, который не трогает UI.
using System.Management.Automation;
// Следующие два метода предполагаются внутри класса окна или формы.
//
// cancellationToken нужно принимать обязательно. Если экземпляр PowerShell
// спрятать в локальную переменную этого метода, у вызывающей стороны не будет
// способа вызвать Stop(). Когда команда зависает или пользователь закрывает
// окно, обработчик, который ждёт await, так и останется с отключённой кнопкой
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesAsync(
CancellationToken cancellationToken)
{
// using не используем: экземпляр нужно уничтожить только после завершения
// остановки (об этом ниже)
PowerShell ps = PowerShell.Create();
Task? stopping = null;
try
{
// `-EQ` — переключатель без значения; сравниваемое значение передают
// в `-Value` (упрощённый синтаксис: `-Property <String> -EQ -Value <Object>`).
// Запись AddParameter("EQ", "Running") падает уже на назначении параметра
ps.AddCommand("Get-Service")
.AddCommand("Where-Object")
.AddParameter("Property", "Status")
.AddParameter("EQ")
.AddParameter("Value", "Running")
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "DisplayName", "Status" });
// Не регистрируйте отмену до запуска. Register, если переданный токен
// уже отменён, выполняет обратный вызов сразу. BeginStop улетает в ещё
// не стартовавший конвейер и промахивается, затем InvokeAsync запускает
// команду, а сигнал остановки уже израсходован — в итоге команда
// продолжает работать даже после закрытия окна.
// Сначала отсекаем уже отменённый токен, затем запускаем, затем
// подключаем точку остановки
cancellationToken.ThrowIfCancellationRequested();
Task<PSDataCollection<PSObject>> running = ps.InvokeAsync();
// При отмене останавливаем конвейер. Stop() не возвращается, пока
// остановка не завершится, а отмена может прийти с UI-потока, поэтому
// берём асинхронный вариант. Саму остановку держим как Task и ждём её
// в finally ниже
using (cancellationToken.Register(
state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
{
PSDataCollection<PSObject> output = await running;
return output.ToList();
}
}
finally
{
// Если остановку запросили, ждём её завершения и только потом
// уничтожаем экземпляр. Возврат из await running и завершение остановки
// происходят порознь: если вызвать Dispose, не дождавшись, EndStop
// потом тронет уже уничтоженный PowerShell. И это колбэк пула потоков,
// так что пойманного исключения ждать негде (падает весь процесс)
Task? pending = Volatile.Read(ref stopping);
if (pending is not null)
{
try { await pending; }
catch { /* сбой остановки не должен перекрыть исходный результат или исключение */ }
}
ps.Dispose();
}
}
// BeginStop / EndStop оборачиваем в Task. Если вызвать EndStop прямо внутри
// колбэка, его исключение утечёт из пула потоков
private static Task StopAsync(PowerShell ps) =>
Task.Factory.FromAsync(ps.BeginStop, ps.EndStop, null);
Вызывающая сторона — обработчик события окна или формы. Здесь допустим async void. Обработчики событий — одно из немногих мест, где async void разрешён.
// Предполагается класс окна WPF.
// Для WinForms замените RoutedEventArgs на EventArgs,
// IsEnabled на Enabled, ItemsSource на DataSource.
// Источник отмены во время выполнения. Им пользуются и кнопка «Отмена»,
// и закрытие окна
private CancellationTokenSource? _running;
private async void RunButton_Click(object sender, RoutedEventArgs e)
{
RunButton.IsEnabled = false;
using var cts = new CancellationTokenSource();
_running = cts;
try
{
IReadOnlyList<PSObject> services = await GetRunningServicesAsync(cts.Token);
ResultList.ItemsSource = services
.Select(row => row.Properties["Name"]?.Value?.ToString() ?? "")
.ToList();
}
catch (PipelineStoppedException)
{
// Нормальный путь при кнопке «Отмена» или закрытии окна. Ничего не показываем
}
catch (RuntimeException ex)
{
MessageBox.Show($"PowerShell failed: {ex.Message}");
}
finally
{
_running = null;
RunButton.IsEnabled = true;
}
}
private void CancelButton_Click(object sender, RoutedEventArgs e) => _running?.Cancel();
protected override void OnClosed(EventArgs e)
{
_running?.Cancel(); // не оставлять конвейер работающим в фоне после закрытия
base.OnClosed(e);
}
Зафиксируйте четыре момента.
- Место, куда возвращается
await, и в WinForms, и в WPF — это UI-поток. Синхронизационный контекст возвращает туда сам, поэтомуInvokeиDispatcher.Invokeне нужны. - На время выполнения кнопку отключают. Иначе ту же обработку запустят дважды.
- Исключения ловят как
RuntimeException. ЕслиErrorActionне выставлен вStop, проверяют ещё и поток ошибок (глава 10). - Точку остановки нужно предусмотреть всегда. Если экземпляр
PowerShellспрятать в локальную переменную метода, вызывающая сторона не сможет вызватьStop(). Когда команда зависает или пользователь закрывает окно, экран остаётся с отключённой кнопкой и продолжает ждать.
При остановке ожидающий InvokeAsync бросает PipelineStoppedException. Как в примере выше, ловите это не как сбой, а как нормальный путь. У API остановки PowerShell есть синхронный Stop(), асинхронные BeginStop / EndStop и StopAsync (см. справочные материалы в конце). Поскольку останавливать могут с UI-потока, берут асинхронный вариант, который не заставляет ждать.
И если остановили асинхронно, дождитесь завершения, прежде чем уничтожать экземпляр. Возврат из await running и завершение остановки, начатой через BeginStop, — это разные события. Если выйти из using PowerShell ps как есть, EndStop отработает после Dispose и тронет уже уничтоженный экземпляр. Причём это колбэк пула потоков, и поймать исключение негде — приложение падает на выходе без понятной причины. Именно поэтому в коде выше отказались от using в пользу try / finally и держат остановку как Task, который затем await. Обёртка Task.Factory.FromAsync кладёт исключение EndStop в этот Task, так что оно не утекает из пула потоков.
Если пишете под Windows PowerShell 5.1 и InvokeAsync недоступен, разделяйте BeginInvoke / EndInvoke. Обёртка синхронного Invoke() в Task.Run тоже возможна, но только когда отменой можно пренебречь. Task.Run освобождает UI-поток и больше ничего: до работающего конвейера никто не дотягивается. Пользователь нажимает «Отмена», а зависшая команда продолжает работать в фоне.
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesLegacyAsync(
CancellationToken cancellationToken)
{
PowerShell ps = PowerShell.Create();
Task? stopping = null;
try
{
ps.AddCommand("Get-Service")
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "Status" });
// Порядок и завершение те же, что в примере с InvokeAsync выше.
// Сначала отсекаем, затем запускаем, затем подключаем точку остановки.
// Синхронный Invoke() не даёт «сначала запустить, потом зарегистрировать»,
// поэтому нужно разделить BeginInvoke / EndInvoke
cancellationToken.ThrowIfCancellationRequested();
// BeginInvoke / EndInvoke оборачиваем в Task. В отличие от Task.Run,
// который занимает поток пула до конца ожидания, о завершении сообщает
// сторона PowerShell
Task<PSDataCollection<PSObject>> running =
Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
using (cancellationToken.Register(
state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
{
// При остановке будет PipelineStoppedException
PSDataCollection<PSObject> output = await running;
return output.ToList();
}
}
finally
{
Task? pending = Volatile.Read(ref stopping);
if (pending is not null)
{
try { await pending; } catch { }
}
ps.Dispose();
}
}
Не оборачивайте в Task.Run и затем вызывайте Register. Если внутри Task.Run написать «отсечь → зарегистрировать → Invoke()», отмена, пришедшая между регистрацией и запуском, промахнётся. BeginStop улетает в ещё не стартовавший конвейер, затем Invoke() запускает команду, а сигнал остановки уже израсходован — та же ловушка, которую в примере с InvokeAsync выше как раз обходили.
В любом варианте правила одни. Не выполнять долгий Invoke() в UI-потоке, не трогать UI напрямую из метода, который вызывает PowerShell, и открывать вызывающей стороне точку остановки уже после запуска конвейера.
Параллельное выполнение и стоимость первого запуска
В управляющих утилитах сразу появляется требование: «по одному узлу слишком медленно, хотим параллельно». Разберём места, где на этом спотыкаются.
Во-первых, первый Invoke() медленный — так и задумано. Инициализация Runspace и поиск модулей происходят при первом выполнении. Если сразу после запуска один вызов долгий, а со второго уже быстро — это не аномалия. Для замеров сравнивайте без первого запуска. В приложении с экраном можно при старте один раз прогнать лёгкую команду и прогреть среду.
Во-вторых, один экземпляр PowerShell не разделяют между несколькими потоками. Повторный Invoke или InvokeAsync на уже выполняющемся экземпляре бросает InvalidOperationException с причиной «команда уже запущена». Если нужно работать параллельно, на каждую операцию вызывайте PowerShell.Create().
Правда, PowerShell.Create() на каждую операцию каждый раз создаёт Runspace. Когда операций много, заводят RunspacePool и берут Runspace оттуда.
using System.Management.Automation;
using System.Management.Automation.Runspaces;
static async Task<IReadOnlyList<PSObject>> GetServiceAsync(
RunspacePool pool,
string serviceName)
{
using PowerShell ps = PowerShell.Create();
ps.RunspacePool = pool;
ps.AddCommand("Get-Service")
.AddParameter("Name", serviceName)
.AddCommand("Select-Object")
.AddParameter("Property", new[] { "Name", "Status" });
PSDataCollection<PSObject> output = await ps.InvokeAsync();
return output.ToList();
}
Со стороны вызова сначала открывают пул, затем бросают задачи параллельно.
using System.Management.Automation;
using System.Management.Automation.Runspaces;
string[] serviceNames = { "Spooler", "W32Time", "EventLog" };
using RunspacePool pool = RunspaceFactory.CreateRunspacePool(1, 4);
pool.Open();
IReadOnlyList<PSObject>[] results = await Task.WhenAll(
serviceNames.Select(name => GetServiceAsync(pool, name)));
foreach (IReadOnlyList<PSObject> rows in results)
{
foreach (PSObject row in rows)
{
Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}
}
Здесь для объяснения использован Get-Service, но на практике имейте в виду команды, которые на каждый объект занимают заметное время. Главное — задавать ps.RunspacePool, а не ps.Runspace, и создавать/уничтожать сам экземпляр PowerShell на каждую операцию.
Наконец, увеличение размера пула само по себе не гарантирует ускорение. Реальный потолок задают нагрузка на целевой сервер, аутентификация, сеть и то, насколько целевой модуль переносит параллельный запуск. Сначала запускайте с небольшим значением и поднимайте его по замерам.
Размер приложения при распространении
Microsoft.PowerShell.SDK удобен, но увеличивает число зависимостей приложения. Для небольшой утилиты это может быть приемлемо, но в зависимости от формата распространения и способа обновления размер может стать проблемой. Стоит заранее проверить это на реальном способе распространения — ClickOnce, MSIX, единый exe, внутренний инструмент развёртывания и так далее.
14. Зачем получать объекты, а не строки
Напоследок — почему так важно настаивать именно на PSObject. Запуск PowerShell как внешнего процесса с чтением стандартного вывода прост:
Вывод PowerShell
↓
Строка
↓
Split / регулярные выражения / Substring
↓
Значения C#
Однако этот способ зависит от формата отображения. Он легко ломается из-за ширины столбцов, локали, переносов строк, пробелов, текста ошибок и разделителей внутри самих значений.
С другой стороны, при использовании PowerShell SDK поток такой:
Вывод PowerShell
↓
PSObject
↓
Properties / BaseObject
↓
Тип C#
Здесь значения извлекаются на основе структуры данных, а не формата отображения. Для бизнес-приложений и управляющих утилит второй вариант заметно легче поддерживать.
15. Итог
Если вы запускаете PowerShell из C# и работаете с результатом, стоит рассмотреть не просто запуск powershell.exe с чтением стандартного вывода, а использование PowerShell SDK.
Базовый порядок действий такой:
Подключаем Microsoft.PowerShell.SDK
↓
Создаём объект выполнения через PowerShell.Create()
↓
Собираем обработку через AddCommand / AddParameter / AddScript
↓
Выполняем через Invoke()
↓
Получаем Collection<PSObject>
↓
Извлекаем значения через BaseObject или Properties
↓
Преобразуем в DTO / record / class C#
Особенно важны на практике три момента:
- если результат используется для дальнейшей обработки в C#, применяйте не
Format-Table, аSelect-Objectили[pscustomobject]; - не встраивайте пользовательский ввод напрямую в строку
AddScript, по возможности передавайте его черезAddParameter; - обрабатывайте
PSObjectна границе, а внутри приложения преобразуйте его в типы C#.
PowerShell силён в администрировании Windows и повторном использовании существующих наработок, а C# — в построении приложений, интерфейсов и типобезопасной бизнес-логики. Если связать их между собой, существующие скрипты PowerShell не придётся выбрасывать: их можно постепенно встраивать в .NET-приложение.
Справочные материалы
- Полный набор примеров кода к этой статье (библиотека, демо, юнит-тесты) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-run-powershell-receive-objects
- Microsoft Learn: краткое руководство по размещению Windows PowerShell
https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/windows-powershell-host-quickstart - Microsoft Learn: добавление и вызов команд
https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/adding-and-invoking-commands - Microsoft Learn: PowerShell Class
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell - Microsoft Learn: PSObject Class
https://learn.microsoft.com/ja-jp/dotnet/api/system.management.automation.psobject - Microsoft Learn: PowerShell.InvokeAsync Method
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.invokeasync - Microsoft Learn: PowerShell.BeginStop Method (асинхронно останавливает выполняющуюся команду; возвращённый
IAsyncResultпринимают черезEndStop)
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.beginstop - Microsoft Learn: PowerShell.Stop Method (синхронный вариант; не возвращается, пока остановка не завершится)
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.stop - Microsoft Learn: создание нескольких пространств выполнения
https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/creating-multiple-runspaces - NuGet Gallery: Microsoft.PowerShell.SDK
https://www.nuget.org/packages/Microsoft.PowerShell.SDK/ - NuGet Gallery: Microsoft.PowerShell.5.1.ReferenceAssemblies
https://www.nuget.org/packages/Microsoft.PowerShell.5.1.ReferenceAssemblies/
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
WMI/CIM из C# и PowerShell — практическое руководство по сведениям об оборудовании, мониторингу процессов и удалённым запросам
WMI/CIM — стандартный способ получить серийный номер ПК, следить за свободным местом на диске и ловить запуск процессов. Разбираем команд...
Почему ломаются аргументы ── правила аргументов командной строки Windows
В Windows массива аргументов нет: в CreateProcess уходит одна строка, делит её принимающая сторона. Правила деления CommandLineToArgvW, C...
Окончание драйверов принтера Windows ── как готовить печать форм и этикеток в бизнес-приложениях
Microsoft поэтапно прекращает сопровождение драйверов принтера v3/v4; с июля 2026 IPP class driver предпочтут. Что исчезает в Windows pro...
Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
Проверенные приёмы проектирования на .NET/C#, чтобы код не «иногда падал или зависал»: не создавать потоки вручную и опираться на Task, с...
Глубины ввода-вывода Windows (часть 4) — диспетчер кэша: когда WriteFile оказывается на диске
Четвёртая часть серии со схемами диспетчера кэша Windows. Разбираем кэш как проекцию файла, упреждающее чтение и отложенную запись, когда...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Каким способом лучше запускать PowerShell из C#?
- В целом есть два подхода: запустить powershell.exe / pwsh.exe как внешний процесс через ProcessStartInfo или использовать System.Management.Automation.PowerShell (PowerShell SDK). Если нужно только прочитать стандартный вывод как строку, первый вариант тоже работает. Но для управляющих утилит и бизнес-приложений, где результат обрабатывают на стороне C#, лучше второй: он возвращает коллекцию PSObject. Тогда парсить строки не нужно, и можно писать безопасную обработку, которая не зависит от формата отображения.
- Как выбрать между BaseObject и Properties у PSObject?
- BaseObject используют, когда нужен исходный .NET-объект, который вернул PowerShell, как есть. Например, результат прямого выполнения Get-Process можно извлечь как System.Diagnostics.Process. Результат, у которого столбцы оформили через Select-Object или [pscustomobject], чаще приходит как пользовательский объект PowerShell — тогда естественнее брать значение по имени столбца через Properties["ИмяСтолбца"]?.Value.
- На что обратить внимание, когда из C# в PowerShell передают пользовательский ввод?
- Не встраивайте пользовательский ввод в скрипт AddScript через конкатенацию строк: ввод может быть интерпретирован как код PowerShell. Если нужно передать значение, используйте AddCommand и AddParameter — тогда значение обрабатывается не как строка кода, а как значение параметра. AddScript разумнее оставить для фиксированных коротких скриптов или загрузки уже существующих скриптов.
- Почему не стоит использовать Format-Table, когда результат PowerShell нужно получить в C#?
- Если пропустить результат через Format-Table или Format-List, вместо исходного объекта вы получите информацию для форматирования на экране, и извлечь значения как свойства на стороне C# уже не получится. Если результат нужен для дальнейшей обработки в C#, сужайте столбцы через Select-Object либо оформляйте результат на стороне PowerShell как [pscustomobject]. Правило простое: «для просмотра на экране — команды Format-*, для передачи в C# — Select-Object».
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.