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

Взаимодействие между процессами – ключевая часть многих современных приложений. Один из способов реализовать это взаимодействие – использование именованных каналов. В Windows именованные каналы представляют собой специализированный механизм IPC (Inter-Process Communication), который могут быть использованы для асинхронного или синхронного обмена данными между сервером и клиентами. В POSIX-совместимых системах аналогичная функциональность достигается через такие механизмы, как FIFO (first-in, first-out) файлы, которые предоставляют однонаправленный канал для передачи данных.

Особенности в Windows

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

Особенности в POSIX

  • Однонаправленность: Каждый FIFO файл предназначен либо для чтения, либо для записи относительно одной стороны (например, сервера).
  • Простота: FIFO файлы представляют собой специальные файлы в файловой системе и используются аналогично обычным файлам.

Пример реализации на D

Пример сервера

import std.stdio;
import std.process;
import std.string;
import std.typecons : RefCounted;
import std.uuid;

void main()
{
	string pipeName = randomUUID().toString;
	auto serverPipe = NamedPipe(pipeName);
	writeln("Server: Create a named pipe.");

	auto childProcess = spawnProcess(["./client", pipeName]);
	//auto childProcess = spawnProcess(["java", "-jar", "client.jar", pipeName]); // launching a client in java
	writeln("Server: Launch the client.");

	auto pipeFile = serverPipe.connect();
	writeln("Server: Connection established.");

	// Sending a message to a client
	pipeFile.writeln("hello children");
	pipeFile.flush();
	writeln("Server: Message sent.");

	// Reading a response from a client
	string response = pipeFile.readln();
	writeln("Server: Response received -", response.strip);

	childProcess.wait();
	writeln("Server: Shutting down.");
}

struct NamedPipeImpl
{
	immutable string fileName;

	/// Create a named pipe, and reserve a filename.
	this()(string name)
	{
		version (Windows)
		{
			import core.sys.windows.windows;

			SECURITY_ATTRIBUTES sa;
			sa.nLength = SECURITY_ATTRIBUTES.sizeof;
			sa.bInheritHandle = TRUE; // Allow inheritance of handle
			sa.lpSecurityDescriptor = null; // Use the parent process's security descriptor

			fileName = `\\.\pipe\` ~ name;
			auto h = CreateNamedPipe(fileName.toUTF16z(), PIPE_ACCESS_DUPLEX,
				PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
				1, 4096, 4096, NMPWAIT_USE_DEFAULT_WAIT, &sa)
				.wenforce("CreateNamedPipe");
			f.windowsHandleOpen(h, "wb+");
		}
		else
		{
			import core.sys.posix.sys.stat;

			fileName = `/tmp/` ~ name ~ `.fifo`;
			mkfifo(fileName.toStringz, S_IWUSR | S_IRUSR);
		}
	}

	/// Wait for a peer to open the other end of the pipe.
	File connect()()
	{
		version (Windows)
		{
			import core.sys.windows.windows;
			import core.sys.windows.windef;

			BOOL bSuccess = ConnectNamedPipe(f.windowsHandle, null);

			// "If a client connects before the function is called, the function returns zero
			// and GetLastError returns ERROR_PIPE_CONNECTED. This can happen if a client
			// connects in the interval between the call to CreateNamedPipe and the call to
			// ConnectNamedPipe. In this situation, there is a good connection between client
			// and server, even though the function returns zero."
			if (!bSuccess)
				wenforce(GetLastError() == ERROR_PIPE_CONNECTED, "ConnectNamedPipe");

			return f;
		}
		else
		{
			return File(fileName, "w");
		}
	}

	~this()
	{
		version (Windows)
		{
			// File.~this will take care of cleanup
		}
		else
			fileName.remove();
	}

private:
	File f;
}

alias NamedPipe = RefCounted!NamedPipeImpl;


version (Windows):

import core.sys.windows.windows;
import std.string;

class WindowsException : Exception
{
	DWORD code; /// The error code.

	this(DWORD code, string str=null)
	{
		this.code = code;

		wchar *lpMsgBuf = null;
		FormatMessageW(
			FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
			null,
			code,
			MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
			cast(LPWSTR)&lpMsgBuf,
			0,
			null);

		auto message = lpMsgBuf.fromWString();
		if (lpMsgBuf)
			LocalFree(lpMsgBuf);

		message = strip(message);
		message ~= format(" (error %d)", code);
		if (str)
			message = str ~ ": " ~ message;

		super(message);
	}
}

import std.utf;

/// Convert from a potentially zero-terminated wide string.
string fromWString(in wchar[] buf)
{
	foreach (i, c; buf)
		if (!c)
			return toUTF8(buf[0 .. i]);
	return toUTF8(buf);
}

/// Convert from a zero-terminated wide string.
string fromWString(in wchar* buf)
{
	if (!buf)
		return null;
	const(wchar)* p = buf;
	for (; *p; p++)
	{
	}
	return toUTF8(buf[0 .. p - buf]);
}

/// Convert to a zero-terminated wide string.
LPCWSTR toWStringz(string s)
{
	return s is null ? null : toUTF16z(s);
}

T wenforce(T)(T cond, string str=null)
{
	if (cond)
		return cond;

	throw new WindowsException(GetLastError(), str);
}

В этом коде мы изолировали работу с именованным каналом в структуру NamedPipeImpl, которая занимается всей работой по созданию, настройке и открытию канала в Windows или POSIX системах. Также для Windows добавлена удобная обработка ошибок. За NamedPipeImpl и обработку ошибок спасибо создателям библиотеки AE во главе с Владимиром Пантелеевым. NamedPipe позволяет автоматически освобождать ресурсы при завершении работы с каналом. В SECURITY_ATTRIBUTES настроено наследование дескриптора процесса дочерним процессом, что позволяет запретить подключение к каналу не дочерних процессов, а также в аргументах функции CreateNamedPipe выставлена возможность создания только одного экземпляра канала, то есть подключиться сможет только 1 клиент. В интернете вы можете найти подробное описание и документацию всех используемых функций и аргументов Windows и POSIX API.

Пример клиента

import std.logger;
import std.stdio;

void main(string[] args)
{
	Logger logger = new FileLogger("out.log");
	string pipePath = r"\\.\pipe\";

	if (args.length < 2)
	{
		logger.info("Usage: client <channel name>");
		return;
	}

	string pipeName = pipePath ~ args[1];
	auto pipeFile = File(pipeName, "r+"); // Use "r+" to read and write
	logger.info("Client: Connection established.");

	// Reading a message from the server
	string message = pipeFile.readln();
	logger.info("Client: Message received - ", message);

	// Sending a response to the server
	pipeFile.writeln("hello parent");
	pipeFile.flush();
	logger.info("Client: Response sent.");

	logger.info("Client: Shut down.");
}

В этом коде клиент работает с каналом как с обычным файлом по определенному пути. Обратите внимание, что для POSIX путь нужно изменить в соответствии с путем, определенным в NamedPipeImpl. Кроме того, в POSIX для двунаправленной связи придется создавать 2 канала, как мы уже писали выше. Один канал на сервере должен быть настроен на запись, другой на прием, соответственно, на клиенте все наоборот. В примере кода клиента имя канала передается просто в аргументах приложения, но можно использовать и другие пути передачи, например анонимные каналы. Запись лога в файл сделана для удобства демонстрации работы клиента.

Пример клиента на Java

Клиент (впрочем, как и сервер) можно написать на любом языке программирования. Для примера, мы использовали Java для создания клиента и подключения к каналу, открытому сервером написанным на D.

import java.io.*;

public class Main {
    public static void main(String[] args) {
        String pipePath = "\\\\.\\pipe\\";

        try (PrintStream log = new PrintStream(new FileOutputStream("client.log"))) {
            System.setOut(log);
            System.setErr(log);

            if (args.length < 1) {
                System.out.println("Usage: client <channel name>");
                return;
            }

            String pipeName = pipePath + args[0];
            try {
                // Named pipe connection
                try (RandomAccessFile pipeFile = new RandomAccessFile(pipeName, "rw")) {
                    System.out.println("Client: Connection established.");

                    // Reading a message from the server
                    String message = pipeFile.readLine();
                    System.out.println("Client: Message received - " + message);

                    // Отправка ответа серверу
                    pipeFile.writeBytes("hello parent\n");
                    System.out.println("Client: Response sent.");
                }
            } catch (IOException e) {
                System.err.println("Error when working with named pipe:" + e.getMessage());
            }

            System.out.println("Client: Shut down.");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Безопасность и шифрование

Обращаем ваше внимание на необходимость обеспечить безопасность передаваемых данных через каналы, особенно при обмене конфиденциальной информацией, особенно в POSIX системах, так как по умолчанию FIFO-каналы никак не защищены. Рекомендуем использовать шифрование данных, например, протокол (Elliptic Curve Diffie-Hellman) для обмена ключами и последующего шифрования передаваемой информации.

Заключение

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