Files
eduplan-cpp-eltex/lessons/D1_linux.md
T

11 KiB
Raw Blame History

D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)

Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения + открытые дескрипторы. fork копирует это, exec заменяет содержимое, wait собирает результат, сигнал — асинхронное уведомление.

1. fork(): что реально происходит

pid_t fork(void) создаёт новый процесс — копию вызывающего: своё адресное пространство, свой PID, свой поток. Родитель продолжает с того же места.

Возвращает дважды — и это ключ к пониманию:

  • в родителе — PID ребёнка (> 0);
  • в ребёнке — 0;
  • при ошибке — −1 (и errno), ребёнок не создан.

Поэтому классический код всегда ветвится:

pid_t pid = fork();
if (pid == 0) {
    // это ребёнок
    execlp("ls", "ls", "-l", nullptr);
    _exit(127);                      // exec не вернулся — значит не смог
} else if (pid > 0) {
    // это родитель: pid — номер ребёнка
    int status = 0;
    waitpid(pid, &status, 0);        // дождаться и забрать код возврата
} else {
    perror("fork");                  // ошибка
}

Copy-on-write. Память физически не копируется: обе стороны смотрят на одни страницы, помеченные «только чтение». При первой записи в страницу ядро делает её копию — только тогда. Поэтому fork дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают в формулировке «что происходит со страницами памяти при fork?» — ответ: COW, копируются при первой записи.

Совет: fflush(stdout) перед fork, иначе буфер вывода может продублироваться в ребёнке.

2. exec(): замена образа

exec* не создаёт процесс, а заменяет содержимое текущего: код, данные, стек — всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда: при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.

Отсюда рабочий шаблон: fork + exec в ребёнке = запуск внешней программы. Семейство: execlp ищет в PATH, execv — по полному пути, execvp — по имени в PATH.

3. wait/waitpid и коды возврата

Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код возврата. Такой процесс называется зомби (состояние Z в ps). Зомби не занимает память, но занимает слот в таблице процессов — их накопление плохо.

  • wait(&status) — ждёт любого ребёнка;
  • waitpid(pid, &status, 0) — конкретного; WNOHANG — не блокироваться.

Разбор status делается макросами, а не вручную:

if (WIFEXITED(status))        printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));

Откуда 137 и 139. Оболочка показывает код как 128 + номер сигнала:

  • 137 = 128 + 9 → SIGKILL (убит kill -9, часто OOM-killer);
  • 139 = 128 + 11 → SIGSEGV (падение по памяти);
  • 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).

Сирота — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби.

4. Сигналы

Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9, нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11), SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился).

Обработчик ставится sigaction (надёжнее устаревшего signal), внутри обработчика можно менять только volatile sig_atomic_t — никаких printf/malloc (не async-signal-safe).

static volatile sig_atomic_t stop = 0;
static void on_term(int) { stop = 1; }        // минимум действий

int main() {
    struct sigaction sa{}; sa.sa_handler = on_term;
    sigaction(SIGTERM, &sa, nullptr);          // ловим мягкое завершение
    while (!stop) { /* работа */ }
}

5. errno и возвраты системных вызовов

Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и errno. Читать errno можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже; EINPROGRESS — неблокирующее соединение в процессе.

ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
    if (errno == EINTR) continue;      // прервали сигналом — просто повторить
    perror("read");                    // иначе настоящая ошибка
    break;
}

6. Разбор примера: мини-шелл на 40 строк

Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.

#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <string>
#include <vector>
#include <sstream>

int main() {
    std::string line;
    while (std::printf("sh> "), std::fflush(stdout), std::getline(std::cin, line)) {
        if (line == "exit") break;
        if (line.empty()) continue;

        std::istringstream is(line);
        std::vector<std::string> argv; std::string tok;
        while (is >> tok) argv.push_back(tok);
        if (argv.empty()) continue;

        std::vector<char*> cargv;
        for (auto& a : argv) cargv.push_back(a.data());
        cargv.push_back(nullptr);

        pid_t pid = fork();
        if (pid == 0) {
            execvp(cargv[0], cargv.data());
            std::fprintf(stderr, "не могу запустить %s\n", cargv[0]);
            _exit(127);                       // exec не удался
        } else if (pid > 0) {
            int status = 0;
            waitpid(pid, &status, 0);
            if (WIFEXITED(status))
                std::printf("код возврата: %d\n", WEXITSTATUS(status));
            else if (WIFSIGNALED(status))
                std::printf("убит сигналом: %d (код %d)\n",
                            WTERMSIG(status), 128 + WTERMSIG(status));
        } else {
            std::perror("fork");
        }
    }
}

Что тут проверить руками: ls -l работает; sleep 5 в фоне (& — уже доработка); kill -9 по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»; несуществующая команда даёт 127.

Проверка на утечки и падения — санитайзеры: g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell

7. Что спросят на собеседовании (готовые ответы)

  • «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка, в ребёнке 0, при ошибке −1.
  • «Что с памятью при fork?» — copy-on-write, страницы копируются при первой записи.
  • «Чем exec отличается от fork?» — fork создаёт новый процесс, exec заменяет образ текущего, не создавая процесса.
  • «Зачем waitpid?» — забрать код возврата и не оставлять зомби.
  • «Как узнать, что процесс убит сигналом?» — WIFSIGNALED/WTERMSIG, код оболочки 128 + сигнал, например 137 = SIGKILL, 139 = SIGSEGV.
  • «SIGTERM против SIGKILL?» — SIGTERM можно перехватить и завершиться корректно, SIGKILL не перехватывается и не игнорируется.

8. Материалы (первопартийные)

9. Проверь себя

  1. Что вернёт fork() в ребёнке и в родителе?
  2. Что происходит со страницами памяти при fork()?
  3. Что такое зомби и кто его убирает?
  4. Откуда код возврата 137 и 139?
  5. Почему exec не возвращает управление при успехе?
Ответы
  1. В ребёнке 0, в родителе — PID ребёнка, при ошибке −1.
  2. Ничего сразу: copy-on-write, копия страницы создаётся при первой записи.
  3. Завершившийся процесс, чья запись ждёт wait/waitpid; убирает родитель (или init, если родитель умер).
  4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
  5. Потому что адресное пространство заменено новым образом: старого кода больше нет.

10. Задачи дня

  • tasks/03_ring — кольцевой буфер (база для сетевого кода).
  • Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).