Cómo elegir la comunicación entre procesos en Windows — canalizaciones con nombre / TCP / gRPC / memoria compartida / COM: tabla de decisión

· Actualizado el: · · Comunicación entre procesos, Canalizaciones con nombre, Windows, .NET, C#, gRPC, Memoria compartida, COM, Diseño, Tabla de decisión, Consultoría técnica

«Si separamos la interfaz de usuario del servicio, ¿cómo debería ser la comunicación entre ambos?», «Quiero usar la funcionalidad de una DLL de 64 bits desde una aplicación de 32 bits», «Quiero enviar datos desde un motor de medición que corre en otro proceso hasta la pantalla». Dividir una aplicación en varios procesos aparece a menudo como una solución realista frente a la robustez, la separación de privilegios y los problemas de bitness, pero en el mismo instante en que se divide surge inevitablemente la pregunta de cómo comunicar esas partes entre sí.

En este blog ya hemos escrito sobre medios de comunicación concretos: las trampas de la memoria compartida, el framing en TCP, el manejo seguro de procesos hijos y la integración por archivos y el bloqueo. Sin embargo, todavía no habíamos tratado la cuestión general de qué medio elegir en primer lugar. En este artículo organizamos, con el formato de tabla de decisión habitual en este blog, los puntos fuertes y las trampas de las principales opciones de comunicación entre procesos (IPC) en Windows: integración por archivos, canalizaciones con nombre, TCP local, gRPC, memoria compartida y COM.

Terminología que conviene tener clara desde el principio

Antes de continuar, reunimos aquí los términos que aparecen como siglas en el resto del artículo.

Término Significado
IPC (Inter-Process Communication) Comunicación entre procesos. Nombre genérico para los mecanismos que permiten intercambiar datos o señales con otro proceso
ACL (Access Control List) Lista de control de acceso. Un listado de «a quién se le permite qué operación», que en Windows se asocia a canalizaciones, archivos, memoria compartida, etc. En .NET se construye con clases como PipeSecurity
UAC (User Account Control) Control de cuentas de usuario. Incluso con una cuenta de administrador, de forma predeterminada se ejecuta con privilegios estándar y solo se eleva cuando es necesario. Como el lado elevado y el no elevado operan con privilegios distintos, la comunicación entre ambos es el tema del apartado 2.2
RPC (Remote Procedure Call) Mecanismo que permite escribir el procedimiento de otro proceso o de otra máquina como si fuera una llamada a función local. Las llamadas COM fuera de proceso también se apoyan en esto
data plane / control plane Plano de datos y plano de control. Forma de referirse por separado al flujo de datos reales y al flujo de instrucciones como iniciar, detener o cambiar la configuración (apartados 2.5 y 4)
Framing Mecanismo que determina dónde empieza y dónde termina un mensaje dentro de una secuencia de bytes. En TCP es obligatorio diseñarlo por cuenta propia (apartados 2.3 y 6)

1. La conclusión primero

  • Para un intercambio de solicitud-respuesta dentro de la misma máquina (enviar un comando y recibir el resultado), las canalizaciones con nombre son la primera opción. Son estándar del sistema operativo, no requieren puerto y están integradas con el control de acceso de Windows; desde .NET se implementan de forma directa con System.IO.Pipes.1
  • Si existe la posibilidad de cruzar la red en el futuro, conviene optar desde el principio por TCP local o gRPC. Migrar de canalizaciones a sockets más adelante suele ser un cambio que no se resuelve con «un pequeño ajuste posterior». Sin embargo, el TCP puro es un flujo de bytes, así que es obligatorio diseñar el framing.
  • Cuando el número de servicios o de tipos de llamada crece y mantener un protocolo propio se vuelve una carga, la opción es gRPC. Aporta definición de esquema y generación de código mediante proto, además de streaming bidireccional, y desde .NET 8 ASP.NET Core (Kestrel) puede usar las canalizaciones con nombre como transporte.2
  • La memoria compartida se reserva solo para datos de gran volumen y alta frecuencia (fotogramas de imagen, formas de onda). Es la más rápida, pero obliga a diseñar toda la sincronización por cuenta propia, así que la regla de oro es no llevar los mensajes de control a la memoria compartida.3
  • Para una integración desacoplada y asíncrona en la que se quiera dejar un registro de auditoría, la integración por archivos sigue siendo una opción sólida. No obstante, el éxito depende por completo del diseño del control de exclusión.
  • COM (fuera de proceso) ya no es la primera opción para un diseño nuevo, pero sigue siendo una herramienta vigente en contextos como el uso desde VBA u otros lenguajes, o como puente entre 32 y 64 bits.4
  • Sea cual sea el medio elegido, no se puede escapar de los retos de diseño comunes: la delimitación de mensajes, el número de versión, los tiempos de espera y la reconexión (capítulo 6). Dedique a esto tanto tiempo como a la elección del medio.

2. El perfil de cada opción

2.1 Integración por archivos — desacoplada, asíncrona y auditable

Es la integración clásica en la que «A coloca un archivo en una carpeta de salida y B lo recoge y lo procesa». No hace falta que ambos estén activos al mismo tiempo, el rastro del procesamiento queda como archivo, y en caso de fallo una persona puede examinar el archivo directamente, corregirlo y volver a introducirlo. Sigue siendo una opción sólida para integraciones de tipo lote que no exigen tiempo real.

La trampa se concentra casi por completo en el control de exclusión. «Leer un archivo que todavía se está escribiendo» o «dos procesos disputándose el mismo archivo» son incidentes clásicos, y hay que aplicar prácticas consolidadas como escribir con un nombre de archivo temporal y luego renombrarlo. Los detalles están reunidos en «Buenas prácticas de integración por archivos y bloqueo»; consúltelo siempre que opte por este método. No es adecuado para comunicación interactiva que requiera respuesta, ni para intercambios que ocurren decenas de veces por segundo.

2.2 Canalizaciones con nombre — la opción principal para la IPC dentro de la misma máquina

Las canalizaciones con nombre son una vía de comunicación bidireccional que Windows ofrece a nivel de kernel, y son la opción principal para la IPC de tipo cliente-servidor dentro de la misma máquina. En .NET se manejan con NamedPipeServerStream / NamedPipeClientStream, y varios clientes pueden conectarse a un mismo nombre de canalización.5 A diferencia de TCP, no requieren gestionar números de puerto ni chocan con el firewall.

A continuación, los puntos que conviene tener presentes en la práctica.

  • Se puede usar el modo de mensaje. Si se indica PipeTransmissionMode.Message, cada escritura llega delimitada como un único mensaje. Es una ventaja práctica considerable, porque el sistema operativo se encarga del framing que en TCP es obligatorio resolver con prefijos de longitud u otros mecanismos (el lado receptor debe leer hasta completar el mensaje con IsMessageComplete; véase el capítulo 5).
  • Los permisos predeterminados son sorprendentemente laxos. El descriptor de seguridad predeterminado de una canalización otorga control total a LocalSystem, a los administradores y al creador, pero además concede lectura a Everyone y a las cuentas anónimas.6 Para procesos de un mismo usuario basta con añadir PipeOptions.CurrentUserOnly para forzar que solo se conecte con la parte creada por el mismo usuario, y se recomienda convertir esto en la práctica predeterminada.7 Entre cuentas distintas (por ejemplo, al comunicarse con un servicio) hay que declarar explícitamente el ACL con PipeSecurity.
  • El nombre de la canalización vive en un espacio de nombres visible para todos. Los nombres de canalización se ubican en un único espacio de nombres bajo \\.\pipe\, visible incluso desde procesos de otros usuarios de la máquina. Aquí hay que prestar atención a la ocupación del nombre (squatting): si un proceso malicioso levanta antes un servidor con el mismo nombre y se queda a la escucha, el cliente terminará conectándose a él. En máquinas donde conviven usuarios con distintos privilegios conviene tomar medidas como que el servidor use PipeOptions.FirstPipeInstance para fallar si ya existe una canalización con el mismo nombre, y que el cliente verifique la cuenta propietaria de la canalización tras conectarse.8
  • Permite comunicación que cruza límites de privilegio. También es la vía habitual entre una «interfaz de usuario con privilegios estándar» y un «broker con privilegios de administrador» separados por UAC. Sin embargo, en esta configuración no se puede usar CurrentUserOnly (porque exige que, incluso siendo el mismo usuario, también coincida el nivel de elevación7). Hay que diseñar el ACL de forma explícita; el detalle concreto está en «Separar los privilegios de administrador (broker)».

Su punto débil es que, en la práctica, no es adecuado para comunicación entre máquinas distintas (técnicamente es posible, pero con muchas restricciones operativas) y que resulta difícil de interoperar con sistemas que no sean Windows. Si ese requisito ya está a la vista, conviene elegir TCP o gRPC.

2.3 TCP local — versatilidad entre lenguajes y sistemas operativos

Una conexión TCP contra localhost es la IPC más versátil, hablable prácticamente desde cualquier lenguaje, runtime o sistema operativo. En configuraciones mixtas como «un motor de análisis en Linux con una interfaz de usuario en Windows», TCP (o HTTP/gRPC sobre él) es la primera candidata.

Hay tres puntos de atención.

  • Es un flujo de bytes. TCP no garantiza que «llegue en la misma unidad en que se envió». Es normal que tres mensajes enviados con Send lleguen concatenados en una sola llamada a Receive, y también que un solo mensaje llegue fragmentado. Hay que diseñar el framing —por ejemplo, con un prefijo de longitud— en la capa de la aplicación; el código que se salta esto simplemente «funciona por casualidad». Para más detalle, consulte «El malentendido de que se puede recibir con Receive cada unidad enviada con Send en TCP».
  • Limite el alcance de exposición del socket a la escucha. Aunque la intención sea una comunicación dentro de la misma máquina, si se escucha en 0.0.0.0 se podrá conectar cualquier otra máquina de la red. Como norma para IPC local, vincúlese a 127.0.0.1 (loopback). Aun así, cualquier otro usuario de la misma máquina podrá conectarse, así que si es necesario verificar la identidad de la otra parte hay que añadir autenticación en la capa de aplicación. La ausencia de un control de acceso integrado con el sistema operativo, como sí tienen las canalizaciones, marca una diferencia clara.
  • La gestión del puerto viene de la mano. Un puerto fijo puede chocar con otro software, y algunos productos de firewall pueden detectarlo como «escucha sospechosa». Incluya desde el principio en el diseño un mecanismo para cambiar el número de puerto.

2.4 gRPC — se compra un esquema y generación de código

Comparado con un socket puro más un protocolo propio, lo que aporta gRPC no es tanto la comunicación en sí como un marco de desarrollo. Al definir servicios y mensajes en un archivo proto, se generan automáticamente la serialización, el framing y el código de cliente y servidor, y el streaming bidireccional (notificaciones push desde el servidor) se puede escribir casi como una característica más del lenguaje. Cuando se entra en la fase de «tener que corregir el switch del protocolo propio y su documentación cada vez que se añade un mensaje», este marco empieza a valer la pena.

Desde .NET 8, ASP.NET Core (Kestrel) admite directamente, además de TCP, sockets de dominio Unix y canalizaciones con nombre como transporte.8 En el servidor basta con llamar a ListenNamedPipe, y también se puede configurar el control de acceso mediante PipeSecurity.2 La combinación de «mantener la canalización como vía de comunicación, sin puerto y con ACL integrado, y usar gRPC en la capa de protocolo» es una opción sólida para la separación entre interfaz de usuario y servicio (capítulo 4).

Por otro lado, tiende a ser excesivo para la comunicación entre herramientas pequeñas. Levantar un servidor gRPC implica cargar con la infraestructura de hospedaje de ASP.NET Core, lo que aumenta los artefactos de distribución, las dependencias y el coste de arranque. Para herramientas que solo intercambian dos o tres tipos de comandos, mantenga presente el criterio de que una canalización con nombre más JSON (capítulo 5) sale más barata en conjunto. No es tarde para adoptar gRPC cuando se cumple alguna de estas condiciones: los mensajes que hay que escribir en el proto superan los diez, el streaming de notificaciones es realmente necesario, o la otra parte no está en .NET.

2.5 Memoria compartida (archivo mapeado en memoria) — la más rápida, pero con toda la sincronización a cargo del desarrollador

La IPC más rápida dentro de la misma máquina es la memoria compartida. En .NET se crea memoria compartida con nombre mediante MemoryMappedFile.CreateNew, y varios procesos pueden leer y escribir directamente la misma secuencia de bytes.3 Como no interviene ni copia ni serialización, para datos «grandes y rápidos» como fotogramas de imagen o formas de onda ofrece un rendimiento que la convierte prácticamente en la única opción razonable.

Sin embargo, la memoria compartida no es una «canalización rápida». Solo hace visible la misma secuencia de bytes; no ofrece ni un solo byte de sincronización. El mecanismo para no leer datos a medio escribir, la detección de si la otra parte sigue viva y la recuperación tras la terminación anómala de una de las partes: todo eso queda a cargo del propio diseño. La discusión de diseño, incluyendo configuraciones de búfer circular y el versionado del layout, está reunida por completo en «Las trampas de la memoria compartida y buenas prácticas en la práctica».

Su lugar en la práctica está claro: una configuración en la que solo el plano de datos (data plane) se apoya en memoria compartida, mientras el plano de control (control plane) se desvía a otro canal, como una canalización con nombre (configuración 3 del capítulo 4). Si se empieza a forzar también por memoria compartida los mensajes de control, como inicio, detención o cambios de configuración, es una señal de que hay que revisar el diseño.

2.6 COM (fuera de proceso) — ya no es la primera opción para algo nuevo, pero sigue teniendo su lugar

El servidor COM fuera de proceso (servidor EXE) es un mecanismo que Windows tiene desde hace mucho tiempo para «llamar a un objeto de otro proceso como si fuera una función local».4 Ya no es la primera opción para una comunicación nueva entre aplicaciones, pero sigue siendo práctico en los siguientes contextos.

  • Puente entre 32 y 64 bits: una DLL de 64 bits (o viceversa) no se puede cargar en el mismo proceso que una aplicación de 32 bits, pero COM fuera de proceso sí puede cruzar esa frontera. Como la infraestructura COM se encarga del marshaling, apenas hay que tocar el código del lado que llama. Puede ver un caso real en «Caso práctico de puente COM de 32 a 64 bits».
  • Uso desde VBA u otros lenguajes: cuando se quiere que un entorno de ejecución antiguo, como VBA de Excel, llame a funcionalidad de .NET, exponerla como COM sigue siendo hoy la vía más directa.
  • Integración con activos COM existentes: si la otra parte solo sabe hablar COM, hablar COM también desde este lado es el camino más corto (para una explicación de COM en sí, véase «Qué son COM, ActiveX y OCX»).

Los motivos para dudar de adoptarlo en algo nuevo son la molestia de una distribución que implica registro en el registro de Windows, el coste de aprendizaje del diseño de interfaces y del conteo de referencias, y la dificultad de investigar los problemas cuando surgen. Antes de decidir, compare con la alternativa —convertir el lado de 64 bits en un simple proceso auxiliar que se comunica mediante una canalización con nombre— si el objetivo es solo el puente entre 32 y 64 bits (configuración 2 del capítulo 4).

2.7 Mecanismos clásicos — no elegirlos para algo nuevo

Windows también ofrece otros mecanismos de IPC, como WM_COPYDATA (enviar datos mediante mensajes de ventana), el portapapeles, DDE o mailslots, y siguen apareciendo en la página oficial de resumen de IPC.4 Sin embargo, o bien presuponen la existencia de una ventana, o bien están en proceso de retirada (los mailslots remotos están en vías de desaparición), así que casi no hay razón para elegirlos en un diseño nuevo. Basta con saber reconocerlos cuando aparecen al mantener una aplicación existente.

3. Tabla de decisión

Antes de nada, definimos el significado de los símbolos. Representan, en cuatro niveles, «cuánto diseño e implementación adicional hace falta, al elegir ese medio, para satisfacer ese criterio». No son una valoración absoluta de velocidad ni de funcionalidad.

Símbolo Criterio de evaluación
Diseñado precisamente para ese uso. Basta con usar la funcionalidad estándar tal cual, sin apenas diseño adicional
Se puede usar sin problemas, pero requiere implementación o configuración según las prácticas consolidadas (redacción del ACL, framing, etc.)
Es posible, pero exige un diseño propio considerable, o conlleva restricciones o efectos secundarios que no se pueden ignorar
En la práctica no es posible. Hay que combinarlo con otro medio

Solo la fila «Coste de implementación» no usa símbolos, sino Bajo, Medio y Alto, y en ese caso lo deseable es un valor bajo. La fila «Rendimiento/latencia» va en sentido contrario: lo deseable es un valor alto (◎ es lo más rápido).

Aspecto Archivo Canalización con nombre TCP local gRPC Memoria compartida COM
Alcance de la comunicación Cruza máquinas mediante carpeta compartida La misma máquina es el rango práctico Puede cruzar máquinas Puede cruzar máquinas Limitado a la misma máquina La misma máquina es el rango práctico
Modelo de comunicación Entrega de archivos (asíncrona) Flujo + modo de mensaje Flujo de bytes RPC + streaming Estado compartido Llamada a método
Notificación servidor→cliente ✕ (sondeo) ◎ (streaming bidireccional) △ (combinado con eventos) △ (se puede construir, pero es complejo)
Cruce de límites de privilegio (UAC, servicios) ○ (ACL de carpeta) ◎ (PipeSecurity) △ (autenticación propia) △ a ○ (◎ con transporte por canalización) ○ (ACL posible, diseño difícil)
Mezcla de 32/64 bits y de lenguajes ◎ (generación desde proto para cada lenguaje) △ (obliga a diseñar la ABI) ○ (se le da bien el puente)
Coste de implementación Bajo Bajo a medio Medio (framing propio) Medio (adoptar la infraestructura) Alto Alto (para algo nuevo)
Facilidad de depuración ◎ (el contenido queda en un archivo) ○ (se puede capturar) △ (HTTP/2 + binario) △ (los síntomas son aparatosos)
Rendimiento/latencia Bajo Medio a alto Medio Medio Medio

Una aclaración más: el uso correcto de esta tabla no es elegir «el medio con más ◎», sino fijarse solo en las filas que afectan a los requisitos concretos e ir descartando opciones. Por ejemplo, en el momento en que se decide «imágenes a 30 fotogramas por segundo», el plano de datos queda reducido a la única opción razonable: la memoria compartida.

Y, tras el descarte, el resultado suele encajar en alguna de las tres configuraciones habituales del capítulo siguiente. Una vez decidido el medio con la tabla, vuelva al capítulo 4 siguiendo esta correspondencia.

Medio y requisito que quedaron de la tabla Destino
Canalización con nombre + cruzar un límite de privilegio (UAC, servicio) Configuración 1: separación entre la aplicación de interfaz de usuario y el servicio de Windows
Canalización con nombre o COM + requisito de mezcla de 32/64 bits Configuración 2: puente desde una aplicación de 32 bits hacia funcionalidad de 64 bits
Memoria compartida + también hace falta control de inicio, detención, etc. Configuración 3: arquitectura de dos canales, control y datos
Quedó gRPC Es la configuración 1 con la capa de comunicación sustituida por el transporte de canalización con nombre de gRPC2
Quedó la integración por archivos Más que una configuración habitual, es un lote desacoplado. El diseño gira en torno al control de exclusión (apartado 2.1)

4. Ejemplos de configuraciones habituales

Al aplicar la tabla de decisión a requisitos concretos, en la práctica el resultado suele reducirse a estas tres configuraciones. Expresadas en un diagrama, quedan así.

Configuración 3: datos de alta frecuencia del motor de medición a la visualizaciónMotor de medición y procesamiento de imagenAplicación de interfaz de usuario(visualización)Canal de control: canalización con nombreinicio, detención, cambios de configuración. Unas pocas veces por segundoCanal de datos: memoria compartida + evento con nombrefotogramas, formas de onda. Decenas de veces por segundo o másConfiguración 2: puente desde una aplicación de 32 bits hacia funcionalidad de 64 bitsAplicación de 32 bits(activo existente)Canalización con nombreo COM fuera de procesoProceso auxiliar de 64 bitsterminación conjunta con el padre mediante Job ObjectDLL/SDK exclusivo de 64 bitsConfiguración 1: separación entre la aplicación de interfaz de usuario y el servicio de WindowsAplicación de interfaz de usuarioprivilegios del usuario con sesión iniciadaCanalización con nombresolicitud y respuesta en JSONcomo son cuentas distintas, el ACL se declara explícitamente con PipeSecurityServicio de Windowsotra cuenta, como LocalSystemprocesamiento residente y con privilegios

Configuración 1: separación entre la aplicación de interfaz de usuario y el servicio de Windows. Se coloca en el servicio el procesamiento residente o el que requiere privilegios, y la interfaz de usuario queda como un proceso de usuario normal (para la forma de construir el lado del servicio, véase el «artículo sobre servicios de Windows», publicado el mismo día). La comunicación se apoya principalmente en una canalización con nombre, y lo más seguro es empezar con un protocolo de mensajes JSON en modo mensaje (o en modo byte con prefijo de longitud); si crece el número de tipos de llamada, también existe el camino de migrar al transporte de canalización con nombre de gRPC.2 Como el servicio se ejecuta con otra cuenta (por ejemplo, LocalService), no se puede usar CurrentUserOnly, y el punto clave es declarar explícitamente el ACL con PipeSecurity, por ejemplo «permitir lectura y escritura a Users, denegar el acceso remoto».

Configuración 2: puente de una aplicación de 32 bits hacia funcionalidad de 64 bits. Cuando se quiere usar desde una aplicación de 32 bits existente una DLL o un SDK de controlador que solo funciona en 64 bits, se separa el lado de 64 bits en un proceso independiente. Hay dos formas de implementarlo: (a) levantar un proceso auxiliar de 64 bits y hablar mediante una canalización con nombre, o (b) convertirlo en un servidor COM fuera de proceso de 64 bits. Si quien llama es VBA o un lenguaje antiguo, la opción (b) es la más natural; si ambos extremos son C#, la opción (a) resulta más sencilla tanto de distribuir como de investigar. En el caso de (a), use directamente el patrón de Job Object descrito en «Manejar procesos hijos de forma segura» para el arranque, la supervisión y la terminación conjunta del proceso auxiliar con su padre.

Configuración 3: datos de alta frecuencia del motor de medición hacia la visualización. En una configuración donde el motor de medición o de procesamiento de imagen corre en un proceso separado y envía datos a la interfaz de usuario, la arquitectura habitual son dos canales: el control (inicio, detención, configuración) por canalización con nombre, y los datos (fotogramas, formas de onda) por memoria compartida más un evento con nombre. El control ocurre pocas veces por segundo, así que la canalización basta; los datos, en cambio, se acercan a una copia cero gracias a la memoria compartida. Separar los canales permite optimizar el diseño del lado de datos (búfer circular, etc.) al margen de las necesidades del control. Véase también «Visualización del estado de dispositivos externos» para el estado del motor o de equipos externos.

5. Ejemplo de implementación — servidor y cliente asíncronos de canalización con nombre

Mostramos la configuración mínima en .NET 8 que sirve de base para una implementación de canalización con nombre. Incluye el modo de mensaje, soporte para varios clientes y el manejo de la desconexión. El protocolo es «un mensaje JSON en UTF-8 por cada mensaje», y siempre lleva un campo version. El ejemplo de abajo usa CurrentUserOnly asumiendo comunicación entre procesos del mismo usuario. Si se comunica con un servicio (otra cuenta), como en la configuración 1, quite CurrentUserOnly y declare el ACL de forma explícita con PipeSecurity, tal como se explica más adelante.

Empezamos por el lado del servidor. Cada vez que se recibe una conexión se crea de nuevo un flujo de servidor, y el procesamiento de cada cliente se separa en una tarea.

using System.IO.Pipes;
using System.Text.Json;

public sealed class PipeServer(string pipeName)
{
    // Valor predeterminado "Web": camelCase e insensible a mayúsculas y minúsculas.
    // El valor predeterminado de System.Text.Json sí distingue mayúsculas de
    // minúsculas: sin compartir esto, "version" no se enlaza con
    // Request.Version y una solicitud correcta cae en unknown_type
    internal static readonly JsonSerializerOptions JsonOptions =
        new(JsonSerializerDefaults.Web);

    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var pipe = new NamedPipeServerStream(
                pipeName,
                PipeDirection.InOut,
                NamedPipeServerStream.MaxAllowedServerInstances,
                PipeTransmissionMode.Message,   // 1 escritura = 1 mensaje
                PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

            await pipe.WaitForConnectionAsync(ct);
            _ = Task.Run(() => HandleClientAsync(pipe, ct), ct);  // se separa por cada conexión
        }
    }

    // Límite de tamaño de un mensaje. Protege la memoria del servicio aunque
    // la otra parte envíe un mensaje enorme (el interlocutor de la IPC
    // también es entrada externa; ver capítulo 6)
    private const int MaxMessageBytes = 1024 * 1024;

    private static async Task HandleClientAsync(
        NamedPipeServerStream pipe, CancellationToken ct)
    {
        await using (pipe)
        {
            var buffer = new byte[64 * 1024];
            try
            {
                while (!ct.IsCancellationRequested)
                {
                    // Aun en modo mensaje, no hay garantía de que un solo Read
                    // lea el mensaje completo. Hay que leer hasta IsMessageComplete
                    using var ms = new MemoryStream();
                    do
                    {
                        int n = await pipe.ReadAsync(buffer, ct);
                        if (n == 0) return;  // el cliente se desconectó
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxMessageBytes) return;  // se superó el límite; se desconecta
                    } while (!pipe.IsMessageComplete);

                    byte[] response = Dispatch(ms.ToArray());
                    await pipe.WriteAsync(response, ct);
                }
            }
            catch (IOException)
            {
                // Solo se rompió el canal con este cliente concreto.
                // No se detiene todo el servidor: se sigue atendiendo el resto de conexiones
            }
        }
    }

    private static byte[] Dispatch(byte[] payload)
    {
        Request? req;
        try { req = JsonSerializer.Deserialize<Request>(payload, JsonOptions); }
        catch (JsonException) { req = null; }

        // Formato inválido, versión desconocida o type desconocido: se valida y se rechaza aquí
        object result = req switch
        {
            null => new { version = 1, error = "bad_request" },
            // version no especificada (se enlaza a 0) o versión desconocida: se rechaza aquí
            { Version: not 1 } => new { version = 1, error = "version_unsupported" },
            { Type: "getStatus" } => new { version = 1, running = true },
            { Type: "startJob" } => new { version = 1, jobId = StartJob(req) },
            _ => new { version = 1, error = "unknown_type" },
        };
        return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
    }
}

public sealed record Request(int Version, string Type, JsonElement? Body);

El lado del cliente encierra «conectar → solicitar → responder» en un único método, con el tiempo de espera y los reintentos incluidos desde el principio.

using System.IO.Pipes;
using System.Text.Json;

public sealed class PipeClient(string pipeName)
{
    // idempotent: solo se reintenta cuando quien llama declara que es seguro
    // que esta solicitud llegue dos veces. El valor predeterminado es "no reintentar"
    public async Task<TResponse?> RequestAsync<TResponse>(
        object request, CancellationToken ct, bool idempotent = false)
    {
        for (int attempt = 1; ; attempt++)
        {
            // Se pone un límite de tiempo no solo a la conexión, sino a todo
            // el intercambio solicitud→respuesta. Evita una espera infinita
            // si el servidor acepta la conexión pero se queda colgado sin
            // escribir la respuesta
            using var deadline =
                CancellationTokenSource.CreateLinkedTokenSource(ct);
            deadline.CancelAfter(TimeSpan.FromSeconds(10));
            try
            {
                using var pipe = new NamedPipeClientStream(
                    ".", pipeName, PipeDirection.InOut,
                    PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                // No espera indefinidamente. Si el servidor no está iniciado, lanza una excepción
                await pipe.ConnectAsync(timeout: 3000, deadline.Token);
                pipe.ReadMode = PipeTransmissionMode.Message;

                await pipe.WriteAsync(
                    JsonSerializer.SerializeToUtf8Bytes(
                        request, PipeServer.JsonOptions),
                    deadline.Token);

                using var ms = new MemoryStream();
                var buffer = new byte[64 * 1024];
                do
                {
                    int n = await pipe.ReadAsync(buffer, deadline.Token);
                    if (n == 0) throw new IOException("El servidor cerró la conexión.");
                    ms.Write(buffer, 0, n);
                } while (!pipe.IsMessageComplete);

                return JsonSerializer.Deserialize<TResponse>(
                    ms.ToArray(), PipeServer.JsonOptions);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                throw;  // cancelación de quien llama; no se reintenta
            }
            catch (Exception ex) when (
                ex is IOException or TimeoutException or OperationCanceledException
                && idempotent && attempt < 3)
            {
                // Reintenta fallos transitorios, como un servidor reiniciándose.
                // En el caso de "se envió, pero la conexión se cortó antes de
                // leer la respuesta", la solicitud puede haberse ejecutado ya,
                // así que las solicitudes con efectos secundarios, como
                // startJob, no se reintentan por defecto: diseñe primero un
                // mecanismo para descartar duplicados mediante un ID de
                // solicitud y solo entonces pase idempotent: true (capítulo 6)
                await Task.Delay(500 * attempt, ct);
            }
        }
    }
}

Añadimos tres aclaraciones sobre las decisiones de diseño de este código.

  • Incluya version desde la primera versión. Tarde o temprano llega el momento en que la interfaz de usuario y el servicio se actualizan por separado (solo uno de los dos se renueva). Con que el lado que responde «rechace explícitamente las versiones que no conoce» basta para convertir un incidente de comportamiento extraño y silencioso en un «error comprensible».
  • Decida si la conexión será de usar y desechar o permanente. El ejemplo de arriba es del tipo desechable, que conecta en cada solicitud: no necesita gestionar el estado de desconexión y reconexión, pero no es adecuado para llamadas de alta frecuencia. Si hace falta una conexión permanente con notificaciones push desde el servidor, esa complejidad es un buen argumento para migrar al streaming bidireccional de gRPC.
  • Al comunicarse con un servicio, quite CurrentUserOnly y diseñe PipeSecurity. El ejemplo de arriba supone procesos del mismo usuario. Si la otra parte es un servicio con otra cuenta, cree el flujo de servidor con ACL mediante NamedPipeServerStreamAcl.Create y declare explícitamente el grupo al que se le permite conectarse.6

5.1 Verificación del funcionamiento — orden de arranque y qué comprobar para decir que funciona

El código anterior funciona si se pega en dos aplicaciones de consola. Tras copiarlo, compruébelo en el siguiente orden.

Primero el lado del servidor. En el Program.cs del proyecto donde pegó PipeServer, escriba solo el arranque y la detención.

// Program.cs del lado del servidor (.NET 8 / sentencias de nivel superior)
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
Console.WriteLine(@"Escuchando en: \\.\pipe\demo-ipc  (Ctrl+C para detener)");
try
{
    await new PipeServer("demo-ipc").RunAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Detenido.");
}

A continuación, el lado del cliente. Solo envía una solicitud y muestra la respuesta.

// Program.cs del lado del cliente (.NET 8 / sentencias de nivel superior)
using System.Text.Json;

var client = new PipeClient("demo-ipc");
var res = await client.RequestAsync<JsonElement>(
    new { version = 1, type = "getStatus" }, CancellationToken.None);
Console.WriteLine(res);   // {"version":1,"running":true}

Los pasos de verificación son los siguientes.

  1. Arranque primero el servidor. Cuando aparezca «Escuchando en» estará listo.
  2. Ejecute el cliente. Se muestra tal cual el JSON que devolvió Dispatch en el servidor. La respuesta es {"version":1,"running":true} porque JsonSerializerDefaults.Web produce la salida en camelCase.
  3. Detenga el servidor a propósito y ejecute solo el cliente. Compruebe que ConnectAsync(timeout: 3000, ...) actúa y que el fallo llega en 3 segundos. Si aquí se queda colgado, más adelante volverá en forma de «si el servicio se cae, se cuelga toda la interfaz de usuario».
  4. Pruebe tres casos anómalos. Solo cuando todo esto funciona se puede decir que «el protocolo está funcionando».
Qué probar Qué envía el cliente Resultado esperado
Versión desconocida new { version = 2, type = "getStatus" } {"version":1,"error":"version_unsupported"}
Comando desconocido new { version = 1, type = "reboot" } {"version":1,"error":"unknown_type"}
Mensaje que supera el límite Un cuerpo de más de 1 MB (MaxMessageBytes) El servidor se desconecta en silencio y el cliente recibe IOException

Cuando se quiera comprobar desde el sistema operativo si la canalización realmente está levantada, pipelist de Sysinternals permite listar los nombres bajo \\.\pipe\. Con esto se puede aislar en un minuto si el motivo de «no conecta» es un error de tipeo en el nombre de la canalización o que el servidor está caído; conviene incluirlo en el procedimiento de investigación.

6. Consideraciones comunes del diseño de protocolo — el trabajo que viene después de elegir el medio

Sea cual sea la IPC elegida, el diseño del contenido de la comunicación comparte los mismos retos. De hecho, esta parte es, más que la elección del medio, la verdadera fuente de los incidentes.

  • Delimitación de mensajes (framing): salvo cuando el sistema operativo delimita por usted, como en el modo mensaje de una canalización, determinar «dónde empieza y dónde termina un mensaje» es responsabilidad de la aplicación. El límite de los registros en memoria compartida plantea el mismo problema. Use como base el esquema de prefijo de longitud.
  • Versionado: incluya en el mensaje una versión de formato, y haga que el receptor «rechace explícitamente las versiones que no conoce». No hay garantía de que ambos procesos se actualicen siempre al mismo tiempo, ni siquiera si se distribuyen con el mismo instalador.
  • Tiempos de espera: diseñe partiendo de que «la otra parte no responde» va a ocurrir sí o sí. Fije un límite de tiempo para la conexión, la solicitud y la respuesta por separado, y no deje código que espere indefinidamente. Esperar de forma síncrona en el hilo de la interfaz de usuario queda directamente descartado.
  • Reconexión e idempotencia: reintentar significa crear la posibilidad de que la misma solicitud llegue dos veces. Clasifique por tipo de solicitud si es «segura de ejecutar dos veces», y a las que no lo sean (como el envío de un trabajo) añádales un ID de solicitud para descartar duplicados.
  • Valide también a la otra parte como entrada externa: en comunicaciones dentro de la misma máquina se tiende a omitir la validación pensando que «la otra parte es mi propia aplicación», pero cuando existe un límite de privilegio, el interlocutor de la IPC es una «entrada no confiable» igual que una entrada procedente de la red. Un broker o un servicio con privilegios de administrador no debe ejecutar tal cual las rutas o los comandos que le llegan de un cliente con privilegios estándar. La buena práctica al cruzar límites de privilegio en la IPC es desconfiar tanto de «quién» como de «qué», incluyendo la ocupación del nombre de la canalización (apartado 2.2).
  • Deje mecanismos de observación: poder generar un registro de la comunicación (al menos el tipo de mensaje, la contraparte y el resultado) cambia en un orden de magnitud el tiempo de investigación de incidentes del tipo «no conecta» o «no responde».

7. Resumen

Pensar la elección de la comunicación entre procesos en este orden casi elimina las dudas: primero, si basta con limitarse a la misma máquina. Si basta, la solicitud-respuesta va con canalización con nombre, los datos de gran volumen y alta frecuencia solo con memoria compartida, y el lote desacoplado con integración por archivos. Si ya se ve la necesidad de cruzar máquinas o de mezclar lenguajes, se va a TCP local o a gRPC, y si la escala hace que gestionar un protocolo propio sea una carga, se va a gRPC. COM no se convierte en la primera opción para algo nuevo, sino que se reserva como herramienta para el puente entre 32 y 64 bits y la integración con VBA. La tabla de decisión de este artículo no es más que este razonamiento puesto en forma de tabla.

Y, una vez decidido el medio, incluya desde la primera versión el diseño común del capítulo 6: framing, versión, tiempos de espera, reconexión y validación de la otra parte. En la práctica, la sensación es que los problemas de IPC se deben con mucha más frecuencia a haber «omitido el diseño del protocolo» que a haber «elegido mal el medio». Para revisar la división en procesos o el método de comunicación, se recomienda empezar siempre por un inventario de «qué proceso, con qué privilegios, qué intercambia y con qué frecuencia».

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se ocupa de revisiones de diseño de la división en procesos y del método de comunicación, del diseño de integración con activos existentes —incluidos los puentes entre 32 y 64 bits— y de la investigación de la causa de problemas de comunicación del tipo «no conecta» o «no responde».

Referencias

  1. Microsoft Learn, How to: Use Named Pipes for Network Interprocess Communication. Sobre un ejemplo de implementación de servidor y cliente con soporte para varios clientes mediante NamedPipeServerStream / NamedPipeClientStream. 

  2. Microsoft Learn, Inter-process communication with gRPC and Named pipes. Sobre la configuración, desde .NET 8, para ejecutar gRPC sobre una canalización con nombre mediante ListenNamedPipe de Kestrel, y sobre el control de acceso con PipeSecurity.  2 3 4

  3. Microsoft Learn, MemoryMappedFile Class. Sobre la API de .NET para archivos mapeados en memoria, y sobre cómo la memoria compartida sin archivo asociado que se crea con CreateNew resulta adecuada para IPC.  2

  4. Microsoft Learn, Interprocess communications. Sobre el listado de mecanismos de IPC que ofrece Windows (portapapeles, COM, WM_COPYDATA, DDE, mapeo de archivos, mailslots, canalizaciones, RPC, sockets de Windows) y las pautas para elegir entre ellos.  2 3

  5. Microsoft Learn, NamedPipeServerStream Class. Sobre el flujo del lado servidor de una canalización con nombre, y los constructores que permiten especificar PipeTransmissionMode o PipeSecurity. 

  6. Microsoft Learn, Named Pipe Security and Access Rights. Sobre que el descriptor de seguridad predeterminado de una canalización con nombre concede lectura a Everyone y a las cuentas anónimas, y sobre la composición de los derechos de acceso.  2

  7. Microsoft Learn, PipeOptions Enum. Sobre cómo CurrentUserOnly permite conectar solo con «una contraparte creada por el mismo usuario», y cómo en Windows verifica, además de la cuenta de usuario, también el nivel de elevación.  2

  8. Microsoft Learn, Inter-process communication with gRPC. Sobre los transportes (sockets de dominio Unix, canalizaciones con nombre) al usar gRPC para IPC, y sobre consideraciones de seguridad como la verificación del propietario del servidor y las medidas contra la suplantación (impersonation).  2

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Qué debería ser la primera opción para la comunicación entre procesos en Windows?
Si se trata de un intercambio de solicitud-respuesta dentro de la misma máquina (enviar un comando y recibir el resultado), las canalizaciones con nombre son la primera opción. Son un mecanismo estándar del sistema operativo, no requieren gestionar números de puerto, no chocan con el firewall, están integradas con el control de acceso de Windows (PipeSecurity) y desde .NET se implementan de forma directa con System.IO.Pipes. Si existe la posibilidad de cruzar la red en el futuro, conviene optar desde el principio por TCP local o gRPC; para datos de gran volumen y alta frecuencia (fotogramas de imagen, formas de onda) la memoria compartida es la única opción razonable; y para una integración desacoplada y asíncrona en la que se quiera dejar un registro de auditoría, la integración por archivos sigue siendo válida. Elegir por descarte entre estas opciones es el enfoque correcto.
¿En qué casos conviene usar gRPC para la comunicación entre procesos?
Cuando el número de servicios o de tipos de llamada crece y mantener un switch de protocolo propio junto con su documentación se vuelve una carga. gRPC aporta definición de esquema mediante archivos proto, generación de código y streaming bidireccional, y desde .NET 8 ASP.NET Core (Kestrel) admite directamente las canalizaciones con nombre como transporte. En cambio, para herramientas que solo intercambian dos o tres tipos de comandos, cargar con la infraestructura de hospedaje de ASP.NET Core resulta excesivo, y una canalización con nombre más JSON sale más barata en conjunto. No es tarde para adoptar gRPC una vez que se cumple alguna de estas condiciones: los mensajes que hay que escribir en el proto superan los diez, el streaming de notificaciones es realmente necesario, o la otra parte no está en .NET.
¿Cómo se puede usar una DLL de 64 bits desde una aplicación de 32 bits?
Como una DLL de 64 bits no se puede cargar en el mismo proceso que una aplicación de 32 bits, hay que sacar la parte de 64 bits a un proceso independiente. Hay dos formas de implementarlo: (a) levantar un proceso auxiliar de 64 bits y comunicarse con él mediante una canalización con nombre, o (b) convertirlo en un servidor COM fuera de proceso de 64 bits. Si quien llama es VBA o un lenguaje antiguo, la opción (b) es la más natural, porque la infraestructura COM se encarga del marshaling y apenas hay que tocar el código de la parte que llama. Si ambos extremos son C#, la opción (a) resulta más sencilla tanto de distribuir como de investigar. En el caso de (a), hay que diseñar con un Job Object la terminación conjunta del proceso auxiliar y su padre.
¿En qué situaciones conviene usar memoria compartida?
Se debe usar exclusivamente para datos de gran volumen y alta frecuencia, como fotogramas de imagen o formas de onda. Es la IPC más rápida dentro de la misma máquina porque no interviene ni copia ni serialización, pero no ofrece ni un solo byte de sincronización, así que hay que diseñar por cuenta propia el mecanismo para no leer datos a medio escribir, la detección de si la otra parte sigue viva y la recuperación tras una terminación anómala. En la práctica, la configuración habitual consiste en poner solo el plano de datos (data plane) sobre memoria compartida y desviar el plano de control (control plane) —inicio, detención, cambios de configuración— a otro canal, como una canalización con nombre, en una arquitectura de dos canales. Si se empieza a forzar también los mensajes de control por memoria compartida, es una señal de que hay que revisar el diseño.

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.

Volver al blog