Роберт Лав - Разработка ядра Linux
Копирование при записи
Традиционно при выполнении функции fork() делался дубликат всех ресурсов родительского процесса и передавался порожденному. Такой подход достаточно наивный и неэффективный. В операционной системе Linux вызов fork() реализован с использованием механизма копирования при записи (copy-on-write) страниц памяти. Технология копирования при записи (copy-on-write, COW) позволяет отложить или вообще предотвратить копирование данных. Вместо создания дубликата адресного пространства процесса родительский и порожденный процессы могут совместно использовать одну и ту же копию адресного пространства. Однако при этом данные помечаются особым образом, и если вдруг один из процессов начинает изменять данные, то создается дубликат данных, и каждый процесс получает уникальную копию данных. Следовательно, дубликаты ресурсов создаются только тогда, когда в эти ресурсы осуществляется запись, а до того момента они используются совместно в режиме только для чтения (read-only). Такая техника позволяет задержать копирование каждой страницы памяти до того момента, пока в эту страницу памяти не будет осуществляться запись. В случае, если в страницы памяти никогда не делается запись, как, например, при вызове функции exec() сразу после вызова fork(), то эти страницы никогда и не копируются. Единственные накладные расходы, которые вносит вызов функции fork(), — это копирование таблиц страниц родительского процесса и создание дескриптора порожденного процесса. Данная оптимизация предотвращает ненужное копирование большого количества данных (размер адресного пространства часто может быть более 10 Мбайт), так как процесс после разветвления в большинстве случаев сразу же начинает выполнять новый исполняемый образ. Эта оптимизация очень важна, потому чти идеология операционной системы Unix предусматривает быстрое выполнение процессов.
Функция fork()В операционной системе Linux функция fork() реализована через системный вызов clone(). Этот системный вызов может принимать в качестве аргументов набор флагов, определяющих, какие ресурсы должны быть общими (если вообще должны) у родительского и порожденного процессов. Далее в разделе "Реализация потоков в ядре Linux" об этих флагах рассказано более подробно. Библиотечные вызовы fork(), vfork() и __clone() вызывают системную функцию clone() с соответствующими флагами. В свою очередь системный вызов clone() вызывает функцию ядра do_fork().
Основную массу работы по разветвлению процесса выполняет функция do_fork(), которая определена в файле kernel/fork.c. Эта функция, в свою очередь, вызывает функцию copy_process() и запускает новый процесс на выполнение. Ниже описана та интересная работа, которую выполняет функция copy_process().
• Вызывается функция dup_task_struct(), которая создает стек ядра, структуры thread_info и task_struct для нового процесса, причем все значения указанных структур данных идентичны для порождающего и порожденного процессов. На этом этапе дескрипторы родительского и порожденного процессов идентичны.
• Проверяется, не произойдет ли при создании нового процесса переполнение лимита на количество процессов для данного пользователя.
• Теперь необходимо сделать порожденный процесс отличным от родительского. При этом различные поля дескриптора порожденного процесса очищаются или устанавливаются в начальные значения. Большое количество данных дескриптора процесса является совместно используемым.
• Далее состояние порожденного процесса устанавливается в значение TASK_UNINTERRUPTIBLE, чтобы гарантировать, что порожденный процесс не будет выполняться.
• Из функции copy_process() вызывается функция copy_flags(), которая обновляет значение поля flags структуры task struct. При этом сбрасывается флаг PF_SUPERPRIV, который определяет, имеет ли процесс права суперпользователя. Флаг PF_FORKNOEXEC, который указывает на то, что процесс не вызвал функцию exec(), — устанавливается.
• Вызывается функция get_pid(), которая назначает новое значение идентификатора PID для новой задачи.
• В зависимости от значений флагов, переданных в функцию clone(), осуществляется копирование или совместное использование открытых файлов, информации о файловой системе, обработчиков сигналов, адресного пространства процесса и пространства имен (namespace). Обычно эти ресурсы совместно используются потоками одного процесса. В противном случае они будут уникальными и будут копироваться на этом этапе.
• Происходит разделение оставшейся части кванта времени между родительским и порожденным процессами (это более подробно обсуждается в главе 4, "Планирование выполнения процессов").
• Наконец, происходит окончательная зачистка структур данных и возвращается указатель на новый порожденный процесс.
Далее происходит возврат в функцию do_fork(). Если возврат из функции copy_process() происходит успешно, то новый порожденный процесс возобновляет выполнение. Порожденный процесс намеренно запускается на выполнение раньше родительского[16].
В обычной ситуации, когда порожденный процесс сразу же вызывает функцию exec(), это позволяет избежать накладных расходов, связанных с тем, что если родительский процесс начинает выполняться первым, то он будет ожидать возможности записи в адресное пространство посредством механизма копирования при записи.
Функция vfork()Системный вызов vfork() позволяет получить тот же эффект, что и системный вызов fork(), за исключением того, что записи таблиц страниц родительского процесса не копируются. Вместо этого порожденный процесс запускается как отдельный поток в адресном пространстве родительского процесса и родительский процесс блокируется до того момента, пока порожденный процесс не вызовет функцию exec() или не завершится. Порожденному процессу запрещена запись в адресное пространство. Такая оптимизация была желанной в старые времена 3BSD, когда реализация системного вызова fork() не базировалась на технике копирования страниц памяти при записи. Сегодня, при использовании техники копирования страниц памяти при записи и запуске порожденного процесса перед родительским, единственное преимущество вызова vfork() — это отсутствие копирования таблиц страниц родительского процесса. Если когда-нибудь в операционной системе Linux будет реализовано копирование полей таблиц страниц при записи[17], то вообще не останется никаких преимуществ. Поскольку семантика функции vfork() достаточно ненадежна (что, например, будет, если вызов exec() завершится неудачно?), то было бы здорово, если бы системный вызов vfork() умер медленной и мучительной смертью. Вполне можно реализовать системный вызов vfork() через обычный вызов fork(), что действительно имело место в ядрах Linux до версии 2.2.
Сейчас системный вызов vfork() реализован через специальный флаг в системном вызове clone(), как показано ниже.
• При выполнении функции copy_process() поле vfork_done структуры task_struct устанавливается в значение NULL.
• При выполнении функции do_fvork(), если соответствующий флаг установлен, поле vfork_done устанавливается в ненулевое значение (начинает указывать на определенный адрес).
• После того как порожденный процесс в первый раз запущен, родительский процесс, вместо того чтобы возвратиться из функции copy_process() к выполнению, начинает ожидать, пока порожденный процесс не подаст ему сигнал через указатель vfork_done.
• При выполнении порожденным процессом функции mm_release() (которая вызывается, когда задание заканчивает работу со своим адресным пространством), если значение поля vfork_done не равно NULL, родительский процесс получает указанный выше сигнал.
• При возврате в функцию do_fork() родительский процесс возобновляет выполнение и выходит из этой функции.
Если все прошло так, как запланировано, то теперь порожденный процесс выполняется в новом адресном пространстве, а родительский процесс — в первоначальном адресном пространстве. Накладные расходы меньше, но реализация не очень привлекательна.
Реализация потоков в ядре Linux
Многопоточность — это популярная сегодня программная абстракция. Она обеспечивает выполнение нескольких потоков в совместно используемом адресном пространстве памяти. Потоки также могут совместно использовать открытые файлы и другие ресурсы. Многопоточность используется для параллельного программирования (concurrent programming), что на многопроцессорных системах обеспечивает истинный параллелизм.