В этой статье я хотел бы рассказать о том, какой жизненный путь проходят процессы в семействе ОС Linux. В теории и на примерах я рассмотрю как процессы рождаются и умирают, немного расскажу о механике системных вызовов и сигналов.
Данная статья в большей мере рассчитана на новичков в системном программировании и тех, кто просто хочет узнать немного больше о том, как работают процессы в Linux.
Всё написанное ниже справедливо к Debian Linux с ядром 4.15.0.
Содержание
Введение
Системное программное обеспечение взаимодействует с ядром системы посредством специальных функций — системных вызовов. В редких случаях существует альтернативный API, например, procfs или sysfs, выполненные в виде виртуальных файловых систем.
Атрибуты процесса
Процесс в ядре представляется просто как структура с множеством полей (определение структуры можно прочитать здесь).
Но так как статья посвящена системному программированию, а не разработке ядра, то несколько абстрагируемся и просто акцентируем внимание на важных для нас полях процесса:
- Идентификатор процесса (pid)
- Открытые файловые дескрипторы (fd)
- Обработчики сигналов (signal handler)
- Текущий рабочий каталог (cwd)
- Переменные окружения (enivron)
- Код возврата
Жизненный цикл процесса
Рождение процесса
Только один процесс в системе рождается особенным способом —
init
— он порождается непосредственно ядром. Все остальные процессы появляются путём дублирования текущего процесса с помощью системного вызова fork(2)
. После выполнения fork(2)
получаем два практически идентичных процесса за исключением следующих пунктов:
fork(2)
возвращает родителю PID ребёнка, ребёнку возвращается 0;- У ребёнка меняется 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, все параметры передаются в нуль-терминированном массиве;
- l обозначает list, все параметры передаются как
- Далее может следовать буква p, которая обозначает path. Если аргумент
file
начинается с символа, отличного от "/", то указанныйfile
ищется в каталогах, перечисленных в переменной окруженияPATH
- Последней может быть буква e, обозначающая enivron. В таких вызовах последним аргументом идёт нуль-терминированный массив нуль-терминированных строк вида
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 приводит к падению.
Передача 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» за понятные ответы на сложные вопросы.
Они стойко перенесли напавшее на меня вдохновение и напавший на них шквал моих вопросов.
Комментариев нет:
Отправить комментарий