Share:

Берем процессы под контроль в .NET

YPermitinв.NET

2024-07-31

#.NET

#C#

#разработка

#процессы

#контроль

Контроль - это очень опасная страсть, если вы зайдёте слишком далеко,
то рискуете полностью его потерять.
(с) Эрика Стрэйндж

При разработке собственных решений не редко возникает потребность в запуске дополнительных исполняемых процессов. Не всегда эти процессы представлены собственными приложениями. Часто это внешние программы, доступа к исходному коду которых у нас нет. Поэтому нам приходится адаптироваться.

Содержание

Суть вопроса

Сегодня и здесь мы будем запускать процессы. Свои и не только. Но самое главное - мы будем контролировать их работу и разбирать их состояние. Запустить процесс дело не трудное, но контролировать его и убедиться, что он не завис - дело совсем непростое.

Мы пройдемся от самых простых и плохих примеров, до самых сложных. Здесь нет готовых и универсальных решений, только указание направления для поисков нужных ответов.

В качестве подопытного стороннего приложения мы будем использовать утилиту "Bulk Copy Insert" (BCP.exe), используемую для массовой выгрузки / загрузки данных из баз данных SQL Server. Сама утилита устанавливается вместе с другими компонентами SQL Server, подробнее читайте по ссылке. Но и этим примеры не ограничатся.

И хоть примеры будут сделаны в среде Windows, фактически информация актуальная как при разработке приложений для *.nix / MaxOS. Итак, поехали!

Исходный материал

Для всех последующих примеров с утилитой "Bulk Copy Insert" (BCP.exe) мы создадим базу "TestDB" со следующими объектами:

CREATE TABLE [dbo].[Programms](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](50) NULL,
[AuhthorName] [nvarchar](50) NULL,
[Price] [int] NULL,
PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

Таблица Programms содержит некоторую сгенерированную информацию о программах. Чтобы создать достаточный объем данных для тестов, заполним ее следующим скриптом, добавив порядка 10 миллионов записей.

Declare @count int
Set @count = 1
DECLARE
@Name nvarchar(50),
@AuhthorName nvarchar(50),
@Price int;
While @count <= 9000000
Begin
Select @Name = 'Prog - ' + CAST(Rand() AS nvarchar(max)) + ' - ' + CAST(GETDATE() AS nvarchar(max));
Select @AuhthorName = 'Dev - ' + CAST(Rand() AS nvarchar(max));
Select @Price = Rand() * 100;
Insert Into [dbo].[Programms] values (@Name, @AuhthorName, @Price)
Print @count
Set @count = @count + 1
End

Допустим, нам нужно выгрузить эти данные в файл, чтобы потом загрузить на другом сервере в таблицу аналогичной структуры. Утилита BCP для этого дела подходящий вариант.

В случае, если бы мы запускали утилиту полностью вручную, то команда выгрузки данных в файл выглядела бы следующим образом:

# Команда имеет следующие параметры:
# > bcp - имя утилиты для запуска.
# > dbo.Programms - имя таблицы для выгрузки данных.
# > out - это направление выгрузки. В данном случае выгрузка ИЗ базы В файл.
# Путь к файлу указан после этого параметра.
# > -d - имя базы.
# > -l - таймаут установки соединения. В примере 15 секунд.
# > -P - пароль пользователя для входа.
# > -S - имя инстанса SQL Server для подключения.
# > -U - имя пользователя. В нашем примере это пользователь SA.
# > -N - признак использования нативных типов данных базы данных для выгрузки.
# О некоторых случаях значительно ускоряет операции выгрузки / загрузки данных.
# > -o - путь к файлу вывода полного лога работы утилиты BCP.
#
bcp dbo.Programms out "C:\Temp\BCP\Programms.bak"
-d TestDB
-l 15
-P "<ПарольПользователя>"
-S "SRV-SQL-1"
-U "sa"
-N
-o "C:\Temp\BCP\Programms_Export.out"

В результате мы получим файл "C:\Temp\BCP\Programms.bak" c данными исходной таблицы. А по пути "C:\Temp\BCP\Programms_Export.out" можно посмотреть подробный лог работы утилиты. Пример части вывода в файл лога:

Starting copy...
1000 rows successfully bulk-copied to host-file. Total received: 1000
1000 rows successfully bulk-copied to host-file. Total received: 2000
1000 rows successfully bulk-copied to host-file. Total received: 3000
1000 rows successfully bulk-copied to host-file. Total received: 4000
1000 rows successfully bulk-copied to host-file. Total received: 5000
1000 rows successfully bulk-copied to host-file. Total received: 6000
1000 rows successfully bulk-copied to host-file. Total received: 7000
1000 rows successfully bulk-copied to host-file. Total received: 8000
1000 rows successfully bulk-copied to host-file. Total received: 9000
1000 rows successfully bulk-copied to host-file. Total received: 10000
1000 rows successfully bulk-copied to host-file. Total received: 11000
1000 rows successfully bulk-copied to host-file. Total received: 12000
...

Если бы мы выполняли команду, наоборот, для импорта данных из файла в, например, таблицу dbo.Programms_New той же структуры, то выглядела бы она следующим образом:

# Команда имеет следующие параметры:
# > bcp - имя утилиты для запуска.
# > dbo.Programms_New - имя таблицы для загрузки данных.
# > in - это направление загрузки. В данном случае загрузка ИЗ файла В базу.
# Путь к файлу указан после этого параметра.
# > -b - размер пакета для загрузки данных. В примере 10000 записей.
# > -d - имя базы.
# > -l - таймаут установки соединения. В примере 15 секунд.
# > -P - пароль пользователя для входа.
# > -S - имя инстанса SQL Server для подключения.
# > -U - имя пользователя. В нашем примере это пользователь SA.
# > -N - признак использования нативных типов данных базы данных для загрузки.
# О некоторых случаях значительно ускоряет операции выгрузки / загрузки данных.
# > -o - путь к файлу вывода полного лога работы утилиты BCP.
#
bcp dbo.Programms_New in "C:\Temp\BCP\Programms.bak"
-b 10000
-d TestDB
-l 15
-P "<ПарольПользователя>"
-S "SRV-SQL-1"
-U "sa"
-N
-o "C:\Temp\BCP\Programms_Import.out"

В целом, логика работы команды такая же, как и при выгрузке, за исключением направления выгрузки и параметра размера пакета.

Как итог, у нас есть тестовая база данных и команды для утилиты BCP, которые нам нужно выполнять программно. Хоть мы привели две команды - для выгрузки и для загрузки, в примерах мы будем использовать только первую, т.к. цель у нас это работа с процессами, а не манипуляции с данными.

Вперед и за дело!

Просто и плохо

Сначала рассмотрим самое топорное решение для запуска приложения. Ну, если честно, он не совсем топорное, но что просто так точно. В .NET есть встроенный системный класс Process, который предоставляет доступ к локальным и удаленным процессам, в том числе и для управления ими. Ничто не мешает нам запустить нужный процесс через него.

Ниже представлен пример запуска команды экспорта через утилиту BCP:

// Параметры запуска утилиты
string userName = "sa";
string userPassword = "<ПарольПользователя>";
string objectName = "dbo.Programms";
string uploadFilePath = "C:\Temp\BCP\Programms.bak";
string databaseName = "TestDB";
string instanceName = "SRV-SQL-1";
string logFilePath = "C:\Temp\BCP\Programms_Export.out";
// Инициализация объекта с параметрами запуска приложения
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.FileName = "bcp";
startInfo.Arguments =
$"{objectName} out "{uploadFilePath}" " +
$"-d {databaseName} " +
"-l 15 " +
$"-P "{userPassword}" " +
$"-S "{instanceName}" " +
$"-U "{userName}" " +
"-N " +
$"-o "{logFilePath}"";
// Запуск процесса
using (Process process = Process.Start(startInfo))
{
// ... и ожидание завершения приложения
// Примечание: можно ожидать завершения таким способом,
// но тогда мы не сможем интерактивно выводить
// сообщение об ожидании process.WaitForExit()
while (!process.HasExited)
{
Console.WriteLine($"[{DateTime.Now}]: Ожидаем завершения...");
Thread.Sleep(1000);
}
}
Console.WriteLine($"[{DateTime.Now}]: Работа процесса завершена.");
// Выводим на экран последние 10 строк лога работы утилиты
Console.WriteLine($"[{DateTime.Now}]: Вывод работы программы (последние 10 строк):");
Console.WriteLine("<<<<<");
if (File.Exists(logFilePath))
{
var lastOutputLines = File.ReadAllLines(logFilePath)
.TakeLast(10)
.ToList();
foreach (var line in lastOutputLines)
{
Console.WriteLine(line);
}
}
Console.WriteLine(">>>>>");

Тестовая программа сначала задает параметры запуска утилиты BCP, а после формирует параметры запуска процесса через класс ProcessStartInfo, который задает набор параметров для запуска.

Затем выполняется запуск непосредственно процесса через класс Process вызовом метода Start и передачей объекта типа ProcessStartInfo, который мы инициировали ранее.

Класс Process реализует интерфейс IDisposable, поэтому используется конструкция using для освобождения ресурсов после завершения работы с процессом. Затем в цикле проверяем не завершился ли процесс и так ожидаем, пока он не сделает свое дело. Ну а после завершения выводим последние 10 строк файла с логом работы утилиты.

Пример можно было сделать еще проще, не обращаясь к классу ProcessStartInfo, но здесь мы задействовали его для наглядности.

Пример вывода утилиты может быть таким:

[31.07.2024 12:27:18]: Ожидаем завершения...
[31.07.2024 12:27:19]: Ожидаем завершения...
[31.07.2024 12:27:20]: Ожидаем завершения...
[31.07.2024 12:27:21]: Ожидаем завершения...
[31.07.2024 12:27:22]: Ожидаем завершения...
[31.07.2024 12:27:23]: Ожидаем завершения...
[31.07.2024 12:27:24]: Ожидаем завершения...
[31.07.2024 12:27:25]: Ожидаем завершения...
[31.07.2024 12:27:26]: Ожидаем завершения...
[31.07.2024 12:27:27]: Ожидаем завершения...
[31.07.2024 12:27:28]: Ожидаем завершения...
[31.07.2024 12:27:29]: Ожидаем завершения...
[31.07.2024 12:27:30]: Ожидаем завершения...
[31.07.2024 12:27:31]: Работа процесса завершена.
[31.07.2024 12:27:31]: Вывод работы программы (последние 10 строк):
<<<<<
1000 rows successfully bulk-copied to host-file. Total received: 10395000
1000 rows successfully bulk-copied to host-file. Total received: 10396000
1000 rows successfully bulk-copied to host-file. Total received: 10397000
1000 rows successfully bulk-copied to host-file. Total received: 10398000
1000 rows successfully bulk-copied to host-file. Total received: 10399000
1000 rows successfully bulk-copied to host-file. Total received: 10400000
10400008 rows copied.
Network packet size (bytes): 4096
Clock Time (ms.) Total : 12594 Average : (825790.69 rows per sec.)
>>>>>

Таким образом, запускать простые приложения уже можно, и даже ожидать их завершения. Но что, если запущенный процесс зависнет или начнет ожидать какого-то интерактивного действия от пользователя? Тогда мы будем ждать вечно!

А чтобы этого не произошло, усложним пример!

Завис или не завис?

Теперь нам нужно модифицировать свою программу для решения следующих проблем:

  • Ограничение времени выполнения?
  • Зависло ли приложение?
  • Есть ли активность приложения?

Начнем по-порядку. Предположим, что для работы процесса отведено максимум 1 минута. Этот шаг реализовать не сложно, ведь мы уже в цикле проверяем завершено ли приложение. Ничто нам не мешает ограничить работу цикла указанным временем, а по окончанию времени принудительно завершить процесс. Например, мы можем сделать это так:

// Параметры запуска утилиты
string userName = "sa";
string userPassword = "<ПарольПользователя>";
string objectName = "dbo.Programms";
string uploadFilePath = "C:TempBCPProgramms.bak";
string databaseName = "TestDB";
string instanceName = "SRV-SQL-1";
string logFilePath = "C:TempBCPProgramms_Export.out";
// !!! Таймаут выполнения процесса
int timeoutSeconds = 60;
// Инициализация объекта с параметрами запуска приложения
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.FileName = "bcp";
startInfo.Arguments =
$"{objectName} out "{uploadFilePath}" " +
$"-d {databaseName} " +
"-l 15 " +
$"-P "{userPassword}" " +
$"-S "{instanceName}" " +
$"-U "{userName}" " +
"-N " +
$"-o "{logFilePath}"";
// Запуск процесса
DateTime startProcess = DateTime.Now;
using (Process process = Process.Start(startInfo))
{
// ... и ожидание завершения приложения
// Примечание: можно ожидать завершения таким способом,
// но тогда мы не сможем интерактивно выводить
// сообщение об ожидании.
// В качестве параметра передаем таймаут ожидания
// process.WaitForExit(timeoutSeconds * 1000);
// !!! Обновляем время работы программы
int workingTimeSec = (int)((DateTime.Now - startProcess).TotalSeconds);
while (!process.HasExited
// !!! Проверка времени со старта процесса на превышение таймаута
&& workingTimeSec <= timeoutSeconds)
{
Console.WriteLine($"[{DateTime.Now}]: Ожидаем завершения. Время работы {workingTimeSec}/{timeoutSeconds} (с)...");
Thread.Sleep(1000);
// !!! Обновляем время работы программы
workingTimeSec = (int)((DateTime.Now - startProcess).TotalSeconds);
}
// !!! Если процесс еще активен, то завершаем его принудительно
if (!process.HasExited)
{
process.Kill(true);
}
Console.WriteLine($"[{DateTime.Now}]: Работа процесса завершена. Код завершения: {process.ExitCode}");
}
// Выводим на экран последние 10 строк лога работы утилиты
Console.WriteLine($"[{DateTime.Now}]: Вывод работы программы (последние 10 строк):");
Console.WriteLine("<<<<<");
if (File.Exists(logFilePath))
{
var lastOutputLines = File.ReadAllLines(logFilePath)
.TakeLast(10)
.ToList();
foreach (var line in lastOutputLines)
{
Console.WriteLine(line);
}
}
Console.WriteLine(">>>>>");

Теперь мы установили программе время на работу, при превышении которого процесс будет принудительно завершен. На каждой итерации цикла ожидания завершения процесса выводим время работы и проверяем превышения таймаута. При выходе за пределы времени ожидания, процесс завершаем принудительно, если он еще активен. Пример вывода программы, если программа уложилась в отведенное время и превышения таймаута не случилось:

[31.07.2024 12:54:01]: Ожидаем завершения. Время работы 0/60 (с)...
[31.07.2024 12:54:02]: Ожидаем завершения. Время работы 1/60 (с)...
[31.07.2024 12:54:03]: Ожидаем завершения. Время работы 2/60 (с)...
[31.07.2024 12:54:04]: Ожидаем завершения. Время работы 3/60 (с)...
[31.07.2024 12:54:05]: Ожидаем завершения. Время работы 4/60 (с)...
[31.07.2024 12:54:06]: Ожидаем завершения. Время работы 5/60 (с)...
[31.07.2024 12:54:07]: Ожидаем завершения. Время работы 6/60 (с)...
[31.07.2024 12:54:08]: Ожидаем завершения. Время работы 7/60 (с)...
[31.07.2024 12:54:09]: Ожидаем завершения. Время работы 8/60 (с)...
[31.07.2024 12:54:10]: Ожидаем завершения. Время работы 9/60 (с)...
[31.07.2024 12:54:11]: Ожидаем завершения. Время работы 10/60 (с)...
[31.07.2024 12:54:12]: Ожидаем завершения. Время работы 11/60 (с)...
[31.07.2024 12:54:13]: Ожидаем завершения. Время работы 12/60 (с)...
[31.07.2024 12:54:14]: Работа процесса завершена. Код завершения: 0
[31.07.2024 12:54:14]: Вывод работы программы (последние 10 строк):
<<<<<
1000 rows successfully bulk-copied to host-file. Total received: 10395000
1000 rows successfully bulk-copied to host-file. Total received: 10396000
1000 rows successfully bulk-copied to host-file. Total received: 10397000
1000 rows successfully bulk-copied to host-file. Total received: 10398000
1000 rows successfully bulk-copied to host-file. Total received: 10399000
1000 rows successfully bulk-copied to host-file. Total received: 10400000
10400008 rows copied.
Network packet size (bytes): 4096
Clock Time (ms.) Total : 12141 Average : (856602.25 rows per sec.)
>>>>>

При превышении таймаута вывод программы будет таким (для проверки поставили таймаут в 2 секунды):

[31.07.2024 12:55:25]: Ожидаем завершения. Время работы 0/2 (с)...
[31.07.2024 12:55:26]: Ожидаем завершения. Время работы 1/2 (с)...
[31.07.2024 12:55:27]: Ожидаем завершения. Время работы 2/2 (с)...
[31.07.2024 12:55:28]: Работа процесса завершена. Код завершения: -1
[31.07.2024 12:55:28]: Вывод работы программы (последние 10 строк):
<<<<<
1000 rows successfully bulk-copied to host-file. Total received: 2195000
1000 rows successfully bulk-copied to host-file. Total received: 2196000
1000 rows successfully bulk-copied to host-file. Total received: 2197000
1000 rows successfully bulk-copied to host-file. Total received: 2198000
1000 rows successfully bulk-copied to host-file. Total received: 2199000
1000 rows successfully bulk-copied to host-file. Total received: 2200000
1000 rows successfully bulk-copied to host-file. Total received: 2201000
1000 rows successfully bulk-copied to host-file. Total received: 2202000
1000 rows successfully bulk-copied to host-file. Total received: 2203000
1000 rows succ
>>>>>

Таким образом, бесконечного ожидания завершения процесса уже не будет и окончательного "подвисания" не случится. Однако для полной надежности необходимо проверять активно ли приложение. Это можно сделать несколькими способами:

  • Есть ли вывод на консоль новых сообщений, если это, конечно, консольное приложение.
  • Есть ли какая-либо активность процесса в части потребления ЦП.
Реализуем эти проверки, дополнив пример выше.

Для создания ситуации подвисания приложения закомментируем параметры запуска утилиты BCP:

// Инициализация объекта с параметрами запуска приложения
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.FileName = "bcp";
startInfo.Arguments =
$"{objectName} out "{uploadFilePath}" " +
$"-d {databaseName} " +
"-l 15 " +
$"-P "{userPassword}" " +
$"-S "{instanceName}" " +
$"-U "{userName}" ";
// Отключаем использование нативных типов базы данных
// и вывод лога работы утилиты в файл.
//"-N " +
//$"-o "{logFilePath}"";

Если бы мы запустили утилиту с такими параметрами интерактивно в терминале, то получили бы интерактивный запрос на уточнение параметров:

Enter the file storage type of field Id [int]:

Конечно, таймаут выполнения процесса через 60 секунд сработал бы и процесс был бы завершен. Но в некоторых сценариях этот вариант бы не сработал. Например, запускаемое приложение может делать какую-то тяжелую работу и выполняться 10 минут. Мы установим таймаут, например. 15 минут. Но если программа будет запущена и получит запрос на интерактивный ввод сразу же, то мы фактически теряем эти 15 минут на ожидание, хотя сразу можно было бы узнать, что ничего не произойдет.

В таких случаях на поможет динамический анализ потребления ресурсов ЦП запущенным процессом. Можно сказать, что если процесс не использовал ресурсы ЦП какое-то обозначенное время, то процесс можно считать зависшим и завершить. Ниже дополненный пример с новым ограничительным параметром - таймаутом простоя приложения.

// Параметры запуска утилиты
string userName = "sa";
string userPassword = "<ПарольПользователя>";
string objectName = "dbo.Programms";
string uploadFilePath = "C:\Temp\BCP\Programms.bak";
string databaseName = "TestDB";
string instanceName = "SRV-SQL-1";
string logFilePath = "C:\Temp\BCP\Programms_Export.out";
// Таймаут выполнения процесса
int timeoutSeconds = 60;
// Таймаут простоя процесса
int timeoutProcessCpuIdleSec = 5;
// !!! Последнее значение использования ресурсов ЦП процессом
TimeSpan lastCpuProcessUsage = TimeSpan.MinValue;
// !!! Дата последней активности процеса
DateTime lastCpuActivity = DateTime.MinValue;
// !!! Время простоя в секундах с последней активности
int processCpuIdleSec = 0;
// Инициализация объекта с параметрами запуска приложения
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
// Переопредлеяем вывод сообщений из стандартной консоли
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardInput = true;
startInfo.FileName = "bcp";
startInfo.Arguments =
$"{objectName} out "{uploadFilePath}" " +
$"-d {databaseName} " +
"-l 15 " +
$"-P "{userPassword}" " +
$"-S "{instanceName}" " +
$"-U "{userName}" " +
"-N " +
$"-o "{logFilePath}"";
// Запуск процесса
DateTime startProcess = DateTime.Now;
using (Process process = Process.Start(startInfo))
{
// ... и ожидание завершения приложения
// Примечание: можно ожидать завершения таким способом,
// но тогда мы не сможем интерактивно выводить
// сообщение об ожидании.
// В качестве параметра передаем таймаут ожидания
// process.WaitForExit(timeoutSeconds * 1000);
// Обновляем время работы программы
int workingTimeSec = (int)((DateTime.Now - startProcess).TotalSeconds);
while (!process.HasExited
// !!! Проверка времени со старта процесса на превышение таймаута
&& workingTimeSec <= timeoutSeconds
&& processCpuIdleSec <= timeoutProcessCpuIdleSec)
{
Console.WriteLine($"[{DateTime.Now}]: Ожидаем завершения. Время работы {workingTimeSec}/{timeoutSeconds} (с). " +
$"Использование ЦП: {process.TotalProcessorTime}...");
Thread.Sleep(1000);
// Обновляем время работы программы
workingTimeSec = (int)((DateTime.Now - startProcess).TotalSeconds);
// !!! Обновлям информацию о последней активности процесса
if (lastCpuProcessUsage != process.TotalProcessorTime)
{
lastCpuProcessUsage = process.TotalProcessorTime;
lastCpuActivity = DateTime.Now;
processCpuIdleSec = 0;
}
if (lastCpuActivity != DateTime.MinValue)
{
processCpuIdleSec = (int)(DateTime.Now - lastCpuActivity).TotalSeconds;
}
}
// Если процесс еще активен, то завершаем его принудительно
if (!process.HasExited)
{
process.Kill(true);
}
Console.WriteLine($"[{DateTime.Now}]: Работа процесса завершена. Код завершения: {process.ExitCode}");
}
// Выводим на экран последние 10 строк лога работы утилиты
Console.WriteLine($"[{DateTime.Now}]: Вывод работы программы (последние 10 строк):");
Console.WriteLine("<<<<<");
if (File.Exists(logFilePath))
{
var lastOutputLines = File.ReadAllLines(logFilePath)
.TakeLast(10)
.ToList();
foreach (var line in lastOutputLines)
{
Console.WriteLine(line);
}
}
Console.WriteLine(">>>>>");

Здесь мы в самом начале обозначили переменную для хранения таймаута простоя приложения timeoutProcessCpuIdleSec в значении 5 секунд. То есть, если запущенный процесс не использует ЦП 5 секунд, то приложение считаем зависшим. Вспомогательные переменные lastCpuProcessUsage и lastCpuActivity используются для отслеживания состояния процесса в динамике. По ним определяется изменилось ли потребление ресурсов ЦП с последней проверки, а результат в виде длительности простоя сохраняется в переменную processCpuIdleSec.

Затем в цикле проверяется, чтобы длительность простоя не привысила заданный в переменной timeoutProcessCpuIdleSec таймаут. Таким образом, у нас реализована двойная защита от зависания процесса:

  • Таймаут работы приложения (в нашем примере это 60 секунд), защищающий от общего подвисания или неприемлемо долгого выполнения процесса.
  • Таймаут простоя приложения (в нашем примере это 5 секунд), защищающий от зависания процесса.
Стоит отметить, что параметры таймаута нужно подбирать в зависимости от типа процесса, а также специфики работы, которую он проделывает. Ниже пример вывода при срабатывании таймаута простоя процесса:

[31.07.2024 14:21:56]: Ожидаем завершения. Время работы 0/60 (с). Использование ЦП: 00:00:00.0312500...
[31.07.2024 14:21:57]: Ожидаем завершения. Время работы 1/60 (с). Использование ЦП: 00:00:00.0468750...
[31.07.2024 14:21:58]: Ожидаем завершения. Время работы 2/60 (с). Использование ЦП: 00:00:00.0468750...
[31.07.2024 14:21:59]: Ожидаем завершения. Время работы 3/60 (с). Использование ЦП: 00:00:00.0468750...
[31.07.2024 14:22:00]: Ожидаем завершения. Время работы 4/60 (с). Использование ЦП: 00:00:00.0468750...
[31.07.2024 14:22:01]: Ожидаем завершения. Время работы 5/60 (с). Использование ЦП: 00:00:00.0468750...
[31.07.2024 14:22:02]: Ожидаем завершения. Время работы 6/60 (с). Использование ЦП: 00:00:00.0468750...
[31.07.2024 14:22:08]: Работа процесса завершена. Код завершения: -1
[31.07.2024 14:22:08]: Вывод работы программы (последние 10 строк):
<<<<<
1000 rows successfully bulk-copied to host-file. Total received: 10395000
1000 rows successfully bulk-copied to host-file. Total received: 10396000
1000 rows successfully bulk-copied to host-file. Total received: 10397000
1000 rows successfully bulk-copied to host-file. Total received: 10398000
1000 rows successfully bulk-copied to host-file. Total received: 10399000
1000 rows successfully bulk-copied to host-file. Total received: 10400000
10400008 rows copied.
Network packet size (bytes): 4096
Clock Time (ms.) Total : 13016 Average : (799017.19 rows per sec.)
>>>>>

В самом начале процесс использовал ЦП, а потом следующие 5 секунд никакой активности не было обнаружено. Процесс в итоге был принудительно завершен.

Однако, даже эти контроли не всегда могут удовлетворить решению задачи.

Читаем вывод консоли

В некоторых сценариях у приложения может не быть возможности вывода результата работы в файл лога, как мы это делали с помощью параметра -o с указанием результатирующего файла. В этих случаях остается единственный вариант получить вывод работы программы - это прочитать консольный вывод.

На основе консольного вывода мы также можем сделать два вида проверок:

  • Таймаут вывода сообщений в консоль. То есть, если приложение долго не подает никаких сообщений, то можем посчитать его зависшим.
  • Анализ вывода на наличие ошибок. Если в консоль выведено сообщение об ошибке, и при этом ожидается какое-то интерактивное действия, то мы можем среагировать и завершить приложение.

Объект класса Process позволяет это сделать. Для этого достаточно перенаправить стандартный вывод консоли, включил следующие опции у процесса:

Также установим свойству CreateNoWindow процесса флаг в True, чтобы для процесса не создавалось окна, ведь интерактивного взаимодействия с ним не предполагается.

Теперь мы можем подписаться на события объекта для обработки поступающих сообщений:

  • OutputDataReceived - событие записи строки в перенаплавненный поток вывода.
  • ErrorDataReceived - событие записи строки в перенаплавненный поток вывода об ошибках.
Но дьявол, как обычно, в деталях. Мы не можем просто так взять и подписаться на события получения событий вывода. Во-первых нам нужно учесть возможность ожидания вывода при ожидании завершения работы процесса. Во-вторых, нам необходимо учесть таймаут вывода сообщений на консоль и собирать их, в том числе для последующего вывода или передачи вызывающему коду приложения.

Также есть еще один важный нюанс, который нужно учесть. Это возможное переполнение внутреннего буфера консольного вывода при ожидании завершения процесса методом ErrorDataReceived. Такое происходит по двум основным причинам:

  • Если начать ожидание завершения процесса перед считыванием потоков вывода StandardOutput и StandardError. Процесс может заблокировать запись в них, из-за чего происходит взаимоблокировка и бесконечное ожидание потоков друг друга.
  • Если начать чтение потоков вывода StandardOutput и StandardError через вызов метода ReadToEnd, процесс также блокирует буфер, что также приводит к взаимоблокировке.

Для решения этих проблем нужно воспользоваться асинхронным чтением данных вывода из потоков, что позволит избежать ситуации переполнения внутреннего буфера. Ниже пример реализации такого чтения, а также таймаута активности консольного вывода приложения:

// Параметры запуска утилиты
string userName = "sa";
string userPassword = "<ПарольПользователя>";
string objectName = "dbo.Programms";
string uploadFilePath = "C:\Temp\BCP\Programms.bak";
string databaseName = "TestDB";
string instanceName = "SRV-SQL-1";
string logFilePath = "C:\Temp\BCP\Programms_Export.out";
// Таймаут выполнения процесса
int timeoutSeconds = 60;
// Таймаут простоя процесса
int timeoutProcessCpuIdleSec = 60;
// Последнее значение использования ресурсов ЦП процессом
TimeSpan lastCpuProcessUsage = TimeSpan.MinValue;
// Дата последней активности процеса
DateTime lastCpuActivity = DateTime.MinValue;
// Время простоя в секундах с последней активности
int processCpuIdleSec = 0;
// !!! Таймаут активности конольного вывода
int timeoutConsoleOutputActivity = 10;
// !!! Время простоя консольного вывода
int consoleOutputIdleSec = 0;
// !!! Информация о выводе консольного приложения
StringBuilder outputMessage = new StringBuilder();
StringBuilder outputErrorMessage = new StringBuilder();
// !!! Даты последнего вывода
DateTime lastOutputDate = DateTime.MinValue;
// !!! Объекты для синхронизации обновления дат последнего вывода на консоль
object lockLastOutputDateUpdate = new object();
// Инициализация объекта с параметрами запуска приложения
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
// !!! Переопредлеяем вывод сообщений из стандартной консоли
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
// !!! Создание окна приложения отключаем
startInfo.CreateNoWindow = true;
startInfo.FileName = "bcp";
startInfo.Arguments =
$"{objectName} out "{uploadFilePath}" " +
$"-d {databaseName} " +
"-l 15 " +
$"-P "{userPassword}" " +
$"-S "{instanceName}" " +
$"-U "{userName}" " +
"-N " +
$"-o "{logFilePath}"";
// !!! Создаем события синхронизации потоков.
// outputWaitHandle - для вывода сообщений основного потока вывода.
// errorWaitHandle - для вывода сообщений потока вывода об ошибках.
using (AutoResetEvent outputWaitHandle = new AutoResetEvent(false))
{
using (AutoResetEvent errorWaitHandle = new AutoResetEvent(false))
{
DateTime startProcess = DateTime.UtcNow;
lastOutputDate = DateTime.UtcNow;
// Запуск процесса
using (Process process = new Process())
{
process.StartInfo = startInfo;
// !!! Подписываемся на события получения сообщений в потоки вывода
process.OutputDataReceived += (_, e) =>
{
if (e.Data == null)
{
// !!! Передаем сигнал на продолжение ожидающего потока
outputWaitHandle.Set();
}
else
{
// !!! Добаляем сообщение вывода и обновляем дату последней активности в консоли
outputMessage.AppendLine(e.Data);
lock (lockLastOutputDateUpdate)
{
lastOutputDate = DateTime.UtcNow;
}
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data == null)
{
// !!! Передаем сигнал на продолжение ожидающего потока
errorWaitHandle.Set();
}
else
{
// !!! Добаляем сообщение вывода и обновляем дату последней активности в консоли
outputErrorMessage.AppendLine(e.Data);
lock (lockLastOutputDateUpdate)
{
lastOutputDate = DateTime.UtcNow;
}
}
};
process.Start();
// Инициируем чтение вывода сообщений на консоль
process.BeginOutputReadLine();
process.BeginErrorReadLine();
// ... и ожидание завершения приложения
// Примечание: можно ожидать завершения таким способом,
// но тогда мы не сможем интерактивно выводить
// сообщение об ожидании.
// !!! В качестве параметра передаем таймаут ожидания в мс.
// int timeoutMs = timeoutSeconds * 1000;
// process.WaitForExit(timeoutMs)
// && outputWaitHandle.WaitOne(timeoutMs)
// && errorWaitHandle.WaitOne(timeoutMs)
// Обновляем время работы программы
int workingTimeSec = (int)((DateTime.UtcNow - startProcess).TotalSeconds);
while (!process.HasExited
// Проверка времени со старта процесса на превышение таймаута
&& (timeoutSeconds == 0 || workingTimeSec <= timeoutSeconds)
// Проверка времени простоя при использовании ЦП
&& (timeoutProcessCpuIdleSec == 0 || processCpuIdleSec <= timeoutProcessCpuIdleSec)
// Проверка времени простоя консоли вывода
&& (timeoutConsoleOutputActivity == 0 || consoleOutputIdleSec <= timeoutConsoleOutputActivity))
{
Console.WriteLine(
$"[{DateTime.UtcNow}]: Ожидаем завершения. Время работы {workingTimeSec}/{timeoutSeconds} (с). " +
$"Использование ЦП: {process.TotalProcessorTime}. Простой консоли: {consoleOutputIdleSec}...");
Thread.Sleep(1000);
// Обновляем время работы программы
workingTimeSec = (int)((DateTime.UtcNow - startProcess).TotalSeconds);
// Обновлям информацию о последней активности процесса
if (lastCpuProcessUsage != process.TotalProcessorTime)
{
lastCpuProcessUsage = process.TotalProcessorTime;
lastCpuActivity = DateTime.UtcNow;
processCpuIdleSec = 0;
}
if (lastCpuActivity != DateTime.MinValue)
{
processCpuIdleSec = (int)(DateTime.UtcNow - lastCpuActivity).TotalSeconds;
}
// !!! Обновление состояния активности консоли
consoleOutputIdleSec = (int)(DateTime.UtcNow - lastOutputDate).TotalSeconds;
}
// Если процесс еще активен, то завершаем его принудительно
if (!process.HasExited)
{
process.Kill(true);
}
Console.WriteLine(
$"[{DateTime.Now}]: Работа процесса завершена. Код завершения: {process.ExitCode}");
}
}
}
// Выводим на экран последние 10 строк лога работы утилиты
Console.WriteLine($"[{DateTime.Now}]: Вывод работы программы (последние 10 строк):");
Console.WriteLine("<<<<<");
if (File.Exists(logFilePath))
{
var lastOutputLines = File.ReadAllLines(logFilePath)
.TakeLast(10)
.ToList();
foreach (var line in lastOutputLines)
{
Console.WriteLine(line);
}
}
Console.WriteLine(">>>>>");

Листинг уже значительно расширился, по сравнению с первыми примерами. Рассмотрим что здесь поменялось.

В самом начале мы объявили ряд новых переменных:

  • timeoutConsoleOutputActivity - таймаут появления новых сообщений в консоли вывода. То есть, если в течении заданного периода времени в консоли не появлялось новых сообщений, то считаем процесс зависшим.
  • consoleOutputIdleSec - здесь будет храниться текущий простой консольного вывода для сравнения его с таймаутом. Значение будет обновляться в цикле ниже.
  • outputMessage и outputErrorMessage - это набор строк, которые пришли из потоков вывода консоли. В будущем эти даные можно использовать для анализа работы программы.
  • lastOutputDate - дата последнего сообщения в консоли.
  • lockLastOutputDateUpdate - объект синхронизации потоков, чтобы безопасно обновлять дату последнего сообщения в консоли lastOutputDate.

Кроме этого мы создаем два объекта типа AutoResetEvent для реализации надежного ожидания завершения процесса, если такое понадобиться использовать. Ведь мы делаем асинхронное чтение сообщений консоли из буфера и логичнее было бы дождаться, когда все они попадут в коллекции outputMessage и outputErrorMessage перед выходом. Это достигается путем вызова метода Set при обнаружении пустого буфера в событиях OutputDataReceived и ErrorDataReceived. Теперь при необходимости ожидать завершения процесса мы можем так:

int timeoutMs = timeoutSeconds * 1000;
process.WaitForExit(timeoutMs)
&& outputWaitHandle.WaitOne(timeoutMs)
&& errorWaitHandle.WaitOne(timeoutMs);

Также, при превышении времени простоя сообщений в консоль процесс будет завершен принудительно. Вот пример вывода программы в такой ситуации:

[31.07.2024 13:31:13]: Ожидаем завершения. Время работы 0/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 0...
[31.07.2024 13:31:14]: Ожидаем завершения. Время работы 5/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 5...
[31.07.2024 13:31:15]: Ожидаем завершения. Время работы 6/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 6...
[31.07.2024 13:31:16]: Ожидаем завершения. Время работы 7/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 7...
[31.07.2024 13:31:17]: Ожидаем завершения. Время работы 8/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 8...
[31.07.2024 13:31:18]: Ожидаем завершения. Время работы 9/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 9...
[31.07.2024 13:31:19]: Ожидаем завершения. Время работы 10/0 (с). Использование ЦП: 00:00:00.0468750. Простой консоли: 10...
[31.07.2024 16:31:20]: Работа процесса завершена. Код завершения: -1
[31.07.2024 16:31:20]: Вывод работы программы (последние 10 строк):
<<<<<
>>>>>

Таким образом, мы решили дополнительно несколько вопросов:

  • Реализован таймаут работы процесса при простое вывода сообщений на консоль.
  • Решена проблема ожидания завершения процесса при чтении консольного вывода, когда могло произойти зависание из-за взаимоблокировки потоков.
  • Возможность анализа вывода на консоль для поиска ошибок и их обработки.

Что еще для счастья нужно при запуске приложений?

Окружение, ресурсы и так далее

К идеалу можно идти бесконечно и запуск процессов не является исключением. Рассмотрим еще несколько сторон контроля запускаемых процессов, которые, возможно, стоит реализовать.

Самое первое, что приходит в голову - это потребление ресурсов. Кто Вам теперь может помешать контролировать, например, потребляемую память процессом. В цикле ожидания завершения процесса можно добавить проверку объема занимаемой памяти процессов и если значение превышено, то процесс также будет завершен принудительно. На листинге эта часть может выглядеть примерно так:

while (!process.HasExited
// Проверка времени со старта процесса на превышение таймаута
&& (timeoutSeconds == 0 || workingTimeSec <= timeoutSeconds)
// Проверка времени простоя при использовании ЦП
&& (timeoutProcessCpuIdleSec == 0 || processCpuIdleSec <= timeoutProcessCpuIdleSec)
// Проверка времени простоя консоли вывода
&& (timeoutConsoleOutputActivity == 0 || consoleOutputIdleSec <= timeoutConsoleOutputActivity))
{
// !!! Контроль потребления процессов
if (process.WorkingSet64 >= 1073741824)
{
// Если память рабочего набора запущенного процесса больше 1 ГБ,
// то прерываем ожидание и завершаем процесс принудительно
break;
}
// ...
}

Но это лишь небольшой пример, ведь проверять можно любые свойства процессов, состояние операционной системы и так далее. Все ограничивается лишь Вашим воображением и потребностями.

Еще одна интересная ситуация - это завершение работы дочернего процесса приложения, в котором мы все эти контроли и непосредственно запуск реализовали. Может сложиться ситуация, что наше приложение запустило внешний процесс и даже контролировало его работу пару минут, но потом по какой-то причине было завершено (ошибка приложения, кто-то закрыл его через диспетчер задач и так далее). В этом случае, запущенный процесс BCP.exe останется активным, не смотря на то, что родительское приложение уже не работает. Все это может привести к непредсказуемым результатам и поломкам.

Решить такую неприятную ситуацию можно разными способами. Например, при запуске приложения можно проверять наличие уже запущенных процессов BCP.exe. Если они есть, то завершать.

// Завершаем все процессы BCP.exe при запуске основного приложения
Process.GetProcesses()
.Where(e => e.ProcessName.Equals("bcp", StringComparison.CurrentCultureIgnoreCase))
.ToList()
.ForEach(p => p.Kill(true));

Но это тоже нужно делать осторожно. Иногда лучше дождаться завершения такого процесса. А что еще страшнее, еще может понадобиться исправлять подобный некорректный запуск какими-то более сложными действиям. В общем, тут есть разные сложные схемы, но в отрыве от контекста описывать их смысла будет немного.

И еще стоит упомянуть, что в ходе работы приложения может иметь смысл анализировать вывод на консоль сразу при получении сообщений. Или общий лог работы приложения. Здесь варианты анализа полностью зависят от приложения и какие логи оно записывает, поэтому примера как такового не будет.

Идеальный контроль

Мы рассмотрели последовательный пример усиления контроля над запускаемыми процессами на примере утилиты BCP. По итогу у нас реализованы следующие контроли:

  • Таймаут работы приложения - это общее время ожидания, сколько может работать запущенный процесс.
  • Таймаут простоя процесса - это время, которое процесс может ничего не делать, то есть не потреблять ресурсы ЦП.
  • Таймаут простоя консоли вывода - это время, которое приложение может не подавать никаких сообщений в консоль вывода. То есть, если приложение не будет писать в консоль никаких сообщений некоторое время, то контроль может посчитать процесс зависшим.
  • Безопасное ожидание завершения - учитывается ошибка взаимоблокировки потоков из-за блокировки внутреннего буфера процесса.
  • Контроль ресурсов, окружения и логов - мы упомянули, что контроль процесса можно расширять до бесконечно. От потребляемой им памяти, то окружения ОС и прочего.

Все вышеперечисленное можно использовать по отдельному или вместе, все зависит от задачи. Но достаточно ли этого?

Для тех случаев, когда мы запускаем какой-то сторонний процесс, к исходному коду которого у нас нет доступа, данный подход поможет решить большинство вопросов. Но если мы запускаем процесс приложения, которое мы программируем сами или хотя бы имеем доступ к его исходному коду, то возможно есть смысл сделать контроль и взаимодействие процессов более надежным.

Речь здесь идет о том, что в такой ситуации мы можем подружить процессы между собой, наладить межпроцессное взаимодействие между ними. Через обмен сообщений можно будет реализовать не только контроль выполнения, но и обмен другими полезными сообщениями.

Создадим для примера два простых консольных приложения с самым примитивным контролем. Первое консольное приложение будет дочерним (то есть запускаться из другого приложения). В качестве параметров командной строки оно будет принимать:

  • Идентификатор родительского процесса
  • Имя родительского процесса

Переданные параметры сохраняются на время работы приложения и периодически проверяется наличие такого процесса в системе. Если родительский процесс с такими параметрами отсутствует, то и это приложение само себя завершит.

Ниже листинг дочернего консольного приложения ChildProcess:

using System.Diagnostics;
string parentProcessId = args[0];
string parentProcessName = args[1];
Console.WriteLine($"Родительский процесс. ID: {parentProcessId}");
Console.WriteLine($"Родительский процесс. Имя: {parentProcessName}");
var currentProcess = Process.GetCurrentProcess();
string currentProcessId = currentProcess.Id.ToString();
string currentProcessName = currentProcess.ProcessName;
bool parentProcessExists = Process
.GetProcesses()
.Where(e => e.ProcessName.Equals(parentProcessName, StringComparison.CurrentCultureIgnoreCase))
.Any(e => e.Id == int.Parse(parentProcessId));
while (parentProcessExists)
{
Console.WriteLine($"Дочерний процесс. ID: {currentProcessId}");
Console.WriteLine($"Дочерний процесс. Имя: {currentProcessName}");
Console.WriteLine("Родительский процесс активен. Продолжаем работу...");
Thread.Sleep(1000);
parentProcessExists = Process
.GetProcesses()
.Where(e => e.ProcessName.Equals(parentProcessName, StringComparison.CurrentCultureIgnoreCase))
.Any(e => e.Id == int.Parse(parentProcessId));
}
Console.WriteLine("Родительский процесс остановлен. Завершаем работу...");

Листинг дочернего приложения ParentProcess:

using System.Diagnostics;
var currentProcess = Process.GetCurrentProcess();
string parentProcessId = currentProcess.Id.ToString();
string parentProcessName = currentProcess.ProcessName;
string childApp = "ChildProcess.exe";
string argumentsApp = $""{parentProcessId}" "{parentProcessName}"";
Process.Start(childApp, argumentsApp);
Console.WriteLine("Для выхода нажмите любую клавишу...");
Console.ReadKey();

Файлы обоих приложений поместим в один каталог и запустим дочернее приложение ParentProcess. Вот как это будет выглядеть.

Запуск зависимых процессов

Нажав любую клавишу мы завершим родительский процесс, а дочерний процесс завершится сам, как только проверка это обнаружит.

Конечно, это лишь простейший пример. Но его суть и посыл должны быть понятны:

  • Зависимые процессы должны мониторить состояние друг друга.
  • Обмен сообщениями между процессами возможен через TCP-соединения, файлы, общую память, HTTP-запросы и многие другие способы.
  • Нужно обязательно предусмотреть на стороне родительского процесса поведение, если дочерний процесс был внезапно завершен.
  • Аналогично на стороне дочернего процесса предусмотреть действия, если родительский процесс был завершен.

Все это лишь базовая информация и межпроцессное взаимодействие более обширная и интересная тема. Но именно благодаря такому подходу взаимодействие и контроль процессов можно выстроить максимально эффективным. Но сегодня на этом все.

Мысли напоследок

Путь был небольшой, но мы успели рассмотреть шаги по укреплению контроля за внешними процессами, запущенными из среды .NET (C#). По сути контролировать процесс, поведение которого не может быть гарантировано стабильным, дело неблагодарное. Хоть мы и добавили разные виды таймаутов работы процесса, анализируем его консольный вывод и вывод лога на примере утилиты BCP.exe для SQL Server, контролируем потребляемые ресурсы этого процесса и так далее, но гарантировать приемлемый результат работы все равно нельзя.

Описанный подход контроля запущенного процесса лишь дает возможность защититься от зависаний и обработать типичные ошибки. Но если результат работы запущенного процесса будет непредсказуемым, то и обработать такие "внезапные" ошибки у нас нормально не получится. Например, если BCP.exe внезапно удалит существующие таблицы, то такое развитие событий Вы никак предусмотреть не сможете. Отсюда следует, что запуск внешних процессов это всегда риск и нужно лишь определять приемлемо ли это для решения Вашей задачи или нет.

Под конец статьи мы коснулись такого способа контроля и взаимодействия с процессами, который позволяет выстроить более тесные проверки и даже способы взаимодействия между процессами. Это максимально эффективный подход, но требует доступа к исходным кодам всех приложений, что не всегда возможно. Конечно, примеры были совсем примитивными и мы даже не создали пример сетевого взаимодействия между процессами, но это здесь и не было нужно. Главное - показать, что для эффективной работы нужно наладить хоть какой-то канал связи между процессами.

За рамками статьи остается обширная тема межпроцессного взаимодействия, создание дочерних процессов и многое другое. Ниже Вы найдете ссылки на некоторые интересные библиотеки, в которых описанные в статье подходы в какой-то мере используются. Также там Вы найдете и альтернативные подходы.

Спасибо, что дочитали до конца! Теперь Вы знаете в какую сторону держать путь, если Вам нужно работать с внешними процессами и приложениями. Удачи в делах!

Y

YPermitin

.NET, TSQL, DevOps, 1C:Enterprise

Developer, just developer.

Поделиться

Другие статьи

Расширение для SQL Server. Быстро и просто. SQLCLR снова в деле
Расширение для SQL Server. Быстро и просто. SQLCLR снова в деле
Решение проблем с модулями VMware в Ubuntu 22.04
Решение проблем с модулями VMware в Ubuntu 22.04
Берем процессы под контроль в .NET
Берем процессы под контроль в .NET

Все статьи от автора: YPermitin

Copyright © 2024 Убежище инженера