Второе издание
Скачать 3.09 Mb.
|
Дескриптор процесса и структура task structure Ядро хранит информацию о всех процессах в двухсвязном списке, который на- зывается task list 3 (список задач). Каждый элемент этого списка является дескрипто- ром процесса и имеет тип структуры s t r u c t task_struct, которая описана в файле i n c l u d e / l i n u x / s c h e d . h . Дескриптор процесса содержит всю информацию об определенном процессе. 2 В ядре реализован системный вызов w a i t 4 ( ) . В операционной системе Linux через библиотеку функций языка С доступны функции w a i t ( ) , w a i t p i d ( ) , w a i t 3 ( ) и w a i t 4 ( ) . Все эти функции возвращают информацию о состоянии завершившегося процесса, хотя в несколько разной семан- тике. 3 Иногда в литературе по построению операционных систем этот список называется t a s k a r r a y (массив задач). Поскольку в ядре Linux используется связанный список, а не статический массив, его называют task l i s t . i 46 Глава 3 Структура t a s k _ s t r u c t — достаточно большая структура данных размером по- рядка 1,7 Кбайт на 32-разрядной машине. Однако этот размер не такой уж большой, учитывая, что в данной структуре содержится вся информация о процессе, которая необходима ядру. Дескриптор процесса содержит данные, которые описывают вы- полняющуюся программу, — открытые файлы, адресное пространство процесса, ожи- дающие на обработку сигналы, состояние процесса и многое другое (рис. 3.1). Выделение дескриптора процесса Память для структуры task_struct выделяется с помощью подсистемы выделе- ния памяти, которая называется слябовый распределитель (slab allocator), для возмож- ности повторного использования объектов и раскрашивания кэша (cache coloring) (см. главу 11, "Управление памятью"). В ядрах до серии 2.6 структура t a s k _ s t r u c t хранилась в конце стека ядра каждого процесса. Это позволяет для аппаратных плат- форм, у которых достаточно мало регистров процессора (как, например, платформа х86), вычислять местоположение дескриптора процесса, только зная значение ре- гистра указателя стека (stack pointer), без использования дополнительных регистров для хранения самого адреса этого местоположения. Так как теперь дескриптор про- цесса создается с помощью слябового распределителя, была введена новая структура thread_info, которая хранится в области дна стека (для платформ, у которых стек растет в сторону уменьшения значения адреса памяти) или в области вершины стека (для платформ, у которых стек растет в сторону увеличения значения адреса памя- ти) 4 (рис. 3.2.). struct task_struct struct task_struct struct task struct Дескриптор процесса unsigned long state; int prio; unsigned long policy; struct task_struct *parent; struct list_head tasks; pid_t pid; Список задач (task list) Рис. З.1. Дескриптор процесса и список задач 4 Причиной создания структуры thread_info было не только наличие аппаратных платформ, обед- ненных регистрами процессора, но и то, что положение этой структуры позволяет достаточно просто рассчитыпать смешения адресов для значений ее нолей при использовании языка ассем- блера. Управление процессами 47 struct task struct Стек ядра процесса current_thread_infо() Начало стека Структура struct thread_infо Наибольшее значение адреса памяти Указатель стека Наименьшее значение адреса Структура thread_infо содержит указатель на дескриптор процесса Структура struct task_struct процесса Рис 3.2. Дескриптор процесса и стек ядра Структура struct thread_info для платформы х86 определена в файле struct thread_info { struct task_struct *task; struct exec_domain *exec_domain; unsigned long flags; unsigned long status; u32 cpu; __s32 preempt_count; mm_segment_t addr_limit; struct restart_block restart_block; unsigned long previous_esp; __u8 supervisorytack[0]; }; Для каждой задачи ее структура thread_info хранится в конце стека ядра этой задачи. Элемент структуры thread_info с именем t a s k является указателем на структуру task_struct этой задачи. Хранение дескриптора процесса Система идентифицирует процессы с помощью уникального значения, которое называется идентификатором процесса (process identification, PID). Идентификатор PID — это целое число, представленное с помощью скрытого типа pid_t 5 , который обыч- но соответствует знаковому целому— int. 5 Скрытый тип (opaque type) — это тип данных, физическое представление которого неизвестно или не существенно. 48 Глава 3 Однако, для обратной совместимости со старыми версиями ОС Unix и Linux мак- симальное значение этого параметра по умолчанию составляет всего лишь 32768 (что соответствует типу данных short int). Ядро хранит значение данного парамет- ра в поле pid дескриптора процесса. Это максимальное значение является важным, потому что оно определяет мак- симальное количество процессов, которые одновременно могут существовать в си- стеме. Хотя значения 32768 и достаточно для офисного компьютера, для больших серверов может потребоваться значительно больше процессов. Чем меньше это зна- чение, тем скорее нумерация процессов будет начинаться сначала, что приводит к нарушению полезного свойства: больший номер процесса соответствует процессу, который запустился позже. Если есть желание нарушить в системе обратную совме- стимость со старыми приложениями, то администратор может увеличить это макси- мальное значение во время работы системы с помощью записи его в файл /ргос/ sys/kernel/pid_max. Обычно в ядре на задачи ссылаются непосредственно с помощью указателя на их структуры t a s k _ s t r u c t . И действительно, большая часть кода ядра, работаю- щего с процессами, работает прямо со структурами task_struct. Следовательно, очень полезной возможностью было бы быстро находить дескриптор процесса, ко- торый выполняется в данный момент, что и делается с помощью макроса current. Этот макрос должен быть отдельно реализован для всех поддерживаемых аппарат- ных платформ. Для одних платформ указатель на структуру t a s k _ s t r u c t процес- са, выполняющегося в данный момент, хранится в регистре процессора, что обе- спечивает более эффективный доступ. Для других платформ, у которых доступно меньше регистров процессора, чтобы зря не тратить регистры, используется тот факт, что структура thread_infо хранится в стеке ядра. При этом вычисляется по- ложение структуры thread_info, а вслед за этим и адрес структуры task_struct процесса. Для платформы х86 значение параметра current вычисляется путем маскирования 13 младших бит указателя стека для получения адреса структуры thread_infо. Это мо- жет быть сделано с помощью функции current_thread_info (). Соответствующий код на языке ассемблера показан ниже. movl $-8192, %eax andl %esp, %eax Окончательно значение параметра c u r r e n t получается путем разыменования значения поля task полученной структуры thread_info: current_thread_info()->task; Для контраста можно сравнить такой подход с используемым на платформе PowerPC (современный процессор на основе RISC-архитектуры фирмы IBM), для которого значение переменной c u r r e n t хранится в регистре процессора r2. На платформе РРС такой подход можно использовать, так как, в отличие от платформы х8б, здесь регистры процессора доступны в изобилии. Так как доступ к дескриптору процесса — это очень частая и важная операция, разработчики ядра для платформы РРС сочли правильным пожертвовать одним регистром для этой цели. Управление процессами 49 Состояние процесса Поле s t a t e дескриптора процесса описывает текущее состояние процесса (рис. 3-3). Каждый процесс в системе гарантированно находится в одном из пяти различных состояний. Существующий процесс вызывает функцию fork() и создает новый процесс TASK_ZOMBIE (процесс завершен) Планировщик отправляет задачу на выполнение: функция schedule () вызывает функцию concext_switch () Задача завершается через do exit() TASK_RUNNING (готов, но пока не выполняется) TASK_RUNNING (выполняется] Задача вытесняется более приоритетной задачей TASK_INTЕRRUPTIBLE TASK_UNINTERRUPTTВLE (задача ожидает) Событие произошло, задача возобновляет выполнение и помещается обратно в очередь готовых к выполнению задач Задача находится в приостановленном состоянии в очереди ожиданий на определенное событие Рис. 3.3. Диаграмма состояний процесса Эти состояния представляются значением одного из пяти возможных флагов, описанных ниже. • TASK_RUNNING— процесс готов к выполнению (runnable). Иными словами, либо процесс выполняется в данный момент, либо находится в одной из оче- редей процессов, ожидающих на выполнение (эти очереди, runqueue, обсуж- даются в главе 4. "Планирование выполнения процессов"). • TASK_INTERRUPTIBLE — процесс приостановлен (находится в состоянии ожидания, sleeping), т.е. заблокирован в ожидании выполнения некоторого 50 Глава 3 Задача разветвляется условия. Когда это условие выполнится, ядро переведет процесс в состояние TASK__RUNNING. Процесс также возобновляет выполнение (wake up) преждевре- менно при получении им сигнала. • TASK_UNNTERRUPTIBLE - аналогично TASK_INTERRUPTIBLE, за исключени- ем того, что процесс не возобновляет выполнение при получении сигнала. Используется в случае, когда процесс должен ожидать беспрерывно или когда ожидается, что некоторое событие может возникать достаточно часто. Так как задача в этом состоянии не отвечает на сигналы, TASK_UNINTERRUPTIBLE ис- пользуется менее часто, чем TASK_INTERRUPTIBLE 6 • TASK_ZOMBIE — процесс завершен, однако порождающий его процесс еще не вызвал системный вызов wait4 ( ) . Дескриптор такого процесса должен оста- ваться доступным на случай, если родительскому процессу потребуется доступ к этому дескриптору. Когда родительский процесс вызывает функцию wait4 (), то такой дескриптор освобождается. • TASK_STOPPED — выполнение процесса остановлено. Задача не выполняется и не имеет право выполняться. Такое может случиться, если задача получает ка- кой-либо из сигналов SIGSTOP, SIGTSTP, SIGTTIN или SIGTTOU, а также если сигнал приходит в тот момент, когда процесс находится в состоянии отладки. Манипулирование текущим состоянием процесса Исполняемому коду ядра часто необходимо изменять состояние процесса. Наиболее предпочтительно для Этого использовать функцию set_task state(task, state); /* установить задание 'task' в состояние 'state' */ которая устанавливает указанное состояние для указанной задачи. Если применимо, то эта функция также пытается применить барьер памяти (memory barrier), чтобы га- рантировать доступность установленного состояния для всех процессоров (необхо- димо только для SMP-систем). В других случаях это эквивалентно выражению: task->state = state; Вызов s e t c u r r e n t s t a t e ( s t a t e ) является синонимом к вызову set_task_ s t a t e ( c u r r e n t , s t a t e ) . Контекст процесса Одна из наиболее важных частей процесса— это исполняемый программный код. Этот код считывается из выполняемого файла (executable) и выполняется в адрес- ном пространстве процесса. Обычно выполнение программы осуществляется в про- странстве пользователя. Когда программа выполняет системный вызов (см. главу 5, "Системные вызовы") или возникает исключительная ситуация, то программа вхо- дит в пространство ядра. 6 Именно из-за этого появляются наводящие ужас "неубиваемые" процессы, для которых команда ps (1) показывает значение состояния, равное D, Так как процесс не отвечает на сигналы, ему нель- зя послать сигнал SIGKILL. Более того, завершать такой процесс было бы неразумно, так как этот процесс, скорее всего, выполняет какую-либо важную операцию и может удерживать семафор. Управление процессами 51 С этого момента говорят, что ядро "выполняется от имени процесса" и делает это в контексте процесса. В контексте процесса макрос current является действитель- ным 1 . При выходе из режима ядра процесс продолжает выполнение в пространстве пользователя, если в это время не появляется готовый к выполнению более приори- тетный процесс. В таком случае активизируется планировщик, который выбирает для выполнения более приоритетный процесс. Системные вызовы и обработчики исключительных ситуаций являются строго определенными интерфейсами ядра. Процесс может начать выполнение в простран- стве ядра только посредством одного из этих интерфейсов — любые обращения к ядру возможны только через эти интерфейсы. Дерево семейства процессов В операционной системе Linux существует четкая иерархия процессов. Все про- цессы являются потомками процесса i n i t , значение идентификатора PID для кото- рого равно 1. Ядро запускает процесс i n i t на последнем шаге процедуры загрузки системы. Процесс i n i t , в свою очередь, читает системные файлы сценариев началь- ной загрузки (initscripts) и выполняет другие программы, что в конце концов заверша- ет процедуру загрузки системы. Каждый процесс в системе имеет всего один порождающий процесс. Кроме того, каждый процесс может иметь один или более порожденных процессов. Процессы, которые порождены одним и тем же родительским процессом, назы- ваются родственными (siblings). Информация о взаимосвязи между процессами хра- нится в дескрипторе процесса. Каждая структура task_struct содержит указатель на структуру t a s k _ s t r u c t родительского процесса, который называется parent, эта структура также имеет список порожденных процессов, который называется children. Следовательно, если известен текущий процесс (current), то для него можно определить дескриптор родительского процесса с помощью выражения: struct task_struct *task = current->parent; Аналогично можно выполнить цикл по процессам, порожденным от текущего процесса, с помощью кода: struct task_struct *task; struct list_head *list; list_for_each (list, scurrent->children) { task = list_entry(list, struct task_struct, sibling); /* переменная task теперь указывает на один из процессов, порожденных текущим процессом */ } Дескриптор процесса i n i t — это статически выделенная структура данных с име- нем i n i t t a s k . Хороший пример использования связей между всеми процессами — это приведенный ниже код, который всегда выполняется успешно. 1 Отличным от контекста процесса является контекст прерывания, описанный в главе 6, "Прерыва- ния и обработка прерываний". В контексте прерывания система работает не от имени процесса, а выполняет обработчик прерывания. С обработчиком прерывании не связан ни один процесс, поэтому и контекст процесса отсутствует. 52 Глава 3 struct task_struct *task for (task = current; task ! = $init_task; task = task->parent) /* переменная task теперь указывает на процесс init */ Конечно, проходя по иерархии процессов, можно перейти от одного процесса системы к другому. Иногда, однако, желательно выполнить цикл по всем процессам системы. Такая задача решается очень просто, так как список задач — это двухсвяз- ный список. Для того чтобы получить указатель на следующее задание из этого спи- ска, имея действительный указатель на дескриптор какого-либо процесса, можно ис- пользовать показанный ниже код: list_entry(task->tasks.next, struct task_struct, tasks) Получение указателя на предыдущее задание работает аналогично. list_entry (task->tasks.prev, struct task_struct, tasks) Дна указанных выше выражения доступны также в виде макросов next_task (task) (получить следующую задачу), prev_task (task) (получить предыдущую задачу). Наконец, макрос for_each_process (task) позволяет выполнить цикл по всему списку задач. На каждом шаге цикла переменная t a s k указывает на следующую за- дачу из списка: struct task_struct *task; for_each_process(task) { /* просто печатается имя команды и идентификатор PID для каждой задачи */ printk("%s[%d]\n", task->comm, task->pid); } Следует заметить, что организация цикла по всем задачам системы, в которой выполняется много процессов, может быть достаточно дорогостоящей операцией. Для применения такого кода должны быть веские причины (и отсутствовать другие альтернативы). Создание нового процесса В операционной системе Unix создание процессов происходит уникальным об- разом. В большинстве операционных систем для создания процессов используется метод порождения процессов (spawn). При этом создается новый процесс в новом адресном пространстве, в которое считывается исполняемый файл, и после этого начинается исполнение процесса. В ОС Unix используется другой подход, а именно разбиение указанных выше операций на две функции: fork () и exec () 8 8 Под e x e c ( ) будем понимать любую функцию из семейства e x e c * ( ) . В ядре реализован системный вызов e x e c v e ( ) , на основе которого реализованы библиотечные функции e x e c l p ( ) , execle(), e x e c v ( ) и execvp(). Управление процессами 53 В начале с помощью функции f o r k ( ) создается порожденный процесс, который является копией текущего задания. Порожденный процесс отличается от родитель- ского только значением идентификатора PID (который является уникальным в си- стеме), значением параметра PPID (идентификатор PID родительского процесса, который устанавливается в значение PID порождающего процесса), некоторыми ре- сурсами, такими как ожидающие на обработку сигналы (которые не наследуются), а также статистикой использования ресурсов - Вторая функция — exec () — загружает исполняемый файл в адресное пространство процесса и начинает исполнять его. Комбинация функций f o r k () и exec () аналогична той одной функции создания процесса, которую предоставляет большинство операционных систем. Копирование при записи Традиционно при выполнении функции f o r k ( ) делался дубликат всех ресурсов родительского процесса и передавался порожденному. Такой подход достаточно наивный и неэффективный. В операционной системе Linux вызов fork () реализо- ван с использованием механизма копирования при записи (copy-on-write) страниц памяти. Технология копирования при записи (copy-on-write, COW) позволяет отложить или вообще предотвратить копирование данных. Вместо создания дубликата адресного пространства процесса родительский и порожденный процессы могут совместно ис- пользовать одну и ту же копию адресного пространства. Однако при этом данные помечаются особым образом, и если вдруг один из процессов начинает изменять данные, то создается дубликат данных, и каждый процесс получает уникальную ко- пию данных. Следовательно, дубликаты ресурсов создаются только тогда, когда в эти ресурсы осуществляется запись, а до того момента они используются совместно в режиме только для чтения (read-only). Такая техника позволяет задержать копи- рование каждой страницы памяти до того момента, пока в эту страницу памяти не будет осуществляться запись. В случае, если в страницы памяти никогда не делается запись, как, например, при вызове функции exec () сразу после вызова fork (), то эти страницы никогда и не копируются. Единственные накладные расходы, которые вносит вызов функции fork ( ) , — это копирование таблиц страниц родительского процесса и создание дескриптора порожденного процесса. Данная оптимизация пре- дотвращает ненужное копирование большого количества данных (размер адресного пространства часто может быть более 10 Мбайт), так как процесс после разветвле- ния в большинстве случаев сразу же начинает выполнять новый исполняемый образ. Эта оптимизация очень важна, потому чти идеология операционной системы Unix предусматривает быстрое выполнение процессов. |