Системное программирование на C++: процессы, C-API и WinAPI

—

от автора

Привет, Хабр!

Это вторая статья цикла про C++. В первой я разбирал базу для тех, кто начинает с нуля: переменные, циклы, классы, контейнеры. Здесь идём на уровень ниже — как обычная C+±программа договаривается с операционной системой.

Сразу скажу, зачем я вообще полез в эту тему. В колледже была лаба, где нужно было поднять процесс, дождаться его и снять код возврата. Я неделю не понимал, почему fork() в одном случае возвращает ноль, а в другом — PID, и почему после exec ребёнок «исчезает». Препод объяснил это минут за десять — и я понял, что половину объяснений всё равно не понял. С тех пор я пишу такие вещи с расстановкой: не «fork() создаёт процесс», а что именно он возвращает, что происходит в коде после вызова и что будет, если забыть закрыть дескрипторы. Поэтому статей будет много, а примеров в каждой — ещё больше.

Что дальше в цикле, чтобы было понятно, куда мы движемся:

  • работа с чужым кодом на C: extern "C", ABI, C-фасад;

  • процессы: fork/exec в POSIX и CreateProcess в Windows;

  • потоки, гонки данных и синхронизация;

  • память: malloc/free против new/delete, аллокаторы, mmap и HeapAlloc;

  • динамические библиотеки: DLL и .so, экспорт символов, dlopen и GetProcAddress;

  • хуки и перехват API — в том числе то, что делают отладчики и античиты.

Сегодня разбираем первые два пункта.

Где проходит граница «системного»

Граница тут слегка условная, поэтому договоримся сразу. Я буду считать системным программированием работу через API операционной системы: процессы, файлы, сокеты, память, модули. Драйверы, режим ядра и ассемблер — соседняя профессия, туда я пока не лезу.

Почему граница размытая: внутри std::ofstream всё равно лежит syscall, а std::thread в Linux — это futex в ядре. Разница в том, какой интерфейс вам дают. std::ofstream даёт файл, с которым можно только читать и писать. А open() даёт дескриптор — целое число, с которым можно сделать ещё что-нибудь: перенаправить его, закрыть, повесить на себя, получить из другого процесса через SCM_RIGHTS. В этом и разница между «прикладным» и «системным» уровнем.

Глава 1. Чужой код: как C++ дружит с C

Зачем это нужно

Короткий ответ: огромная часть реальных библиотек написана на C. Драйверы, старые SDK, компиляторы, базы данных, криптография, всё, что вытащили из C-эпохи и не переписывают уже лет двадцать. Плюс огромный пласт сгенерированного кода: SWIG, ctypes-обвязки, экспорт функций из модулей Python. Если вы пишете на C++ и хотите использовать что-то из этого — знать про extern "C" придётся рано или поздно.

Вторая причина — обратная. Иногда на C++ нужно написать библиотеку, которую потом дёрнут из C, из Python или из скрипта. Это тоже умеют, об этом ниже.

Проблема искажения имён

C++ искажает имена функций при компиляции — name mangling. Это нужно, чтобы перегрузки с одинаковым именем не слились в одну. Для компилятора C ничего подобного не требуется, он оставляет имя как есть.

Возьмём тот же greet:

// legacy.c#include <stdio.h>void greet(const char* name) {    printf("Hello, %s!\n", name);}int add(int a, int b) {    return a + b;}

И подключим его из C++:

// main.cpp#include <cstdlib>#include <iostream>extern "C" {    void greet(const char* name);    int add(int a, int b);}auto main() -> int {    greet("World");    const int result = add(2, 3);    std::cout << result << '\n';    return EXIT_SUCCESS;}

Собираем:

gcc -c legacy.c -o legacy.og++ main.cpp legacy.o -o program

Без extern "C" линковщик не найдёт функцию. Соберём так, чтобы посмотреть, как именно он её ищет:

g++ -c main.cpp -o main.onm main.o | grep -E "greet|add"

Вывод будет примерно такой:

                 U _Z3addii                 U _Z5greetPKc

_Z5greetPKc — это не «greet» в каком-то неизвестном кодировании. Это Itanium ABI: буква Z, длина имени (5), само имя, типы параметров. PKc — это const char*. Компиляторы GCC и Clang используют именно этот стандарт, он же называется Itanium C++ ABI.

MSVC искажет имя иначе — примерно так:

?greet@@YAXPEBD@Z

Это я не выдумывал: тот же код, собранный MSVC без extern "C", даёт именно такую строку в списке символов.

Поэтому библиотека, собранная одним компилятором, обычно не подходит другому, даже если архитектура та же. Об этом ниже отдельно.

Ошибка, которую все встречают

Уберите extern "C" из main.cpp и попробуйте собрать. Линковщик выдаст:

undefined reference to `greet(char const*)'collect2: error: ld returned 1 exit status

На MSVC то же самое выглядит так:

error LNK2019: unresolved external symbol "void __cdecl greet(char const *)"

Меня это всегда сбивало с толку, потому что в сообщении упоминается greet(char const*) — то есть имя и типы в нём есть, и код вроде бы правильный. Неправильное только одно-единственное слово в трёх строках над этим местом. Если столкнётесь — ищите в первую очередь extern "C".

Заголовочный файл

В реальных проектах объявления выносят в отдельный .h, и его подключают и из C, и из C++:

// legacy.h#ifndef LEGACY_H#define LEGACY_H#ifdef __cplusplusextern "C" {#endifvoid greet(const char* name);int add(int a, int b);#ifdef __cplusplus}#endif#endif

Макрос __cplusplus определён только в C++, поэтому такой заголовок спокойно подключается с обеих сторон. В C++ extern "C" не меняет правила вызова, он меняет только линковку: имя функции останется greet, а не _Z5greetPKc.

Подводный камень, на котором я сам пару раз споткнулся: MSVC без специального ключа всегда определяет cplusplus как 199711L, даже когда вы компилируете в режиме C++20. Лечится ключом /Zc:cplusplus. Если у вас в коде проверка вида

#if __cplusplus >= 202002L    // фичи C++20#endif

то на MSVC без этого ключа ветка просто не включится, и вы будете полчаса гадать, почему код из документации не собирается. GCC и Clang дают корректное значение всегда.

Заголовки C: и

В C++ есть два варианта подключения заголовков C:

#include <stdio.h>   // имена в глобальном пространстве#include <cstdio>    // имена в std::

Разница не только в косметике. <cstdio> кладёт printf в std::printf и формально не обязан класть его в глобальное пространство. На практике почти все реализации кладут в оба, так что код с std::printf работает везде, а код с printf без std:: — это «просто работает у меня».

Правильнее писать std::printf и std::string: такой код не сломается, если кто-то определит свою функцию printf в глобальном пространстве. Мелочь, но такие конфликты в больших проектах реально случаются.

Ещё момент: некоторые заголовки C в C++ дают дополнительные перегрузки. Например, в <cmath> из C++ есть перегруженные sqrt для float и long double, в C — только double. Если полагаться на неявное приведение типа, можно получить результат с потерей точности. Тут помогает static_cast из первой статьи — приведение становится явным, и компилятор не даст вам забыть про него.

C-фасад: как отдать C++ наружу

А вот обратная задача — у вас есть класс на C++, а использовать его хотят из C, из Python или из чего-то ещё, что про классы не знает.

Решение классическое: снаружи торчит набор функций с обычными C-именами, а внутри — ваш класс. Наружу при этом отдаётся не сам объект, а непрозрачный указатель на него.

// wrapper.cpp#include <iostream>#include <string>#include <utility>class Logger {public:    explicit Logger(std::string path) : path_(std::move(path)) {}    void log(const std::string& message) {        std::cout << '[' << path_ << "] " << message << '\n';    }private:    std::string path_;};extern "C" {Logger* logger_create(const char* path) {    return new Logger(path);}void logger_log(Logger* logger, const char* message) {    if (logger == nullptr) {        return;    }    try {        logger->log(message);    } catch (...) {        // Исключение не должно улететь в C-код    }}void logger_destroy(Logger* logger) {    delete logger;}}

Клиент на C видит только это:

#ifndef LOGGER_H#define LOGGER_Htypedef struct Logger Logger;Logger* logger_create(const char* path);void logger_log(Logger* logger, const char* message);void logger_destroy(Logger* logger);#endif

Структура Logger в заголовке объявлена, но не определена — клиенту достаточно знать, что это непрозрачный указатель. Он может передавать его дальше, хранить в переменной, но не может посмотреть внутрь. Это и есть смысл: реализация может быть на C++, а контракт — на C.

Так сделан, к примеру, OpenCV: ядро на C++, а снаружи обычный C API, по которому с ним работают из Python и из питон-обвязок. То же самое у LLVM и Clang — сам компилятор на C++, а наружу C API для IDE, для плагинов и для всего, кто пишет инструменты.

Три правила, которые ломаются чаще всего

Первое: не передавайте C+±объекты через C-границу. Ни std::string, ни std::vector, ни тем более объект с нетривиальными конструктором и деструктором. В C у этих типов просто нет представления. Наружу — только POD: числа, указатели, структуры из чистых данных.

Второе: исключения не должны пересекать границу. Если C-код вызовет функцию, которая бросит std::runtime_error, программа, скорее всего, просто упадёт — unwind-таблицы на C-стороне никто не подготовил. Ловите всё внутри обёртки и возвращайте код ошибки числом.

Третье: владелец объекта должен быть явным. Кто создал — тот и удаляет. Не отдавайте указатель на объект, который живёт на стеке вызывающей стороны, и не давайте C-коду вызывать delete напрямую — только через logger_destroy, где вы внутри решаете, как освобождать.

Про ABI

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

C API в рамках одной пары «компилятор + ABI» стабилен. Имена функций не искажаются, структуры из POD передаются одинаково. Но и здесь есть нюансы: соглашение о вызове, модель выделения памяти (у CRT в Visual Studio своя куча), выравнивание структур, поведение long (в Windows long всегда 32 бита, в Linux — 64), wchar_t (в Windows 16 бит, в Linux 32).

C++ ABI нестабилен вообще. Тот же код, пересобранный другой версией GCC или другим компилятором, может получить другие имена символов, другой порядок инициализации членов класса и другие настройки для шаблонов. Порядок объявления членов в разных единицах трансляции стал гарантированно одинаковым только в C++20, до этого разные компиляторы могли вести себя по-разному — и это отдельная боль при сборке библиотеки и приложения разными компиляторами.

Практический вывод: если хотите библиотеку, которую можно отдать чужим людям — пишите на C API. OpenCV, SQLite, libpng, zlib, libcurl — все они держат наружу именно C-интерфейс, а внутри у них C++, Rust, Go или что угодно. ABI у C-библиотеки привязан к конкретной паре «платформа + компилятор», поэтому Windows-версию и Linux-версию обычно собирают отдельно и кладут рядом с соответствующим .dll/.so.

Глава 2. Процессы

Что такое процесс

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

У процесса есть:

  • идентификатор — PID, а на Windows ещё и HANDLE на сам процесс;

  • собственное виртуальное адресное пространство;

  • своя таблица открытых дескрипторов (файлы, сокеты, пайпы);

  • учётные данные и права доступа;

  • окружение, рабочий каталог, аргументы командной строки;

  • обработчики сигналов (в POSIX).

Про память стоит сказать отдельно, потому что здесь у новичков чаще всего неверная картина. При fork() ядро не копирует всю память процесса: страницы помечаются как «копируемые при записи» (copy-on-write). Пока никто в страницу не пишет, она физически одна и та же, а своя копия появляется в момент записи. Поэтому создание процесса дешевле, чем кажется, но всё равно дороже, чем создание потока: появляется новое адресное пространство, свои таблицы страниц, свой PID, свои права.

Жизненный цикл процесса

Упрощённо, для Linux:

  1. Вы вызываете fork() (в POSIX) или CreateProcess() (в Windows) — ядро создаёт запись о процессе.

  2. Ядро выделяет адресное пространство, проверяет бинарник, назначает PID.

  3. Создаётся первый поток (главный), ему передаётся стек и аргументы.

  4. Вызывается точка входа: start → _libc_start_main → main. Для графических приложений Windows — WinMainCRTStartup → WinMain.

  5. Процесс живёт, пока не вызовет exit(), _exit(), ExitProcess() либо пока его не убьют сигналом или TerminateProcess.

  6. Ядро освобождает ресурсы, а родителю остаётся запись о завершении — код возврата и, если он был убит сигналом, номер сигнала.

Шестой шаг — источник всех разговоров про зомби.

POSIX: fork, exec и waitpid

Классический способ создать процесс в Unix — fork(). Он делает копию текущего процесса, и после вызова вы имеете два независимых процесса с одинаковым кодом.

#include <cstdlib>#include <iostream>#include <sys/wait.h>#include <unistd.h>auto main() -> int {    const pid_t pid = fork();    if (pid == -1) {        std::cerr << "fork failed\n";        return EXIT_FAILURE;    }    if (pid == 0) {        // Ребёнок        std::cout << "Child PID: " << getpid() << '\n';        execlp("ls", "ls", "-la", static_cast<char*>(nullptr));        // Сюда попадём, только если exec не удался        _exit(EXIT_FAILURE);    }    // Родитель    std::cout << "Parent PID: " << getpid()              << ", child PID: " << pid << '\n';    int status = 0;    waitpid(pid, &status, 0);    if (WIFEXITED(status)) {        std::cout << "Child exited with code " << WEXITSTATUS(status) << '\n';    } else if (WIFSIGNALED(status)) {        std::cout << "Child killed by signal " << WTERMSIG(status) << '\n';    }    return EXIT_SUCCESS;}

Что здесь важно понимать:

  • fork() возвращает три разные вещи. В родителе — PID ребёнка (положительное число). В ребёнке — ноль. При ошибке — минус один. Именно поэтому проверка идёт по значению, а не по флагу: pid == 0 — это уже не «процесс создан», а «я теперь ребёнок».

  • После fork() выполнение продолжается в обоих процессах с точки после вызова. Ребёнку почти всегда нужен exec* — иначе он просто отработает тот же код, что и родитель, и вы получите два одинаковых процесса вместо одного.

  • waitpid() ждёт завершения и забирает код возврата. Пока вы его не вызвали, процесс-родитель считается неаккуратным — об этом ниже в разделе про зомби.

_exit() вместо return

В коде выше в ребёнке стоит _exit(), а не return из main. Это не стилистика, а защита от неприятного бага.

Когда вы вызываете fork(), в ребёнке копируется и буфер stdio. Если буфер stdout не был пустым (например, программа печатала в строку без перевода строки), то при завершении ребёнка этот буфер выведется — а потом выведется ещё раз, уже из родителя. В логах это выглядит как задвоенные строки, и найти причину по коду почти невозможно.

Стандартный ответ: перед fork() сбросить буферы (fflush(nullptr)), а в ребёнке завершаться через exit() — она не вызывает ни flush, ни деструкторы, ни обработчики atexit. У exit() есть и побочный эффект, о котором надо помнить: деструкторы C+±объектов в ребёнке тоже не выполнятся. В подавляющем большинстве случаев это не страшно, потому что сразу за _exit() идёт exec, но помнить стоит.

Строгие правила для ребёнка

Есть требование POSIX, которое почти никто не соблюдает: до exec() в ребёнке разрешено вызывать только async-signal-safe функции. То есть printf и std::cout — уже нельзя. Это касается программ, в которых есть потоки: fork() копирует только вызывающий поток, а мьютексы могут остаться в заблокированном состоянии, и любая попытка взять такой мьютекс в ребёнке приведёт к зависанию.

На практике в примере выше я всё равно печатаю через std::cout — это распространённая практика в прикладном коде, но формально она неверна. Если пишете что-то серьёзное, в ребёнке пишите через write(2) или ставьте stdio в режим без буферизации (setvbuf(stdout, nullptr, _IONBF, 0)), либо вообще сразу переходите к exec.

Ещё один момент: после fork() ребёнок наследует все открытые дескрипторы родителя. Закрываются только те, что помечены O_CLOEXEC. Это удобный механизм: помечаете дескриптор как CLOEXEC, он автоматически закроется при exec, и ребёнок его не увидит. Если забыть про это, можно получить ситуацию, когда дочерний процесс держит открытый файл, на который рассчитывал родитель, и отец не может его удалить или переименовать.

posix_spawn, чтобы не мучиться

Если вся эта кухня с async-signal-safe не нужна, есть posix_spawn(). Он делает то же самое — создаёт процесс и запускает в нём программу, — но не заставляет ваш процесс делать fork() самостоятельно: вся работа внутри идёт в библиотеке. Появился в glibc 2.24 и macOS 10.15.

#include <spawn.h>#include <sys/wait.h>#include <iostream>// На glibc переменная environ объявлена в <unistd.h>, здесь объявляем самиextern char** environ;posix_spawn_file_actions_t actions;posix_spawn_file_actions_init(&actions);posix_spawnattr_t attr;posix_spawnattr_init(&attr);pid_t pid = 0;const char* argv[] = {"ls", "-la", nullptr};const int rc = posix_spawn(&pid, "/bin/ls", &actions, &attr, argv, environ);posix_spawn_file_actions_destroy(&actions);posix_spawnattr_destroy(&attr);if (rc != 0) {    std::cerr << "posix_spawn failed: " << rc << '\n';}int status = 0;waitpid(pid, &status, 0);

Обратите внимание: posix_spawn() возвращает не -1 при ошибке, а код ошибки в стиле errno. Это непривычно и каждый раз сбивает.

Windows: CreateProcess

В Windows fork() нет и не будет — слишком уж он завязан на копирование адресного пространства и таблиц дескрипторов в стиле Unix. Работающий аналог называется CreateProcess().

#include <windows.h>#include <cstdlib>#include <iostream>#include <string>auto main() -> int {    STARTUPINFOW si{};    si.cb = sizeof(si);    PROCESS_INFORMATION pi{};    // CreateProcess правит командную строку на месте, поэтому нужна своя копия    std::wstring cmd = L"notepad.exe";    if (!CreateProcessW(            nullptr,            cmd.data(),            nullptr, nullptr,            FALSE,            0,            nullptr, nullptr,            &si, &pi)) {        std::cerr << "CreateProcess failed, error "                  << GetLastError() << '\n';        return EXIT_FAILURE;    }    std::cout << "Started process PID: " << pi.dwProcessId << '\n';    const DWORD waitResult = WaitForSingleObject(pi.hProcess, INFINITE);    if (waitResult != WAIT_OBJECT_0) {        std::cerr << "WaitForSingleObject failed, error "                  << GetLastError() << '\n';        CloseHandle(pi.hThread);        CloseHandle(pi.hProcess);        return EXIT_FAILURE;    }    DWORD exitCode = 0;    if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {        std::cerr << "GetExitCodeProcess failed, error "                  << GetLastError() << '\n';    }    std::cout << "Exit code: " << exitCode << '\n';    CloseHandle(pi.hThread);    CloseHandle(pi.hProcess);    return EXIT_SUCCESS;}

Что тут можно пропустить только по незнанию:

  • Командная строка должна быть изменяемой. Параметр lpCommandLine имеет тип LPWSTR, а строковый литерал L"notepad.exe" — это const wchar_t[]. MSVC на такой вызов ругается (ошибка C2664 про несовместимость const), и поэтому в примере выше сначала делается копия в std::wstring. Через cmd.data() вы получаете изменяемый буфер — как раз то, что нужно API.

  • si.cb обязателен. Это размер структуры STARTUPINFO, и ядро проверяет, что вы его заполнили. Если забыть — CreateProcess вернёт FALSE с ERROR_INVALID_PARAMETER. Именно поэтому в коде выше он выставлен явно, а не через { sizeof(si) } в списке инициализации.

  • Дескрипторы надо закрывать. pi.hProcess и pi.hThread — это HANDLE, обычные дескрипторы ядра. Их закрывают через CloseHandle. Забудете — получите утечку дескрипторов, и процесс будет «есть» по числу открытых дескрипторов, а не по потреблённой памяти. В играх и серверах с этим сталкиваются постоянно.

  • WaitForSingleObject надо проверять. Он возвращает WAIT_OBJECT_0 в случае успеха, WAIT_TIMEOUT если истекло время ожидания и WAIT_FAILED при ошибке. Если просто написать WaitForSingleObject(...) и пойти дальше, можно случайно продолжить работу с ещё не завершившимся процессом.

  • GetExitCodeProcess имеет ловушку. Если процесс ещё работает, функция вернёт STILL_ACTIVE (это константа 259). При этом процесс, честно завершившийся с кодом 259, вернёт то же самое. Поэтому сначала дождитесь завершения через WaitForSingleObject и только потом берите код возврата. Проверять «exitCode != STILL_ACTIVE» — плохая идея.

  • GetLastError() надо вызывать сразу. Это значение живёт в специальном хранилище потока и перезаписывается следующим вызовом API. Если между CreateProcess и GetLastError вы успели напечатать что-то в cout, номер ошибки может оказаться совсем другим.

Про lpApplicationName и lpCommandLine

В CreateProcess есть два параметра, которые постоянно путают. lpApplicationName — это путь к исполняемому файлу. lpCommandLine — командная строка целиком, с аргументами; система передаёт её дочернему процессу как есть, а разбирает её уже сам дочерний процесс.

Если lpApplicationName равен nullptr, система берёт первый «токен» командной строки и ищет по нему файл: сначала каталог приложения, потом текущий каталог, потом system32, потом Windows, потом PATH. И разбирает токены по правилам CRT — то есть по правилам Microsoft, а не по POSIX. Если путь содержит пробелы, его надо оборачивать в кавычки, иначе получите «файл не найден» или запуск не того файла.

Короткий практический совет: передавайте путь в lpApplicationName, а в lpCommandLine — тоже полную строку, но с первым токеном, соответствующим этому же пути. Тогда не зависите от того, как в вашем окружении настроен PATH.

Флаги, которые пригодятся

  • CREATE_NO_WINDOW — процесс запустится без консольного окна. Именно это нужно, если ваша программа — обычное GUI-приложение, которое не должно мигать чёрным окном при запуске.

  • DETACHED_PROCESS — процесс не привязан к консоли родителя и переживает его.

  • CREATE_UNICODE_ENVIRONMENT — обязателен, если вы передаёте свой блок переменных окружения. Без него блок трактуется как ANSI, и русские пути в переменных превратятся в мусор.

  • CREATE_NEW_CONSOLE — дочерний процесс получает своё окно консоли.

Ещё момент про перенаправление вывода: чтобы прочитать stdout дочернего процесса, нужно создать анонимный канал (CreatePipe), передать нужный конец в STARTUPINFO как hStdOutput, выставить dwFlags |= STARTF_USESTDHANDLES и читать из другого конца. Тут есть грабли: если вы не выставили STARTF_USESTDHANDLES, поля hStdInput/hStdOutput/hStdError игнорируются.

Завершение процесса

Тут два разных механизма, которые постоянно путают:

  • ExitProcess() — штатное завершение. Выполняются обработчики atexit, деструкторы статических объектов, сбрасываются буферы stdio, ядро освобождает ресурсы. Именно это происходит при возврате из main.

  • TerminateProcess() — убийство. Процессу не даётся ничего: обработчики atexit не выполняются, а DLL-ки об отключении вообще не уведомляются, так что их глобальные данные остаются в том виде, в каком были. Ресурсы ядро всё равно освободит, когда процесс умрёт.

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

Сравнение по пунктам

Задача

POSIX

Windows

Создать процесс

fork() + exec()

CreateProcess()

Дождаться завершения

waitpid()

WaitForSingleObject()

Свой PID

getpid()

GetCurrentProcessId()

Завершить изнутри

exit()

ExitProcess()

Завершить снаружи

kill()

TerminateProcess()

Посмотреть на процесс

/proc/<pid> в Linux

Get-Process, Process Explorer

Своё окружение

setenv(), putenv()

SetEnvironmentVariable()

Обратите внимание на последнюю строку: в POSIX окружение процесса меняется только у себя и у детей, а глобальное окружение оболочки живёт отдельно. В Windows SetEnvironmentVariable() меняет окружение текущего процесса, и дети наследуют его при создании. Практический вывод одинаковый — мутация окружения в программе, которая запускает другие программы, почти всегда портит жизнь.

Зомби и сироты

Зомби — процесс, который завершился, но родитель не забрал его статус через wait(). Сама запись о процессе остаётся в таблице ядра: она уже не выполняет код, но PID всё ещё занят, и информация о коде возврата ещё лежит на месте.

В Windows с этим проще — там нет отдельной таблицы зомби, статус забирается через WaitForSingleObject и GetExitCodeProcess, а дескриптор процесса живёт ровно до тех пор, пока его не закрыли.

Сирота — процесс, у которого родитель умер раньше него. В POSIX его переподчиняют: в Linux это PID 1 (systemd) либо ближайший «субадоптер», если он настроен. В Windows процесс просто продолжает жить, потому что тут нет отдельной сущности «родитель», которая должна забрать статус.

Практические способы не плодить зомби:

  • вызывать waitpid() — обязательно, если вы создаёте процессы;

  • перебрать всех детей циклом: waitpid(-1, &status, 0) в цикле, пока не вернётся -1;

  • в Linux выставить signal(SIGCHLD, SIG_IGN) — тогда ядро будет убирать зомби автоматически. Внимание: POSIX оставляет за этим поведение неопределённым, и на других Unix это может не сработать;

  • классический приём — двойной fork. Вы делаете fork(), в ребёнке делаете ещё один fork(), «внук» продолжает жить, а первый ребёнок сразу завершается. Внук оказывается сиротой, и его переподчиняют инициализатору (в Linux это systemd). Побочный эффект — вы теряете возможность узнать код возврата.

Процесс и поток — в чём разница

Коротко, потому что подробно будет в следующей статье.

Процесс — изолированная единица. У него собственное адресное пространство, своя таблица дескрипторов, свой PID, свои права, свои обработчики сигналов. Два процесса не видят переменные друг друга вообще. Общаться они могут только через ядро: каналы, сокеты, разделяемая память, сигналы.

Поток — единица выполнения внутри процесса. Адресное пространство, дескрипторы, файлы и переменные у потоков общие. Своё у каждого потока: стек, набор регистров, thread-local storage, errno. В Linux у потоков один общий PID (точнее, у процесса есть TGID, а у потока — TID, и getpid() возвращает именно TGID).

Отсюда практические выводы:

  • Создать поток дешевле, чем процесс: не надо создавать адресное пространство и таблицы страниц, ядро заводит для потока свой стек ядра и запись в списке задач.

  • Но потоки сложнее синхронизировать. Ошибка в логике с блокировкой в процессе просто убьёт процесс. Ошибка в логике с блокировкой в потоках воспроизводится через раз и зависит от тайминга.

  • Гонка данных в процессе невозможна по построению. Ровно та же гонка в потоке — это классический баг, который поймать в тестах почти невозможно.

  • Если поток должен вызвать код, написанный на C, помните про правило из первой главы: исключения через границу C-функции лететь не должны. Обёртывайте вызовы в try/catch прямо в обёртке.

Практика: маленький лаунчер

Соберём вместе то, что разобрали, — программу, которая запускает дочерний процесс, ждёт его и печатает код возврата. Один исходник под обе платформы:

#include <cstdlib>#include <iostream>#include <string>#ifdef _WIN32#include <windows.h>#else#include <cerrno>#include <cstring>#include <sys/wait.h>#include <unistd.h>#endif#ifdef _WIN32auto run(const std::wstring& command) -> int {    STARTUPINFOW si{};    si.cb = sizeof(si);    PROCESS_INFORMATION pi{};    // CreateProcess может править командную строку, поэтому нужна своя копия    std::wstring cmd = command;    if (!CreateProcessW(nullptr, cmd.data(),                        nullptr, nullptr, FALSE, 0,                        nullptr, nullptr, &si, &pi)) {        std::cerr << "CreateProcess failed, error " << GetLastError() << '\n';        return EXIT_FAILURE;    }    const DWORD waitResult = WaitForSingleObject(pi.hProcess, INFINITE);    DWORD exitCode = 0;    if (waitResult != WAIT_OBJECT_0 || !GetExitCodeProcess(pi.hProcess, &exitCode)) {        std::cerr << "Waiting failed, error " << GetLastError() << '\n';        exitCode = EXIT_FAILURE;    }    CloseHandle(pi.hThread);    CloseHandle(pi.hProcess);    return static_cast<int>(exitCode);}#elseauto run(const std::string& command) -> int {    const pid_t pid = fork();    if (pid == -1) {        std::cerr << "fork failed: " << std::strerror(errno) << '\n';        return EXIT_FAILURE;    }    if (pid == 0) {        execl("/bin/sh", "sh", "-c", command.c_str(), static_cast<char*>(nullptr));        std::cerr << "exec failed: " << std::strerror(errno) << '\n';        _exit(EXIT_FAILURE);    }    int status = 0;    if (waitpid(pid, &status, 0) == -1) {        std::cerr << "waitpid failed: " << std::strerror(errno) << '\n';        return EXIT_FAILURE;    }    if (WIFEXITED(status)) {        return WEXITSTATUS(status);    }    return -1;}#endifauto main() -> int {#ifdef _WIN32    const int code = run(L"cmd.exe /c echo hello from child");#else    const int code = run("echo hello from child");#endif    std::cout << "Child exited with code " << code << '\n';    return EXIT_SUCCESS;}

Сборка:

g++ -std=c++20 -Wall -Wextra launcher.cpp -o launcher   # Linux, macOScl /std:c++20 /EHsc /W4 launcher.cpp                    # MSVC

Смотрите, что в двух ветках получился одинаковый поток управления: создать, дождаться, забрать код возврата, освободить ресурсы. Различается только синтаксис вызовов. Именно это и делает API похожими — POSIX и WinAPI проектировались под разные традиции, но задачи у них одинаковые.

Ещё пара деталей, которые пригодятся, когда вы будете делать это в реальном проекте:

  • обработка ошибок в POSIX идёт через errno, в Windows — через GetLastError(). В коде выше это разные ветки, и это нормально;

  • если запускать не один процесс, а дерево, код возврата верхнего уровня стоит делать кодом ошибки, а не всегда нулём. Иначе система сборки не заметит, что у вас внутри что-то упало;

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

Итог

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

extern "C" нужен не для красоты, а потому что без него линковщик ищет другое имя. Если вы получаете undefined reference на функцию, которая вроде бы точно есть, — проверьте эту строчку первым делом.

fork() возвращает три разных значения, и разница между ними определяет, кто вы. exec* нужен, чтобы ребёнок занялся нужным делом. waitpid() нужен, чтобы не плодить зомби.

В Windows руками надо заполнять структуры (si.cb), проверять результат WaitForSingleObject, закрывать дескрипторы и не путать STILL_ACTIVE с настоящим кодом возврата.

И главное: читайте документацию по конкретной функции, а не по общему впечатлению. Мой совет — перед сдачей любого системного кода на ревью пройтись по нему с тремя вопросами:

  • что будет при ошибке;

  • что я не закрыл;

  • что останется в системе после моего процесса.

Большая часть неприятных сюрпризов в системном коде живёт именно в этих трёх местах.

Что дальше

Следующая статья — многопоточность: std::thread, std::async, мьютексы, условия, гонки данных и jthread из C++20 с автоматическим присоединением. После неё — память и аллокаторы, потом динамические библиотеки и хуки.

Отдельно хочу сказать про Rust. Цикл у меня по C++, но есть мысль сделать парные статьи: те же задачи (процессы, память, FFI) на Rust, с разбором того, как он решает то, с чем мы сегодня боролись. С FFI и unsafe там своя специфика, и сравнение в лоб («Rust лучше / хуже») обычно бесполезно — куда интереснее разобрать конкретные решения по конкретным задачам. Если вам это интересно — напишите в комментариях, какие темы для сравнения стоит взять в первую очередь. Тот же вопрос и по основному циклу: если из двух тем «потоки» и «память» вам нужны обе, но одна нужна прямо сейчас — говорите, что разбираем первой.

ссылка на оригинал статьи https://habr.com/ru/articles/1100718/