Разбор задачи 8: fork, разделяемая память и синхронизация
Программа
#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 и продолжает цикл. Получается цепочка:
Посмотрите на это в симуляторе: режим «родитель выходит», 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 может быть в любой стадии:
- Число строк. Условие
i < mem->counterпроверяется на каждой итерации, а counter в это время растёт. Если C1 ещё не успел выполнитьcounter++, будет 0 строк. Если вся цепочка завершилась — 5. Возможно всё от 0 до 5. - Какие пары. Печатается то, что лежит в
memв момент печати. Каждый следующий писатель затирает предыдущего, поэтому пары предыдущих процессов теряются, а одна и та же пара может напечататься несколько раз. - Склеенные пары.
pidиppidзаписываются двумя операциями, аprintfчитает их двумя операциями. Можно прочитать ppid одного процесса и pid другого. - ppid = 1. Писатель C(k) вызывает
getppid(), когда его родитель C(k−1) уже, скорее всего, завершился. Сироту усыновляет init (PID 1) или ближайший процесс-subreaper (например,systemd --user), иgetppid()вернёт его PID. - Порядок 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;
}
Строки с /* + */ — добавленные. Ничего из исходного кода не удалено.
Почему это работает
- Нет потерь: следующий писатель не начнёт, пока родитель не напечатал предыдущую пару (
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
Сколько всего процессов существует за время работы программы, включая исходный? (число, если все fork успешны)
Чему равен mem->counter, когда все процессы завершились? (число)
Сколько процессов записывают пару pid/ppid в разделяемую память? (число)
Сколько строк может напечатать исходная программа?
Какие варианты вывода возможны у исходной программы? (P — исходный процесс, C0…C5 — потомки)
| Возможно | Невозможно | |
|---|---|---|
| Ничего не напечатано | ||
Одна строка C0 : C1 (ppid = C0, pid = C1) |
||
Две одинаковые строки 1 : C2 |
||
| Строка «склеенная» из разных процессов: ppid от C1, pid от C3 | ||
| Шесть строк | ||
| Строка с pid = C5 |
Исправление (б): допишите тело цикла печати в родителе. Перед printf дождитесь готовности пары
(sem_wait(&mem->rd)), после — разрешите писать следующему процессу (sem_post(&mem->wr[i + 1]),
если i + 1 < NUM_PROCS). Строку printf не удаляйте.
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]);
}
Почему в исправленной программе wait(NULL) не приводит к взаимоблокировке?
Зачем родителю перед циклом NUM_PROCS раз выполнять sem_wait(&mem->inc)?
Войдите, чтобы отмечать пройденные уроки.