Привет, Хабр!
Это вторая статья цикла про 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:
-
Вы вызываете
fork()(в POSIX) илиCreateProcess()(в Windows) — ядро создаёт запись о процессе. -
Ядро выделяет адресное пространство, проверяет бинарник, назначает PID.
-
Создаётся первый поток (главный), ему передаётся стек и аргументы.
-
Вызывается точка входа:
start→_libc_start_main→main. Для графических приложений Windows —WinMainCRTStartup→WinMain. -
Процесс живёт, пока не вызовет
exit(),_exit(),ExitProcess()либо пока его не убьют сигналом илиTerminateProcess. -
Ядро освобождает ресурсы, а родителю остаётся запись о завершении — код возврата и, если он был убит сигналом, номер сигнала.
Шестой шаг — источник всех разговоров про зомби.
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 |
|---|---|---|
|
Создать процесс |
|
|
|
Дождаться завершения |
|
|
|
Свой PID |
|
|
|
Завершить изнутри |
|
|
|
Завершить снаружи |
|
|
|
Посмотреть на процесс |
|
Get-Process, Process Explorer |
|
Своё окружение |
|
|
Обратите внимание на последнюю строку: в 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/