воскресенье, 22 февраля 2015 г.

Работаем с "заданиями" (Job)

Буквально на неделе на форуме появились два интересных вопроса, ответ на которые был очевиден, но... Программист, как вы знаете, существо с очень пытливым мозгом, он любит различные эксперименты, не смотря на то, что ответ на задачу мог быть уже озвучен :)

Впрочем, давайте посмотрим на первый вопрос:
Запускаю в отдельном потоке некий процесс (не мой, переделывать его не имею возможности), который необходимо завершить вместе с завершением основной (моей) программы.Если моя программа завершается штатно - то ничего сложного нет. Но если не штатно (пользователь убил через диспетчер задач) - так как быть тут?
В голову пока приходит только CreateRemoteThread+LoadLibrary+моя dll, которая будет следить за основным процессом.Подскажите более изящные решения.
Попробуем подсказать...


Конечно же, это называется планирование, и самое первое, что приходит на ум - создать JOB, в который подключить дочерний процесс посредством вызова AssignProcessToJobObject.

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

Делается это достаточно просто:
У структуры TJobObjectExtendedLimitInformation, передаваемой на вход функции SetInformationJobObject, выставляем флаг JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, после чего все дочерние процессы запускаем с флагом CREATE_BREAKAWAY_FROM_JOB при вызове CreateProcess.

Как-то не понятно?
Тогда по шагам:
  1. создаем JOB вызовом CreateJobObject
  2. выставляем флаг закрытия всех дочерних процессов вызовом SetInformationJobObject
  3. запускаем дочерние процессы через CreateProcess
  4. каждый дочерний процесс подсоединяем к JOB вызовом AssignProcessToJobObject
  5. работаем как обычно
Фишка в том, что JOB существует до тех пор, пока на него есть линк (читай кто-то держит его хэндл). Как только закрывается последний хэндл, JOB умирает и грохает все содержащиеся в нем процессы.

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

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

Для начала пишем обвес:

unit job_api;

interface

// непакованные структуры должны быть выровнены правильно, поэтому включаем директиву
{$Align 8}

uses
  Windows;

const
  JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = $00002000;

  JOB_OBJECT_ASSIGN_PROCESS = 1;

  CREATE_BREAKAWAY_FROM_JOB =  $01000000;

type
  PSecurityAttributes = ^TSecurityAttributes;
  _SECURITY_ATTRIBUTES = record
    nLength: DWORD;
    lpSecurityDescriptor: Pointer;
    bInheritHandle: BOOL;
  end;
  TSecurityAttributes = _SECURITY_ATTRIBUTES;

  PJobObjectBasicLimitInformation = ^TJobObjectBasicLimitInformation;
  TJobObjectBasicLimitInformation = packed record
    PerProcessUserTimeLimit: TLargeInteger;
    PerJobUserTimeLimit: TLargeInteger;
    LimitFlags,
    MinimumWorkingSetSize,
    MaximumWorkingSetSize,
    ActiveProcessLimit,
    Affinity,
    PriorityClass,
    SchedulingClass: DWORD;
  end;

  TIOCounters = record
      ReadOperationCount,
      WriteOperationCount,
      OtherOperationCount,
      ReadTransferCount,
      WriteTransferCount,
      OtherTransferCount: Int64;
  end;

  PJobObjectExtendedLimitInformation = ^TJobObjectExtendedLimitInformation;
  TJobObjectExtendedLimitInformation = record
      BasicLimitInformation: TJobObjectBasicLimitInformation;
      IoInfo: TIOCounters;
      ProcessMemoryLimit,
      JobMemoryLimit,
      PeakProcessMemoryUsed,
      PeakJobMemoryUsed: DWORD;
  end;

  TJobObjectInfoClass = (
    JobObjectBasicLimitInformation = 2,
    JobObjectBasicUIRestrictions = 4,
    JobObjectSecurityLimitInformation = 5,
    JobObjectEndOfJobTimeInformation = 6,
    JobObjectAssociateCompletionPortInformation = 7,
    JobObjectExtendedLimitInformation = 9,
    JobObjectGroupInformation = 11,
    JobObjectNotificationLimitInformation = 12,
    JobObjectGroupInformationEx = 14,
    JobObjectCpuRateControlInformation = 15
  );

  function CreateJobObjectA(lpJobAttributes: PSecurityAttributes;
    lpName: LPCSTR): THandle; stdcall;
    external kernel32 name 'CreateJobObjectA';

  function SetInformationJobObject(hJob: THandle;
    JobObjectInformationClass: TJobObjectInfoClass;
    lpJobObjectInformation: Pointer;
    cbJobObjectInformationLength: DWORD): BOOL; stdCall;
    external kernel32 Name 'SetInformationJobObject';

  function AssignProcessToJobObject(hJob, hProcess: THandle): BOOL; stdcall;
    external kernel32 name 'AssignProcessToJobObject';

  function OpenJobObject(dwDesiredAccess: DWORD; bInheritHandle: BOOL;
    lpName: LPCTSTR): THandle; stdcall; external kernel32 name 'OpenJobObjectA';

  procedure RunCalcAndAttachToJob(hJob: THandle);

implementation

uses
  SysUtils;

procedure RunCalcAndAttachToJob(hJob: THandle);
var
  SI: TStartupInfo;
  PI: TProcessInformation;
  SystemDir: AnsiString;
begin
  ZeroMemory(@SI, SizeOf(TStartupInfo));
  SI.cb := SizeOf(TStartupInfo);
  SetLength(SystemDir, MAX_PATH);
  GetSystemDirectoryA(@SystemDir[1], MAX_PATH);
  SystemDir := IncludeTrailingPathDelimiter(PAnsiChar(@SystemDir[1])) + 'calc.exe';
  CreateProcessA(PChar(SystemDir), nil, nil, nil, True,
    CREATE_BREAKAWAY_FROM_JOB, nil, nil, SI, PI);
  CloseHandle(PI.hThread);
  AssignProcessToJobObject(hJob, PI.hProcess);
end;

end.

Тут просто декларация структур и API для работы с JOB, плюс процедура запуска калькулятора и аттача его к созданному ранее JOB-у RunCalcAndAttachToJob.
Пока не сильно вникайте в этот код, я на нем остановлюсь ниже.

А теперь посмотрим как будет выглядеть основное приложение:

program main_app1;

uses
  Windows,
  SysUtils,
  job_api in '..\common\job_api.pas';

const
  JobName = 'MyJobName1';

var
  hJob: THandle;
  Limit: TJobObjectExtendedLimitInformation;
begin
  hJob := CreateJobObjectA(nil, @JobName[1]);
  try
    if hJob = 0 then
      RaiseLastOSError;
    ZeroMemory(@Limit, SizeOf(TJobObjectExtendedLimitInformation));
    Limit.BasicLimitInformation.LimitFlags := JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
    SetInformationJobObject(hJob, JobObjectExtendedLimitInformation,
      @Limit, SizeOf(TJobObjectExtendedLimitInformation));
    RunCalcAndAttachToJob(hJob);
    RunCalcAndAttachToJob(hJob);
    RunCalcAndAttachToJob(hJob);
    RunCalcAndAttachToJob(hJob);
    MessageBox(0, 'После закрытия окна все дочерние процессы будут завершены.', nil, 0);
  finally
    CloseHandle(hJob);
  end;
end.

Ага - это все.
Просто создаем JOB и подключаем к нему процессы калькуляторов.

Просто?
Конечно.

А знаете как эта задача была решена в итоге?
Через создание удаленного потока, и не спрашивайте у меня: "почему это было сделано именно через него", не я автор вопроса и не я решал эту задачу :)

Впрочем...

Вопрос номер 2:

Есть программа, которая должна работать в единственном экземпляре и только пока работает хотя бы одна интересующая ее программа.
Например, есть программы:A.exeB.exeC.exeиSupport.exe
A, B и C при запуске запускают Support.exe. Про этом Support.exe должен работать в единственном экземпляре (это легко сделать).Но как только закроются все экземпляры A, B и C - то Support тоже должен закрыться. (вот это не совсем понятно как сделать просто).При этом не надо полагаться на то, что A, B или C закроются корректно.А также, если пользователь прибьет Support в диспетчере задач, то это его личное дело.
Пока самая простая идея такая:Создать/Открыть именованный объект ядра во всех A, B и C.А в Support в потоке с интервалом проверять существует ли этот именованный объект.
Пробуем решить эту задачу через тот-же JOB.
Здесь задача немного усложняется, т.к. рутовых процессов может быть много, а дочерний - (support.exe) только один.

Решаем ее по стандартной схеме через наименование, а именно - у функции CreateJobObject вторым параметром идет имя задачи.
Зная это имя, второй процесс может открыть эту задачу и подключить самого себя к JOB-у посредством OpenJobObject и того-же AssignProcessToJobObject.

В качестве дочернего процесса (который изначально был support.exe) возьмем тот-же калькулятор и пишем логику по таким шагам:
  1. если при старте мы не можем открыть задачу по ее имени - значит мы первые
  2. если мы первые - создаем задачу и подключаем к ней самого себя и калькулятор
  3. если вдруг такая задача уже существует - просто открываем ее и подключаем самого себя
Таким образом, все копии процесса и калькулятор будут работать в рамках одного JOB-а, при завершении которого все оставшиеся процессы будут закрыты.
Нюанс - теперь у нас хэндлов на задачу много (при каждом старте рутового процесса добавляется новый) но закрывать процессы мы можем в произвольном порядке и калькулятор все равно будет жить.

Смотрим код:

program main_app2;

uses
  Windows,
  SysUtils,
  job_api in '..\common\job_api.pas';

const
  JobName = 'MyJobName2';

procedure WaitStop;
begin
  MessageBox(0, PChar('Закройте окно дла завершения процесса: ' +
    IntToStr(GetCurrentProcessId)), nil, 0);
end;

function HaseJob: Boolean;
var
  hJob: THandle;
begin
  hJob := OpenJobObject(JOB_OBJECT_ASSIGN_PROCESS, False, PChar(@JobName[1]));
  Result := hJob <> 0;
  if Result then
  begin
    AssignProcessToJobObject(hJob, GetCurrentProcess);
    WaitStop;
  end;
  CloseHandle(hJob);
end;

procedure RunMainApp;
var
  hJob: THandle;
  Limit: TJobObjectExtendedLimitInformation;
begin
  hJob := CreateJobObjectA(nil, @JobName[1]);
  try
    if hJob = 0 then
      RaiseLastOSError;
    ZeroMemory(@Limit, SizeOf(TJobObjectExtendedLimitInformation));
    Limit.BasicLimitInformation.LimitFlags := JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
    SetInformationJobObject(hJob, JobObjectExtendedLimitInformation,
      @Limit, SizeOf(TJobObjectExtendedLimitInformation));
    RunCalcAndAttachToJob(hJob);
    WaitStop;
  finally
    CloseHandle(hJob);
  end;
end;

begin
  if not HaseJob then
    RunMainApp;
end.

Функция HaseJob проверяет наличие JOB и, будь таковой, подключает себя к нему.
Ну а если JOB не создан - создаем процесс калькулятор и аттачим его к JOB-у.

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

Немного комментариев по коду


Обратите внимание на выравнивание структур в модуле job_api.
Если структуры TIOCounters или TJobObjectExtendedLimitInformation будут неверно размещены в памяти - SetInformationJobObject выполнится не успешно.

Забудете флаг CREATE_BREAKAWAY_FROM_JOB - все это не заработает (см. MSDN).

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

Писал все за вечер на Delphi7 и не проверял на юникодных вариантах Delphi (хард недавно умер - лень все переустанавливать), поэтому под юникодом не ручаюсь за правильность исполнения, но навскидку вроде ничего не пропустил и должно работать.

Исходный код можно забрать тут.

---

© Александр (Rouse_) Багель

Февраль, 2015


6 комментариев:

  1. Спасибо. Интересно и полезно.

    ОтветитьУдалить
  2. Очень познавательно. Спасибо.

    ОтветитьУдалить
  3. Тут вот что интересно, в связи с этой темой... К сожалению, в дочернем приложении запускаемом как JOB, похоже невозможно получить управление, если это приложение закрывается при снятии основного (например, через диспетчер задач). По крайней мере эксперименты и изучение MSDN такой способ не выявили.
    Если Вам известно как это сделать - был бы крайне признателен, если бы Вы его обозначили.

    В двух словах, зачем это нужно. Основное приложение работает с БД Oracle. Часть работы (например, формирование отчёта) выполняется в дочернем приложении, запускаемом из основного как JOB. Дочернее приложение для работы с данными в согласованном состоянии в начале своей работы стартует транзакцию в режиме READ_ONLY.
    Так вот, при снятии основного приложения, дочерний JOB будет закрыт, а транзакция, объявленная на сервере БД останется "висеть", что очень нехорошо. Если бы в дочернем приложении был способ получить управление при закрытии основного - можно было бы эту транзакцию корректно завершить перед завершением работы.

    ОтветитьУдалить
    Ответы
    1. Более подробно есть у Рихтера: http://www.e-reading.link/bookreader.php/135055/Windows_dlya_professionalov:_sozdanie_effektivnyh_Win32-prilozheniii_s_uchetom_specifiki_64-razryadnoii....pdf
      страница 112 (Уведомление заданий).

      Удалить
    2. Большое спасибо Александр! :-)
      Действительно, без книжки догадаться о необходимости создания "порта завершения ввода-вывода" несколько нетривиально.
      Попробуем - действительно интересно, что получится...

      Удалить
  4. Блин... А я велосипед изобретал 😁..

    ОтветитьУдалить