В этой статье я хотел бы рассказать о том, какой жизненный путь проходят процессы в семействе ОС Linux. В теории и на примерах я рассмотрю как процессы рождаются и умирают, немного расскажу о механике системных вызовов и сигналов.

Данная статья в большей мере рассчитана на новичков в системном программировании и тех, кто просто хочет узнать немного больше о том, как работают процессы в Linux.

Всё написанное ниже справедливо к Debian Linux с ядром 4.15.0.

Содержание


  1. Введение
  2. Атрибуты процесса
  3. Жизненный цикл процесса
    1. Рождение процесса
    2. Состояние «готов»
    3. Состояние «выполняется»
    4. Перерождение в другую программу
    5. Состояние «ожидает»
    6. Состояние «остановлен»
    7. Завершение процесса
    8. Состояние «зомби»
    9. Забытье
  4. Благодарности

Введение


Системное программное обеспечение взаимодействует с ядром системы посредством специальных функций — системных вызовов. В редких случаях существует альтернативный API, например, procfs или sysfs, выполненные в виде виртуальных файловых систем.

Атрибуты процесса


Процесс в ядре представляется просто как структура с множеством полей (определение структуры можно прочитать здесь).
Но так как статья посвящена системному программированию, а не разработке ядра, то несколько абстрагируемся и просто акцентируем внимание на важных для нас полях процесса:

  • Идентификатор процесса (pid)
  • Открытые файловые дескрипторы (fd)
  • Обработчики сигналов (signal handler)
  • Текущий рабочий каталог (cwd)
  • Переменные окружения (environ)
  • Код возврата

Жизненный цикл процесса




Рождение процесса


Только один процесс в системе рождается особенным способом — init — он порождается непосредственно ядром. Все остальные процессы появляются путём дублирования текущего процесса с помощью системного вызова fork(2). После выполнения fork(2) получаем два практически идентичных процесса за исключением следующих пунктов:

  1. fork(2) возвращает родителю PID ребёнка, ребёнку возвращается 0;
  2. У ребёнка меняется PPID (Parent Process Id) на PID родителя.

После выполнения fork(2) все ресурсы дочернего процесса — это копия ресурсов родителя. Копировать процесс со всеми выделенными страницами памяти — дело дорогое, поэтому в ядре Linux используется технология Copy-On-Write.
Все страницы памяти родителя помечаются как read-only и становятся доступны и родителю, и ребёнку. Как только один из процессов изменяет данные на определённой странице, эта страница не изменяется, а копируется и изменяется уже копия. Оригинал при этом «отвязывается» от данного процесса. Как только read-only оригинал остаётся «привязанным» к одному процессу, странице вновь назначается статус read-write.

Пример простой бесполезной программы с fork(2)

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <sys/types.h>

int main() {
    int pid = fork();
    switch(pid) {
        case -1:
            perror("fork");
            return -1;
        case 0:
            // Child
            printf("my pid = %i, returned pid = %i\n", getpid(), pid);
            break;
        default:
            // Parent
            printf("my pid = %i, returned pid = %i\n", getpid(), pid);
            break;
    }
    return 0;
}


$ gcc test.c && ./a.out
my pid = 15594, returned pid = 15595
my pid = 15595, returned pid = 0


Состояние «готов»


Сразу после выполнения fork(2) переходит в состояние «готов».
Фактически, процесс стоит в очереди и ждёт, когда планировщик (scheduler) в ядре даст процессу выполняться на процессоре.

Состояние «выполняется»


Как только планировщик поставил процесс на выполнение, началось состояние «выполняется». Процесс может выполняться весь предложенный промежуток (квант) времени, а может уступить место другим процессам, воспользовавшись системным вывозом sched_yield.

Перерождение в другую программу


В некоторых программах реализована логика, в которой родительский процесс создает дочерний для решения какой-либо задачи. Ребёнок в данном случае решает какую-то конкретную проблему, а родитель лишь делегирует своим детям задачи. Например, веб-сервер при входящем подключении создаёт ребёнка и передаёт обработку подключения ему.
Однако, если нужно запустить другую программу, то необходимо прибегнуть к системному вызову execve(2):

int execve(const char *filename, char *const argv[], char *const envp[]);

или библиотечным вызовам execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3):

int execl(const char *path, const char *arg, ... /* (char  *) NULL */);
int execlp(const char *file, const char *arg, ...  /* (char  *) NULL */);
int execle(const char *path, const char *arg, ...
                             /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

Все из перечисленных вызовов выполняют программу, путь до которой указан в первом аргументе. В случае успеха управление передаётся загруженной программе и в исходную уже не возвращается. При этом у загруженной программы остаются все поля структуры процесса, кроме файловых дескрипторов, помеченных как O_CLOEXEC, они закроются.

Как не путаться во всех этих вызовах и выбирать нужный? Достаточно постичь логику именования:

  • Все вызовы начинаются с exec
  • Пятая буква определяет вид передачи аргументов:
    • l обозначает list, все параметры передаются как arg1, arg2, ..., NULL
    • v обозначает vector, все параметры передаются в нуль-терминированном массиве;
  • Далее может следовать буква p, которая обозначает path. Если аргумент file начинается с символа, отличного от "/", то указанный file ищется в каталогах, перечисленных в переменной окружения PATH
  • Последней может быть буква e, обозначающая environ. В таких вызовах последним аргументом идёт нуль-терминированный массив нуль-терминированных строк вида key=value — переменные окружения, которые будут переданы новой программе.

Пример вызова /bin/cat --help через execve

#define _GNU_SOURCE
#include <unistd.h>

int main() {
    char* args[] = { "/bin/cat", "--help", NULL };
    execve("/bin/cat", args, environ);
    // Unreachable
    return 1;
}


$ gcc test.c && ./a.out
Usage: /bin/cat [OPTION]... [FILE]...
Concatenate FILE(s) to standard output.

*Вывод обрезан*


Семейство вызовов exec* позволяет запускать скрипты с правами на исполнение и начинающиеся с последовательности шебанг (#!).

Пример запуска скрипта с подмененным PATH c помощью execle

#define _GNU_SOURCE
#include <unistd.h>

int main() {
    char* e[] = {"PATH=/habr:/rulez", NULL};
    execle("/tmp/test.sh", "test.sh", NULL, e);
    // Unreachable
    return 1;
}


$ cat test.sh
#!/bin/bash
echo $0
echo $PATH
$ gcc test.c && ./a.out
/tmp/test.sh
/habr:/rulez


Есть соглашение, которое подразумевает, что argv[0] совпадает с нулевым аргументов для функций семейства exec*. Однако, это можно нарушить.

Пример, когда cat становится dog с помощью execlp

#define _GNU_SOURCE
#include <unistd.h>

int main() {
    execlp("cat", "dog", "--help", NULL);
    // Unreachable
    return 1;
}


$ gcc test.c && ./a.out
Usage: dog [OPTION]... [FILE]...

*Вывод обрезан*


Любопытный читатель может заметить, что в сигнатуре функции int main(int argc, char* argv[]) есть число — количество аргументов, но в семействе функций exec* ничего такого не передаётся. Почему? Потому что при запуске программы управление передаётся не сразу в main. Перед этим выполняются некоторые действия, определённые glibc, в том числе подсчёт argc.

Состояние «ожидает»


Некоторые системные вызовы могут выполняться долго, например, ввод-вывод. В таких случаях процесс переходит в состояние «ожидает». Как только системный вызов будет выполнен, ядро переведёт процесс в состояние «готов».
В Linux так же существует состояние «ожидает», в котором процесс не реагирует на сигналы прерывания. В этом состоянии процесс становится «неубиваемым», а все пришедшие сигналы встают в очередь до тех пор, пока процесс не выйдет из этого состояния.
Ядро само выбирает, в какое из состояний перевести процесс. Чаще всего в состояние «ожидает (без прерываний)» попадают процессы, которые запрашивают ввод-вывод. Особенно заметно это при использовании удалённого диска (NFS) с не очень быстрым интернетом.

Состояние «остановлен»


В любой момент можно приостановить выполнение процесса, отправив ему сигнал SIGSTOP. Процесс перейдёт в состояние «остановлен» и будет находиться там до тех пор, пока ему не придёт сигнал продолжать работу (SIGCONT) или умереть (SIGKILL). Остальные сигналы будут поставлены в очередь.

Завершение процесса


Ни одна программа не умеет завершаться сама. Они могут лишь попросить систему об этом с помощью системного вызова _exit или быть завершенными системой из-за ошибки. Даже когда возвращаешь число из main(), всё равно неявно вызывается _exit.
Хотя аргумент системного вызова принимает значение типа int, в качестве кода возврата берется лишь младший байт числа.

Состояние «зомби»


Сразу после того, как процесс завершился (неважно, корректно или нет), ядро записывает информацию о том, как завершился процесс и переводит его состояние «зомби». Иными словами, зомби — это завершившийся процесс, но память о нём всё ещё хранится в ядре.
Более того, это второе состояние, в котором процесс может смело игнорировать сигнал SIGKILL, ведь что мертво не может умереть ещё раз.

Забытье


Код возврата и причина завершения процесса всё ещё хранится в ядре и её нужно оттуда забрать. Для этого можно воспользоваться соответствующими системными вызовами:

pid_t wait(int *wstatus); /* Аналогично waitpid(-1, wstatus, 0) */

pid_t waitpid(pid_t pid, int *wstatus, int options);

Вся информация о завершении процесса влезает в тип данных int. Для получения кода возврата и причины завершения программы используются макросы, описанные в man-странице waitpid(2).

Пример корректного завершения и получения кода возврата

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <sys/types.h>

int main() {
    int pid = fork();
    switch(pid) {
        case -1:
            perror("fork");
            return -1;
        case 0:
            // Child
            return 13;
        default: {
            // Parent
            int status;
            waitpid(pid, &status, 0);
            printf("exit normally? %s\n", (WIFEXITED(status) ? "true" : "false"));
            printf("child exitcode = %i\n", WEXITSTATUS(status));
            break;
        }
    }
    return 0;
}


$ gcc test.c && ./a.out
exit normally? true
child exitcode = 13


Пример некорректного завершения

Передача argv[0] как NULL приводит к падению.

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <sys/types.h>

int main() {
    int pid = fork();
    switch(pid) {
        case -1:
            perror("fork");
            return -1;
        case 0:
            // Child
            execl("/bin/cat", NULL);
            return 13;
        default: {
            // Parent
            int status;
            waitpid(pid, &status, 0);
            if(WIFEXITED(status)) {
                printf("Exit normally with code %i\n", WEXITSTATUS(status));
            }
            if(WIFSIGNALED(status)) {
                printf("killed with signal %i\n", WTERMSIG(status));
            }
            break;
        }
    }
    return 0;
}


$ gcc test.c && ./a.out
killed with signal 6


Бывают случаи, при которых родитель завершается раньше, чем ребёнок. В таких случаях родителем ребёнка станет init и он применит вызов wait(2), когда придёт время.

После того, как родитель забрал информацию о смерти ребёнка, ядро стирает всю информацию о ребёнке, чтобы на его место вскоре пришёл другой процесс.

Благодарности


Спасибо Саше «Al» за редактуру и помощь в оформлении;

Спасибо Саше «Reisse» за понятные ответы на сложные вопросы.

Они стойко перенесли напавшее на меня вдохновение и напавший на них шквал моих вопросов.

Комментарии (60)


  1. roginvs
    16.09.2018 22:48

    Можно поподробнее про то, что именно получает дочерний процесс (fork и/или exec): открытые файлы, активные соединения, ждущие входящих запросов соединения, итд.


    1. Firemoon Автор
      16.09.2018 22:56

      Проще перечислить то, чего дочерний процесс НЕ получает.
      Википедия в этом плане чуть более развёрнуто объясняет:

      Между процессом-потомком и процессом-родителем существуют различия:

      — PID процесса-потомка отличен от PID процесса-родителя;
      значению PPID процесса-потомка присваивается значение PID процесса-родителя;
      — Процесс-потомок получает собственную таблицу файловых дескрипторов, являющуюся копией таблицы процесса-родителя на момент вызова fork(). Это означает, что открытые файлы наследуются, но если процесс-потомок, например, закроет какой-либо файл, то это не повлияет на таблицу дескрипторов процесса-родителя.
      — для процесса-потомка очищаются все ожидающие доставки сигналы;
      — временная статистика выполнения процесса-потомка в таблицах ОС обнуляется;
      — блокировки памяти и записи, установленные в процессе-родителе, не наследуются.


      Всё остальное наследуется. Если открыт сокет, то после fork'а он будет открыт и у родителя, и у ребёнка. Так как сокет остаётся один, то и очередь у них будет общая.


      1. khim
        16.09.2018 23:43

        Это не совсем так. Есть ещё такие вещи, как «информация о файловой системе» (что для нас корень, текущая директория и тому подобное), соединение с дебаггером (если кто-то наш процесс ptrace'ит, то он, по умолчанию, не будет ptrace'ить наших детей), semadj-лист и ещё какие-то вещи. Они по умлочанию не шарятся. Есть гораздо больше вещей (много разных namespaces, cgroup и прочее), которые по умолчанию шарятся, но можно этого не делать.

        В man clone это всё описано. Собственно в Linux fork(2) — это обёртка над clone(2) (правда внутри ядра, не внутри системной библиотеки).


      1. red75prim
        17.09.2018 07:59

        Потоки (threads) тоже не наследуются.


        1. khim
          17.09.2018 12:59

          В Linux (в ядре) понятие «threads» отсутствует. С точки зрения ядра все потоки — это полноценные, отдельные, процессы (просто с общим адресным пространством).

          Потому даже сложно представить как могли бы наследоваться потоки и что это могло бы значить…


  1. sashz
    17.09.2018 05:52

    enivron?


    1. Firemoon Автор
      17.09.2018 10:10

      Исправил


      1. tru_pablo
        17.09.2018 12:35

        не все


  1. 1KoT1
    17.09.2018 06:21

    А где бы почитать, почему fork() устроен именно так или иначе?
    Мне непонятно, зачем ребёнку наследовать открытые сокеты, файлы и т. д.


    1. fishHook
      17.09.2018 08:20

      Например, чтобы писать что-то в общий лог, или читать что-то из общего файла (который мы можем передать в stdin, т.о. программа заранее не знает, какой файл надо открыть в дочернем процессе). Допустим, у нас программа веб-сервер. Сервер слушает 80-й порт и как только он получает новый запрос на создание соединения, он запускает дочерний процесс, разумеется, мы хотим, чтобы stdout и stderr остались такими же, какие были и главного процесса.


    1. Firemoon Автор
      17.09.2018 10:11

      Помимо того, что написал fishHook, могу добавить, что возможно просто так исторически сложилось.


      1. iig
        17.09.2018 14:56

        Раз потомок получает копию адресного пространства родителя, значит, заодно получает и все открытые файлы (это же тоже структура в памяти). Делать иначе — очевидно, дороже (ядро, вместо Copy-On-Write должно будет сделать полную копию памяти и освободить некоторые ресурсы, пользовательская программа — должна проверять валидность некоторых указателей..)


        1. khim
          17.09.2018 16:10

          Раз потомок получает копию адресного пространства родителя, значит, заодно получает и все открытые файлы (это же тоже структура в памяти).
          Это структура в другой памяти. И её нужно копировать, да.

          Делать иначе — очевидно, дороже (ядро, вместо Copy-On-Write должно будет сделать полную копию памяти и освободить некоторые ресурсы, пользовательская программа — должна проверять валидность некоторых указателей..)
          Какой ужас. У вас вообще представление есть о том, как файловые дескрипторы работают? Даю подсказку — это ни разу не указатель. Это число. Маленькое. От 0 до 1023 в далёком прошлом, сейчас верхний предел динамический, но принцип тот же. Дальше — рассказывать или сами догадаетесь?

          Копировать файловые дескрипторы сложнее и дороже, но удобнее. Hint: Unix, в ранних версиях, сокетов не имел — но зато имел pipes. Дальше рассказывать или сами догадаетесь?


          1. iig
            17.09.2018 22:01

            Я программист ненастоящий, man в репозитории нашел :) Если есть маленькое число, идентифицирующее файловый дескриптор, должна существовать структура, указывающая на имя файла и его атрибуты.


            1. khim
              17.09.2018 22:42

              Если есть маленькое число, идентифицирующее файловый дескриптор, должна существовать структура, указывающая на имя файла и его атрибуты.
              Угу. Вот только самое важное вы пропустили — если число маленькое, то это, скорее всего, индекс в массиве. И массив копируется, когда вы делаете fork. И количество ссылок пересчитывается.

              В общем совершается довольно много работы, которую можно было бы и не делать, если бы у «копии» процесса все файлы бы закрывались. Но так — этим было бы сложнее пользоваться…


  1. maquefel
    17.09.2018 08:47
    +2

    image


    1. nfw
      17.09.2018 10:51
      +1

      http://turnoff.us/geek/dont-sigkill/
      Вы мне глаза решили сломать?


  1. lega
    17.09.2018 10:01

    Не указана важная «фича», exec не запускает новый процесс, он запускает приложение в текущем процессе, в итоге для того что-бы запустить новый процесс сначала нужно сделать fork и после — exec.

    Отсюда есть проблема — т.к. fork делает дублирование текущего процесса, происходит проверка есть ли свободная памяти под этот форк (в 100% размера от текщего процесса), в итоге процесс который использует много памяти (>60%) не сможет запустить другое приложение (тот же /bin/cat) по причине не хватки памяти под форк.


    1. Firemoon Автор
      17.09.2018 10:12

      Хм. У меня появилось желание опытным путём проверить истинность этого высказывания. Как только доберусь до ПК, проверю и отпишусь.


    1. fishHook
      17.09.2018 10:24

      А разве copy-on-write не решает эту проблему на корню?


      1. Firemoon Автор
        17.09.2018 10:43

        Вот мне кажется, что решает.


      1. lega
        17.09.2018 11:53

        Решает, но linux все равно возвращает ошибку «не достаточно памяти», т.к. идет проверка есть ли память для всего объема, т.е. проблема в проверке, как минимум у меня на серевере с linux 3.13 это воспроизводится (в свежем linux ожидаю аналогичного).


        1. fishHook
          17.09.2018 11:56

          В таком случае вообще непонятно, а нафига в линуксе есть swap? Может быть у вас просто раздел подкачки не подключен или у него размер крошечный?


        1. 0xd34df00d
          18.09.2018 18:42

          А оверкоммит у вас включён?

          А, про это уже ниже написали.


      1. sh1kel
        18.09.2018 11:05

        зависит от vm_overcommit


    1. Tangeman
      17.09.2018 15:12
      +1

      Это зависит от того, разрешен ли безусловный overcommit (значение 1). Если да, то ядро не будет проверять наличие памяти до того как она реально используется (т.е. в неё реально что-то запишут). По умолчанию значение параметра 0 (использовать эвристику), и в этом случае ядро будет (скорее всего) проверять память с учётом свопа.

      Просто из вредности я проверил это на 4.15 (поставив vm.overcommit_memory=1), без проблем всё работает (как и ожидалось). Ядро 4.15 (Ubuntu 18.04), реальной памяти 2G, свопа нет, процесс запрашивает 1G памяти через calloc() и потом делает fork() 16 раз — без проблем.

      Если посмотреть на процессы, то видно 17 процессов и каждый имеет резидентных чуть более 1G — но реально этот 1G является shared memory. Если же дети начнут писать в эту память — вот тогда начнутся проблемы.


      1. lega
        17.09.2018 16:38

        Просто из вредности я проверил это на 4.15 (поставив vm.overcommit_memory=1), без проблем всё работает
        Да, это опционально, и vm.overcommit_memory=1 может быть не безопасно, но в дефолтном состоянии будет валится.
        Так что не плохо упомянуть «особый» способ запуска новых процессов в статье про процессы.


        1. sh1kel
          18.09.2018 17:47

          Есть приложения (например редис), которые могут занимать десятки ГБ памяти и любят форкаться (для сохранения данных на диск, в случае редиса в примере), и им необходим безусловный оверкоммит, хотя по факту там за счет cow перерасход памяти будет совсем небольшой, но вот виртуальной памяти это будет занимать х2


          1. lega
            18.09.2018 17:59

            В таком случае обычно форкается мастер процесс, который использует минимум памяти, а работой уже занимаются дочерние ворекры. При таком подходе этой проблемы нет.


  1. im_stD
    17.09.2018 10:25

    Здравствуйте. Спасибо за статью.
    Обьясните пожалуйста, Вы или кто-то другой, более развернуто, что такое ядро? То есть обозначьте его рамки что-ли. Его везде описывают как некую абстракцию, а хочется чуть больше конкретики, и простым языком.

    И ещё один вопрос:

    void child_sm_kill() { wait(NULL); } 
    
    void SendMessage(char *chat_id, char *send_text, int cod) 
    {
        pid_t smpid;  
        signal(SIGCHLD, child_sm_kill);  
        smpid = fork();
        if(smpid == 0) 
         { 
           char json_str[LENJSONSEND] = {0,}; 
           char str[BREADSIZE] = {0,}; 
           
           if(cod == 0) // strat
            {
               ...
    

    Функция SendMessage вызывается время от времени, выполняет свою работу и завершается.

    Скажите, правильно ли я убиваю зомби?
    signal(SIGCHLD, child_sm_kill); ? void child_sm_kill() { wait(NULL); }

    Заранее спасибо.


    1. Firemoon Автор
      17.09.2018 12:55

      Вряд ли я отвечу Вам как-то более подробно, что такое ядро. В моём понимании, ядро — это просто большая программа и ничего более. А рамки… Про привилегированный и непривилегированный режим знаете?

      Функция SendMessage вызывается время от времени, выполняет свою работу и завершается.

      Во-первых, использование signal(2) не рекомендуется, даже ман-страница об этом говорит:

      The behavior of signal() varies across UNIX versions, and has also varied historically across different versions of Linux. Avoid its use: use sigaction(2) instead.

      Во-вторых, мне кажется несколько избыточным устанавливать обработчик сигналов КАЖДЫЙ раз, это достаточно сделать один раз при инициализации.

      К самому обработчику вопросов нет, я бы так же сделал.


      1. im_stD
        17.09.2018 15:16

        Спасибо.

        В моём понимании, ядро — это просто большая программа и ничего более.
        Собственно про это и хотел спросить. Мне однажды задали вопрос — «что такое ядро»? — и я не смог дать какого-либо ответа, кроме похожего на Ваш. Углубление в режимы (привилегированный и непривилегированный) приводит к ещё большему размыванию понятия. )))
        Просто хотел услышать Ваше мнение и мнение других людей в виде тезиса.

        Во-первых, использование signal(2) не рекомендуется, даже ман-страница об этом говорит:
        Приму к сведению.

        Во-вторых, мне кажется несколько избыточным устанавливать обработчик сигналов КАЖДЫЙ раз, это достаточно сделать один раз при инициализации.
        Достаточно будет поместить его в майн()?


        1. Firemoon Автор
          17.09.2018 15:27

          Достаточно будет поместить его в майн()?

          Зависит от того, как вы написали программу. Если инициализация программы происходит в main() — то да, самое место. Если где-то ещё — то лучше вставить его туда, где логически ему самое место.


          1. im_stD
            17.09.2018 15:47

            Спасибо.


          1. im_stD
            17.09.2018 16:06

            Я немного недопонимаю. Если у меня несколько разных функций вызывающих форки…

            void child_sm_kill() { wait(NULL); } 
            void child_ui_kill() { wait(NULL); } 
            
            void SendMessage(char *chat_id, char *send_text, int cod) 
            {
                pid_t smpid;  
                signal(SIGCHLD, child_sm_kill);  
                smpid = fork();
                if(smpid == 0) 
                 { 
                   ...
            
            void update_instag(char *chat_id) 
            { 
              pid_t geekfork;
              signal(SIGCHLD, child_ui_kill);
              geekfork = fork();
              if(geekfork == 0) 
               {
                  ...
            
            int main() 
            {   
               ...
            


            … то как быть? Сделать в main() один вызов сигнала для обоих функций?


            1. khim
              17.09.2018 16:15
              +1

              Не очень понимаю чего вы не понимаете. Вы ж не можете на один сигнал два обработчика поставить!

              А процесс может завершаться сколько угодно времени (например если NFS-сервер «выйдет погулять»).

              Так что если у вас несколько обработчиков, то это всё равно надёжно работать не будет, а если один — то почему бы его в main и не проинициализировать?


              1. im_stD
                17.09.2018 16:40

                Как бы Вы сделали? Применительно к выше указанному коду.


                1. mayorovp
                  17.09.2018 16:55

                  Применительно к указанному вами коду, у вас обработчики абсолютно одинаковые и их можно заменить одним. Который и проставлять в main.

                  Если же обработчики будут разные — значит, нужно сохранять значения возвращаемые fork в какой-нибудь структуре данных, а в обработчике сигнала проверять что вам вернула wait и принимать на основе этого нужное решение.


                  1. im_stD
                    17.09.2018 17:08

                    Спасибо.
                    Если обработчик будет один, будет ли он правильно работать (подчищать зомби) для обоих функций, или нужно обязательно писать два разных обработчика?


                    1. khim
                      17.09.2018 17:31

                      Если вы напишите два обработчика — то что вы с ними будете делать? Ещё раз: вы можете вызвать signal(2) (или, лучше, sigaction(2)) хоть 100 раз — но работать-то будет только обрабочик, поставленный последним!

                      Потому обработчик должен быть один… а дальше уже всё, что писал mayorovp. Да, конечно, «чистый» wait — для «зачистки зомби» достаточен, а если вам нужно что-то большее, то писать два обработчика всё равно бессмысленно, так как использовать-то можно безопасно только один! Нужно как-то в рамках вот этого одного всё разруливать…


                      1. im_stD
                        17.09.2018 17:48

                        Спасибо.
                        Мне нужно только убийство зомби. Если я оставлю один вызов сигнала в main(), и один обработчик, то этого будет достаточно для убийства зомби обоих функций?

                        До этого момента я думал, что понимаю работу signal и wait, оказывается нет.


                        1. khim
                          17.09.2018 20:04

                          До этого момента я думал, что понимаю работу signal и wait, оказывается нет.
                          У меня есть ощущение, что вы всей картины не видите.

                          То, что вы изобразили — это то, что называется code smell — то есть код, который не то, что на 100% неверен, но, скорее, код, который скорее всего неверен — потому что вы не смотрите на всю картину с достаточно большой высоты.

                          То есть начнём сначала: зачем вы вообще вешаете обработчик на SIGCHLD? Если вам нужно просто запустить ребёнка и дождаться пока он отработает — то никакие сигналы вам не нужны! Просто вызываете waitpid(2) и ждёте, пока процесс завершится.

                          Если же вы устраиваете возню с сигналами — то это значит, что вы хотите, чтобы программа работала параллельно со своим ребёнком.

                          А тогда как вы обеспечите, что ребёнок умрёт и будет «подметён» обрабочиком SIGCHLD, который вы установили до вызова fork(2), до того, как другая функция с другим обработчиком вызовется? Даже если ребёнок сообщает родителю о том, что он завершил работу — я вам сейчас 2-3 сценария могу нарисовать, когда от завершения функции main до завершения процесса будет полчаса проходить при корректно написанном коде!

                          Да, можно навернуть какие-то локи, семафоры и как-то «разрулить» эту ситуацию… но зачем? В 99% программ проще иметь один обработчик SIGCHLD, который будет «обслуживать» все форки. Ибо, как я уже сказал — этот обрабочик это глобальный ресурс (когда-то давно в Линуксе можно было повесить этот обработчик на поток, но потом кто-то огрел Линуса талмудом с распечаткой POSIX-стандарта по башке и это стало невозможно)!


                          1. im_stD
                            17.09.2018 21:11

                            У меня есть ощущение, что вы всей картины не видите.

                            Вы правы.

                            это значит, что вы хотите, чтобы программа работала параллельно со своим ребёнком.

                            Да, нужно чтоб программа работала независимо от «детей». Функция маин() крутится в цикле и время от времени вызывает функции с форками.

                            А тогда как вы обеспечите, что ребёнок умрёт и будет «подметён» обрабочиком SIGCHLD, который вы установили до вызова fork(2), до того, как другая функция с другим обработчиком вызовется?

                            Мыслил исходя из того, что каждый форк делает копию всей программы.

                            Сделал так:

                            void ckill_all_childl() { wait(NULL); } 
                            
                            void SendMessage(char *chat_id, char *send_text, int cod) 
                            {
                                pid_t smpid;  
                                smpid = fork();
                                if(smpid == 0) 
                                 { 
                                   ...
                            
                            void update_instag(char *chat_id) 
                            { 
                              pid_t geekfork;
                              geekfork = fork();
                              if(geekfork == 0) 
                               {
                                  ...
                            
                            int main() 
                            {  
                              signal(SIGCHLD, kill_all_child);   
                               ...
                            


                            1. khim
                              17.09.2018 22:50

                              Мыслил исходя из того, что каждый форк делает копию всей программы.
                              Ребёнок — да, копия… но оригинал-то не дублируется!

                              Сделал так:
                              Да — так оно разумнее будет. И кода меньше.


                        1. lorc
                          17.09.2018 20:09

                          Сигналы — вообще страшная штука. Практически, как прерывания. Могут прийти тогда, когда вы их совершенно не ожидаете (и когда не ожидает рантайм), например во время работы malloc() или во время pthread_mutex_lock(). Поэтому и не рекомендуют использовать signal (2), а в особо сложных случаях — рекомендуют обрабатывать сигналы синхронно, через sigwait (3)/sigwaitinfo(2)


                          1. khim
                            17.09.2018 20:30

                            С сигналами вообще много мороки если вы не всё программу пишете, а используете чужие библиотеки. Так-то удобнее всего через signalfd, но, увы, для него требуются те ещё пляски с бубном.


        1. lorc
          17.09.2018 20:00

          Мне однажды задали вопрос — «что такое ядро»? — и я не смог дать какого-либо ответа, кроме похожего на Ваш.


          Зависит от того что хочет услышать вопрошающий. Еще круче спросить «что такое ОС»?

          Особенно, принимая во внимание существование всяких baremetal OS (типа freertos или minios), микроядер и попытки запихнуть веб-сервер прямо в linux kernel.


          1. im_stD
            17.09.2018 21:13

            Что бы Вы ответили?


            1. lorc
              17.09.2018 21:27

              Зависит от того кто и когда спрашивает. Незнакомому мужику на остановке я бы сказал одно, жене — другое, интервьюеру на собеседовании — третье.

              Вообще наиболее полный ответ, который я могу дать звучит приблизительно так: «ОС — понятие довольно размытое. В большинстве случаев под этим словом подразумевают минимальных набор системных программ, который позволит пользователю запускать их прикладные приложения и таким образом получать какую-то пользу от использования компьютера». Можно еще ввернуть что-то про управление и разделение ресурсов (процессор, память, сеть, диск, батарея и другая периферия) и абстрагирование от железа, но все равно совершенно точное определение дать не получится потому что практически всегда можно будет привести контрпример.


              1. im_stD
                17.09.2018 21:55

                Спасибо. Возьму на вооружение.


          1. mayorovp
            18.09.2018 09:44

            и попытки запихнуть веб-сервер прямо в linux kernel.

            В другой ОС эта попытка даже оказалась успешной...


    1. Tangeman
      17.09.2018 22:44

      Честно говоря, в вашем конкретном случае, раз уж неважно как завершится child, вам вообще не нужно обрабатывать сигналы. Просто делаете (в начале main()):

      signal(SIGCHLD, SIG_IGN);


      и забываете про зомби как страшный сон — их просто не будет. Для этого простейшего случая использование signal() вместо sigaction() вполне адекватно.

      wait() нужно только если вам важен код завершения процесса или если нужно дождаться завершения оного.


      1. im_stD
        18.09.2018 01:57

        Да, действительно этого достаточно, и даже в википедии про это написано.) Спасибо.


        1. im_stD
          18.09.2018 03:15

          Обращаюсь и к Вам, и к khim. Правильно ли я понимаю работу signal()?
          Функция signal() вызванная единожды из main() висит где-то в памяти на протяжении всего времени жизни приложения и ожидает сигналов. То есть функция signal() это что-то вроде какого-то отдельного процесса? Или это что-то похожее на обработчик прерываний в микроконтроллерах?

          И с параметрами поясните пожалуйста. Первый параметр — это ожидаемый сигнал, второй параметр — это то, что нужно сделать при поступлении сигнала.
          То есть, вот это — signal(SIGCHLD, SIG_IGN) нужно понимать как — signal(при получении сигнала от ребёнка, игнорировать сигнал), что значит игнорировать? Типа пошёл ты к чёрту, плевать я на тебя хотел. ))


          1. mayorovp
            18.09.2018 09:49

            Это не просто "похоже", сигнал — это и есть аналог прерывания. Только прерывания идут из "железа" в ядро, а сигналы — из ядра в прикладную программу.


            1. im_stD
              18.09.2018 10:51

              Спасибо, я очень надеялся это услышать. Офигенно круто.


  1. amarao
    17.09.2018 11:25

    Это всё работает пока мы не начинаем рассматривать поведение процесса в состоянии TASK_UNINTERRUPTIBLE. Для однопоточного приложения всё просто — процесс застыл в IO (или чём-то подобном) и его нельзя убить.

    Но!

    Как только приложение становится многопоточным и один из его тредов застревает в этом состоянии, то ситуация становится creepy.

    Во-первых, все потоки работают нормально (кроме залипшего). Во-вторых, попытка сделать SIG_STOP приводит к тому, что ядро не возвращает управление из обработчика сигнала (и SIG_CONT не работает). В третьих, выход из треда переводит процесс в состояние (которое я не могу толком описать).

    Короче, всё хорошо, пока не случается TASK_UNINTERRUPTIBLE (D+ в ps'е).


  1. domix32
    17.09.2018 12:44

    Отличная статья. Жду подобную же статью для процессов в fuchsia


  1. Tangeman
    17.09.2018 14:34
    +1

    Вся информация о завершении процесса влезает в тип данных int.

    На самом деле это не совсем так — можно получить несколько больше информации о завершении процесса, если использовать waitid() и потом изучить данные из siginfo_t. Это тоже далеко не всё (есть ещё taskstats), хотя в большинстве случаев это мало кому нужно.


  1. maydjin
    18.09.2018 11:58

    Хорошая статья. Но, не раскрыта тема потоков и примитивов синхронизации.


    Например — что будет если ждать в дочернем процессе на cv созданной в родительском? Какие потоки наследуются дочерним процессом? Как с этим жить, какие коллбэки для этого предусмотренны в posix?