Разбор задачи 8: fork, разделяемая память и синхронизация

⏱ 50 мин · 🧩 заданий: 8

Программа

#define NUM_PROCS 5

typedef struct
{
    int state;
    unsigned int counter;
    pid_t pid;
    pid_t ppid;
} Mem;

int main()
{
    Mem *mem;

    mem = mmap(NULL, sizeof(Mem), PROT_READ|PROT_WRITE,
               MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (mem == MAP_FAILED)
        _exit(1);

    mem->counter = 0;
    mem->state   = 0;
    pid_t pid = fork();
    if (pid == -1)
        _exit(1);

    if (pid == 0)
    {
        for (int i = 0; i < NUM_PROCS; ++i)
        {
            pid_t pid = fork();
            if (pid == -1)
            {
                kill(0, SIGINT);
                _exit(1);
            }
            if (pid == 0)
            {
                mem->counter++;
            }
            if (pid > 0)
            {
                mem->pid = getpid();
                mem->ppid = getppid();
                exit(0);
            }
        }
        exit(0);
    }

    wait(NULL);
    for (int i = 0; i < mem->counter; ++i)
    {
        printf("%d: %d\n", mem->ppid, mem->pid);
    }
    return 0;
}

(а) Как ведёт себя программа

Шаг 1. Разделяемая память

mmap(... MAP_SHARED | MAP_ANONYMOUS ...) создаёт анонимную разделяемую область. После fork она не копируется (COW здесь не действует): все потомки видят одну и ту же структуру Mem. Это главное отличие от обычных переменных.

Шаг 2. Дерево процессов

Внешний fork создаёт C0. Родитель P идёт в wait(NULL). Внутри цикла каждый процесс делает fork, после чего родитель записывает pid/ppid и выходит, а потомок увеличивает counter и продолжает цикл. Получается цепочка:

P ── fork ──▶ C0 P: wait(NULL) — ждёт только C0 │ i=0: fork ──▶ C1 (counter++ → 1) │ C0 пишет (pid=C0, ppid=P), exit │ i=1: fork ──▶ C2 (counter++ → 2) │ C1 пишет (pid=C1, ppid=C0 или 1), exit │ i=2: fork ──▶ C3 (→ 3) │ C2 пишет, exit │ i=3: fork ──▶ C4 (→ 4) │ C3 пишет, exit │ i=4: fork ──▶ C5 (→ 5) │ C4 пишет, exit C5: цикл окончен, exit(0)

Посмотрите на это в симуляторе: режим «родитель выходит», 5 итераций. В симуляторе корень P — это процесс, выполняющий цикл, то есть наш C0; P.0 — C1, P.0.1 — C2 и так далее.

Итого:

  • процессов: P, C0…C5 — 7;
  • counter в конце = 5 (увеличивают C1…C5 по очереди);
  • пару pid/ppid пишут 5 процессов: C0…C4. C5 не пишет.

Шаг 3. Что видит родитель

wait(NULL) ждёт завершения любого одного потомка P. У P он один — C0. C0 выходит сразу после первого fork и записи. Значит, когда P проснётся, цепочка C1…C5 может быть в любой стадии:

  1. Число строк. Условие i < mem->counter проверяется на каждой итерации, а counter в это время растёт. Если C1 ещё не успел выполнить counter++, будет 0 строк. Если вся цепочка завершилась — 5. Возможно всё от 0 до 5.
  2. Какие пары. Печатается то, что лежит в mem в момент печати. Каждый следующий писатель затирает предыдущего, поэтому пары предыдущих процессов теряются, а одна и та же пара может напечататься несколько раз.
  3. Склеенные пары. pid и ppid записываются двумя операциями, а printf читает их двумя операциями. Можно прочитать ppid одного процесса и pid другого.
  4. ppid = 1. Писатель C(k) вызывает getppid(), когда его родитель C(k−1) уже, скорее всего, завершился. Сироту усыновляет init (PID 1) или ближайший процесс-subreaper (например, systemd --user), и getppid() вернёт его PID.
  5. Порядок ppid : pid. Формат печатает сначала ppid, потом pid.

Реальные запуски исходной программы (Linux, контейнер):

run 1:             run 2:             run 4:
1408: 1409         1: 1417            1428: 1430
                   1: 1417

Run 1 — одна строка: C1 записал пару с живым родителем C0. Run 2 — две одинаковые строки: родитель печатал пару C2 (её родитель уже умер, ppid = 1), пока counter был равен 2.

Шаг 4. Ошибка fork и kill(0, SIGINT)

Если fork в цикле вернёт −1, процесс вызывает kill(0, SIGINT): сигнал получают все процессы его группы. Это все процессы программы, включая P, — группа наследуется при fork, а shell помещает задание в отдельную группу. Действие SIGINT по умолчанию — завершение, поэтому программа целиком завершится и ничего не напечатает (или оборвёт вывод).

Ответ на (а) коротко

Создаётся цепочка из 7 процессов: P → C0 → C1 → … → C5. counter станет 5, пары pid/ppid пишут C0…C4 в одну и ту же ячейку, затирая друг друга. wait ждёт только C0, поэтому родитель печатает от 0 до 5 строк (counter растёт параллельно), каждая — текущее содержимое ячейки: повторяющиеся пары, «склеенные» из разных процессов, ppid = 1 у осиротевших писателей. При ошибке fork — kill(0, SIGINT) завершает всю группу.

(б) Исправление

Нужно, чтобы каждая из 5 пар была напечатана ровно один раз. Это задача производитель — потребитель с ячейкой на одно значение, только производителей пять и они должны писать по очереди.

План:

Что Семафор Начальное значение
Очередь писателей: писатель k ждёт своей очереди wr[k] wr[0] = 1, остальные 0
Пара записана и готова к печати rd 0
Счётчик увеличен (чтобы условие цикла видело 5) inc 0

Писатель на шаге i — это процесс, выполнявший fork на итерации i (C0 при i=0, C1 при i=1…). Поэтому номер очереди — просто i.

#include <semaphore.h>                              /* + компилировать с -pthread */
#define NUM_PROCS 5

typedef struct
{
    int state;
    unsigned int counter;
    pid_t pid;
    pid_t ppid;
    sem_t wr[NUM_PROCS];                            /* + очередь писателя k   */
    sem_t rd;                                       /* + пара готова к печати */
    sem_t inc;                                      /* + counter увеличен     */
} Mem;

int main()
{
    Mem *mem;

    mem = mmap(NULL, sizeof(Mem), PROT_READ|PROT_WRITE,
               MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (mem == MAP_FAILED)
        _exit(1);

    mem->counter = 0;
    mem->state   = 0;
    for (int k = 0; k < NUM_PROCS; ++k)              /* + */
        sem_init(&mem->wr[k], 1, k == 0 ? 1 : 0);    /* + pshared = 1 */
    sem_init(&mem->rd, 1, 0);                        /* + */
    sem_init(&mem->inc, 1, 0);                       /* + */
    pid_t pid = fork();
    if (pid == -1)
        _exit(1);

    if (pid == 0)
    {
        for (int i = 0; i < NUM_PROCS; ++i)
        {
            pid_t pid = fork();
            if (pid == -1)
            {
                kill(0, SIGINT);
                _exit(1);
            }
            if (pid == 0)
            {
                mem->counter++;
                sem_post(&mem->inc);                 /* + */
            }
            if (pid > 0)
            {
                sem_wait(&mem->wr[i]);               /* + ждать своей очереди */
                mem->pid = getpid();
                mem->ppid = getppid();
                sem_post(&mem->rd);                  /* + пара готова */
                exit(0);
            }
        }
        exit(0);
    }

    wait(NULL);
    for (int k = 0; k < NUM_PROCS; ++k)              /* + дождаться counter == 5 */
        sem_wait(&mem->inc);                         /* + */
    for (int i = 0; i < mem->counter; ++i)
    {
        sem_wait(&mem->rd);                          /* + ждать пару */
        printf("%d: %d\n", mem->ppid, mem->pid);
        if (i + 1 < NUM_PROCS)                       /* + */
            sem_post(&mem->wr[i + 1]);               /* + разрешить следующему */
    }
    return 0;
}

Строки с /* + */ — добавленные. Ничего из исходного кода не удалено.

Почему это работает

C0: wait(wr[0]=1) ✓ → пишет → post(rd) → exit ──▶ P просыпается из wait(NULL) P: wait(inc)×5 — ждёт, пока C1…C5 увеличат counter P: i=0: wait(rd) ✓ → печать пары C0 → post(wr[1]) C1: wait(wr[1]) ✓ → пишет → post(rd) P: i=1: wait(rd) ✓ → печать пары C1 → post(wr[2]) ... P: i=4: wait(rd) ✓ → печать пары C4 → (wr[5] нет — i+1 = NUM_PROCS)
  • Нет потерь: следующий писатель не начнёт, пока родитель не напечатал предыдущую пару (wr[i+1] открывается после printf).
  • Нет склеек: в каждый момент пишет ровно один процесс, а родитель читает только после rd.
  • Условие цикла: благодаря inc к началу цикла counter == 5, ровно по одной итерации на писателя.
  • Нет взаимоблокировки с wait(NULL): родитель ждёт C0, а C0 пишет первым — wr[0] изначально 1 — и сразу выходит. Остальные писатели ждут разрешения, которое родитель выдаёт уже после wait.
  • sem_init(..., 1, ...) — второй аргумент 1 означает «между процессами», и семафоры лежат в разделяемой памяти, как требуется.

Реальные запуски исправленной программы:

1374: 1375        ← C0 (родитель P жив)
1: 1376           ← C1 (C0 уже вышел — усыновлён init)
1376: 1377        ← C2
1377: 1378
1: 1379

Всегда 5 строк, у каждого процесса свой pid. ppid = 1 у тех, чей родитель успел завершиться: это не ошибка, а честное значение getppid() в момент записи.

Частые ошибки в (б)

  • Семафор в обычной переменной, а не в Mem: после fork у каждого своя копия, синхронизации нет.
  • sem_init(&s, 0, ...) — pshared = 0 годится только для потоков.
  • Родитель ждёт «пары» до wait(NULL), а C0 ждёт родителя — взаимоблокировка.
  • Забыли про условие цикла i < mem->counter: без ожидания inc цикл может закончиться раньше.

Другой вариант: поле state

Можно обойтись без новых полей, используя state как номер шага. Писатель i ждёт state == 2*i, пишет пару и ставит state = 2*i + 1. Родитель на итерации i ждёт state == 2*i + 1, печатает и ставит state = 2*i + 2. Но ожидание здесь активное (while (... ) ;): тратит процессор, а поле нужно объявить volatile или читать атомарно (__atomic_load_n), иначе компилятор может «закешировать» значение в регистре. Семафоры правильнее, и это стоит сказать на экзамене.

Повторить: уроки «fork», «exec, exit и wait», «Группы и сеансы» и модуль «IPC и синхронизация».

Практика решено 0 из 8

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Ввод ответа

Сколько всего процессов существует за время работы программы, включая исходный? (число, если все fork успешны)

Задание 2 Ввод ответа

Чему равен mem->counter, когда все процессы завершились? (число)

Задание 3 Ввод ответа

Сколько процессов записывают пару pid/ppid в разделяемую память? (число)

Задание 4 Один вариант ответа

Сколько строк может напечатать исходная программа?

Задание 5 Заполнить таблицу

Какие варианты вывода возможны у исходной программы? (P — исходный процесс, C0…C5 — потомки)

ВозможноНевозможно
Ничего не напечатано
Одна строка C0 : C1 (ppid = C0, pid = C1)
Две одинаковые строки 1 : C2
Строка «склеенная» из разных процессов: ppid от C1, pid от C3
Шесть строк
Строка с pid = C5
Отметьте ячейку — «+», пустая — «−».
Задание 6 Написать код

Исправление (б): допишите тело цикла печати в родителе. Перед printf дождитесь готовности пары (sem_wait(&mem->rd)), после — разрешите писать следующему процессу (sem_post(&mem->wr[i + 1]), если i + 1 < NUM_PROCS). Строку printf не удаляйте.

Задание 7 Один вариант ответа

Почему в исправленной программе wait(NULL) не приводит к взаимоблокировке?

Задание 8 Один вариант ответа

Зачем родителю перед циклом NUM_PROCS раз выполнять sem_wait(&mem->inc)?

Войдите, чтобы отмечать пройденные уроки.