Что происходит с файлами открытыми в процессе когда данный процесс вызывает системный вызов fork
Раньше заголовок темы был «Написание многопоточных программ на PHP». В PHP есть ровно один «нормальный» способ писать приложения, которые используют несколько ядер/процессоров — это fork(). О прикладном использовании системного вызова fork() в языке PHP и расширения pcntl я и расскажу. В качестве примера мы напишем достаточно быструю параллельную реализацию grep (со скоростью работы, аналогичной find . -type f -print0 | xargs -0 -P $NUM_PROCS grep $EXPR ).
Реализация
Реализация этого системного вызова в PHP очень проста:
Что такое системный вызов fork()
Системный вызов fork() в *nix-системах представляет из себя такой системный вызов, который делает полную копию текущего процесса. Системный вызов fork() возвращает своё значение два раза: родитель получает PID потомка, а потомок получает 0. Как ни странно, во многих случаях только этого достаточно для того, чтобы писать приложения, использующие несколько CPU.
Подводные камни при использовании fork()
К сожалению, PHP в конце выполнения скрипта осуществляет вызов деструкторов (в том числе и внутренних деструкторов ресурсов соединений с базой данных). Пример для расширения mysqli:
Вывод программы будет не обязательно таким, как написано. Иногда потомок «успевает» до исполнения процедуры закрытия соединения в родителе и всё работает, как надо.
Боремся с отложенным исполнением функций / деструкторов
На самом деле, проблему с отложенным исполнением можно решить, если вы точно знаете, что хотите. Например, в Си есть функция _exit(), которая выходит, не запуская никаких установленных обработчиков. К сожалению, в PHP такой функции нет, но её поведение можно частично эмулировать с использованием сигналов:
Этого «хака» нам будет достаточно, чтобы соединение с базой оставалось активным для двух PHP-процессов одновременно, хотя лучше, конечно, так на практике не делать :):
Пишем grep
Давайте теперь, для примера, напишем простенькую версию grep, которая будет искать по маске в текущей директории.
Пишем параллельную версию grep
Теперь подумаем, как же можно ускорить данную программу, распараллелив её. Можно легко заметить, что мы можем разделить массив $files (список файлов) на несколько частей и обработать эти части независимо. Причём мы можем так делать во всех случаях, когда у нас есть какой-то большой список задач: просто берем каждый N-ный в соответствующем процессе и обрабатываем его. Поэтому, напишем более-менее общую функцию для этого:
Осталось заменить foreach() на использование нашей функции parallelForeach и добавить обработку ошибок:
Проверим работу нашего грепа на исходном коде PHP 5.3.10:
Работает! Я описал один из часто используемых паттернов при параллельном программировании на PHP — параллельная обработка очереди из задач. Надеюсь, моя статья кому-нибудь поможет перестать бояться писать многопоточные приложения на PHP, если задача допускает такую декомпозицию, как в примере с грепом. Спасибо.
Материал этой статьи ни в коем случае не претендует на свою избыточность. Более подробно о процессах вы можете прочитать в книгах, посвященных программированию под UNIX.
Процессы. Системные вызовы fork() и exec(). Нити.
Процесс в Linux (как и в UNIX) - это программа, которая выполняется в отдельном виртуальном адресном пространстве. Когда пользователь регистрируется в системе, автоматически создается процесс, в котором выполняется оболочка (shell), например, /bin/bash.
В Linux поддерживается классическая схема мультипрограммирования. Linux поддерживает параллельное (или квазипараллельного при наличии только одного процессора) выполнение процессов пользователя. Каждый процесс выполняется в собственном виртуальном адресном пространстве, т.е. процессы защищены друг от друга и крах одного процесса никак не повлияет на другие выполняющиеся процессы и на всю систему в целом. Один процесс не может прочитать что-либо из памяти (или записать в нее) другого процесса без "разрешения" на то другого процесса. Санкционированные взаимодействия между процессами допускаются системой.
Ядро предоставляет системные вызовы для создания новых процессов и для управления порожденными процессами. Любая программа может начать выполняться только если другой процесс ее запустит или произойдет какое-то прерывание (например, прерывание внешнего устройства).
В связи с развитием SMP (Symmetric Multiprocessor Architectures) в ядро Linux был внедрен механизм нитей или потоков управления (threads). Нить - это процесс, который выполняется в виртуальной памяти, используемой вместе с другими нитями процесса, который обладает отдельной виртуальной памятью.
Если интерпретатору (shell) встречается команда, соответствующая выполняемому файлу, интерпретатор выполняет ее, начиная с точки входа (entry point). Для С-программ entry point - это функция main. Запущенная программа тоже может создать процесс, т.е. запустить какую-то программу и ее выполнение тоже начнется с функции main.
Для создания процессов используются два системных вызова: fork() и exec. fork() создает новое адресное пространство, которое полностью идентично адресному пространству основного процесса. После выполнения этого системного вызова мы получаем два абсолютно одинаковых процесса - основной и порожденный. Функция fork() возвращает 0 в порожденном процессе и PID (Process ID - идентификатор порожденного процесса) - в основном. PID - это целое число.
Теперь, когда мы уже создали процесс, мы можем запустить программу с помощью вызова exec. Параметрами функции exec является имя выполняемого файла и, если нужно, параметры, которые будут переданы этой программе. В адресное пространство порожденного с помощью fork() процесса будет загружена новая программа и ее выполнение начнется с точки входа (адрес функции main).
В качестве примера рассмотрим этот фрагмент программы
if (fork()==0) wait(0);
else execl("ls", "ls", 0); /* порожденный процесс */
- Выделяется память для описателя нового процесса в таблице процессов
- Назначается идентификатор процесса PID
- Создается логическая копия процесса, который выполняет fork() - полное копирование содержимого виртуальной памяти родительского процесса, копирование составляющих ядерного статического и динамического контекстов процесса-предка
- Увеличиваются счетчики открытия файлов (порожденный процесс наследует все открытые файлы родительского процесса).
- Возвращается PID в точку возврата из системного вызова в родительском процессе и 0 - в процессе-потомке.
Сигнал - способ информирования процесса ядром о происшествии какого-то события. Если возникает несколько однотипных событий, процессу будет подан только один сигнал. Сигнал означает, что произошло событие, но ядро не сообщает сколько таких событий произошло.
- окончание порожденного процесса (например, из-за системного вызова exit (см. ниже))
- возникновение исключительной ситуации
- сигналы, поступающие от пользователя при нажатии определенных клавиш.
Установить реакцию на поступление сигнала можно с помощью системного вызова signal
func = signal(snum, function);
snum - номер сигнала, а function - адрес функции, которая должна быть выполнена при поступлении указанного сигнала. Возвращаемое значение - адрес функции, которая будет реагировать на поступление сигнала. Вместо function можно указать ноль или единицу. Если был указан ноль, то при поступлении сигнала snum выполнение процесса будет прервано аналогично вызову exit. Если указать единицу, данный сигнал будет проигнорирован, но это возможно не для всех процессов.
С помощью системного вызова kill можно сгенерировать сигналы и передать их другим процессам.
kill(pid, snum);
где pid - идентификатор процесса, а snum - номер сигнала, который будет передан процессу. Обычно kill используется для того, чтобы принудительно завершить ("убить") процесс.
Pid состоит из идентификатора группы процессов и идентификатора процесса в группе. Если вместо pid указать нуль, то сигнал snum будет направлен всем процессам, относящимся к данной группе (понятие группы процессов аналогично группе пользователей). В одну группу включаются процессы, имеющие общего предка, идентификатор группы процесса можно изменить с помощью системного вызова setpgrp. Если вместо pid указать -1, ядро передаст сигнал всем процессам, идентификатор пользователя которых равен идентификатору текущего выполнения процесса, который посылает сигнал.
Сигналы (точнее их номера) описаны в файле singnal.h
Для нормального завершение процесса используется вызов
exit(status);
где status - это целое число, возвращаемое процессу-предку для его информирования о причинах завершения процесса-потомка.
Вызов exit может задаваться в любой точке программы, но может быть и неявным, например при выходе из функции main (при программировании на C) оператор return 0 будет воспринят как системный вызов exit(0);
Команды для управления процессами
Предназначена для вывода информации о выполняемых процессах. Данная команда имеет много параметров, о которых вы можете прочитать в руководстве (man ps). Здесь я опишу лишь наиболее часто используемые мной:
Параметр | Описание |
-a | отобразить все процессы, связанных с терминалом (отображаются процессы всех пользователей) |
-e | отобразить все процессы |
-t список терминалов | отобразить процессы, связанные с терминалами |
-u идентификаторы пользователей | отобразить процессы, связанные с данными идентификаторыми |
-g идентификаторы групп | отобразить процессы, связанные с данными идентификаторыми групп |
-x | отобразить все процессы, не связанные с терминалом |
Например, после ввода команды ps -a вы увидите примерно следующее:
Для вывода информации о конкретном процессе мы можем воспользоваться командой:
Программа top
Предназначена для вывода информации о процессах в реальном времени. Процессы сортируются по максимальному занимаемому процессорному времени, но вы можете изменить порядок сортировки (см. man top). Программа также сообщает о свободных системных ресурсах.
Изменение приоритета процесса - команда nice
nice [-коэффициент понижения] команда [аргумент]
Команда nice выполняет указанную команду с пониженным приоритетом, коэффициент понижения указывается в диапазоне 1..19 (по умолчанию он равен 10). Суперпользователь может повышать приоритет команды, для этого нужно указать отрицательный коэффициент, например --10. Если указать коэффициент больше 19, то он будет рассматриваться как 19.
nohup - игнорирование сигналов прерывания
nohup команда [аргумент]
nohup выполняет запуск команды в режиме игнорирования сигналов. Не игнорируются только сигналы SIGHUP и SIGQUIT.
kill - принудительное завершение процесса
kill [-номер сигнала] PID
где PID - идентификатор процесса, который можно узнать с помощью команды ps.
Команды выполнения процессов в фоновом режиме - jobs, fg, bg
Команда jobs выводит список процессов, которые выполняются в фоновом режиме, fg - переводит процесс в нормальные режим ("на передний план" - foreground), а bg - в фоновый. Запустить программу в фоновом режиме можно с помощью конструкции &
Для порождения процессов в ОС Linux существует два способа. Один из них позволяет полностью заменить другой процесс, без замены среды выполнения. Другим способом можно создать новый процесс с помощью системного вызова fork() . Синтаксис вызова следующий:
pid_t является примитивным типом данных, который определяет идентификатор процесса или группы процессов. При вызове fork() порождается новый процесс (процесс-потомок), который почти идентичен порождающему процессу-родителю. Процесс-потомок наследует следующие признаки родителя:
- сегменты кода, данных и стека программы;
- таблицу файлов, в которой находятся состояния флагов дескрипторов файла, указывающие, читается ли файл или пишется. Кроме того, в таблице файлов содержится текущая позиция указателя записи-чтения;
- рабочий и корневой каталоги;
- реальный и эффективный номер пользователя и номер группы;
- приоритеты процесса (администратор может изменить их через nice );
- контрольный терминал;
- маску сигналов;
- ограничения по ресурсам;
- сведения о среде выполнения;
- разделяемые сегменты памяти.
- идентификатора процесса (PID, PPID);
- израсходованного времени ЦП (оно обнуляется);
- сигналов процесса-родителя, требующих ответа;
- блокированных файлов (record locking).
Процесс-потомок и процесс-родитель получают разные коды возврата после вызова fork() . Процесс-родитель получает идентификатор (PID) потомка. Если это значение будет отрицательным, следовательно при порождении процесса произошла ошибка. Процесс-потомок получает в качестве кода возврата значение 0, если вызов fork() оказался успешным.
Таким образом, можно проверить, был ли создан новый процесс:
Пример порождения процесса через fork() приведен ниже:
Когда потомок вызывает exit() , код возврата передается родителю, который ожидает его, вызывая wait() . WEXITSTATUS() представляет собой макрос, который получает фактический код возврата потомка из вызова wait() .
Функция wait() ждет завершения первого из всех возможных потомков родительского процесса. Иногда необходимо точно определить, какой из потомков должен завершиться. Для этого используется вызов waitpid() с соответствующим PID потомка в качестве аргумента. Еще один момент, на который следует обратить внимание при анализе примера, это то, что и родитель, и потомок используют переменную rv . Это не означает, что переменная разделена между процессами. Каждый процесс содержит собственные копии всех переменных.
Рассмотрим следующий пример:
В этом случае будет создано семь процессов-потомков. Первый вызов fork() создает первого потомка. Как указано выше, процесс наследует положение указателя команд от родительского процесса. Указатель команд содержит адрес следующего оператора программы. Это значит, что после первого вызова fork() указатель команд и родителя, и потомка находится перед вторым вызовом fork() .После второго вызова fork() и родитель, и первый потомок производят потомков второго поколения - в результате образуется четыре процесса. После третьего вызова fork() каждый процесс производит своего потомка, увеличивая общее число процессов до восьми.
У меня есть вот такой короткий пример кода, нам его дали что бы продемонстрировать работу функции fork():
Мне не совсем понятен результат. В итоге у меня 4 раза в терминале пишет Fork-Test Не понимаю, во-первых почему это происходит больше одного раза, ведь я вызваю метод printf("Fork-Test\n"); только один раз, во-вторых, раз уже несколько раз, то почему именно 4? При чем еще и в следующем виде:
смысл мне не понятен. буду благодарна за любые пояснения по поводу fork()
2 ответа 2
Трудность понимания fork состоит в том, что все запускаемые процессы имеют дело с одним и тем же вами написанным кодом.
Когда вы вызвали fork первый раз
То та часть вашей программы, которая расположена после комментария, выполняется уже двумя процессами. Правда, каждый из процессов имеет свое адресное пространство. Вы можете представить это следующим образом
Теперь каждый из процессов встречает на своем пути следующий вызов fork . Поэтому появляется еще два процесса. И все четыре процесса выполняют каждый в своем адресном пространстве предложения
В документации в описании fork написано
Обратите внимание на последнее предложение из цитаты. Это означают, что каждый процесс имеет, грубо говоря, тот же самый исходный код вашей программы, выполнение которого для нового процесса начинается после предложения с вызовом fork .
Процесс после системного вызова fork , раздваивается, у исходного процесса создаётся идентичный потомок-двойник в идентичном состоянии (ну почти). Создавшийся процесс будет занят выполнением того же кода ровно с той же точки, что и исходный процесс.
Различить кто создал, а кто создался, можно по возвращаемому значению fork , поэтому его результат обычно передаётся в if , чтобы эти процессы выполнили какие-то разные вещи, один пошёл в ветку if , другой в ветку else .
Вы же возвращаемое значение игнорируете (сохраняете, но никак не используете), и потому оба процесса продолжают идти по одному и тому же пути. И натыкаются на ещё один вызов fork . И каждый из них раздваивается снова. Получается следующая картина:
После второго форка процессов уже 4. И после этих форков каждый процесс доходит до вызова printf и выводит указанную строку. Каждый процесс делает это сам. Поэтому вывод происходит столько раз, сколько вышло процессов.
Можете убедиться в этом, поместив вывод перед вторым форком (вывод произойдёт дважды) или перед первым (единожды).
Завершаются они независимо друг от друга.
У вас получилось, что процесс P завершился вторым. А нечто, через что вы запускали этот процесс, следило только за процессом P , но не его "клонами" (т. к. у них собственные pid , process ID), поэтому для остальных такого вывода нет.
Основными активными сущностями в системе Linux являются процессы. Каждый процесс выполняет одну программу и изначально получает один поток управления. Иначе говоря, у процесса есть один счетчик команд, который отслеживает следующую исполняемую команду. Linux позволяет процессу создавать дополнительные потоки (после того, как он начинает выполнение).
Linux представляет собой многозадачную систему, так что несколько независимых процессов могут работать одновременно. Более того, у каждого пользователя может быть одновременно несколько активных процессов, так что в большой системе могут одновременно работать cотни и даже тысячи процессов. Фактически на большинстве однопользовательских рабочих станций (даже когда пользователь куда-либо отлучился) работают десятки фоновых процессов, называемых демонами (daemons). Они запускаются при загрузке системы из сценария оболочки.
Типичным демоном является cron. Он просыпается раз в минуту, проверяя, не нужно ли ему что-то сделать. Если у него есть работа, то он ее выполняет, а затем отправляется спать дальше (до следующей проверки).
Этот демон позволяет планировать в системе Linux активность на минуты, часы, дни и даже месяцы вперед. Например, представьте, что пользователю назначено явиться во военкомат в 3 часа дня в следующий вторник. Он может создать запись в базе данных демона cron, чтобы тот просигналил ему, скажем, в 14:30. Когда наступает назначенный день и время, демон cron видит, что у него есть работа, и запускает в назначенное время программу звукового сигнала (в виде нового процесса).
Демон cron также используется для периодического запуска задач, например ежедневного резервного копирования диска в 4 часа ночи или напоминания забывчивым пользователям каждый год за неделю до 31 декабря купить подарки для празднования нового года. Другие демоны управляют входящей и исходящей электронной почтой, очередями принтера, проверяют, достаточно ли еще осталось свободных страниц памяти и т.д. Демоны реализуются в системе Linux довольно просто, так как каждый из них представляет собой отдельный процесс, независимый от всех остальных процессов.
Процессы создаются в операционной системе Linux очень просто. Системный вызов fork создает точную копию исходного процесса, называемого родительским процессом (parent process). Новый процесс называется дочерним процессом (child process). У родительского и у дочернего процессов есть свои собственные (приватные) образы памяти. Если родительский процесс впоследствии изменяет какие-либо свои переменные, то эти изменения остаются невидимыми для дочернего процесса (и наоборот).
Открытые файлы используются родительским и дочерним процессами совместно. Это значит, что если какой-либо файл был открыт в родительском процессе до выполнения системного вызова fork, то он останется открытым в обоих процессах и в дальнейшем. Изменения, произведенные с этим файлом любым из процессов, будут видны другому. Такое поведение является единственно разумным, так как эти изменения будут видны также и любому другому процессу, который тоже откроет этот файл.
Тот факт, что образы памяти, переменные, регистры и все остальное и у родительского процесса, и у дочернего идентичны, приводит к небольшому затруднению: как процессам узнать, который из них должен исполнять родительский код, а который дочерний? Секрет в том, что системный вызов fork возвращает дочернему процессу число 0, а родительскому — отличный от нуля PID (Process IDentifier — идентификатор процесса) дочернего процесса. Оба процесса обычно проверяют возвращаемое значение и действуют соответственно:
pid = fork( ); /* если fork завершился успешно, pid > 0 в родительском процессе */
if (pid < 0) handle_error(); /* fork потерпел неудачу (например, память или какая-
либо таблица переполнена) */
> else if (pid > 0) /* здесь располагается родительский код */
> else /* здесь располагается дочерний код */
>
Если дочерний процесс желает узнать свой PID, то он может воспользоваться системным вызовом getpid. Идентификаторы процессов используются различным образом. Например, когда дочерний процесс завершается, его родитель получает PID только что завершившегося дочернего процесса. Это может быть важно, так как у родительского процесса может быть много дочерних процессов. Поскольку у дочерних процессов также могут быть дочерние процессы, то исходный процесс может создать целое дерево детей, внуков, правнуков и более дальних потомков.
При помощи каналов организуются конвейеры оболочки. Когда оболочка видит строку вроде
sort
то она создает два процесса, sort и head, а также устанавливает между ними канал таким образом, что стандартный поток вывода программы sort соединяется со стандартным потоком ввода программы head. При этом все данные, которые пишет sort, попадают напрямую к head, для чего не требуется временного файла. Если канал переполняется, то система приостанавливает работу sort до тех пор, пока head не удалит из него хоть сколько-нибудь данных.
Процессы также могут общаться и другим способом — при помощи программных прерываний. Один процесс может послать другому так называемый сигнал (signal). Процессы могут сообщить системе, какие действия следует предпринимать, когда придет сигнал. Варианты такие: проигнорировать сигнал, перехватить его, позволить сигналу убить процесс (действие по умолчанию для большинства сигналов). Если процесс выбрал перехват посылаемых ему сигналов, он должен указать процедуру обработки сигналов. Когда сигнал прибывает, управление сразу же передается обработчику. Когда процедура обработки сигнала завершает свою работу, то управление снова передается в то место, в котором оно находилось, когда пришел сигнал (это аналогично обработке аппаратных прерываний ввода-вывода). Процесс может посылать сигналы только членам своей группы процессов (process group), состоящей из его прямого родителя (и других предков), братьев и сестер, а также детей (и прочих потомков). Процесс может также послать сигнал сразу всей своей группе за один системный вызов.
Сигналы используются и для других целей. Например, если процесс выполняет вычисления с плавающей точкой и непреднамеренно делит на 0, то он получает сигнал SIGFPE (Floating-Point Exception SIGnal — сигнал исключения при выполнении операции с плавающей точкой).
Перенаправление ввода/вывода
Практически все операционные системы обладают механизмом перенаправления ввода/вывода. Linux не является исключением из этого правила. Обычно программы вводят текстовые данные с консоли (терминала) и выводят данные на консоль. При вводе под консолью подразумевается клавиатура, а при выводе - дисплей терминала. Клавиатура и дисплей - это, соответственно, стандартный ввод и вывод (stdin и stdout). Любой ввод/вывод можно интерпретировать как ввод из некоторого файла и вывод в файл. Работа с файлами производится через их дескрипторы. Для организации ввода/вывода в UNIX используются три файла: stdin (дескриптор 1), stdout (2) и stderr(3).
Символ > используется для перенаправления стандартного вывода в файл.
Пример:
$ cat > newfile.txt Стандартный ввод команды cat будет перенаправлен в файл newfile.txt, который будет создан после выполнения этой команды. Если файл с этим именем уже существует, то он будет перезаписан. Нажатие Ctrl + D остановит перенаправление и прерывает выполнение команды cat.
Символ < используется для переназначения стандартного ввода команды. Например, при выполнении команды cat > используется для присоединения данных в конец файла (append) стандартного вывода команды. Например, в отличие от случая с символом >, выполнение команды cat >> newfile.txt не перезапишет файл в случае его существования, а добавит данные в его конец.
Читайте также: