Процессы, задачи, потоки и нити

Виктор Хименко Журнал "Мир ПК", #06/2000

Процессы в системе

Рассказ о жизни процессов естественно начать с самого начала - с их появления на свет. Так вот, процессы размножаются... почкованием: системный вызов Linux, создающий новый процесс, называется clone, а дочерний процесс представляет собой почти точную копию родительского. Только далее он выполняет назначенную ему функцию, а исходный процесс - то, что написано в программе после вызова clone. Потом отличий может стать больше, так что пути-дороги процессов способны разойтись достаточно далеко. Но если нам нужно этому воспрепятствовать, вызов clone позволит задать флаги, указывающие, что порожденный процесс будет иметь со своим предком общие:

Нить и задача

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

Помимо процессов описанного выше вида бывают еще "ущербные", порождаемые с помощью функции kernel_thread для внутренних системных нужд. У них нет параметров командной строки, как правило, они не имеют открытых файлов и т. д. Поскольку, несмотря на свою ущербность, эти процессы все равно фигурируют в списке задач, в литературе иногда различают полноценные процессы, порожденные из "пространства пользователя" (userspace), и задачи, т. е. все процессы, включая внутренние процессы ядра.

Процесс и программа

Вы скажете: все это замечательно, но если новый процесс - всегда копия существующего, то каким образом в системе ухитряются работать разные программы? И откуда берется самая первая из них?

Процессы, выполняющие разные программы, образуются благодаря применению имеющихся в стандартной библиотеке Unix функций "семейства exec": execl, execlp, execle, execv, execve, execvp. Эти функции отличаются форматом вызова, но в конечном итоге делают одну и ту же вещь: замещают внутри текущего процесса исполняемый код на код, содержащийся в указанном файле. Файл может быть не только двоичным исполняемым файлом Linux, но и скриптом командного интерпретатора, и двоичным файлом другого формата (например, классом java, исполняемым файлом DOS). В последнем случае способ его обработки определяется настраиваемым модулем ядра под названием binfmt_misc.

Таким образом, операция запуска программы, которая в DOS и Windows выполняется как единое целое, в Linux (и в Unix вообще) разделена на две: сначала производится запуск, а потом определяется, какая программа будет работать. Есть ли в этом смысл и не слишком ли велики накладные расходы? Ведь создание копии процесса предполагает копирование весьма значительного объема информации.

Смысл в данном подходе определенно есть. Очень часто программа должна совершить некоторые действия еще до того, как начнется собственно ее выполнение. Скажем, в разбиравшемся выше примере мы запускали две программы, передающие друг другу данные через неименованный канал. Такие каналы создаются системным вызовом pipe; он возвращает пару файловых дескрипторов, с которыми в нашем случае оказались связаны стандартный поток ввода (stdin) программы wc и стандартный поток вывода (stdout) программы dd. Стандартный вывод wc (как, кстати, и стандартный ввод dd, хотя он никак не использовался) связывался с терминалом, а кроме того, требовалось, чтобы командный интерпретатор после выполнения команды не потерял связь с терминалом. Как удалось этого добиться? Да очень просто: сначала были отпочкованы процессы, затем проделаны необходимые манипуляции с дескрипторами файлов и только после этого вызван exec.

Аналогичного результата (как показывает, в частности, пример Windows NT) можно было бы добиться и при запуске программы за один шаг, но более сложным путем. Что же касается накладных расходов, то они чаще всего оказываются пренебрежимо малыми: при создании копии процесса его индивидуальные данные физически никуда не копируются. Вместо этого используется техника, известная под названием copy-on-write (копирование при записи): страницы данных обоих процессов особым образом помечаются, и только тогда, когда один процесс пытается изменить содержимое какой-либо своей страницы, она дублируется.

Листинг 2. Окончание процедуры инициализации ядра Linux

if (execute_command)
execve(execute_command,argv_init, envp_init);
execve("/sbin/init",argv_init,envp_init);
execve("/etc/init",argv_init,envp_init);
execve("/bin/init",argv_init,envp_init);
execve("/bin/sh",argv_init,envp_init);
panic("No init found.  Try passing init= option to kernel.");}

Первый процесс в системе запускается при инициализации ядра. Пожалуй, даже человеку, не умеющему программировать, достаточно будет взглянуть на конец процедуры инициализации ядра Linux (см. листинг 2), чтобы понять, как определяется выполняемая в этом процессе программа: вначале делается попытка "переключить" процесс на файл, указанный в командной строке ядра (есть и такая...), потом на файлы /sbin/init, /etc/init, /bin/init и напоследок на /bin/sh.

Смерть процесса

Рассмотрев рождение процесса, логично будет обсудить и его смерть. Когда процесс закончит работу (нормально или аварийно), он уничтожается, освобождая все использовавшиеся им ресурсы компьютера.

Обратимся еще раз к примеру, рассмотренному выше. Когда мы нажатием <Ctrl>+C принудительно завершили выполнение программ dd и wc, соответствующие процессы были уничтожены, и на экране появилось приглашение командного интерпретатора. Пока программы работали, приглашения не было: интерпретатор находился в состоянии ожидания, в которое перешел, послав специальный системный вызов (в действительности таких вызовов существует несколько: wait, waitpid, wait3, wait4). После окончания работы программ вызов вернул управление интерпретатору, и тот выдал на терминал приглашение.

Если родительский процесс по какой-то причине завершится раньше дочернего, последний становится "сиротой" (orphaned process). "Сироты" автоматически "усыновляются" программой init, выполняющейся в процессе с номером 1, которая и принимает сигнал об их завершении.

Если же потомок уже завершил работу, а предок не готов принять от системы сигнал об этом событии, то потомок не исчезает полностью, а превращается в "зомби" (zombie); в поле Stat такие процессы помечаются буквой Z. Зомби не занимает процессорного времени, но строка в таблице процессов остается, и соответствующие структуры ядра не освобождаются. После завершения родительского процесса "осиротевший" зомби на короткое время также становится потомком init, после чего уже "окончательно умирает".

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

О сигналах

Постойте, но ведь приглашение командного интерпретатора появилось и тогда, когда мы нажали <Ctrl>+Z, хотя программы не заканчивали работу, и, следовательно, вызов wait* не мог вернуть управление! Выдача сообщения Stopped (процесс остановлен) и затем приглашения к вводу была реакцией на сигнал CHLD, который ядро посылает при нажатии <Ctrl>+Z предкам - в данном случае одному предку - процессов, работающих с терминалом (сами процессы получают свой сигнал).

Сигналы посылаются одними процессами другим с помощью команды, которая носит устрашающее название kill, хотя в общем случае никого не убивает. Все зависит от конкретного сигнала, и практически любой сигнал при необходимости может быть процессом проигнорирован. Исключение составляют KILL, который "без разговоров" уничтожает процесс, и STOP, который его аналогичным образом останавливает.

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

Работа с нитями требует особой техники, поскольку одни сигналы должны "доводиться до сведения" всех нитей, а другие - посылаться индивидуально. В Linux 2.2 это делалось путем довольно хитрых манипуляций со специальной нитью, единственным назначением которой было управление другими нитями. В версии 2.4 ядро может следить за нитями за счет нового флага CLONE_PARENT (таким образом, если одна нить породит другую и закончит работу, то порожденная нить не останется "сиротой") и нескольких специальных правил доставки сигналов, так что надобность в специальной нити отпала.

Компьютерная демонология

Демоном (daemon) в Unix (и в Linux) называется процесс, предназначенный для работы в фоновом режиме без терминала и выполняющий какие-либо действия для других процессов (не обязательно на вашей машине). Обычно демоны тихо занимаются своим делом, и вспоминают о них только в случае каких-либо неполадок в их работе: например, демону начинает недоставать места, и он посылает пользователю сообщение об этом, или демон перестает работать, и вам звонит босс с вопросом, почему у него принтер опять не печатает и когда это прекратится...

На многих машинах демоны, обслуживающие процессы других компьютеров, нужны достаточно редко, так что держать их в памяти постоянно загруженными и транжирить на это ресурсы системы нерационально. Для управления их работой был создан супердемон, которого зовут вовсе не Вельзевулом (в компьютерных демонах вообще мало "демонического" - они ближе демонам Максвелла), а куда скромнее - inetd (что, как вы догадались, является сокращением от Internet daemon).

В конфигурационном файле inetd (/etc/inetd.conf) записано, какой демон обслуживает обращения к какому сервису Internet. Обычно с помощью inetd вызываются программы pop3d, imap4d, ftpd, telnetd (предоставляю читателю определить, какие именно сервисы они обслуживают) и некоторые другие. Эти программы не являются постоянно активными, а значит, не могут считаться демонами в строгом смысле слова, но поскольку они порождаются "полноценным" демоном, их все равно так называют.

Продвинутые средства общения

Процессы посылают друг другу сигналы, передают данные через неименованные и именованные каналы, а также "гнезда". Все это замечательно, но как быть, если один процесс должен передавать другому огромные объемы информации и притом быстро (это нужно, например, при воспроизведении видео)? Могут ли процессы, адресные пространства которых строго разделены, каким-либо образом получить в совместное пользование часть памяти? Да, с помощью временных файлов.

Для передачи обширных массивов данных между процессами служит системный вызов mmap, представляющий собой довольно неожиданное применение страничной виртуальной памяти. Он позволяет, грубо говоря, сказать: "я хочу обращаться к такому-то участку такого-то файла как к оперативной памяти". Данные, которые процесс читает из указанной области памяти, по мере надобности считываются из файла, а те, которые он туда пишет, когда-нибудь попадут на диск. Но процесс сам не работает с диском, этим занимается ядро.

Если два процесса обращаются таким образом к одному и тому же участку одного и того же файла, данные будут переданы непосредственно от одного процесса к другому. Конечно, периодически ядро сбрасывает данные на диск. В некоторых случаях это полезно, но когда mmap обеспечивает только общение процессов между собой, обмен с диском лишь замедляет работу. Для процессов, имеющих общего предка, можно использовать флаг MAP_ANONYMOUS, указывающий, что данные не должны попадать в файл (дескриптор файла тогда никак не используется и может быть любым).

Вызов mmap применяется также для "загрузки в память" исполняемых файлов и библиотек, так что если программа использует 25 библиотек общим объемом во много десятков мегабайт, это вовсе не значит, что она и в памяти будет занимать такое же количество мегабайт.

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

Иногда создавать временные файлы нежелательно, поэтому в Linux включены также функции для общения процессов из Unix SVR4 (Unix System V Release 4). Это shmget - создание области памяти для общения процессов, semget - создание семафора, msgget - создание очереди сообщений. В версии 2.4 к ним добавились еще более мощные функции mq_open, shm_open из SUS2 (Single Unix Specification Version 2).

Получение информации о процессах

Для работы с информацией о процессах, которую выводят на терминал программы ps и top, в Linux используется достаточно необычный механизм: особая файловая система procfs. В большинстве дистрибутивов она монтируется при запуске системы как каталог /proc. Данные о процессе с номером 1 (обычно это /sbin/init) содержатся в подкаталоге /proc/1, о процессе с номером 364 - в /proc/364, и т. д. Все файлы, открытые процессом, представлены в виде символических ссылок в каталоге /proc/<pid>/fd, а ссылка на корневой каталог процесса хранится как /proc/<pid>/root.

Со временем у файловой системы procfs появились и другие функции. Например, командой

echo 100000 > /proc/sys/fs/file-max

суперпользователь может определить, что в системе разрешается открыть до 100 000 файлов, а команда

echo 0 > /proc/sys/kernel/cap-bound

отнимет у всех процессов в системе все дополнительные права, т. е. фактически лишит систему понятия "суперпользователь".

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

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

ОБ АВТОРЕ

Виктор Хименко, e-mail: khim@mccme.ru