Основы многопроцессорности: анатомия процесса, системные вызовы fork, exec и waitpid в Linux
Основы многопроцессорности: анатомия процесса, системные вызовы fork, exec и waitpid в Linux
Приветствую тебя, студент! Добро пожаловать на курс, который превратит тебя из простого написателя скриптов в настоящего системного инженера. Мы начинаем погружение в мир, где программы не просто выполняют код строчка за строчкой, а живут, размножаются, общаются и иногда даже умирают (и превращаются в зомби, я серьезно).
Сегодня мы не будем говорить о потоках (threads), которые живут внутри одного процесса. Мы начнем с базы — с самих процессов. Ты узнаешь, как операционная система Linux управляет программами и как ты, используя C++, можешь взять это управление в свои руки.
Модуль 1: Анатомия процесса в Linux
Прежде чем создавать процессы, давай разберемся, что это вообще такое. Когда ты запускаешь программу (например, свой скрипт на Python или скомпилированный бинарник C++), операционная система создает для неё процесс.
Процесс — это не просто код. Это контейнер, которому ОС выделяет ресурсы. У каждого процесса есть свой уникальный идентификатор — PID (Process ID). Также у каждого процесса есть родитель — PPID (Parent Process ID), тот процесс, который его запустил.
В памяти Linux процесс выглядит как слоеный пирог. Давай посмотрим на его устройство.
Разберем слои этого пирога:
- Text Segment (Сегмент кода): Здесь лежат машинные инструкции твоей программы. Этот участок памяти обычно доступен только для чтения, чтобы ты случайно не переписал свои же команды.
- Data Segment (Сегмент данных): Здесь живут глобальные и статические переменные.
- Heap (Куча): Динамическая память. Когда ты пишешь
newв C++ илиmalloc, память берется отсюда. Куча растет снизу вверх. - Stack (Стек): Здесь хранятся локальные переменные функций и адреса возврата. Стек растет сверху вниз, навстречу куче.
Важный нюанс: В Linux каждый процесс считает, что у него есть вся память компьютера. Это иллюзия, которую создает Виртуальная память. Процесс А не может просто так залезть в память Процесса Б. Это обеспечивает безопасность и стабильность.
Модуль 2: Системный вызов fork() — Искусство клонирования
В Python ты, возможно, привык запускать скрипты и не думать, откуда они берутся. В C++ на Linux мы используем системные вызовы (system calls или syscalls). Это прямые обращения к ядру операционной системы с просьбой сделать что-то важное.
Самый главный вызов для создания нового процесса — fork().
Когда процесс вызывает fork(), происходит магия: ядро создает точную копию текущего процесса. Клонируется всё: код, переменные, состояние памяти, открытые файлы. С этого момента в системе работают два почти идентичных процесса.
Как же их различить? По возвращаемому значению функции fork().
- Родителю (тому, кто вызвал) функция возвращает
PIDребенка. - Ребенку (новому процессу) функция возвращает
0.
Давай посмотрим на код. Для работы нам понадобятся заголовки <unistd.h> (для fork, pid_t) и <sys/types.h>.
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main() {
std::cout << "Я — родитель, мой PID: " << getpid() << std::endl;
// Момент клонирования
pid_t pid = fork();
if (pid < 0) {
// Если fork вернул отрицательное число — произошла ошибка
std::cerr << "Ошибка при создании процесса!" << std::endl;
return 1;
} else if (pid == 0) {
// Мы внутри дочернего процесса
std::cout << "Привет! Я — ребенок. Мой PID: " << getpid()
<< ", а PID моего родителя: " << getppid() << std::endl;
} else {
// Мы внутри родительского процесса
std::cout << "Я все еще родитель. Я создал ребенка с PID: " << pid << std::endl;
}
return 0;
}
Что здесь происходит?
Строка pid_t pid = fork(); выполняется один раз, но возвращает управление дважды: один раз в родительском процессе, второй раз — в дочернем. После этой строчки код раздваивается.
"Fork — это как если бы вы подошли к ксероксу, положили себя на стекло, нажали кнопку, и из лотка вылезла ваша точная копия, которая сразу же начала жить своей жизнью."
Модуль 3: Семейство exec() — Трансплантация мозга
Клонирование — это весело, но бесполезно, если клон делает то же самое, что и оригинал. Обычно мы хотим, чтобы новый процесс запустил другую программу. Например, так работает терминал: когда ты пишешь ls, терминал делает fork, а затем ребенок превращается в утилиту ls.
Для этого используется семейство функций exec (от слова execute — выполнять). Самые популярные: execl, execv, execvp.
Что делает exec? Он полностью заменяет текущий образ процесса (код, данные, стек) новой программой с диска. PID остается тем же, но "начинка" меняется полностью.
Важный момент: Если exec сработал успешно, управление никогда не вернется в следующую строчку кода. Старая программа стерта из памяти.
Пример использования execl:
#include <iostream>
#include <unistd.h>
int main() {
std::cout << "Запускаем ls..." << std::endl;
// execl(путь, имя_аргумента_0, аргумент_1, ..., NULL)
// Аргументы должны заканчиваться NULL
int result = execl("/bin/ls", "ls", "-l", NULL);
// Если мы попали сюда, значит exec НЕ сработал
if (result == -1) {
std::cerr << "Ошибка запуска!" << std::endl;
}
return 0;
}
Обычно fork и exec используют в связке:
- Родитель делает
fork(). - Ребенок внутри
if (pid == 0)вызываетexec(), чтобы стать новой программой. - Родитель продолжает заниматься своими делами.
Модуль 4: waitpid() и атака зомби
В мире Linux родители обязаны следить за своими детьми. Когда дочерний процесс завершается (делает return или exit()), он не исчезает бесследно. Он превращается в зомби (zombie process).
Зомби — это процесс, который уже умер (память освобождена), но запись о нем все еще висит в таблице процессов ядра. Он ждет, пока родитель прочитает его "предсмертную записку" — код возврата.
Если родитель не прочитает код возврата, зомби будут висеть и засорять таблицу процессов. Чтобы этого избежать, родитель должен использовать системный вызов wait() или waitpid().
Эти функции делают две вещи:
- Приостанавливают родителя, пока ребенок не умрет (блокирующий вызов).
- Считывают статус завершения ребенка и удаляют его из таблицы процессов.
Пример правильного воспитания процессов:
#include <iostream>
#include <unistd.h>
#include <sys/wait.h> // Обязательно для waitpid
int main() {
pid_t pid = fork();
if (pid == 0) {
// Ребенок
std::cout << "Ребенок: Я работаю..." << std::endl;
sleep(2); // Имитация бурной деятельности
std::cout << "Ребенок: Я закончил!" << std::endl;
exit(42); // Возвращаем код 42
} else {
// Родитель
std::cout << "Родитель: Жду завершения ребенка..." << std::endl;
int status;
// waitpid(pid_ребенка, куда_записать_статус, опции)
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
std::cout << "Родитель: Ребенок умер с кодом: "
<< WEXITSTATUS(status) << std::endl;
}
}
return 0;
}
Разбор макросов:
WIFEXITED(status): Проверяет, завершился ли процесс нормально (сам, а не был убит сигналом).WEXITSTATUS(status): Извлекает тот самый код возврата (в нашем примере 42).
Итоги
Сегодня мы разобрали фундамент многозадачности в Linux:
- Процесс — это изолированная программа с памятью и PID.
- fork() — создает копию процесса. Помни:
0— ребенку,PID— родителю. - exec() — заменяет тело процесса новой программой.
- waitpid() — позволяет родителю дождаться завершения ребенка и предотвратить появление зомби.
Это низкоуровневая база. В C++ есть высокоуровневые обертки, но понимание того, как это работает "под капотом" через системные вызовы, делает тебя профессионалом, понимающим цену каждому созданному ресурсу. В следующей статье мы поговорим о том, как эти процессы могут общаться друг с другом (IPC).