Descargar la lista de verificación en Excel con hoja en japonés e inglés
Herramientas de conversión, actualizadores, workers de análisis, CLI externas, PowerShell, ffmpeg, utilidades internas. Las aplicaciones de Windows dependen de procesos secundarios con más facilidad de lo que se imagina.
Sin embargo, lo que suele fallar no es si el proceso se pudo iniciar.
- El padre se cae, pero el proceso secundario queda activo
- Solo sobreviven los procesos nietos
stdout/stderrse bloquean yWaitForExitnunca regresa- El watchdog muere junto con lo que supervisa
- Se cree haber terminado todo con
Kill(entireProcessTree: true), pero solo termina antes la observación
La clave para manejar procesos secundarios de forma segura en Windows no es elegir la API de inicio, sino decidir quién posee el árbol de procesos y diseñar el procedimiento de cierre junto con la E/S.
En este artículo organizamos Job Object, la propagación del cierre, la entrada/salida estándar y el watchdog como un único diseño integral.
Términos usados en este artículo
Antes de continuar, repasamos brevemente, línea por línea, los términos que aparecen en inglés.
| Término | Significado en una línea |
|---|---|
| process tree | Árbol de procesos. La familia completa que incluye el proceso secundario iniciado por el padre y los procesos nietos que ese hijo, a su vez, inicia |
| graceful shutdown | Cierre coordinado. La forma de pedir “termine, por favor” para que el otro proceso haga su propia limpieza antes de finalizar por sí mismo. Es lo opuesto a la terminación forzada |
| I/O completion port | El mecanismo de Windows para notificar la finalización de operaciones de E/S asíncronas. Al asociarlo a un Job Object, se pueden recibir notificaciones de inicio y terminación de procesos |
| message pump | El bucle de mensajes. El mecanismo mediante el cual un hilo con ventana extrae y procesa continuamente los mensajes del sistema operativo. Si esto se detiene, la pantalla se congela |
| heartbeat | Una señal que el proceso secundario emite periódicamente para confirmar que sigue activo. Se usa para detectar el estado en que un proceso “está vivo pero no avanza” |
| restart budget | El presupuesto de reinicios. El límite de cuántas veces se permite reiniciar dentro de un período determinado. Se mantiene para detener un crash loop |
| drain | Drenar. Leer por completo la salida acumulada en el pipe para evitar que el lado que escribe se bloquee |
Panorama general
Antes de entrar en detalle, mostramos en un solo diagrama la relación entre los actores involucrados.
flowchart TB
accTitle: Panorama general de la gestión segura de procesos secundarios en Windows
accDescr: Diagrama que muestra un Job Object con JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE que contiene a la aplicación padre, un hijo helper.exe y dos procesos nietos, y un watchdog colocado fuera del Job que detecta la terminación mediante un exit handle, detecta bloqueos mediante heartbeat y recrea el Job dentro del restart budget.
W["watchdog<br/>se coloca fuera del Job"]
subgraph JOB["Job Object con JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
P["Aplicación padre / worker principal<br/>propietario final del job handle"]
C["Hijo helper.exe"]
G1["Nieto converter.exe"]
G2["Nieto ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"detecta la terminación mediante exit handle"| P
W -.->|"detecta bloqueos (hang) mediante heartbeat"| P
W -.->|"recrea dentro del restart budget"| JOB
Hay dos puntos clave que observar aquí:
- El límite del Job es el límite del árbol de procesos. Como se agrupa por pertenencia al Job y no por la vida o muerte del padre, aunque aumenten los procesos nietos no se produce ninguna omisión en la recuperación
- Solo el watchdog está fuera del Job. Si se lo incluyera dentro, se lo limpiaría junto con lo que supervisa
1. La conclusión primero
Antes de entrar en detalle, enumeramos solo lo que más resultado da en la práctica.
- Si quiere vincular el ciclo de vida del árbol de procesos secundarios a la vida o muerte del padre, el punto de referencia es Job Object
- La solicitud de terminación a una consola y la recuperación del árbol de procesos son cosas distintas
- Lo primero corresponde al process group y a
GenerateConsoleCtrlEvent - Lo segundo corresponde a Job Object
- Lo primero corresponde al process group y a
- Si quiere incluir el proceso en el Job desde el momento de su creación, lo más directo es un diseño que use
STARTUPINFOEXyPROC_THREAD_ATTRIBUTE_JOB_LIST - Lo básico es drenar la salida estándar y el error estándar en paralelo
- Si usa
stdin, diseñe también el paso de cerrarlo al terminar de escribir para transmitir el EOF - Es más seguro colocar el watchdog fuera del Job de lo que supervisa
Kill(entireProcessTree: true)de.NETes útil como API para detener procesos de forma explícita, pero no sustituye un diseño que incluya la recuperación automática ante una caída del padre o el graceful shutdown
2. Qué es lo peligroso
La implementación para iniciar un proceso secundario suele poder escribirse, al principio, en unas 10 líneas más o menos. Pero lo que falla está fuera de esas 10 líneas.
- Después de que el padre se cae, el hijo y el nieto siguen activos
- El helper inicia a su vez otro helper, y el código se conforma con esperar solo al hijo directo
- Uno de los dos,
stdoutostderr, se bloquea y tanto el padre como el hijo terminan esperándose mutuamente - Se espera en el hilo de la UI y se congelan tanto la pantalla como COM
- El watchdog comparte el mismo destino que lo que supervisa, y cae junto con él ante una anomalía
Lo importante aquí es que “la gestión de procesos secundarios” no es una cuestión de una sola API.
Conviene pensar por separado, como mínimo, en estos cuatro puntos, para tener una visión clara.
- Quién posee el árbol de procesos
- Cómo se solicita el cierre coordinado
- Cómo se hace fluir la entrada/salida estándar
- Cómo se supervisan la terminación anómala y los bloqueos
3. No mezclar el rol de cada mecanismo
process handle, process group y Job Object parecen similares, pero cumplen roles distintos.
| Mecanismo | Rol principal | Escenario adecuado | Lo que no cubre por sí solo |
|---|---|---|---|
| process handle | Esperar la terminación de un proceso y obtener el exit code | Esperar la finalización de una herramienta de un solo uso | La recuperación de procesos nietos |
| process group | Propagar Ctrl+Break a la consola | El cierre coordinado de un hijo de consola | El cleanup ante una caída del padre, los procesos secundarios con GUI |
| Job Object | Agrupar el árbol de procesos, aplicar límites y terminarlo todo junto | Árboles de worker, actualizadores, cadenas de helper | El “guardar y luego cerrar” propio de cada aplicación |
process group es un mecanismo que decide a dónde enviar una señal de consola, no un mecanismo para limpiar todo el árbol cuando el padre muere. Job Object, en cambio, es el mecanismo del propio Windows para administrar un grupo de procesos como una sola unidad.
3.1 Tabla de correspondencia por lenguaje
Este artículo mezcla contenido de Win32 y de .NET. Para que pueda seguir solo la columna de su lenguaje, presentamos antes la tabla de correspondencia.
| Qué se quiere hacer | Win32 / C++ | .NET / C# |
|---|---|---|
| Iniciar un proceso | CreateProcessW |
Process.Start |
| Crear un Job y aplicar límites | CreateJobObjectW + SetInformationJobObject |
Invocar la misma API mediante P/Invoke. La biblioteca estándar no incluye un wrapper para Job Object |
| Incluir en el Job desde el momento de la creación | STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST |
Igual que a la izquierda. No se puede especificar desde ProcessStartInfo |
| Incluir en el Job después de iniciado | AssignProcessToJobObject |
Invocar la misma API mediante P/Invoke, pasando Process.Handle |
| Esperar la terminación | WaitForSingleObject |
Process.WaitForExit; de forma asíncrona, WaitForExitAsync(desde .NET 5) |
| Obtener el exit code | GetExitCodeProcess |
Process.ExitCode |
| Leer stdout / stderr | Crear un pipe anónimo y leerlo en otro hilo | RedirectStandardOutput y BeginOutputReadLine |
| Pedirle a un hijo con GUI que se cierre | Enviar WM_CLOSE |
Process.CloseMainWindow |
| Enviar Ctrl+Break a un hijo de consola | CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent |
No hay una API equivalente, se requiere P/Invoke |
| Forzar la terminación de todo el árbol | TerminateJobObject, o cerrar el último job handle |
Process.Kill(entireProcessTree: true)(desde .NET Core 3.0), o el mismo P/Invoke anterior |
| Esperar la terminación de muchos hijos | RegisterWaitForSingleObject / SetThreadpoolWait |
El evento Process.Exited, o WaitForExitAsync |
Lo que queda claro aquí es que, solo en lo relativo a Job Object, incluso en .NET se termina llamando directamente a la API de Win32. Lo que .NET ofrece de forma nativa se limita a operaciones sobre un único proceso.
4. Usar Job Object como punto de referencia
Lo más potente de Job Object es que permite agrupar el árbol de procesos no según “de quién es hijo”, sino según “a qué Job pertenece”. Los procesos hijos que un proceso incluido en un Job crea mediante CreateProcess quedan incluidos en ese mismo Job de forma predeterminada.
Además, al añadir JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, todos los procesos asociados al Job se terminan cuando se cierra el último job handle.
4.1 Cuatro puntos clave para empezar
1. Si quiere limpiar todo el árbol al terminar el padre, use KILL_ON_JOB_CLOSE
Esta es la base para manejar helpers y workers en una aplicación de Windows. Un diseño que llame explícitamente a TerminateJobObject también es válido, pero si quiere que el cleanup quede ligado al ciclo de vida del padre incluso ante una terminación anómala, KILL_ON_JOB_CLOSE es la opción más clara.
2. No añada BREAKAWAY a la ligera
JOB_OBJECT_LIMIT_BREAKAWAY_OK y JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK parecen convenientes, pero también pueden hacer que una parte del árbol que usted creía poder limpiar se escape de él. Salvo que exista una intención deliberada, no añadir breakaway reduce la tasa de incidentes.
3. Si quiere incluir el proceso en el Job desde su creación, use PROC_THREAD_ATTRIBUTE_JOB_LIST
También es posible vincularlo después con AssignProcessToJobObject.
Sin embargo, en los casos donde quiere dar por sentada la pertenencia al Job desde el momento mismo del inicio, es más correcto especificar el Job en la creación usando STARTUPINFOEX y PROC_THREAD_ATTRIBUTE_JOB_LIST.
4. No deje ambiguo quién posee el job handle
KILL_ON_JOB_CLOSE surte efecto cuando se cierra el último handle.
Dicho de otro modo, si el job handle se duplica hacia otro proceso o se hereda sin intención, el cleanup no ocurrirá como se esperaba aunque el padre muera. Debe decidir de antemano quién es el propietario final del job handle.
4.2 Job Object también sirve para la observabilidad, pero las notificaciones no son infalibles
Job Object cuenta con un mecanismo para asociar un I/O completion port y recibir notificaciones. Sin embargo, es más seguro no asumir que las notificaciones del completion port están garantizadas al cien por cien en todos los casos.
Por eso, el completion port resulta útil para:
- Monitoreo
- Agregación
- Registro (logging)
- Métricas
pero es preferible no construir la corrección (correctness) del sistema apoyándose únicamente en él.
4.3 Verlo con el código mínimo
Es más corto que explicarlo con palabras, así que mostramos la forma mínima en ambos lenguajes.
En el lado de C++ son tres pasos: crear el Job → añadir KILL_ON_JOB_CLOSE → especificar el Job en el momento de iniciar el proceso.
// Windows 10 en adelante / C++17. Inicia helper.exe dentro de un Job y limpia todo el árbol cuando termina el padre
#include <windows.h>
#include <memory>
#include <string>
int wmain()
{
// 0. Fija con una ruta absoluta el archivo que se va a iniciar.
// Si se pasa nullptr a lpApplicationName y se deja que la búsqueda
// parta de la primera palabra de la línea de comandos, entre los
// lugares de búsqueda se incluyen "el directorio actual del proceso
// padre" y "PATH".
// Si helper.exe no está en su propia carpeta y alguien coloca un
// ejecutable con el mismo nombre en un lugar con permisos de
// escritura, ese ejecutable se ejecutará con los privilegios del padre
wchar_t modulePath[MAX_PATH]{};
DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
if (moduleLen == 0 || moduleLen >= MAX_PATH) // se trata también como fallo el caso en que se truncó por MAX_PATH
{
return 1;
}
std::wstring application(modulePath, moduleLen);
application.resize(application.find_last_of(L'\\') + 1); // la carpeta donde está el propio ejecutable
application += L"helper.exe";
// 1. Crea el Job y hace que, al cerrarse el último handle, se terminen todos sus procesos
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (job == nullptr)
{
return 1;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
{
CloseHandle(job);
return 1;
}
// 2. Crea la lista de atributos para que el proceso pertenezca al Job desde su creación
SIZE_T attributeSize = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize); // llamada en vacío solo para obtener el tamaño necesario
auto storage = std::make_unique<BYTE[]>(attributeSize);
auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());
if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
{
CloseHandle(job);
return 1;
}
// el valor de job debe mantenerse vivo hasta llamar a DeleteProcThreadAttributeList
if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
&job, sizeof(job), nullptr, nullptr))
{
DeleteProcThreadAttributeList(attributes);
CloseHandle(job);
return 1;
}
// 3. Inicia el proceso
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.lpAttributeList = attributes;
PROCESS_INFORMATION info{};
// CreateProcessW exige un búfer modificable.
// También se coloca la misma ruta en argv[0]. Como contiene espacios,
// siempre debe ir entre comillas
std::wstring commandLine = L"\"" + application + L"\" --input data.bin";
BOOL created = CreateProcessW(
application.c_str(), commandLine.data(), nullptr, nullptr,
FALSE, // limita los handles que se heredan
EXTENDED_STARTUPINFO_PRESENT,
nullptr, nullptr,
&startup.StartupInfo, &info);
DeleteProcThreadAttributeList(attributes);
if (!created)
{
CloseHandle(job);
return 1;
}
WaitForSingleObject(info.hProcess, INFINITE);
DWORD exitCode = 0;
GetExitCodeProcess(info.hProcess, &exitCode);
CloseHandle(info.hThread);
CloseHandle(info.hProcess);
CloseHandle(job); // El último job handle. Los descendientes que aún queden aquí se terminan todos juntos
return static_cast<int>(exitCode);
}
Este código respeta dos restricciones documentadas para UpdateProcThreadAttribute. Ambas son fáciles de pasar por alto al leer la documentación.
PROC_THREAD_ATTRIBUTE_JOB_LISTsolo está disponible en Windows 10 / Windows Server 2016 en adelante. Si necesita cubrir versiones anteriores, debe recurrir aAssignProcessToJobObject- El valor pasado a
UpdateProcThreadAttributedebe seguir vivo hasta que se llame aDeleteProcThreadAttributeList. Un código que pase una variable local y salga de su ámbito inmediatamente después se rompe
El archivo que se inicia debe indicarse siempre con una ruta absoluta
El paso «0.» del principio, donde se construye la ruta a partir de GetModuleFileNameW, no es una cuestión de estilo, sino una forma de fijar con certeza qué ejecutable se va a ejecutar.
Si se pasa nullptr a lpApplicationName, la primera palabra de la línea de comandos se convierte en el nombre del módulo. Cuando esa palabra no incluye una ruta, Windows busca en este orden:
- El directorio desde el que se cargó la aplicación
- El directorio actual del proceso padre
- El directorio de sistema de 32 bits
- El directorio de sistema de 16 bits
- El directorio de Windows
- Los directorios listados en la variable de entorno
PATH
El problema son 2 y 6. Cuando helper.exe no está en 1 —por un despliegue incompleto, una compilación con otra configuración, o restos de una desinstalación—, la búsqueda avanza hasta 2. Si el directorio actual es un lugar con permisos de escritura (se inició directamente desde la carpeta de descargas del usuario, o se usa una carpeta compartida como directorio de trabajo), el helper.exe colocado ahí se ejecuta con los mismos privilegios que el padre. Si el entorno permite modificar PATH, ocurre lo mismo con 6.
La propia documentación de Microsoft dedica una sección independiente a este punto, bajo «Security Remarks» (observaciones de seguridad), y advierte explícitamente que «para evitar este problema, no pase NULL en lpApplicationName». En esa misma sección aparece también el conocido ejemplo de que, si no se encierra entre comillas una ruta con espacios, podría terminar ejecutándose C:\Program.exe. Por eso, en el código, la línea de comandos también va entre comillas dobles.
Lo mismo ocurre con ProcessStartInfo en C#. Cuando UseShellExecute = false, .NET arma una única línea de comandos a partir de FileName y los argumentos, y pasa null a lpApplicationName, de modo que si solo se indica el nombre del archivo, ocurre exactamente la misma búsqueda descrita arriba. Pase una ruta absoluta construida a partir de AppContext.BaseDirectory.
Incluso en un entorno donde parezca que «ese tipo de error de despliegue nunca ocurrirá», el costo de escribirlo bien es prácticamente nulo. En el código que inicia procesos secundarios, básicamente no hay ninguna razón para indicar el ejecutable con un nombre relativo.
.NET no tiene un wrapper para Job Object, así que hay que recurrir a P/Invoke. La definición de las estructuras parece larga, pero en realidad solo se llaman dos funciones.
// .NET 8 / C# 12. Crea el Job, añade KILL_ON_JOB_CLOSE e incluye en él un proceso ya iniciado
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
internal static class KillOnCloseJob
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public nuint MinimumWorkingSetSize;
public nuint MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public nuint Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
public nuint PeakJobMemoryUsed;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);
/// <summary>Crea el Job. El handle devuelto debe permanecer abierto durante toda la vida de la aplicación.</summary>
public static SafeJobHandle Create()
{
var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
if (job.IsInvalid)
{
throw new InvalidOperationException($"Error al ejecutar CreateJobObject. code={Marshal.GetLastWin32Error()}");
}
var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
{
// No se debe dejar tal cual un Job a medio configurar: se creó
// pero no se pudo configurar. Si el código que llama captura el
// error de inicialización y reintenta, cada intento filtra un
// handle de kernel (la versión en C++ de arriba llama a
// CloseHandle en esta misma ruta)
var error = Marshal.GetLastWin32Error();
job.Dispose();
throw new InvalidOperationException($"Error al ejecutar SetInformationJobObject. code={error}");
}
return job;
}
public static void Add(SafeJobHandle job, Process process)
{
if (!AssignProcessToJobObject(job, process.Handle))
{
throw new InvalidOperationException($"Error al ejecutar AssignProcessToJobObject. code={Marshal.GetLastWin32Error()}");
}
}
}
// Si se mantiene como un IntPtr sin envolver, en la ruta donde falla la
// inicialización nadie llega a cerrarlo. Con un SafeHandle basta con
// llamar a Dispose una vez en la ruta de fallo
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// El marshaller lo genera como valor de retorno de P/Invoke, así que debe poder crearse sin argumentos
private SafeJobHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle() => CloseHandle(handle);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
}
El código que lo invoca es este. Si se llama solo a Create y se olvida Add, se llega al estado más difícil de notar: existe el Job, pero no tiene ningún hijo dentro.
// Mantenga el job handle en un campo o similar y no lo cierre hasta que
// la aplicación termine. Como tiene KILL_ON_JOB_CLOSE, en el instante en
// que se cierra, todos los hijos del Job se terminan.
// No le añada using aquí (los hijos morirían en cuanto se saliera del ámbito)
SafeJobHandle job = KillOnCloseJob.Create();
try
{
// Pase el archivo que va a iniciar con una ruta absoluta. Si solo pasa
// el nombre del archivo, el directorio actual y PATH entran entre los
// lugares donde busca CreateProcess
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false,
CreateNoWindow = true,
};
using var child = Process.Start(startInfo)
?? throw new InvalidOperationException("No se pudo iniciar helper.exe.");
try
{
KillOnCloseJob.Add(job, child); // Si olvida esto, el Job queda vacío
}
catch (Exception assignFailed)
{
// Add puede fallar, por ejemplo, si el lado del padre tiene
// límites de Job incompatibles. En ese momento helper.exe ya está
// en ejecución. El Dispose de `using` solo descarta el wrapper de
// Process; no termina el proceso del sistema operativo, y como el
// Job está vacío, tampoco lo resuelve job.Dispose(). Aquí hay que
// detenerlo uno mismo y esperar a que termine
try
{
if (!child.HasExited)
{
child.Kill(entireProcessTree: true);
}
// Kill solicita la terminación y regresa de inmediato. Si se
// lanza throw sin esperar, puede llegar a coexistir con un
// segundo helper.exe creado al reintentar la inicialización
child.WaitForExit();
}
catch (Exception killFailed)
{
// No haber podido detenerlo es más grave que el fallo de Add.
// Si se ignora, se continúa dejando atrás "un hijo que ni
// entró al Job ni se detuvo"
throw new AggregateException(
"No se pudo asignar al Job y tampoco se pudo detener helper.exe.",
assignFailed, killFailed);
}
throw;
}
}
catch
{
// Si falla tanto el inicio como la incorporación al Job, este Job ya
// no se va a usar. Si se sale sin cerrarlo, cada reintento de
// inicialización deja un handle de kernel más. En este punto el Job
// está vacío (o el hijo ya se detuvo en el catch anterior), así que
// cerrarlo no deja nada pendiente que se vea afectado
job.Dispose();
throw;
}
Sin embargo, esta versión en .NET tiene un intervalo entre el inicio del proceso y su incorporación al Job. Si durante ese intervalo el hijo llega a crear un nieto, ese nieto nace fuera del Job. La versión en C++ usa PROC_THREAD_ATTRIBUTE_JOB_LIST precisamente para eliminar ese intervalo. Si va a lidiar con un helper que crea nietos, vale la pena llegar hasta el P/Invoke con STARTUPINFOEX también en .NET.
5. Diseñar la propagación del cierre con un protocolo y un timeout
La terminación de un proceso secundario no se resuelve con una sola llamada a una API de kill. Lo menos propenso a fallos es seguir este proceso en tres pasos:
- Solicitar el cierre coordinado
- Esperar con un timeout corto
- Por último, forzar la terminación de todo el Job
Con este orden, se conserva la ruta de cierre normal y, al mismo tiempo, se puede recuperar el proceso si se queda colgado.
5.1 Hijo con GUI
Si el proceso secundario tiene GUI, en .NET CloseMainWindow equivale a enviar el mensaje de cierre.
Sin embargo, esto es una solicitud de terminación, no una terminación forzada. Por eso, el flujo más natural es:
CloseMainWindow- Esperar un tiempo determinado
- Si no funciona, forzar la terminación de todo el Job
5.2 Hijo de consola
En un hijo de consola no se puede usar el mensaje de cierre de la GUI. En este caso se usan el process group y las señales de consola.
El flujo consiste en iniciar el proceso con CREATE_NEW_PROCESS_GROUP y enviar CTRL_BREAK_EVENT mediante GenerateConsoleCtrlEvent.
Aquí importa lo siguiente:
CTRL_C_EVENTno se presta bien para limitarse a un grupo concreto- Solo los procesos que comparten la consola pueden recibir la señal
- Usar
CREATE_NEW_PROCESS_GROUPtambién cambia el significado deCTRL+C
5.3 Worker / hijo headless
Un worker o un hijo headless a menudo no tiene ni GUI ni consola. En este caso es más seguro contar con un protocolo de terminación propio para el proceso secundario.
- Enviar
quitporstdin - Enviar un comando shutdown mediante named pipe, socket o RPC
- Transmitir la solicitud de detención mediante un event object
La separación en la que, desde el punto de vista de Windows, Job Object se encarga del cleanup del árbol, y desde el punto de vista de la aplicación, el pipe o stdin se encargan del cierre coordinado, resulta menos propensa a fallos.
6. No bloquear la entrada/salida estándar
6.1 Drenar stdout / stderr en paralelo
Esta es la primera regla básica.
Hay que drenar stdout y stderr en paralelo. Leer todo un flujo antes de pasar al otro tiende a bloquearse.
Los pipes de Windows no tienen un búfer infinito. Si el proceso secundario escribe una gran cantidad de datos en stderr y el padre solo lee stdout, es normal que el hijo se detenga en write y el padre se detenga esperando la terminación.
Representado en un diagrama, toma esta forma.
sequenceDiagram
accTitle: Bloqueo de la E/S estándar por no drenar stderr
accDescr: Diagrama de secuencia que muestra cómo el padre sigue leyendo solo stdout mientras el hijo llena el búfer del pipe de stderr, el hijo queda bloqueado en write y el padre queda bloqueado esperando la terminación, de modo que WaitForExit nunca regresa.
participant P as Proceso padre
participant SO as Pipe de stdout
participant SE as Pipe de stderr
participant C as Proceso secundario
P->>SO: Sigue leyendo solo stdout
C->>SO: Escribe un poco
SO-->>P: Se pudo leer
C->>SE: Escribe una gran cantidad de advertencias
Note over SE: El búfer del pipe se llena por completo
C->>SE: Intenta escribir más
Note over C: write no regresa, el hijo se detiene aquí
P->>SO: Intenta leer la continuación
Note over P: No llega nada porque el hijo está detenido
Note over P,C: El padre queda esperando lectura y el hijo esperando escritura, WaitForExit tampoco regresa
Como el lugar donde todo se detiene no es ni el padre ni el hijo, sino el pipe, la causa no aparece reflejada en ningún log de ninguno de los dos lados. La falta de una sola línea, «no se está leyendo stderr», se convierte directamente en un bloqueo.
Si se reciben stdout y stderr con manejadores independientes y se avanza en la lectura de cada uno por separado, este círculo no llega a formarse. En .NET, esto se ve así:
// .NET 8 / C# 12. Drena stdout y stderr en paralelo y espera hasta terminar de leer toda la salida
using System;
using System.ComponentModel; // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;
// Pase el archivo que va a iniciar con una ruta absoluta (el motivo se explica en la sección de Job Object)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");
var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
UseShellExecute = false, // Obligatorio si se va a usar la redirección
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = startInfo };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
// No se debe leer un flujo por completo antes que el otro. Se reciben ambos mediante eventos
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
stderr.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine(); // Con solo registrar el manejador no empieza a leer. Hay que llamar siempre a ambos
process.BeginErrorReadLine();
if (!process.WaitForExit(30_000))
{
// Aquí se decide "dejar de esperar", no se sustituye el cleanup
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
{
// Existe una condición de carrera en la que el hijo termina por sí
// mismo justo "después" de que expiran los 30 segundos de espera.
// En .NET, un Kill durante el procesamiento de la terminación
// produce Win32Exception ("The process is terminating."); en .NET
// Framework, un Kill sobre un proceso ya terminado produce
// InvalidOperationException.
// Si ya terminó, esto no es un fallo, así que se absorbe y se
// continúa hacia el TimeoutException de abajo. Si todavía está
// vivo, es que de verdad no se pudo detener, así que se vuelve a
// lanzar tal cual
if (!process.HasExited)
{
throw;
}
}
// No se absorbe AggregateException (no se pudo detener a parte de los
// descendientes). Eso es, en sí mismo, "el árbol no quedó limpio", así
// que se deja propagar hacia afuera
// Kill solicita la terminación y regresa de inmediato. Si aquí se
// lanza throw sin esperar, el hijo puede seguir vivo en el momento en
// que se ejecuta el Dispose de using, y entonces no se cumple que
// "se lanzó la excepción de timeout = el árbol quedó limpio"
process.WaitForExit();
throw new TimeoutException("helper.exe no terminó dentro de los 30 segundos.");
}
// Aunque el WaitForExit con timeout devuelva true, el procesamiento
// asíncrono de la salida puede no haber terminado todavía. Se llama de
// nuevo a WaitForExit sin argumentos y se espera hasta terminar de leer
// toda la salida.
process.WaitForExit();
Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout : {stdout.Length} caracteres");
Console.WriteLine($"stderr : {stderr.Length} caracteres");
En el límite del timeout siempre existe una condición de carrera. En el breve instante que transcurre entre que WaitForExit(30_000) devuelve false y se llama a Kill, el hijo puede terminar por sí mismo. En ese caso, Kill no tiene éxito ── en .NET se produce un Win32Exception durante el procesamiento de la terminación («The process is terminating.»), y en .NET Framework se produce un InvalidOperationException sobre un proceso ya terminado. Si se deja pasar esto sin más, en lugar del TimeoutException que debía lanzarse, se propaga un fallo de limpieza. Quien llama recibe «salió un error confuso» en vez de «se agotó el tiempo», y además se salta el WaitForExit() final que termina de leer la salida. Como se muestra arriba, primero hay que confirmar con HasExited si el proceso realmente terminó, y solo entonces absorber la excepción. Si todavía sigue vivo, es que de verdad no se pudo detener, así que se vuelve a lanzar tal cual. Tampoco se absorbe el AggregateException que lanza Kill(entireProcessTree: true) (cuando no se pudo detener a parte de los descendientes): eso es, en sí mismo, «el árbol no quedó limpio», precisamente el estado que esta sección intenta evitar.
El WaitForExit() final no es un olvido que quedó sin borrar, sino algo obligatorio.
La documentación de WaitForExit(int) indica que, cuando la salida estándar se redirige a un manejador de eventos asíncrono, es posible que el procesamiento de la salida no haya terminado en el momento en que regresa esta sobrecarga, y recomienda llamar a WaitForExit() sin argumentos después de recibir true. Si se omite esto, el fallo se manifiesta de una forma difícil de reproducir: solo faltan los últimos fragmentos de la salida.
6.2 Si usa stdin, diseñe también hasta el EOF
Poder escribir en stdin y que el hijo pueda terminar no son la misma cosa.
- Se escribe la entrada y no se cierra después
- El padre cree que «ya lo entregó todo»
- El hijo cree que «todavía va a llegar más» y sigue esperando
Puede darse esta situación. Si va a usar stdin, es necesario diseñar también el paso de cerrarlo al terminar de escribir para transmitir el EOF.
6.3 Cierre siempre los extremos del pipe que no se usan
Si no se cierran los extremos sin uso, tanto en el lado del padre como en el del hijo, el EOF no se propaga y la condición de terminación se rompe. Es algo simple, pero en la práctica es un incidente bastante frecuente.
6.4 No deje ambiguo el manejo de UseShellExecute=false y la herencia de handles
Si va a usar la redirección de la entrada/salida estándar, en .NET se da por sentado que UseShellExecute=false.
También en Win32 es más seguro limitar en lo posible qué se hereda. Dejar bInheritHandles=TRUE y heredarlo todo puede causar fugas de handles inesperadas.
7. Coloque el watchdog “fuera”
Lo más importante al incorporar un watchdog es no incluirlo en el mismo Job que aquello que supervisa. Si el worker se cae y usted quiere reiniciarlo, no tiene sentido que el encargado de reiniciarlo muera junto con él.
7.1 Basar la supervisión de la terminación en wait handles
Cuando un proceso termina, pasa al estado signaled.
Por eso, la supervisión de la terminación no debería necesitar, en principio, un bucle de polling que revise HasExited cada 100 ms.
En Win32, lo correcto es:
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
Si se manejan varios hijos, un enfoque basado en wait handles resulta más natural que el polling por temporizador.
7.2 No esperar indefinidamente en el hilo de la UI
WaitForSingleObject(INFINITE) es cómodo, pero si se usa en un hilo con ventana, es fácil que detenga el message pump.
En el hilo de la UI, en un hilo de apartamento COM, o en cualquier hilo con message pump, es más seguro pensar primero dónde colocar la espera.
7.3 Un watchdog de bloqueos necesita heartbeat
Para un watchdog de terminación (exit watchdog), un process handle basta. Pero un watchdog de bloqueos (hang watchdog) es distinto.
- Está congelado con la CPU al 100%
- Está en un deadlock
- El event loop sigue vivo pero no hay progreso
- Está detenido esperando una entrada
Este tipo de estados no se pueden determinar únicamente con «si el proceso está vivo». Por eso, si quiere detectar también los bloqueos, necesita una verificación de vida a nivel de aplicación, como:
- heartbeat
- una secuencia de progreso
- la marca de tiempo del último trabajo completado con éxito
- una sonda de salud (health probe)
7.4 Coloque al encargado de reiniciar fuera de lo que supervisa
En la práctica, son comunes estos dos patrones:
- La aplicación padre solo inicia un helper de forma temporal
- El padre posee el Job y recupera el árbol del helper al terminar
- Se mantiene un worker activo durante mucho tiempo y se quiere reiniciarlo si se cae
- Un proceso o servicio watchdog externo crea un Job por cada generación del worker
En el segundo caso, el diseño resulta más estable si se separan el árbol de procesos del worker y la autoridad para reiniciar.
7.5 Mantenga la política de reinicio como un presupuesto (budget)
Al incorporar un watchdog, lo siguiente que puede empezar es un crash loop.
- Se reinicia de inmediato
- Vuelve a caerse de inmediato
- Solo se genera una gran cantidad de logs
Para evitarlo, conviene mantener un restart budget con:
- backoff
- un límite de número de reinicios dentro de un período determinado
- detenerse y notificar cuando hay fallos consecutivos
8. Configuraciones recomendadas según el patrón típico
| Escenario | Configuración recomendada |
|---|---|
| Una aplicación de escritorio inicia un helper de CLI de un solo uso | 1 inicio = 1 Job. Añadir KILL_ON_JOB_CLOSE y drenar stdout / stderr en paralelo. Al cancelar: cierre coordinado → timeout → kill del Job |
| El helper, a su vez, inicia procesos nietos | Dar por sentado el uso de Job Object y no permitir breakaway. Si se quiere fijarlo desde el inicio, usar PROC_THREAD_ATTRIBUTE_JOB_LIST |
| Un service / watchdog supervisa durante mucho tiempo un árbol de worker | El watchdog es un process / service externo. Crear un Job por cada generación del worker y supervisar con exit handle + heartbeat |
| Se quiere detener con cuidado una herramienta de consola | Iniciar con CREATE_NEW_PROCESS_GROUP y hacer el cierre coordinado con CTRL_BREAK_EVENT. Después, kill del Job con timeout |
| Se quiere cerrar un helper con GUI | CloseMainWindow / el equivalente a WM_CLOSE → timeout → kill del Job |
| Se quieren supervisar muchos procesos secundarios | En vez de multiplicar hilos bloqueantes, usar RegisterWaitForSingleObject / SetThreadpoolWait |
Lo más importante aquí es separar el mecanismo de cierre coordinado (graceful shutdown) del mecanismo de cleanup.
9. Lo que no se debe hacer
Reunimos aquí, en un formato listo para usar directamente en una revisión, los puntos de atención mencionados en cada capítulo. Se presentan juntos «qué ocurre» y «dónde está explicado», de modo que desde la fila que le llame la atención pueda volver al cuerpo del artículo.
| Lo que no se debe hacer | Qué ocurre | Sección |
|---|---|---|
Creer que basta con Kill(entireProcessTree: true) para resolver también el graceful shutdown o la recuperación ante una caída del padre |
Solo funciona cuando se detiene de forma explícita. Se pierden la recuperación cuando el padre se cae y la ruta que permite al hijo hacer su propia limpieza | Sección 5 |
Heredar todo dejando bInheritHandles=TRUE |
Handles no deseados pasan al hijo, lo que causa fugas de handles y fallos en la propagación del EOF | 6.4 |
Leer stdout por completo antes de leer stderr |
El otro pipe se llena, el padre queda esperando lectura y el hijo esperando escritura, y todo se detiene | 6.1 |
| No cerrar el extremo del pipe que no se usa | El EOF no se propaga y la condición de terminación del lado que lee no llega a cumplirse | 6.3 |
Llamar a WaitForSingleObject(INFINITE) en el hilo de la UI |
Se detiene el message pump y se congelan la pantalla y COM | 7.2 |
| Incluir el watchdog en el mismo Job que aquello que supervisa | Al limpiar lo supervisado, el encargado de reiniciar desaparece junto con ello | Sección 7 |
| Usar 259 como un exit code normal | GetExitCodeProcess devuelve STILL_ACTIVE, es decir, 259, mientras el proceso sigue en ejecución. Si un hijo termina normalmente con el código 259, se le da por erróneamente en ejecución aunque ya haya terminado |
7.1 |
| Tomar las notificaciones del completion port del Job como la única fuente de verdad | Las notificaciones están pensadas para monitoreo y agregación; construir sobre ellas la corrección del sistema deja huecos | 4.2 |
10. Resumen
Al manejar procesos secundarios de forma segura en una aplicación de Windows, lo que más resultado da es esta organización.
Quién posee el árbol de procesos Cómo se transmite la solicitud de terminación Cómo se hace fluir por completo la entrada/salida estándar Dónde se coloca el watchdog
Decida primero estos cuatro puntos.
Sobre esa base, dicho de forma resumida:
- El punto de referencia para el cleanup del árbol es Job Object
- El cierre coordinado se separa según sea GUI, consola o worker
- El diseño de la E/S estándar incluye el drenaje en paralelo y llegar hasta el EOF
- El watchdog se coloca fuera de lo que supervisa, y se observa con wait handles y heartbeat en lugar de polling
CreateProcess o Process.Start son, en sí mismos, solo la puerta de entrada.
Lo que realmente influye en la tasa de incidentes es dónde recae la responsabilidad del cierre y hacer fluir la E/S hasta el final.
11. Referencias
- Microsoft Learn, Job Objects
- Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION
- Microsoft Learn, UpdateProcThreadAttribute
- Microsoft Learn, InitializeProcThreadAttributeList
- Microsoft Learn, Inheritance (Processes and Threads)
- Microsoft Learn, CreateProcessW
- Microsoft Learn, Creating a Child Process with Redirected Input and Output
- Microsoft Learn, Pipe Handle Inheritance
- Microsoft Learn, Process.Kill
- Microsoft Learn, Process.CloseMainWindow
- Microsoft Learn, GenerateConsoleCtrlEvent
- Microsoft Learn, WaitForSingleObject
- Microsoft Learn, RegisterWaitForSingleObject
- Microsoft Learn, GetExitCodeProcess
- Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Buenas prácticas de multithreading en la práctica — Edición .NET: qué decidir antes de aumentar los hilos
Reglas de diseño en .NET/C# para evitar fallos y bloqueos intermitentes con hilos: usar Task en lugar de hilos propios, reducir el estado...
Usar WMI/CIM desde C# y PowerShell ── Guía práctica de obtención de información de hardware, monitorización de procesos y consultas remotas
WMI/CIM es la solución estándar para leer el número de serie, monitorizar el disco y detectar procesos. Cmdlets CIM, migración desde Get-...
¿Hasta cuándo se puede usar MSMQ? — La decisión de migración de una cola legacy que «ni siquiera está en desuso»
MSMQ no figura en la lista oficial de funciones en desuso, pero System.Messaging solo existe en .NET Framework y bloquea la migración a ....
Compatibilidad retroactiva de interfaces DLL y COM — Tabla de decisión sobre qué cambios rompen al lado que llama
Qué cambios de DLL o COM rompen al lado que llama: los tres niveles de compatibilidad, la tabla de decisión por cambio y la regla de inmu...
Cómo entender el aislamiento de sesiones de Windows — Session 0, RDP y la ejecución simultánea de varios usuarios
Este artículo aclara el concepto de «sesión» de Windows, que suele confundir a los desarrolladores de aplicaciones. Explica por qué el ai...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
En aplicaciones de Windows que manejan CLI externas, herramientas de conversión, workers o actualizadores, la gestión del árbol de procesos y el diseño del cierre determinan la estabilidad más que el método de inicio.
Investigación de fallos y causas
Los incidentes operativos difíciles de reproducir, como procesos secundarios que quedan huérfanos tras la caída del padre, un stdout que se bloquea o un watchdog que cae junto con lo que supervisa, suelen mejorar al revisar el diseño de la gestión de procesos.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Por qué quedan procesos secundarios activos después de que el proceso padre se cae?
- Porque el process handle o el process group por sí solos no tienen ningún mecanismo para recuperar el árbol de procesos cuando el padre se bloquea. Si quiere vincular el ciclo de vida del árbol de procesos secundarios a la vida o muerte del padre, el punto de referencia es Job Object. Al añadir JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, todos los procesos que pertenecen al Job se terminan cuando se cierra el último job handle, de modo que el cleanup queda ligado al ciclo de vida del padre incluso ante una terminación anómala.
- ¿Por qué WaitForExit nunca regresa?
- Lo más probable es que el pipe de la salida estándar o del error estándar esté bloqueado. Los pipes de Windows no tienen un búfer infinito, así que si el proceso secundario escribe una gran cantidad de datos en stderr mientras el padre solo lee stdout, el hijo se detiene en la escritura y el padre se detiene esperando la terminación. Lo básico es drenar stdout y stderr en paralelo; una implementación que lee todo un flujo antes de pasar al otro se bloquea con facilidad. Además, si no se cierra el extremo no utilizado del pipe, el EOF no se propaga y la condición de terminación se rompe.
- ¿No basta con Kill(entireProcessTree: true) de .NET?
- No es suficiente. Es útil como API para detener procesos de forma explícita, pero no sustituye un diseño que incluya la recuperación automática ante una caída del padre o el cierre coordinado (graceful shutdown). Lo menos propenso a fallos es un proceso en tres pasos: solicitar el cierre coordinado, esperar con un timeout corto y, por último, forzar la terminación de todo el Job. El medio para el cierre coordinado depende del tipo de proceso secundario: CloseMainWindow para un hijo con GUI, CREATE_NEW_PROCESS_GROUP y CTRL_BREAK_EVENT para un hijo de consola, y un protocolo de terminación vía stdin o pipe para un worker.
- ¿Dónde debería colocarse el proceso watchdog?
- Lo más importante es no incluirlo en el mismo Job que el proceso que supervisa. Si el worker se cae y usted quiere reiniciarlo, no tiene sentido que el encargado de reiniciarlo muera junto con él. Cuando un worker se mantiene activo durante mucho tiempo, es más estable que un proceso o servicio watchdog externo cree un Job por cada generación del worker. La supervisión de la terminación debe basarse en wait handles en lugar de polling, y si además necesita detectar bloqueos (hangs), conviene combinarla con una verificación de vida a nivel de aplicación, como un heartbeat.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.