original in it: Leonardo Giordani
it to en: Leonardo Giordani
en to ru Kolobynin Alexey
Студент факультета телекоммуникаций Миланского политехнического института, работает сетевым администратором, интересуется программированием (в основном на ассемблере и C/C++). C 1999 года работает исключительно с Linux/Unix.
Для реализации чередования выполнения программ необходимо произвести сильное усложнение операционной системы. Чтобы избежать конфликтов между выполняемыми программами, необходимо хранить в памяти вместе с программами и всю информацию нужную для их выполнения.
Перед тем как разбираться, что происходит внутри нашего Linux, определимся с технической терминологией: пусть дана выполняющаяся ПРОГРАММА, в данный момент времени КОД это набор инструкций, из которых она состоит, ОБЛАСТЬ ПАМЯТИ это часть машинной памяти, занятой ее данными, СОСТОЯНИЕ ПРОЦЕССОРА значения параметров микропроцессора, такие как флаги или Счетчик Команд (адрес следующей инструкции для выполнения).
Определим понятие ВЫПОЛНЯЮЩАЯСЯ ПРОГРАММА как совокупность КОДА, ОБЛАСТИ ПАМЯТИ и СОСТОЯНИЯ ПРОЦЕССОРА. Если в какое-то время работы машины сохранить эту информацию и заменить ее на тот же набор другой выполняющейся программы, то выполнение последней будет продолжено с точки, где она была остановлена ранее: проделывая эту процедуру поочередно для первой и второй программы, мы получим ранее описанное чередование выполнения программ. Термин ПРОЦЕСС (или ЗАДАЧА) используется для обозначения выполняющейся программы.
Рассмотрим, что происходит с машиной, о которой мы говорили во введении: в каждый момент времени выполняется только одна задача (есть только один микропроцессор, и он не может делать два дела одновременно), и машина исполняет часть ее кода; после определенного промежутка времени, называемого КВАНТОМ, выполняющийся процесс останавливается, его информация сохраняется и заменяется на ту же информацию другого процесса, чей код будет выполняться в следующий квант времени и т.д. Это то, что мы называем многозадачностью.
Как было отмечено во введении, многозадачность порождает набор проблем, решение многих из них нетривиально, например управление очередью остановленных процессов (ПЛАНИРОВКА). Тем не менее, они должны быть решены в архитектуре каждой операционной системы. Возможно, этим проблемам будет посвящена одна из последующих статей, может быть с представлением некоторых частей кода ядра Linux.
Давайте узнаем кое-что о процессах, выполняющихся на нашей машине. Команда, которая даст нам нужную информацию, называется ps(1) - это сокращение от "process status" (англ. состояние процесса). Открыв обычную командную строку и введя команду ps, мы получим что-то вроде
PID TTY TIME CMD 2241 ttyp4 00:00:00 bash 2346 ttyp4 00:00:00 ps
Я говорил, что данный список не полный, однако давайте на минуту здесь остановимся: ps выдал нам список процессов, выполняющихся на текущем терминале. Мы видим в последнем столбце имя, при помощи которого был запущен процесс (например "mozilla" для браузера Mozilla или "gcc" для GNU Compiler Collection). "ps" появляется в этом списке, так как эта команда выполнялась, когда выводился список выполняющихся процессов. Другой выведенный процесс это Bourne Again Shell оболочка, работающая на моих терминалах.
Пропустим (пока) информацию в столбцах TIME и TTY и рассмотрим PID Process IDentifier (англ. идентификатор процесса). pid уникальное положительное число, которое присваивается каждому выполняющемуся процессу. Если выполнение процесса завершилось, его pid может быть использован заново, однако гарантируется, что во время выполнения процесса, его pid остается неизменным. Из всего этого следует, что вывод, который каждый из вас будет получать при выполнении ps, может отличаться от приведенного выше. Чтобы удостоверится, что я говорю правду, откроем еще одну оболочку, не закрывая первую, и запустим ps: теперь команда выдаст нам тот же список процессов, однако с другими номерами pid, что свидетельствует о том, что это разные процессы, хотя программы одни и те же.
Мы можем также получить список всех процессов, запущенных на нашей машине: страница man говорит, что ключ -e обозначает "выбрать все процессы". Введем "ps -e" в терминале, и ps выведет нам длинный список, отформатированный как выше. Чтобы нам было удобнее анализировать этот список, можем перенаправить вывод в файл ps.log:
ps -e > ps.log
Теперь мы можем посмотреть этот файл, открыв его в любимом редакторе (или просто командой less). Как говорилось в начале статьи, число выполняющихся процессов больше чем мы могли ожидать. Теперь мы видим, что список содержит не только процессы, запущенные нами (через командную строку или графическую оболочку), но и множество других, некоторые из которых имеют странные имена. Количество и состав процессов в списке зависит от конфигурации вашей системы, однако есть несколько процессов, присутствующих на всех машинах. Во-первых, вне зависимости от конфигурации процесс с pid равным 1 это всегда "init", прародитель всех процессов. Он имеет первый pid, так как это первый процесс, запускаемый операционной системой. Также мы можем легко заметить наличие множества процессов с именами, заканчивающимися на "d" это так называемые "демоны" одни из самых важных процессов в системе. Мы в подробностях изучим init и демонов в одной из следующих статей.
Теперь, когда мы имеем представление о процессе и его важной роли в нашей операционной системе, начнем писать многозадачный код. От простого одновременного выполнения процессов мы перейдем к новой проблеме: как организовать связь между параллельными процессами и их синхронизацию. Мы найдем два элегантных решения этих проблем: сообщения и семафоры, однако последние будут глубже рассмотрены в будущей статье, посвященной потокам. После сообщений, у нас будет время, чтобы начать писать нашу программу, основанную на этих идеях.
Стандартная библиотека C (libc, реализованная в Linux в glibc), использует возможности многозадачности Unix System V. Unix System V (далее SysV) коммерческая реализация Unix, породившая одно из двух самых важных семейтв Unix, второе BSD Unix.
В libc тип pid_t определен как целое, способное вместить в себе pid. Впредь мы будем использовать этот тип для работы с pid, однако это нужно только для ясности: использование целого типа дало бы тот же результат.
Рассмотрим функцию, которая сообщает нам pid процесса, содержащего нашу программу
pid_t getpid (void)
(она определена вместе с pid_t в unistd.h и sys/types.h) и напишем программу, которая выведет в стандартный вывод свой pid. При помощи любого редактора напишите следующий код
#include <unistd.h> #include <sys/types.h> #include <stdio.h> int main() { pid_t pid; pid = getpid(); printf("pid присвоенный процессу - %d\n", pid); return 0; }Сохраните программу в print_pid.c и скомпилируйте ее.
gcc -Wall -o print_pid print_pid.cКоманда создаст исполняемый файл print_pid. Я напоминаю, что текущая директория не содержится в path, поэтому необходимо запустить программу как "./print_pid". Запустив программу, мы не получим ничего сногсшибательного: она выведет нам положительное число, и, если продолжать запускать ее, вы увидите, что это число будет постоянно увеличиваться на единицу; хотя это может быть и не так, потому что в перерыве между запусками может быть создан другой процесс. Попробуйте, например, выполнить ps между двумя запусками print_pid...
Теперь время научится создавать процессы, но сначала я скажу несколько слов о том, что в действительности происходит при этом. Когда программа (содержащаяся в процессе A) создает новый процесс (B), они оба идентичны, то есть у них одинаковый код, их память наполнена одинаковыми данными (однако области различны) и имеют одинаковое состояние процессора. С этого момента они могут выполнять различные участки кода, например, в зависимости от ввода пользователя или некоторых произвольных данных. Процесс A "родительский процесс", а B "дочерний". Теперь мы можем лучше понять название "прародитель всех процессов", которое мы дали init. Вот функция, которая создает новый процесс
pid_t fork(void)При ее вызове происходит разветвление выполнения процесса, отчего и происходит название функции (to fork, англ. ветвиться). Число, которое она возвращает это pid, однако тут надо обратить кое на что внимание. Мы говорили, что текущий процесс дублируется в родительском и дочернем, которые будут выполняться, чередуясь с другими выполняющимися процессами, производя различные действия. Однако какой процесс будет выполняться сразу после создания копии: родительский или дочерний? Ну, ответ прост: один из двух. Решение, какой процесс должен выполняться, принимается частью операционной системы, которая называется планировщиком, и она не принимает во внимание, является процесс родительским или дочерним, работая по алгоритму, основанному на других параметрах.
Как бы то ни было, но нам важно знать какой процесс выполняется, так как код у них одинаковый. Оба процесса содержат коды, как для родительские, так и для дочерние, однако оба они должны выполнить только свой набор кодов. Чтобы прояснить это, взглянем на алгоритм:
- РАЗВЕТВИТЬ - ЕСЛИ ТЫ ДОЧЕРНИЙ ПРОЦЕСС ВЫПОЛНИТЬ (...) - ЕСЛИ ТЫ РОДИТЕЛЬСКИЙ ПРОЦЕСС ВЫПОЛНИТЬ (...)который представляет собой код нашей программы, написанный на некотором метаязыке. Откроем тайну: функция fork возвращает '0' в дочерний процесс и pid дочернего процесса в родительский. Поэтому достаточно сверить возвращенный pid с нулем, и мы будем знать, какой процесс выполняет этот код. На языке C мы получим
int main() { pid_t pid; pid = fork(); if (pid == 0) { КОД ДОЧЕРНЕГО ПРОЦЕССА } КОД РОДИТЕЛЬСКОГО ПРОЦЕССА }Теперь напишем первый настоящий пример многозадачного кода: вы можете сохранить его в файле fork_demo.c и скомпилировать как ранее. Я поместил номера строк исключительно для ясности. Программа сделает разветвление и оба процесса: родительский и дочерний, выведут кое-что на экран. В результате то, что мы увидим, будет чередованием этих выводов (если все будет правильно).
(01) #include <unistd.h> (02) #include <sys/types.h> (03) #include <stdio.h> (04) int main() (05) { (05) pid_t pid; (06) int i; (07) pid = fork(); (08) if (pid == 0){ (09) for (i = 0; i < 8; i++){ (10) printf("-ДОЧЕРНИЙ-\n"); (11) } (12) return(0); (13) } (14) for (i = 0; i < 8; i++){ (15) printf("+РОДИТЕЛЬСКИЙ+\n"); (16) } (17) return(0); (18) }
Строки (01)(03) содержат включения заголовочных файлов необходимых библиотек
(стандартный ввод/вывод, многозадачность).
Функция main (как обычно в GNU) возвращает целое, которое равно нулю, если
все прошло без ошибок и код ошибки, если что-то случилось не то. Давайте пока будем
считать, что все выполняется без ошибок (мы добавим обработку ошибок, когда
уясним основные идеи). Далее, мы определяем переменную для pid (05) и целое для
счетчика в циклах. Типы этих переменных, как замечалось ранее, одинаковы, однако тут
они указаны различными для ясности.
В строке (07) мы вызываем функцию fork, которая возвратит нуль в программу, выполняющуюся
в дочернем процессе, и pid дочернего процесса в родительском; проверка
производится в строке (08). Теперь код строк (09)(13) будет исполнен в дочернем
процессе, а оставшийся код (14)(16) в родительском.
Эти части кода просто выводят 8 раз в стандартный вывод слово "-ДОЧЕРНИЙ-" или
"+РОДИТЕЛЬСКИЙ+" в зависимости от того, какой процесс выполняется, а затем
завершают выполнение, возвращая 0. Последнее по-настоящему важно, так
как без этого "return" дочерний процесс после завершения цикла будет
выполнять далее код родительского (попробуйте, это не повредит вашей машине,
просто произойдет то, чего мы не хотим). Подобные ошибки очень сложно будет обнаружить, так
как выполнение многозадачных программ (особенно сложных) дает различные результаты при каждом выполнении,
отлаживать их пользуясь результатами просто невозможно.
Возможно, вы будете не удовлетворены выполнением программы: я не могу утверждать, что результатом будет смесь из двух строк, все зависит от скорости выполнения такого короткого цикла. Возможно, на выходе вы получите последовательность строк "+РОДИТЕЛЬСКИЙ+", а затем строк "-ДОЧЕРНИЙ-" или наоборот. Тогда попробуйте еще несколько раз выполнить программу, результат может поменяться.
Вставляя задержку случайной длины перед каждым вызовом prinf, мы сможем нагляднее увидеть эффект многозадачности: мы сделаем это при помощи функций sleep и rand.
sleep(rand()%4)это заставит программу "заснуть" на случайное число секунд: от 0 до 3 (% возвращает остаток от целочисленного деления). Теперь наш код выглядит так
(09) for (i = 0; i < 8; i++){ (->) sleep (rand()%4); (10) printf("-ДОЧЕРНИЙ-\n"); (11) }то же сделаем и с кодом родительского процесса. Сохраним программу в fork_demo2.c, скомпилируем и выполним ее. Теперь она выполняется медленнее, зато мы заметим отличие в порядке вывода:
[leo@mobile ipc2]$ ./fork_demo2 -ДОЧЕРНИЙ- +РОДИТЕЛЬСКИЙ+ +РОДИТЕЛЬСКИЙ+ -ДОЧЕРНИЙ- -ДОЧЕРНИЙ- +РОДИТЕЛЬСКИЙ+ +РОДИТЕЛЬСКИЙ+ -ДОЧЕРНИЙ- -ДОЧЕРНИЙ- +РОДИТЕЛЬСКИЙ+ +РОДИТЕЛЬСКИЙ+ -ДОЧЕРНИЙ- -ДОЧЕРНИЙ- -ДОЧЕРНИЙ- +РОДИТЕЛЬСКИЙ+ +РОДИТЕЛЬСКИЙ+ [leo@mobile ipc2]$
Теперь рассмотрим проблемы, которые встали перед нами сейчас: мы можем создать несколько дочерних процессов данного родительского, так чтобы они выполняли операции, отличные от операций родительского процесса, параллельно. Часто родительскому процессу необходимо обмениваться информацией с дочерними или хотя бы синхронизироваться с ними, чтобы выполнять операции в нужное время. Первый способ синхронизации процессов функция wait
pid_t waitpid (pid_t PID, int *STATUS_PTR, int OPTIONS)где PID это PID ожидаемого процесса, STATUS_PTR указатель на целое, которое будет содержать статус дочернего процесса (NULL, если эта информация не нужна), а OPTIONS это набор опций, на которые мы сейчас не будем обращать внимание. Вот пример программы, где родительский процесс создает дочерний и ждет его завершения
#include <unistd.h> #include <sys/types.h> #include <stdio.h> int main() { pid_t pid; int i; pid = fork(); if (pid == 0){ for (i = 0; i < 14; i++){ sleep (rand()%4); printf("-ДОЧЕРНИЙ-\n"); } return 0; } sleep (rand()%4); printf("+РОДИТЕЛЬСКИЙ+ Ожидаю завершения выполнения дочернего процесса...\n"); waitpid (pid, NULL, 0); printf("+РОДИТЕЛЬСКИЙ+ ...завершен\n"); return 0; }Функция sleep введена в код родительского процесса, чтобы сделать различными результаты выполнения программы. Сохраним код в fork_demo3.c, скомпилируем его и выполним. Мы только что написали наше первое многозадачное синхронизированное приложение!
В следующей статье мы узнаем больше о синхронизации и взаимодействии между процессами. А сейчас напишите несколько программ, используя описанные функции, и пришлите их мне, чтобы я мог использовать их для демонстрации хороших решений и ошибок. Присылайте мне и .c файл с комментариями, и небольшой текстовый файл с описанием программы, свое имя и адрес электронной почты. Удачно поработать!
На русском языке