Раздел 17 — Каналы и обмен данными

Как процессы с изолированной памятью передают друг другу данные

Прогресс курса Раздел 17 из 20

Что вы освоите в этом разделе

7 академических часов теории и 4 часа практики: практическая работа №18 «Обмен данными» — на портале. Программы раздела работают в Windows. Опыты с именованными каналами — в двух окнах консоли: в одном сервер, в другом клиент.
01

Зачем процессам каналы

Процессы изолированы: один не может прочитать память другого (раздел 14, пункт 05). Когда им нужно обменяться данными, они просят систему. Способы:

СпособКакРаздел
файлодин пишет, другой потом читает; медленно, нужна договорённость «когда готово»11
канал (pipe)труба в памяти системы: один процесс пишет в один конец, другой читает из другого — в том же порядке17
сокетто же, но между компьютерами по сети19
общая памятьодна область памяти видна обоим процессам; быстро, но нужна синхронизация18 (упоминание)

Канал — объект ядра с буфером. WriteFile кладёт байты в буфер, ReadFile забирает их в том же порядке. Если буфер пуст, чтение ждёт данных; если полон — ждёт запись. Когда все пишущие концы закрыты и буфер пуст, чтение возвращает «конец данных». Так работает символ | в консоли: в команде dir | sort вывод dir идёт в канал, а sort читает его как свой ввод.

Анонимный каналИменованный канал
СозданиеCreatePipe — сразу два дескриптора: чтения и записиCreateNamedPipeA("\\\\.\\pipe\\имя", …) — сервер
Как второй процесс получает каналпо наследству от родителяоткрывает по имени: CreateFileA
Направлениеодноодно или оба
Кто с кемродитель и потомоклюбые процессы, в том числе на разных компьютерах сети
02

Анонимный канал и наследование дескрипторов

Дескриптор имеет смысл только в своём процессе (раздел 14, пункт 03). Чтобы потомок получил конец канала, дескриптор должен быть наследуемым, а CreateProcessA — вызвана с разрешением наследовать. Тогда система копирует наследуемые дескрипторы родителя в таблицу потомка — с теми же номерами.

SECURITY_ATTRIBUTES inherit = {sizeof(inherit), nullptr, TRUE};    // третье поле: наследуется
HANDLE readEnd;
HANDLE writeEnd;
if (!CreatePipe(&readEnd, &writeEnd, &inherit, 0)) {           // 0 — размер буфера по умолчанию
    printError("CreatePipe");
    return 2;
}
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);           // читающий конец потомку не нужен
03

Перенаправление вывода дочернего процесса

В STARTUPINFOA есть поля для стандартных потоков потомка. Флаг STARTF_USESTDHANDLES говорит системе взять их оттуда:

// Фрагмент capture.cpp: вывод потомка — в канал, родитель читает порциями. Полностью — в примерах раздела
    STARTUPINFOA si = {};
    si.cb = sizeof(si);
    si.dwFlags = STARTF_USESTDHANDLES;                                  // стандартные потоки — из полей ниже
    si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
    si.hStdOutput = writeEnd;
    si.hStdError = writeEnd;
    PROCESS_INFORMATION pi = {};
    if (!CreateProcessA(nullptr, cmd, nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) {
        printError("CreateProcess");
        return 2;
    }
    CloseHandle(writeEnd);                      // свой пишущий конец закрыть — иначе ReadFile не узнает о конце данных
    CloseHandle(pi.hThread);

    char buffer[CHUNK];
    DWORD read = 0;
    long long total = 0;
    int reads = 0;
    int lines = 0;
    while (ReadFile(readEnd, buffer, CHUNK, &read, nullptr) && read > 0) {
        ++reads;
        total += read;
        for (DWORD i = 0; i < read; ++i) {
            if (buffer[i] == '\n') {
                ++lines;
            }
        }
        std::cout.write(buffer, read);          // порция — не строка: '\0' в конце нет
    }
    // ReadFile вернула FALSE с ошибкой 109 (ERROR_BROKEN_PIPE): все пишущие концы закрыты — данных больше не будет
    CloseHandle(readEnd);
> capture "child 3 0"
child 6152: sleep 0 ms, exit 3
---
32 bytes in 1 reads, 1 lines, exit code 3
04

Ввод и вывод потомка: два канала

Анонимный канал односторонний. Чтобы и писать потомку, и читать его ответ, нужны два канала: один — к его stdin, другой — от его stdout. Пример tosort.cpp отправляет строки программе Windows sort и читает их упорядоченными:

// Фрагмент tosort.cpp: после CreateProcessA. Полностью — в примерах раздела
    CloseHandle(toChildRead);                   // концы потомка родителю больше не нужны
    CloseHandle(fromChildWrite);
    CloseHandle(pi.hThread);

    const char lines[] = "banana\r\napple\r\ncherry\r\n";
    if (!writeAll(toChildWrite, lines, sizeof(lines) - 1)) {    // без завершающего '\0'
        printError("WriteFile");
    }
    CloseHandle(toChildWrite);                  // конец ввода: sort начинает сортировать только теперь

    char buffer[CHUNK];
    DWORD read = 0;
    while (ReadFile(fromChildRead, buffer, CHUNK, &read, nullptr) && read > 0) {
        std::cout.write(buffer, read);
    }
apple
banana
cherry
Взаимная блокировка каналов. Родитель пишет всё и только потом читает. Если данных много, а потомок отвечает по ходу, буфер «от потомка» переполнится: потомок встанет на записи, ожидая, пока родитель прочитает, а родитель встанет на записи во входной канал, который потомок перестал читать. Это тот же deadlock, что в разделе 16. Решения: писать и читать в разных потоках (раздел 16) или порциями, проверяя, есть ли что читать (PeekNamedPipe, пункт 08).
05

ReadFile и WriteFile

Каналы, файлы, консоль, устройства — в Windows всё читается и пишется одними функциями: ReadFile(h, буфер, сколькоНадо, &сколькоВышло, nullptr) и WriteFile с такими же параметрами. Последний параметр — для асинхронного обмена, в курсе — nullptr.

СитуацияReadFileWriteFile
всё в порядкеTRUE, в read — от 1 до «сколько надо» байт: сколько было в каналеTRUE, записано обычно всё, но может быть меньше
данных нет, пишущие концы открытыждёт—
буфер канала полон—ждёт, пока читатель заберёт данные
другой конец закрытFALSE, ошибка 109 ERROR_BROKEN_PIPE — нормальный конец данныхFALSE, ошибка 232 ERROR_NO_DATA или 109
сообщение длиннее буфера (режим сообщений)FALSE, ошибка 234 ERROR_MORE_DATA, прочитана часть—

Поэтому запись «всего» — цикл, который дописывает остаток:

// Пишет size байт целиком: WriteFile может записать меньше, чем просили, — тогда дописывает остаток
bool writeAll(HANDLE h, const char data[], DWORD size)
{
    DWORD done = 0;
    while (done < size) {
        DWORD written = 0;
        if (!WriteFile(h, data + done, size - done, &written, nullptr)) {
            return false;
        }
        done += written;
    }
    return true;
}

Чтение «всего» — цикл до конца данных, как в пункте 03. Нельзя рассчитывать, что одна запись на одном конце даст одно чтение на другом: в байтовом канале 100 байт могут прийти за один ReadFile или за три. Если читателю нужны границы («где кончается сообщение»), их задаёт протокол: перевод строки, длина перед данными — или режим сообщений (пункт 08).

06

Именованный канал: сервер

Именованный канал создаёт сервер и ждёт клиентов. Имя — \\.\pipe\любое-имя (точка — «этот компьютер»; с именем компьютера вместо точки к каналу обращаются по сети). В строке C++ каждая обратная черта удваивается: "\\\\.\\pipe\\sysprog-echo". Сервер echoserver отвечает на каждое сообщение им же заглавными буквами:

// Фрагмент echoserver.cpp. Полностью — в примерах раздела
constexpr char PIPE_NAME[] = "\\\\.\\pipe\\sysprog-echo";
constexpr DWORD BUFFER_SIZE = 4096;

int main()
{
    bool running = true;
    while (running) {
        HANDLE pipe = CreateNamedPipeA(PIPE_NAME, PIPE_ACCESS_DUPLEX,
                                       PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
                                       1, BUFFER_SIZE, BUFFER_SIZE, 0, nullptr);
        if (pipe == INVALID_HANDLE_VALUE) {
            printError("CreateNamedPipe");
            return 2;
        }
        std::cout << "waiting for a client on " << PIPE_NAME << '\n';
        // ERROR_PIPE_CONNECTED: клиент подключился между CreateNamedPipe и ConnectNamedPipe — тоже успех
        if (ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED) {
            std::cout << "client connected\n";
            running = serveClient(pipe);
            std::cout << "client disconnected\n";
        } else {
            printError("ConnectNamedPipe");
        }
        FlushFileBuffers(pipe);                 // дождаться, пока клиент прочитает последний ответ
        DisconnectNamedPipe(pipe);
        CloseHandle(pipe);
    }
    std::cout << "server stopped\n";
    return 0;
}

// Сообщение — ответ, пока клиент не отключится; false — клиент попросил остановить сервер
bool serveClient(HANDLE pipe)
{
    char request[BUFFER_SIZE];
    char reply[BUFFER_SIZE];
    DWORD read = 0;
    while (ReadFile(pipe, request, BUFFER_SIZE - 1, &read, nullptr)) {  // одно сообщение целиком
        request[read] = '\0';
        std::cout << "request: " << request << '\n';
        const bool keepRunning = handleRequest(request, reply, BUFFER_SIZE);
        DWORD written = 0;
        if (!WriteFile(pipe, reply, static_cast<DWORD>(length(reply)), &written, nullptr)) {
            printError("WriteFile");
            return true;
        }
        if (!keepRunning) {
            return false;
        }
    }
    // ReadFile = FALSE: 109 — клиент закрыл канал, 234 (ERROR_MORE_DATA) — сообщение длиннее буфера
    return true;
}
Параметр CreateNamedPipeAЗдесьСмысл
направлениеPIPE_ACCESS_DUPLEXчитать и писать; …_INBOUND — только от клиента, …_OUTBOUND — только к клиенту
режимPIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAITсообщения (пункт 08), операции ждут
экземпляров1сколько клиентов одновременно; PIPE_UNLIMITED_INSTANCES — сколько угодно (задание 3 практической работы)
буферы4096, 4096размеры буферов вывода и ввода — подсказка системе
тайм-аут по умолчанию0для WaitNamedPipe клиента с NMPWAIT_USE_DEFAULT_WAIT; 0 — 50 мс

ConnectNamedPipe ждёт, пока клиент откроет канал. DisconnectNamedPipe разрывает соединение с текущим клиентом — после этого экземпляр можно снова ждать или закрыть. Непрочитанные клиентом данные при этом пропадают: если сервер отключит клиента сразу после ответа на shutdown, клиент получит не bye, а ошибку 233 «С обоих концов канала отсутствуют процессы». Поэтому перед DisconnectNamedPipe стоит FlushFileBuffers — он ждёт, пока клиент прочитает всё, что записано в канал. Этот сервер обслуживает клиентов по очереди: пока подключён один, второй ждёт свободного экземпляра.

07

Именованный канал: клиент

// Фрагмент pipeclient.cpp: подключение. Полностью — в примерах раздела
    const std::string name = std::string("\\\\.\\pipe\\") + argv[1];

    if (!WaitNamedPipeA(name.c_str(), CONNECT_TIMEOUT_MS)) {  // ждать свободного экземпляра канала
        printError("WaitNamedPipe");            // 2 — сервера нет, 121 — не дождались
        return 2;
    }
    HANDLE pipe = CreateFileA(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
    if (pipe == INVALID_HANDLE_VALUE) {
        printError("CreateFile");
        return 2;
    }
    DWORD mode = PIPE_READMODE_MESSAGE;         // клиент по умолчанию читает байтами — переключить на сообщения
    if (!SetNamedPipeHandleState(pipe, &mode, nullptr, nullptr)) {
        printError("SetNamedPipeHandleState");
        CloseHandle(pipe);
        return 2;
    }

Дальше клиент в цикле читает строку с клавиатуры, отправляет её WriteFile и ждёт ответ ReadFile. Строка ввода здесь — std::string, а в WriteFile уходит line.c_str() (раздел 10, пункт 08). Два окна консоли:

> echoserver                            > pipeclient sysprog-echo
waiting for a client on \\.\pipe\...   connected to \\.\pipe\sysprog-echo
client connected                        hello, pipe
request: hello, pipe                    HELLO, PIPE
request: shutdown                       shutdown
client disconnected                     bye
server stopped                          quit

CreateFileA — та же функция, что открывает файлы (раздел 14, пункт 04): для системы канал — ещё одно «устройство». Если сервер не запущен, WaitNamedPipeA сразу вернёт ошибку 2; если все экземпляры заняты — будет ждать до тайм-аута (ошибка 121). Клиент pipeclient универсальный: с ним работают все серверы практической работы — достаточно указать имя канала.

08

Байты и сообщения. Заглянуть в канал

Байтовый режим PIPE_TYPE_BYTEРежим сообщений PIPE_TYPE_MESSAGE
Что хранит каналпоток байтов без границсообщения: каждая запись — отдельное сообщение
Что читает ReadFileсколько есть, до размера буфера; границы записей теряютсяровно одно сообщение; не помещается — часть и ошибка 234, остаток — следующим чтением
Когда удобенпоток данных: вывод программы, копия файлазапрос — ответ: команды, как в практической работе

Тип канала (PIPE_TYPE_…) задаёт сервер при создании. Режим чтения (PIPE_READMODE_…) — у каждого конца свой: сервер задаёт свой в CreateNamedPipeA, клиент открывает канал в байтовом режиме и переключает SetNamedPipeHandleState. Анонимные каналы — всегда байтовые.

PeekNamedPipe позволяет заглянуть в канал, ничего не забирая и не ожидая: сколько байт уже пришло. Так читают без риска «зависнуть» — только когда есть что читать:

// Сколько байт можно прочитать из канала прямо сейчас; -1 — канал закрыт или ошибка. Не ждёт
long long available(HANDLE pipe)
{
    DWORD bytes = 0;
    if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &bytes, nullptr)) {
        return -1;
    }
    return bytes;
}

Работает и для анонимных каналов. Цикл «проверить — прочитать, если есть — заняться другим — Sleep» похож на pollKey из раздела 15.

09

Ожидание и тайм-ауты

Кто ждётЧегоКак не ждать вечно
клиентсвободного экземпляра каналаWaitNamedPipeA(имя, мс) — ошибка 121 по истечении
серверклиента в ConnectNamedPipeожидание в отдельном потоке (раздел 16) или асинхронный режим (вне курса)
читательданных в ReadFilePeekNamedPipe перед чтением; чтение в отдельном потоке
писательместа в полном буферечитатель должен читать; не писать много, не читая (пункт 04)
родительзавершения потомкаWaitForSingleObject(hProcess, мс) (раздел 14)

Надёжный сервер не ждёт клиента бесконечно и не зависает из-за одного медленного клиента. Обычная схема — задание 3 практической работы: главный поток создаёт экземпляр канала и ждёт подключения, а каждого подключившегося клиента обслуживает отдельный поток. В разделе 19 сервер на сокетах устроен так же.

10

Ловушки раздела

ОшибкаПризнакСообщение g++ / ошибка WindowsКак избежать
DisconnectNamedPipe сразу после последнего ответаклиент иногда не получает ответ, ошибка 233нетFlushFileBuffers(pipe) перед DisconnectNamedPipe
одинарные \ в имени каналаканал с неверным именемunknown escape sequence: '\.'"\\\\.\\pipe\\имя"
родитель не закрыл свой пишущий конецпрограмма «висит» после вывода потомка—CloseHandle(writeEnd) сразу после CreateProcessA
дескриптор не наследуемый или bInheritHandles = FALSEпотомок пишет в никуда, родитель сразу получает конец данных—SECURITY_ATTRIBUTES с TRUE и TRUE в CreateProcessA
нет STARTF_USESTDHANDLESпотомок пишет в консоль, а не в канал—флаг в si.dwFlags
прочитанное выводится как строкамусор после данных—std::cout.write(buffer, read) или buffer[read] = '\0' с запасом в буфере
ждут, что одна запись = одно чтение (байтовый канал)сообщения «склеиваются» или рвутся—режим сообщений или разделители в протоколе
клиент не переключил режим чтениясообщения читаются как байты—SetNamedPipeHandleState(… PIPE_READMODE_MESSAGE …)
сообщение длиннее буфераReadFile = FALSE234 ERROR_MORE_DATAбуфер не меньше самого длинного сообщения или дочитывание
сервер не запущенклиент не подключается2 у WaitNamedPipeсначала сервер; понятное сообщение клиента
пишет много, не читая ответзависание обоих процессов—чтение в отдельном потоке или порциями

Ключевые выводы раздела

Запомните главное

  • Канал — буфер в ядре: запись в один конец, чтение из другого в том же порядке; пустой — чтение ждёт
  • Анонимный канал — родителю и потомку: наследуемые дескрипторы, bInheritHandles = TRUE, ненужные концы закрыть
  • Перенаправление: STARTF_USESTDHANDLES и концы канала в hStdInput/hStdOutput/hStdError
  • Конец данных — когда закрыты все пишущие концы; ReadFile возвращает FALSE с ошибкой 109
  • ReadFile/WriteFile обмениваются порциями: читать до конца, писать остаток; данные — байты без '\0'
  • Именованный канал: сервер — CreateNamedPipeA, ConnectNamedPipe, FlushFileBuffers, DisconnectNamedPipe; клиент — WaitNamedPipeA, CreateFileA
  • Режим сообщений сохраняет границы записей; клиент включает его SetNamedPipeHandleState
Связи раздела. Опирается на раздел 10 — строки char, std::string и c_str(); раздел 11 — текстовый и двоичный режим; раздел 14 — CreateProcessA, дескрипторы, CreateFileA, printError; раздел 16 — потоки, взаимная блокировка. Нужен для: раздела 19 — клиент и сервер на сокетах, протокол «запрос — ответ»; итогового проекта «Конвейер обработки»: родитель передаёт данные потомку по каналу.
Практическая работа №18 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 16: Потоки Практика на портале