У попередній темі ми розглянули процес як концепцію операційної системи: ізольований адресний простір, таблиця дескрипторів, контекст безпеки. Проте для практичної роботи цієї концептуальної бази недостатньо — нам потрібне конкретне API, що дозволяє взаємодіяти з процесами безпосередньо з коду C#.
Розглянемо три типові сценарії, з якими стикаються розробники:
Сценарій перший: Системне адміністрування та моніторинг. DevOps-інструмент має кожні 30 секунд перевіряти, чи запущені критичні служби, фіксувати їх споживання RAM і CPU, і надсилати alert якщо процес "завис" або перевищив пороговий показник. Без Process API жоден рядок коду тут не стане в пригоді.
Сценарій другий: Інтеграція з CLI-інструментами. CI/CD пайплайн, скрипт збірки або тестовий runner мають запустити dotnet build, npm install, чи зовнішній компілятор і отримати вивід у реальному часі — не чекаючи завершення процесу, а стрімінгом по рядках. Це вимагає розуміння перенаправлення потоків вводу-виводу.
Сценарій третій: Архітектура multi-process систем. Коли безпека або надійність вимагають ізоляції, функціональність виносять в окремий процес. Браузери Chrome та Firefox роблять саме це для кожної вкладки. Два .NET процеси мають "домовитись" про протокол обміну даними — і тут на сцену виходить IPC (Inter-Process Communication).
.NET надає для всього перерахованого єдину точку входу: клас System.Diagnostics.Process. Ця тема — практично орієнтований розбір: теорія пояснює "чому", код демонструє "як".
Перш ніж перейти до API, варто зрозуміти, як Windows "думає" про процеси і як .NET Runtime вписується в цю картину.
Коли ви запускаєте .exe файл (або dotnet run), Windows проходить через наступну послідовність:
Крок перший — Validation і Security. NtCreateProcess() у kernel перевіряє права доступу до виконуваного файлу, створює об'єкт _EPROCESS у kernel memory — внутрішню структуру ядра, що описує процес. Саме _EPROCESS є "справжнім" процесом у розумінні ОС.
Крок другий — Virtual Address Space. Kernel резервує 128 ТБ (на x64) або 2-4 ГБ (на x86) virtual address space. Це не реальна пам'ять — лише діапазон адрес. Реальні сторінки RAM виділяються "ліниво" при першому зверненні (page fault).
Крок третій — DLL Loader. Завантажується сам виконуваний файл, потім всі DLL-залежності: системні (kernel32.dll, ntdll.dll) і прикладні (.NET Runtime). На x64 Windows ASLR (Address Space Layout Randomization) розміщує кожну DLL за випадковою адресою при кожному завантаженні — захист від buffer overflow атак.
Крок четвертий — .NET Runtime Init. Для .NET застосунків coreclr.dll ініціалізує CLR: налаштовує GC, ThreadPool, JIT-компілятор, завантажує збірки збірки за описом з .runtimeconfig.json. Тільки після цього починає виконуватися IL-код вашої програми.
Крок п'ятий — Main Thread. Перший потік процесу (Main Thread, ID = 1 у .NET) починає виконання точки входу (Main() або top-level statements).
Клас Process у .NET — це обгортка над Win32 API функціями OpenProcess, GetProcessTimes, QueryInformationJobObject та десятками інших. Він не надає доступ до _EPROCESS напряму, але виставляє user-mode інформацію через зручний об'єктно-орієнтований інтерфейс.
Простір імен System.Diagnostics містить клас Process, що є центральним API для роботи з процесами. Існує чотири способи отримати екземпляр:
using System.Diagnostics;
// Спосіб 1: Поточний процес — найшвидший, без системних викликів
Process current = Process.GetCurrentProcess();
// Спосіб 2: Всі процеси на локальній машині
// Увага: може зайняти 100-300ms при великій кількості процесів
Process[] all = Process.GetProcesses();
// Спосіб 3: Пошук за іменем (повертає масив — може бути кілька примірників)
// Ім'я без розширення, case-insensitive на Windows
Process[] chromes = Process.GetProcessesByName("chrome");
Process[] dotnetProcs = Process.GetProcessesByName("dotnet");
// Спосіб 4: За конкретним PID — викидає ArgumentException якщо не знайдено
try
{
Process specific = Process.GetProcessById(1234);
}
catch (ArgumentException)
{
Console.WriteLine("Процес з таким PID не знайдено або вже завершився");
}
// Важливо: Process реалізує IDisposable
// Клас тримає native handle на процес — завжди використовуйте using
using var p = Process.GetCurrentProcess();
GetProcesses() виконує snapshot всіх процесів у системі. На завантаженій машині з 200+ процесами це може зайняти 200-400ms. Якщо вам потрібно лише кілька процесів за іменем — завжди використовуйте GetProcessesByName(), він значно ефективніший.Кожен об'єкт Process надає доступ до детальної інформації. Властивості можна розбити на три категорії:
Ідентифікаційні властивості — не вимагають підвищених прав:
.exe. Не унікальне — кілька примірників Chrome мають однакове ProcessName = "chrome". Формується з імені виконуваного файлу, не з заголовка вікна.Ресурсні властивості — вимагають достатніх прав:
HandleCount — класична ознака handle leak. Нормальне значення: 100-1000 для типового застосунку.Часові властивості:
SeDebugPrivilege або читання власного процесу. Разом із DateTime.Now - process.StartTime дає Uptime.TotalProcessorTime / Environment.ProcessorCount — нормалізований відсоток CPU.Наступний код демонструє побудову мінімального консольного Task Manager:
using System.Diagnostics;
static string FormatBytes(long bytes)
{
return bytes switch
{
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F1} GB",
>= 1_048_576 => $"{bytes / 1_048_576.0:F1} MB",
>= 1_024 => $"{bytes / 1_024.0:F1} KB",
_ => $"{bytes} B"
};
}
// Отримуємо всі процеси, сортуємо за WorkingSet
var processes = Process.GetProcesses()
.OrderByDescending(p =>
{
try { return p.WorkingSet64; }
catch { return 0L; } // UnauthorizedAccessException для деяких системних процесів
})
.Take(20);
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine($"{"PID",-8} {"Назва",-25} {"RAM",-12} {"CPU (s)",-10} {"Потоки",-8} {"Handles",-8}");
Console.WriteLine(new string('─', 75));
foreach (var p in processes)
{
try
{
var cpuSeconds = p.TotalProcessorTime.TotalSeconds;
Console.WriteLine(
$"{p.Id,-8} " +
$"{p.ProcessName[..Math.Min(24, p.ProcessName.Length)],-25} " +
$"{FormatBytes(p.WorkingSet64),-12} " +
$"{cpuSeconds,-10:F1} " +
$"{p.Threads.Count,-8} " +
$"{p.HandleCount,-8}");
}
catch
{
// Системні процеси (System, Idle) можуть мати обмежений доступ
Console.WriteLine($"{p.Id,-8} {p.ProcessName,-25} {"<обмежений доступ>"}");
}
finally
{
p.Dispose();
}
}
Запуск зовнішніх процесів в .NET здійснюється через Process.Start() із об'єктом ProcessStartInfo. Цей об'єкт є декларативним описом того, як слід запустити процес — аналог аргументів CreateProcess() у Win32 API.
Найважливіша властивість, що визначає поведінку всіх інших — UseShellExecute:
UseShellExecute = true (поведінка за замовчуванням до .NET Core): Запуск через оболонку ОС (Shell Execute). Дозволяє відкривати документи (Process.Start("report.pdf") відкриє PDF у Adobe Reader), URL, виконувати дії ("open", "print"). Але забороняє перенаправлення stdin/stdout/stderr. Використовується коли потрібен UI, а не програмна взаємодія.
UseShellExecute = false (рекомендовано для програмної взаємодії): Прямий системний виклик CreateProcess(). Дозволяє перенаправлення потоків, але вимагає повного шляху до виконуваного файлу або щоб файл знаходився в PATH. Жодного Shell Extension, жодних verb actions.
using System.Diagnostics;
// Сценарій 1: Відкрити URL у браузері (UseShellExecute = true)
var openBrowser = new ProcessStartInfo
{
FileName = "https://docs.microsoft.com",
UseShellExecute = true // обов'язково для URL
};
Process.Start(openBrowser);
// Сценарій 2: Відкрити файл у відповідній програмі
var openDoc = new ProcessStartInfo
{
FileName = @"C:\report.docx",
UseShellExecute = true,
Verb = "open" // або "print", "edit" — залежить від асоціації файлу
};
Process.Start(openDoc);
// Сценарій 3: Запуск з правами адміністратора
var runAsAdmin = new ProcessStartInfo
{
FileName = "notepad.exe",
Arguments = @"C:\Windows\System32\drivers\etc\hosts",
UseShellExecute = true,
Verb = "runas" // ініціює UAC prompt
};
// Process.Start(runAsAdmin);
// Сценарій 4: Програмна взаємодія (UseShellExecute = false обов'язково!)
var psi = new ProcessStartInfo
{
FileName = "git", // команда у PATH
Arguments = "log --oneline -10",
UseShellExecute = false,
RedirectStandardOutput = true, // читаємо stdout програмно
RedirectStandardError = true, // читаємо stderr
WorkingDirectory = @"C:\MyProject", // де виконувати команду
CreateNoWindow = true // без зайвого консольного вікна
};
UseShellExecute = true може бути URL або шлях до документа. При UseShellExecute = false — виключно виконуваний файл.Arguments. Кожен аргумент — окремий елемент колекції. .NET автоматично виконує правильне екранування (лапки, пробіли). Рекомендується замість Arguments коли аргументи формуються динамічно з user input.Directory.GetCurrentDirectory().Environment["MY_VAR"] = "value") або змінити існуючі. Ізоляція: зміни не впливають на батьківський процес.true — stdin дочірнього процесу підключається до process.StandardInput (StreamWriter). Дозволяє програмно "вводити" дані як ніби з клавіатури. Вимагає UseShellExecute = false.true — stdout підключається до process.StandardOutput (StreamReader). Потрібно читати stdout, навіть якщо вам не потрібен вміст, інакше stdout буфер може переповнитись і процес зависне.true — процес не створює нове вікно консолі. При UseShellExecute = false для консольних застосунків — за замовчуванням вікно не з'являється, але краще указати явно.UseShellExecute = true: Normal, Minimized, Maximized, Hidden. При UseShellExecute = false — ігнорується.Найпростіший спосіб — дочекатися завершення процесу і прочитати весь вивід:
using System.Diagnostics;
// ❌ НЕБЕЗПЕЧНО: синхронне послідовне читання stdout потім stderr!
// Якщо процес генерує багато stderr, буфер ПЕРЕПОВНИТЬСЯ і виникне deadlock
static string RunCommandUnsafe(string command, string arguments)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
process.Start();
// DEADLOCK RISK: якщо stderr переповниться, процес чекає — а ми чекаємо stdout
string stdout = process.StandardOutput.ReadToEnd(); // блокуємо тут...
string stderr = process.StandardError.ReadToEnd(); // ...ніколи не доходимо
process.WaitForExit();
return stdout;
}
// ✅ ПРАВИЛЬНО: async паралельне читання обох потоків
static async Task<(string Output, string Error, int ExitCode)> RunCommandAsync(
string command, string arguments, CancellationToken ct = default)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
process.Start();
// Запускаємо читання stdout і stderr паралельно,
// що унеможливлює deadlock від переповнення буфера
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> errorTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string output = await outputTask;
string error = await errorTask;
return (output, error, process.ExitCode);
}
// Використання
var (output, error, code) = await RunCommandAsync("git", "log --oneline -5");
Console.WriteLine($"Exit: {code}");
Console.WriteLine(output);
if (!string.IsNullOrEmpty(error))
Console.Error.WriteLine($"Errors:\n{error}");
Для довгоіснуючих процесів (збірка, тести, міграція БД) потрібно отримувати рядки по мірі їх появи, а не чекати завершення:
using System.Diagnostics;
static async Task<int> RunWithStreamingOutputAsync(
string command,
string arguments,
Action<string>? onOutput = null,
Action<string>? onError = null,
CancellationToken ct = default)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
// Підписуємося на події ДО виклику Start()
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
onOutput?.Invoke(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
onError?.Invoke(e.Data);
};
process.Start();
// Активуємо асинхронне читання — тепер події будуть вогнем
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(ct);
// WaitForExitAsync без аргументу (або з CT) для BeginOutputReadLine
// правильно чекатиме дочитування буферів після завершення процесу
return process.ExitCode;
}
// Приклад: запуск dotnet test з живим виводом
int exitCode = await RunWithStreamingOutputAsync(
command: "dotnet",
arguments: "test ./MyTests/ --verbosity normal",
onOutput: line =>
{
// Підфарбовуємо рядки за змістом
if (line.Contains("PASSED"))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($" ✓ {line}");
}
else if (line.Contains("FAILED"))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" ✗ {line}");
}
else
{
Console.ResetColor();
Console.WriteLine($" {line}");
}
Console.ResetColor();
},
onError: line => Console.Error.WriteLine($"ERR: {line}")
);
Console.WriteLine($"\nExit code: {exitCode}");
OutputDataReceived після Start() — перші рядки виводу можуть загубитись у race window між запуском процесу та реєстрацією обробника. Завжди: спочатку +=, потім Start().Існує два принципово різних способи зупинити процес:
CloseMainWindow() — "м'яке" завершення. Відправляє повідомлення WM_CLOSE головному вікну процесу (через Windows Message Queue). Еквівалентно натисканню хрестика. Процес може обробити це повідомлення: зберегти файли, запитати підтвердження чи ігнорувати взагалі. Повертає true якщо повідомлення відправлено успішно (не означає, що процес завершився). Не працює для консольних застосунків без вікна.
Kill() / Kill(entireProcessTree: true) — примусове завершення. Аналог TerminateProcess() у Win32 API. Процес негайно завершується без можливості обробки: файли не зберігаються, з'єднання не закриваються, деструктори не викликаються. .NET 5+ додав параметр entireProcessTree: true, що вбиває і всі дочірні процеси.
using System.Diagnostics;
static async Task GracefulShutdownAsync(Process process, int gracePeriodMs = 5000)
{
// Спочатку пробуємо graceful shutdown
bool hasClosed = process.CloseMainWindow();
if (!hasClosed)
{
// Немає головного вікна — для консольних застосунків можна надіслати Ctrl+C через stdin
// Або одразу переходити до Kill()
Console.WriteLine("Немає вікна — примусовий Kill");
process.Kill(entireProcessTree: true);
return;
}
// Чекаємо грейс-period на самостійне завершення
using var cts = new CancellationTokenSource(gracePeriodMs);
try
{
await process.WaitForExitAsync(cts.Token);
Console.WriteLine($"Процес завершився сам. ExitCode={process.ExitCode}");
}
catch (OperationCanceledException)
{
Console.WriteLine($"Не завершився за {gracePeriodMs}ms — примусово вбиваємо");
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
}
// Реакція на завершення через подію (не блокує потік)
process.EnableRaisingEvents = true;
process.Exited += (sender, _) =>
{
var p = (Process)sender!;
Console.WriteLine($"Процес {p.ProcessName} (PID={p.Id}) завершився. Code={p.ExitCode}");
};
Деякі CLI утиліти очікують введення з stdin (паролі, підтвердження, конфігурація):
using System.Diagnostics;
// Імітуємо введення даних до інтерактивної програми
static async Task RunInteractiveAsync()
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "powershell",
Arguments = "-NoLogo -NoProfile -NonInteractive",
UseShellExecute = false,
RedirectStandardInput = true, // будемо "вводити" команди програмно
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
process.Start();
Task<string> outputTask = process.StandardOutput.ReadToEndAsync();
Task<string> errorTask = process.StandardError.ReadToEndAsync();
// Пишемо команди у stdin PowerShell
await process.StandardInput.WriteLineAsync("Get-Date");
await process.StandardInput.WriteLineAsync("$env:USERNAME");
await process.StandardInput.WriteLineAsync("exit");
process.StandardInput.Close(); // сигналізуємо EOF — PowerShell завершиться
await process.WaitForExitAsync();
Console.WriteLine(await outputTask);
}
Побудуємо повноцінний інструмент автоматизації збірки, що виконує послідовність shell-команд з перехопленням виводу, вимірюванням часу кожного кроку та генерацією звіту.
dotnet new console -n BuildRunner
cd BuildRunner
namespace BuildRunner;
/// <summary>Опис одного кроку збірки</summary>
record BuildStep(
string Name,
string Command,
string Arguments,
string? WorkingDirectory = null,
bool ContinueOnError = false
);
/// <summary>Результат виконання кроку</summary>
record StepResult(
string StepName,
bool Success,
int ExitCode,
string Output,
string Error,
TimeSpan Duration
);
using System.Diagnostics;
using System.Text;
using BuildRunner;
class StepRunner
{
public async Task<StepResult> RunAsync(BuildStep step, CancellationToken ct = default)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\n▶ [{step.Name}]");
Console.WriteLine($" {step.Command} {step.Arguments}");
Console.ResetColor();
var outputBuffer = new StringBuilder();
var errorBuffer = new StringBuilder();
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = step.Command,
Arguments = step.Arguments,
WorkingDirectory = step.WorkingDirectory ?? Directory.GetCurrentDirectory(),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
process.OutputDataReceived += (_, e) =>
{
if (e.Data is null) return;
outputBuffer.AppendLine(e.Data);
Console.WriteLine($" {e.Data}"); // live streaming
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is null) return;
errorBuffer.AppendLine(e.Data);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Error.WriteLine($" [WARN] {e.Data}");
Console.ResetColor();
};
var sw = Stopwatch.StartNew();
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(ct);
sw.Stop();
bool success = process.ExitCode == 0;
var statusColor = success ? ConsoleColor.Green : ConsoleColor.Red;
Console.ForegroundColor = statusColor;
Console.WriteLine($" {(success ? "✅" : "❌")} [{step.Name}] exit={process.ExitCode}, time={sw.Elapsed.TotalSeconds:F2}s");
Console.ResetColor();
return new StepResult(
step.Name,
success,
process.ExitCode,
outputBuffer.ToString(),
errorBuffer.ToString(),
sw.Elapsed
);
}
}
using BuildRunner;
class BuildPipeline(IEnumerable<BuildStep> steps)
{
private readonly List<BuildStep> _steps = steps.ToList();
private readonly StepRunner _runner = new();
public async Task<List<StepResult>> RunAsync(CancellationToken ct = default)
{
var results = new List<StepResult>();
Console.WriteLine($"🚀 Починаємо збірку: {_steps.Count} кроків");
Console.WriteLine(new string('═', 60));
foreach (var step in _steps)
{
if (ct.IsCancellationRequested)
{
Console.WriteLine("\n⚠️ Збірку скасовано");
break;
}
var result = await _runner.RunAsync(step, ct);
results.Add(result);
if (!result.Success && !step.ContinueOnError)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n🛑 Зупинено на кроці [{step.Name}] — помилка!");
Console.ResetColor();
break;
}
}
PrintSummary(results);
return results;
}
private static void PrintSummary(List<StepResult> results)
{
Console.WriteLine("\n" + new string('═', 60));
Console.WriteLine("📊 ЗВІТ ЗБІРКИ");
Console.WriteLine(new string('─', 60));
var total = TimeSpan.FromTicks(results.Sum(r => r.Duration.Ticks));
int success = results.Count(r => r.Success);
int failed = results.Count(r => !r.Success);
foreach (var r in results)
{
string icon = r.Success ? "✅" : "❌";
Console.WriteLine($"{icon} {r.StepName,-30} {r.Duration.TotalSeconds,6:F2}s exit={r.ExitCode}");
}
Console.WriteLine(new string('─', 60));
Console.WriteLine($"Кроків: {results.Count} | ✅ {success} | ❌ {failed} | Час: {total.TotalSeconds:F2}s");
}
}
using BuildRunner;
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Console.WriteLine("\n⚠️ Отримано Ctrl+C — скасовуємо збірку...");
cts.Cancel();
};
// Конфігурація пайплайну
var pipeline = new BuildPipeline(
[
new BuildStep("Restore", "dotnet", "restore", WorkingDirectory: "./MyProject"),
new BuildStep("Build", "dotnet", "build --no-restore -c Release", WorkingDirectory: "./MyProject"),
new BuildStep("Test", "dotnet", "test --no-build -c Release", WorkingDirectory: "./MyProject"),
new BuildStep("Publish", "dotnet", "publish --no-build -c Release -o ./publish", WorkingDirectory: "./MyProject"),
new BuildStep("Artifacts", "powershell", "-c \"Get-ChildItem ./publish | Select Name, Length\"",
ContinueOnError: true) // помилка тут не зупиняє збірку
]);
var results = await pipeline.RunAsync(cts.Token);
Environment.Exit(results.Any(r => !r.Success) ? 1 : 0);
dotnet run
Process API
GetCurrentProcess(), GetProcesses(), GetProcessesByName(), GetProcessById()Id, WorkingSet64, PrivateMemorySize64, Threads, HandleCountIDisposable — завжди usingProcessStartInfo
UseShellExecute = false — програмна взаємодіяUseShellExecute = true — відкрити документ/URLArgumentList безпечніший за Arguments для user inputStdout/Stderr Capture
OutputDataReceived + BeginOutputReadLine — real-time streamingStart()Завершення
CloseMainWindow() — graceful, чекає відповіді від вікнаKill(entireProcessTree: true) — примусово, вбиває і дочірніWaitForExitAsync() + CancellationToken — таймаутНапишіть консольну утиліту, що:
--filter <name> для фільтрації за ім'ям процесу--sort cpu)Напишіть інструмент, що:
commands.txt (по одній на рядок)Task.WhenAll)report.md з результатами кожної командиРеалізуйте watchdog-службу:
watchdog.json: список процесів для моніторингу з maxRamMb, maxCpuPercent, restartCommandrestartCommandCtrl+C (CancellationToken)Як Працює Операційна Система
Фундаментальне розуміння ОС, процесів та потоків як основа для системного програмування на C#. Від kernel mode до context switch — все, що потрібно знати перед вивченням багатопоточності.
Процеси в .NET — IPC та Міжпроцесна Комунікація
Поглиблений розбір механізмів міжпроцесної комунікації в .NET — Named Pipes, Memory-Mapped Files, архітектурні патерни та наскрізний приклад IPC чат-сервера від A до Я.