Как выбрать межпроцессное взаимодействие в Windows — таблица решений: именованные каналы / TCP / gRPC / разделяемая память / COM

· · Межпроцессное взаимодействие, Именованные каналы, Windows, .NET, C#, gRPC, Разделяемая память, COM, Проектирование, Таблица решений, Техническая консультация

«Если разделить UI и службу, как между ними организовать связь?» «Хотим использовать функциональность 64-битной DLL из 32-битного приложения». «Хотим передавать данные от движка измерений в отдельном процессе на экран». Разделение приложения на несколько процессов часто становится практичным решением для надёжности, разделения прав и проблем с разрядностью, но в момент разделения неизбежно возникает выбор — как организовать связь между процессами.

На этом блоге мы уже писали об отдельных способах связи: «Подводные камни разделяемой памяти», «Фрейминг TCP», «Безопасная работа с дочерними процессами», «Взаимодействие через файлы и блокировки». Но общего обзора — «какой способ вообще стоит выбирать» — до сих пор не было. В этой статье разберём главные варианты межпроцессного взаимодействия (IPC) в Windows — взаимодействие через файлы, именованные каналы, локальный TCP, gRPC, разделяемую память и COM — по их сильным сторонам и ловушкам, в привычном для этого блога формате таблицы решений.

1. Сначала вывод

  • Для запросов и ответов в пределах одной машины (отправить команду и получить результат) первый кандидат — именованные каналы. Стандартный механизм ОС, не требует порта, интегрирован с системой контроля доступа Windows, а из .NET пишется без затей через System.IO.Pipes.1
  • Если в будущем есть шанс выйти за пределы сети, сразу выбирайте локальный TCP или gRPC. Переход с канала на сокет позже обычно превращается не в «немного подправить», а в полноценный редизайн. Правда, чистый TCP — это байтовый поток, поэтому проектирование фрейминга обязательно.
  • Когда число служб или видов вызовов растёт и поддержка самодельного протокола становится обременительной — gRPC. Вы получаете определение схемы и генерацию кода через proto, а также двунаправленный стриминг, а начиная с .NET 8 ASP.NET Core (Kestrel) может использовать именованные каналы как транспорт.2
  • Разделяемая память — только для больших объёмов высокочастотных данных (кадры изображений, сигналы). Это самый быстрый вариант, но всю синхронизацию придётся проектировать самим, поэтому железное правило — не тащить через неё даже управляющие сообщения.3
  • Для слабосвязанной, асинхронной интеграции, где нужен журнал аудита, взаимодействие через файлы остаётся сильным вариантом и сегодня. Однако успех целиком зависит от проектирования эксклюзивной блокировки.
  • COM (внепроцессный) не делаем первым кандидатом для новых разработок, но в контексте использования из VBA или других языков либо как мост между 32 и 64 битами это по-прежнему рабочий инструмент.4
  • Какой бы способ вы ни выбрали, от общих задач проектирования — границы сообщений, номера версий, тайм-ауты, переподключение — никуда не деться (глава 6). Уделите этому не меньше времени, чем выбору самого способа.

2. Особенности каждого варианта

2.1 Взаимодействие через файлы — слабая связанность, асинхронность, возможность аудита

Классическая схема: «A кладёт файл в выходную папку, B забирает и обрабатывает». Обеим сторонам не обязательно работать одновременно, след обработки остаётся в виде файлов, а при сбое человек может напрямую посмотреть файл, исправить его и заново отправить в обработку. Для пакетной интеграции, не требующей реального времени, этот способ и сегодня остаётся мощным.

Ловушка практически одна — эксклюзивная блокировка. «Чтение файла, который ещё дописывается» и «два процесса дерутся за один и тот же файл» — классические аварии, и нужно следовать стандартным практикам вроде записи под временным именем с последующим переименованием. Подробности собраны в «Лучших практиках интеграции через файлы и блокировки» — обязательно обращайтесь туда при выборе этого способа. Он не подходит для интерактивной связи, требующей ответа, или для обмена, происходящего десятки раз в секунду.

2.2 Именованные каналы — основной выбор для IPC в пределах машины

Именованные каналы — это двунаправленный канал связи, который предоставляет ядро Windows, и основной выбор для клиент-серверного IPC в пределах одной машины. В .NET с ними работают через NamedPipeServerStream / NamedPipeClientStream, и к одному имени канала может подключиться несколько клиентов.5 В отличие от TCP, номер порта управлять не нужно, и брандмауэр их не блокирует.

Перечислим моменты, важные на практике.

  • Доступен режим сообщений. Если указать PipeTransmissionMode.Message, каждая отдельная запись доставляется как одно чётко ограниченное сообщение. Это существенное практическое преимущество: ОС берёт на себя фрейминг (например, префикс длины), обязательный для TCP (принимающая сторона всё же должна дочитывать одно сообщение до конца через IsMessageComplete; см. главу 5).
  • Права доступа по умолчанию неожиданно широки. Дескриптор безопасности канала по умолчанию даёт полный контроль LocalSystem, администраторам и создателю, но при этом разрешает чтение и Everyone, и анонимной учётной записи.6 Для взаимодействия процессов одного пользователя достаточно добавить PipeOptions.CurrentUserOnly, чтобы принудительно разрешать подключение только к собеседнику, созданному тем же пользователем, — рекомендуем сделать это стандартной практикой.7 Для связи между разными учётными записями (например, при общении со службой) явно задавайте ACL через PipeSecurity.
  • Имена каналов лежат в пространстве имён, видимом всем. Имена каналов размещаются в едином пространстве имён под \\.\pipe\, видимом и процессам других пользователей на той же машине. Здесь важно остерегаться захвата имени (squatting): если вредоносный процесс раньше поднимет сервер с тем же именем и начнёт ждать подключений, клиент подключится именно к нему. На машинах, где сосуществуют пользователи с разными уровнями прав, стоит принимать меры: со стороны сервера — указывать PipeOptions.FirstPipeInstance, чтобы завершиться неудачей, если канал с таким именем уже существует, со стороны клиента — проверять учётную запись владельца канала после подключения.8
  • Можно связывать стороны по разные стороны границы прав. Это стандартный канал связи между «UI со стандартными правами + брокер с правами администратора», разделёнными UAC. Однако в такой конфигурации CurrentUserOnly использовать нельзя (он требует совпадения вплоть до уровня повышения прав, даже у одного и того же пользователя7). ACL нужно проектировать явно; конкретная реализация подробно разобрана в «Разделении прав администратора (брокер)».

Слабые стороны: он фактически не подходит для связи между машинами (технически возможно, но много эксплуатационных ограничений), и его неудобно использовать для взаимодействия с системами, отличными от Windows. Если такие требования очевидны заранее, выбирайте TCP / gRPC.

2.3 Локальный TCP — универсальность вне зависимости от языка и ОС

TCP-соединение с localhost — самый универсальный IPC, на котором способен «говорить» практически любой язык, среда выполнения и ОС. В смешанных конфигурациях вроде «движок анализа на Linux и UI на Windows» первым кандидатом становится именно TCP (или HTTP/gRPC поверх него).

Есть три момента, требующих внимания.

  • Это байтовый поток. У TCP нет свойства «доставка теми же порциями, что и отправка». Совершенно нормально, что три сообщения, отправленные Send, придут склеенными в одном Receive, и точно так же нормально, что одно сообщение придёт разбитым на части. Фрейминг (например, префикс длины) нужно проектировать на уровне приложения самостоятельно, а код, пропускающий это, просто «случайно работает». Подробности см. в «Заблуждении, что через TCP можно получать данные Receive той же порцией, что и Send».
  • Ограничивайте область видимости прослушивания. Даже если вы имели в виду связь только в пределах одной машины, прослушивание на 0.0.0.0 позволяет подключиться другим машинам сети. Для локального IPC правило — привязываться к 127.0.0.1 (loopback). Даже в этом случае подключиться сможет другой пользователь на той же машине, поэтому, если нужна проверка собеседника, добавьте аутентификацию на уровне приложения. Явное отличие от каналов — отсутствие встроенного в ОС контроля доступа.
  • Не избежать эксплуатационных вопросов с портом. Фиксированный порт может конфликтовать с другим ПО, а брандмауэры иногда распознают его как «подозрительное прослушивание». Заранее заложите в проектирование способ смены номера порта.

2.4 gRPC — покупаем схему и генерацию кода

По сравнению с чистым сокетом плюс самодельным протоколом, gRPC даёт скорее не саму связь, а фреймворк для разработки. Стоит определить службы и сообщения в proto-файле — и сериализация, фрейминг, код клиента и сервера генерируются автоматически, а двунаправленный стриминг (push-уведомления с сервера) пишется как обычная возможность языка. Как только вы входите в фазу «каждый раз при добавлении сообщения приходится править оператор switch и документацию самодельного протокола», этот фреймворк начинает окупаться.

Начиная с .NET 8, ASP.NET Core (Kestrel) напрямую поддерживает как транспорт не только TCP, но и Unix domain sockets и именованные каналы.8 На стороне сервера достаточно вызвать ListenNamedPipe, а контроль доступа тоже можно настроить через PipeSecurity.2 Комбинация «канал связи остаётся именованным каналом без порта и с интегрированным ACL, а уровень протокола — gRPC» — сильный вариант для разделения UI и службы (глава 4).

С другой стороны, для связи между небольшими инструментами это часто избыточно. Поднять сервер gRPC означает взять на себя хостинговую платформу ASP.NET Core, что увеличивает поставляемые артефакты, зависимости и стоимость запуска. Держите в уме границу: для пары инструментов, обменивающихся всего двумя-тремя видами команд, именованные каналы + JSON (глава 5) в сумме обойдутся дешевле. Не поздно переходить на gRPC, как только выполнится одно из условий: «число сообщений, которые хочется описать в proto, превысило десять», «стриминговые уведомления по-настоящему необходимы» или «собеседник — не .NET».

2.5 Разделяемая память (memory-mapped file) — максимальная скорость, но вся синхронизация на вас

Самый быстрый IPC в пределах одной машины — разделяемая память. В .NET именованную разделяемую память создают через MemoryMappedFile.CreateNew, и несколько процессов могут напрямую читать и писать одну и ту же последовательность байтов.3 Поскольку копирование и сериализация не участвуют, для «больших и быстрых» данных вроде кадров изображений или сигналов производительность практически безальтернативна.

Однако разделяемая память — не «быстрый канал». Стороны видят лишь одну и ту же последовательность байтов, а синхронизация не предоставляется вообще. Механизм, не читающий данные посередине записи, обнаружение того, жив ли собеседник, восстановление после аварийного завершения одной из сторон — всё это придётся проектировать самостоятельно. Полное обсуждение проектирования, включая устройство кольцевого буфера и версионирование раскладки, собрано в «Подводных камнях разделяемой памяти и практических рекомендациях».

Практическое применение чёткое: конфигурация, где только слой данных (data plane) размещается в разделяемой памяти, а слой управления (control plane) выносится в отдельный канал, например именованный канал (конфигурация 3 из главы 4). Если начинаете пытаться протащить через разделяемую память даже управляющие сообщения вроде запуска, остановки или изменения настроек, это сигнал пересмотреть проектирование.

2.6 COM (внепроцессный) — не первый кандидат для нового проекта, но остаётся рабочим инструментом

Внепроцессный сервер COM (EXE-сервер) — это давний механизм Windows, позволяющий «вызывать объект в другом процессе как локальную функцию».4 Сегодня он уже не первый кандидат для связи между новыми приложениями, но в следующих контекстах остаётся практичным.

  • Мост между 32 и 64 битами: 64-битную DLL нельзя загрузить в процесс 32-битного приложения (и наоборот), но внепроцессный COM позволяет пересечь эту границу. Инфраструктура COM берёт маршалинг на себя, поэтому код вызывающей стороны почти не меняется. Реальный пример описан в «Примере моста COM с 32 на 64 бита».
  • Использование из VBA и других языков: если нужно вызывать функциональность .NET из старой среды выполнения вроде Excel VBA, публикация в виде COM и сегодня остаётся самым естественным путём.
  • Интеграция с существующими активами COM: если собеседник умеет «разговаривать» только через COM, кратчайший путь — говорить через COM и со своей стороны (о самом COM см. «Что такое COM, ActiveX и OCX»).

Причины воздержаться от выбора COM для нового проекта — хлопотное распространение с регистрацией в реестре, стоимость освоения проектирования интерфейсов и подсчёта ссылок, а также сложность расследования проблем. Прежде чем принимать решение, сравните с альтернативой «если цель — мост 32/64 бит, сделать 64-битную сторону просто вспомогательным процессом и говорить с ним через именованный канал» (конфигурация 2 из главы 4).

2.7 Классические механизмы — не выбираем для нового проекта

В Windows есть и другие механизмы IPC — WM_COPYDATA (передача данных через оконное сообщение), буфер обмена, DDE, почтовые слоты — и они по-прежнему перечислены на официальной странице обзора IPC.4 Однако они либо предполагают наличие окна, либо постепенно выводятся из употребления (удалённые почтовые слоты находятся в процессе устаревания), поэтому причин выбирать их для нового проектирования практически нет. Достаточно узнавать их при сопровождении существующих приложений.

3. Таблица решений

Аспект Файл Именованный канал Локальный TCP gRPC Разделяемая память COM
Область связи Может пересекать машины через общую папку Практически ограничен одной машиной Может пересекать машины Может пересекать машины Только одна машина Практически ограничен одной машиной
Модель связи Передача файла (асинхронно) Поток + режим сообщений Байтовый поток RPC + стриминг Разделяемое состояние Вызов методов
Уведомление сервер → клиент ✕ (поллинг) ◎ (двунаправленный стриминг) △ (нужен отдельный механизм событий) △ (возможно, но сложно)
Пересечение границы прав (UAC, службы) ○ (ACL папки) ◎ (PipeSecurity) △ (аутентификация своя) △–○ (◎ при транспорте на каналах) ○ (ACL возможен, но проектировать сложно)
32/64 бита, смешение языков ◎ (генерация кода из proto для каждого языка) △ (обязательно проектирование ABI) ○ (сильная сторона — мосты)
Затраты на реализацию Низкие Низкие–средние Средние (фрейминг свой) Средние (внедрение платформы) Высокие Высокие (для нового проекта)
Удобство отладки ◎ (содержимое остаётся в файле) ○ (можно захватить трафик) △ (HTTP/2 + бинарный формат) △ (симптомы эффектные)
Пропускная способность / задержка Низкая Средняя–высокая Средняя Средняя Средняя

Одно замечание. Правильный подход — не выбирать «способ с наибольшим числом ◎», а смотреть только на строки, релевантные требованиям, и сужать выбор методом исключения. Например, как только определено «30 кадров изображений в секунду», для слоя данных остаётся единственный выбор — разделяемая память.

4. Примеры типовых конфигураций

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

Конфигурация 1: разделение UI-приложения и службы Windows. Резидентную обработку или работу, требующую привилегий, размещают в службе, а UI остаётся обычным пользовательским процессом (о том, как строить сторону службы, см. опубликованную в тот же день статью «Служба Windows»). Связь строится на именованных каналах, и надёжный старт — начать с протокола JSON-сообщений плюс режим сообщений (или байтовый режим плюс префикс длины); по мере роста числа видов вызовов есть путь к переходу на транспорт gRPC поверх именованных каналов.2 Поскольку служба работает под другой учётной записью (например, LocalService), CurrentUserOnly использовать нельзя — ключевой момент здесь явно задать ACL через PipeSecurity, например «разрешить Users чтение и запись, запретить удалённый доступ».

Конфигурация 2: мост от 32-битного приложения к 64-битной функциональности. Когда нужно использовать из существующего 32-битного приложения DLL или SDK драйвера, работающие только в 64 битах, 64-битную сторону выносят в отдельный процесс. Реализовать это можно двумя способами: (а) поднять 64-битный вспомогательный процесс и говорить с ним через именованный канал, (б) сделать его 64-битным внепроцессным COM-сервером. Если вызывающая сторона — VBA или другой старый язык, естественнее (б); если обе стороны на C#, вариант (а) проще и в распространении, и в расследовании. Для варианта (а) используйте прямо ту же схему с Job Object для запуска, мониторинга и согласованного завершения родительского и дочернего процессов, что описана в «Безопасной работе с дочерними процессами».

Конфигурация 3: высокочастотные данные от движка измерений к отображению. В конфигурации, где движок измерений или обработки изображений вынесен в отдельный процесс и передаёт данные в UI, стандартна двухканальная схема: управление (запуск, остановка, настройки) через именованный канал, данные (кадры, сигналы) через разделяемую память плюс именованное событие. Управление происходит всего несколько раз в секунду, так что канала достаточно, а данные через разделяемую память приближаются к нулевому копированию. Разделение каналов позволяет оптимизировать проектирование стороны данных (например, кольцевой буфер) независимо от нужд управления. Об отображении состояния движка или внешнего оборудования см. также «Отображение состояния внешнего оборудования».

5. Пример реализации — асинхронные сервер и клиент на именованных каналах

Покажем минимальную конфигурацию на .NET 8, служащую основой для реализации именованных каналов. Она включает режим сообщений, поддержку нескольких клиентов и обработку отключений. Протокол — «один UTF-8 JSON на одно сообщение», и обязательно включает поле version. В примере ниже используется CurrentUserOnly, рассчитанный на взаимодействие процессов одного пользователя. Если связь идёт со службой (другой учётной записью), как в конфигурации 1, уберите CurrentUserOnly и явно задайте ACL через PipeSecurity, как описано далее.

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

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

public sealed class PipeServer(string pipeName)
{
    // Веб-настройки по умолчанию: camelCase и нечувствительность к регистру. Собственные
    // настройки System.Text.Json по умолчанию чувствительны к регистру, и без общих
    // настроек "version" не свяжется с Request.Version, а корректный запрос
    // провалится в 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,   // одна запись = одно сообщение
                PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

            await pipe.WaitForConnectionAsync(ct);
            _ = Task.Run(() => HandleClientAsync(pipe, ct), ct);  // изолируем по каждому подключению
        }
    }

    // Верхняя граница для одного сообщения. Защищает память службы, даже если
    // собеседник пришлёт огромное сообщение (собеседник по IPC — тоже внешний ввод; глава 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)
                {
                    // Даже в режиме сообщений один вызов Read не гарантирует чтение всего
                    // сообщения целиком. Дочитываем до IsMessageComplete
                    using var ms = new MemoryStream();
                    do
                    {
                        int n = await pipe.ReadAsync(buffer, ct);
                        if (n == 0) return;  // клиент отключился
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxMessageBytes) return;  // превышен лимит. Отключаемся
                    } while (!pipe.IsMessageComplete);

                    byte[] response = Dispatch(ms.ToArray());
                    await pipe.WriteAsync(response, ct);
                }
            }
            catch (IOException)
            {
                // Сломался только канал с этим клиентом.
                // Не останавливаем весь сервер — продолжаем обработку других подключений
            }
        }
    }

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

        // Здесь проверяем и отклоняем неверный формат, неизвестную версию, неизвестный type
        object result = req switch
        {
            null => new { version = 1, error = "bad_request" },
            // Неуказанную version (свяжется с 0) или неизвестную версию отклоняем здесь
            { 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);

Сторона клиента заключает последовательность «подключение → запрос → ответ» в один метод и с самого начала включает тайм-аут и повторные попытки.

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

public sealed class PipeClient(string pipeName)
{
    // idempotent: повторяем попытку только если вызывающая сторона объявила,
    // что этот запрос безопасно получить дважды. По умолчанию — «не повторять»
    public async Task<TResponse?> RequestAsync<TResponse>(
        object request, CancellationToken ct, bool idempotent = false)
    {
        for (int attempt = 1; ; attempt++)
        {
            // Ограничиваем по времени не только соединение, но и весь обмен запрос → ответ.
            // Это предотвращает бесконечное ожидание, если сервер принял соединение,
            // но завис, не записав ответ
            using var deadline =
                CancellationTokenSource.CreateLinkedTokenSource(ct);
            deadline.CancelAfter(TimeSpan.FromSeconds(10));
            try
            {
                using var pipe = new NamedPipeClientStream(
                    ".", pipeName, PipeDirection.InOut,
                    PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                // Не ждём бесконечно. Если сервер не запущен, это вызовет исключение
                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("Сервер разорвал соединение.");
                    ms.Write(buffer, 0, n);
                } while (!pipe.IsMessageComplete);

                return JsonSerializer.Deserialize<TResponse>(
                    ms.ToArray(), PipeServer.JsonOptions);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                throw;  // отмена со стороны вызывающего кода. Не повторяем
            }
            catch (Exception ex) when (
                ex is IOException or TimeoutException or OperationCanceledException
                && idempotent && attempt < 3)
            {
                // Повторяем временные сбои — например, во время перезапуска сервера.
                // В случае «отправили, но соединение оборвалось до чтения ответа» запрос,
                // возможно, уже выполнен, поэтому запросы с побочными эффектами вроде
                // startJob по умолчанию не повторяем — сначала спроектируйте отклонение
                // дублей по ID запроса и только потом передавайте idempotent: true (глава 6)
                await Task.Delay(500 * attempt, ct);
            }
        }
    }
}

Дополним три проектных решения, заложенных в этом коде.

  • Включайте version начиная с самого первого релиза. Обязательно наступит момент, когда UI и служба обновляются раздельно (обновляется только одна сторона). Достаточно того, чтобы принимающая сторона «явно отклоняла неизвестную версию», и авария в виде молчаливого некорректного поведения превращается в «понятную ошибку».
  • Решите, будет ли соединение одноразовым или постоянным. В примере выше используется одноразовый тип — подключение при каждом запросе, что избавляет от необходимости управлять состоянием отключения и переподключения, но не подходит для высокочастотных вызовов. Как только понадобится постоянное соединение плюс push-уведомления с сервера, эта сложность станет поводом перейти на двунаправленный стриминг gRPC.
  • При связи со службой убирайте CurrentUserOnly и проектируйте PipeSecurity. Пример выше рассчитан на процессы одного пользователя. Если собеседник — служба под другой учётной записью, создавайте серверный поток с ACL через NamedPipeServerStreamAcl.Create и явно указывайте группу, которой разрешено подключение.6

6. Общие моменты проектирования протокола — работа после выбора способа

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

  • Границы сообщений (фрейминг): за исключением случаев, когда ОС сама размечает границы, как в режиме сообщений канала, «где начинается и заканчивается одно сообщение» — ответственность приложения. Та же проблема — с границами записей в разделяемой памяти. За основу берите схему с префиксом длины.
  • Версионирование: включайте версию формата в сообщение, а принимающая сторона должна «явно отклонять неизвестные версии». Гарантии, что оба процесса всегда обновляются одновременно, нет, даже если они распространяются одним инсталлятором.
  • Тайм-ауты: проектируйте, исходя из того, что «собеседник не ответит» обязательно случится. Задайте верхнюю границу времени для подключения, запроса и ответа по отдельности и не оставляйте кода с бесконечным ожиданием. Синхронное ожидание в UI-потоке вообще недопустимо.
  • Переподключение и идемпотентность: повторная попытка означает создание возможности, что один и тот же запрос придёт дважды. Классифицируйте каждый вид запроса по признаку «безопасно ли выполнить его дважды», а для небезопасных (например, отправка задания) добавляйте ID запроса, чтобы отклонять дубликаты.
  • Проверяйте собеседника как внешний ввод. При связи в пределах одной машины легко пропустить проверку, решив, что «собеседник — наше собственное приложение», но при наличии границы прав собеседник по IPC — такой же «недоверенный ввод», как данные из сети. Брокер или служба с правами администратора не должны напрямую выполнять путь или команду, пришедшую от клиента со стандартными правами. Включая захват имени канала (раздел 2.2), правило IPC через границу прав — подозревать и «кто», и «что».
  • Оставляйте средства наблюдения: возможность вести журнал связи (как минимум тип сообщения, собеседник и результат) на порядок сокращает время расследования проблем вида «не подключается» или «нет ответа».

7. Итог

Если продумывать выбор межпроцессного взаимодействия в таком порядке, сомнений почти не остаётся. Сначала — достаточно ли ограничиться одной машиной. Если да: для запросов и ответов — именованные каналы, только для больших объёмов высокочастотных данных — разделяемая память, для слабосвязанных пакетных задач — взаимодействие через файлы. Если очевидны выход за пределы машины или смешение языков — локальный TCP или gRPC, а gRPC — когда управление самодельным протоколом становится обременительным при таком масштабе. COM не делаем первым кандидатом для нового проекта, а держим в резерве как инструмент для мостов 32/64 бит и интеграции с VBA — таблица решений этой статьи представляет именно эту логику в табличной форме.

И как только способ выбран, включите общее проектирование из главы 6 (фрейминг, версии, тайм-ауты, переподключение, проверка собеседника) уже в первый релиз. По опыту практики, проблемы с IPC подавляющее большинство случаев вызваны не «ошибкой выбора способа», а «пропущенным проектированием протокола». При пересмотре разделения процессов или схемы связи рекомендуем начинать с ревизии: какой процесс, под чьими правами, что и с какой частотой передаёт.

Похожие статьи

Смежные области консультаций

Komura Software Co., Ltd. занимается ревью проектирования разделения процессов и схем связи, проектированием интеграции с существующими активами, включая мосты 32/64 бита, а также расследованием причин проблем связи вида «не подключается / нет ответа».

Источники

  1. Microsoft Learn, How to: Use Named Pipes for Network Interprocess Communication. О примере реализации сервера и клиента с поддержкой нескольких клиентов через NamedPipeServerStream / NamedPipeClientStream. 

  2. Microsoft Learn, Inter-process communication with gRPC and Named pipes. О конфигурации запуска gRPC поверх именованных каналов через ListenNamedPipe Kestrel начиная с .NET 8 и о контроле доступа через PipeSecurity.  2 3

  3. Microsoft Learn, MemoryMappedFile Class. О .NET API для memory-mapped файлов и о том, что разделяемая память, создаваемая через CreateNew без привязки к файлу, хорошо подходит для целей IPC.  2

  4. Microsoft Learn, Interprocess communications. О перечне механизмов IPC, которые предоставляет Windows (буфер обмена, COM, WM_COPYDATA, DDE, отображение файлов, почтовые слоты, каналы, RPC, Windows Sockets), и об ориентирах для их выбора.  2 3

  5. Microsoft Learn, NamedPipeServerStream Class. О серверном потоке именованного канала и о конструкторах, позволяющих указать PipeTransmissionMode и PipeSecurity. 

  6. Microsoft Learn, Named Pipe Security and Access Rights. О том, что дескриптор безопасности именованного канала по умолчанию разрешает чтение Everyone и анонимной учётной записи, а также о структуре прав доступа.  2

  7. Microsoft Learn, PipeOptions Enum. О том, что CurrentUserOnly разрешает подключение только к собеседнику, созданному тем же пользователем, а в Windows дополнительно проверяется и уровень повышения прав, помимо учётной записи пользователя.  2

  8. Microsoft Learn, Inter-process communication with gRPC. О транспортах для использования gRPC как IPC (Unix domain sockets, именованные каналы) и о соображениях безопасности вроде проверки владельца сервера и защиты от подмены (impersonation).  2

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Как создавать и эксплуатировать службы Windows — от выбора между планировщиком заданий и службами до превращения BackgroundService в службу Windows

Разбираем, стоит ли превращать резидентную обработку в службу Windows или достаточно планировщика заданий: таблица решений, создание служ...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что должно быть первым кандидатом для межпроцессного взаимодействия в Windows?
Для запросов и ответов в пределах одной машины (отправить команду и получить результат) первый кандидат — именованные каналы. Это стандартный механизм ОС, не требующий управления номерами портов, не блокируемый брандмауэром, интегрированный с системой контроля доступа Windows (PipeSecurity), и из .NET он пишется без затей через System.IO.Pipes. Правильный подход — сужать выбор методом исключения: если в будущем возможен выход за пределы сети — сразу локальный TCP или gRPC; только для больших объёмов высокочастотных данных (кадры изображений, сигналы) — разделяемая память; для слабосвязанной асинхронной интеграции с журналом аудита — взаимодействие через файлы.
В каких случаях для межпроцессного взаимодействия стоит использовать gRPC?
Когда число служб или видов вызовов растёт и поддерживать оператор switch и документацию самодельного протокола становится обременительно. gRPC даёт определение схемы и генерацию кода через proto-файлы, а также двунаправленный стриминг, а начиная с .NET 8 ASP.NET Core (Kestrel) напрямую поддерживает именованные каналы как транспорт. С другой стороны, для пары инструментов, обменивающихся всего двумя-тремя видами команд, необходимость тащить хостинговую платформу ASP.NET Core становится избыточной — именованные каналы плюс JSON обойдутся дешевле в сумме. Не поздно переходить на gRPC, как только выполнится одно из условий: число сообщений, которые хочется описать в proto, превысило десять, стриминговые уведомления по-настоящему необходимы, или собеседник — не .NET.
Как использовать 64-битную DLL из 32-битного приложения?
64-битную DLL нельзя загрузить в тот же процесс, что и 32-битное приложение, поэтому 64-битную сторону нужно вынести в отдельный процесс. Реализовать это можно двумя способами: (а) поднять 64-битный вспомогательный процесс и общаться с ним через именованный канал, либо (б) сделать его 64-битным внепроцессным COM-сервером. Если вызывающая сторона — VBA или другой старый язык, естественнее вариант (б): инфраструктура COM берёт маршалинг на себя, и код вызывающей стороны почти не меняется. Если обе стороны на C#, вариант (а) проще и в распространении, и в расследовании проблем. Для варианта (а) согласованное завершение родительского и дочернего процессов проектируется через Job Object.
В каких сценариях стоит использовать разделяемую память?
Используйте разделяемую память только для больших объёмов высокочастотных данных — кадров изображений, сигналов и т. п. Это самый быстрый IPC в пределах одной машины, без копирования и сериализации, но синхронизация не предоставляется вообще, поэтому механизм, не читающий данные посередине записи, обнаружение того, жив ли собеседник, и восстановление после аварийного завершения — всё это придётся проектировать самостоятельно. На практике стандартна двухканальная конфигурация: только слой данных (data plane) размещается в разделяемой памяти, а слой управления (control plane) — запуск, остановка, изменение настроек — выносится в отдельный канал, например именованный канал. Если начинаете пытаться протащить через разделяемую память даже управляющие сообщения, это сигнал пересмотреть проектирование.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

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

Публичные ссылки

Вернуться в блог