Глава_1 (1085730), страница 3
Текст из файла (страница 3)
Активизированная таким образом система поддержки исполнения программ перепланирует свои потоки, обычно помечая текущий поток как блокированный, выбирая следующий поток из списка, устанавливая значения его регистров и запуская его. Позже, когда ядро получает информацию о том, что поток снова готов к работе (например, канал, из которого он пытался считывать данные, теперь их содержит, или недостающая страница считана с диска), оно выполняет еще один обратный вызов, информируя об этом систему поддержки исполнения программ. Система поддержки исполнения программ по своему усмотрению запускает блокированный поток тут же или помещает его в список готовых процессов, чтобы (запустить позже.
При возникновении аппаратного прерывания во время работы потока пользователя процессор переключается в режим ядра. Если прерывание вызвано событием, не имеющим отношения к прерванному процессу, например завершением {операции ввода-вывода другого процесса, по завершении работы обработчика прерываний прерванный поток возвращается в состояние, в котором он находился до прерывания. Если же процесс заинтересован в прерывании (например, вызванном поступлением страницы, которую ждал один из потоков процесса), прерванный поток не запускается вновь. Вместо этого прерванный поток приостанавливается, и на этом виртуальном процессоре запускается система поддержки исполнения программ с состоянием прерванного потока на стеке. Дальнейшее зависит от системы поддержки исполнения программ, решающей, запустить ли на этом процессоре прерванный поток, другой, находящийся в состоянии готовности, или какой-либо третий.
Недостатком метода активации планировщика является существенная зависимость от обратных вызовов, концепция, нарушающая свойственную любой многоуровневой системе структуру. Обычно уровень п + 1 может вызывать процедуры уровня л, но не наоборот. Обратные вызовы противоречат этому фундаментальному принципу.
Всплывающие потоки
Потоки часто используются в распределенных системах. Важным примером может служить обработка входящих сообщений, например запросов на обслуживание. Традиционный подход заключается в наличии процесса или потока, который блокируется по системному запросу recieve, ожидая входящего сообщения. Когда сообщение прибывает, оно принимается и обрабатывается.
Возможен и принципиально другой подход, при котором по прибытии сообщения система создает новый поток для его обработки. Такой поток называется всплывающим, его схема проиллюстрирована на рис. 2.11. Основным преимуществом всплывающих потоков является их «свежесть» — у такого потока нет истории: регистров, стека и прочей информации, которую нужно восстанавливать. Всплывающие потоки абсолютно «стерильны» и идентичны, что позволяет создавать их быстро. Новый поток обрабатывает входящее сообщение. Использование всплывающих потоков позволяет значительно сократить промежуток времени между прибытием сообщения и началом его обработки. При использовании всплывающих потоков необходимо предварительное планирование. Например, в каком процессе возникнет новый поток? Если система поддерживает потоки, работающие в контексте ядра, новый поток может возникнуть там (именно поэтому мы не показали ядро на рис. 2.11). Создание всплывающих потоков в пространстве ядра всегда быстрее и проще, чем в пространстве пользователя. К тому же всплывающему потоку в пространстве ядра проще получить доступ ко всем таблицам ядра и устройств ввода- вывода, что может оказаться полезным при обработке прерываний. С другой стороны, наличие ошибок в потоке, расположенном в пространстве ядра, может нанести существенно больший ущерб. Например, если поток работает слишком долго и невозможно воспользоваться приоритетным прерыванием, это может привести к потере входных данных.
Как сделать однопоточную программу многопоточной
Многие из существующих программ были написаны для однопоточных процессов. Сделать их многопоточными гораздо сложнее, чем это может показаться на первый взгляд. Ниже мы рассмотрим некоторые из возможных трудностей.
Прежде всего, программа потока обычно состоит из нескольких процедур, так же как и процесс. У этих процедур могут быть локальные переменные, глобальные переменные и параметры. Проблем с локальными переменными и параметрами не будет, зато проблемы будут с переменными, которые являются глобальными для потока, но не глобальными для всей программы. Эти переменные являются глобальными с точки зрения процедур одного потока (которые ими пользуются, как пользовались бы любыми другими глобальными переменными), но не имеют никакого отношения к другим потокам.
В качестве примера рассмотрим переменную errno в UNIX. Если процесс (или поток) выполняет неудачный системный запрос, код ошибки записывается в errno. На рис, 2.12 поток 1 выполняет системный запрос access, чтобы узнать, имеет ли он разрешение на доступ к конкретному файлу. Операционная система возвращает ответ в глобальной переменной errno. После этого управление возвращается к потоку 1. Однако прежде, чем у него появляется возможность считать значение errno, планировщик решает, что поток 1 уже достаточно попользовался процессором и пора переключиться на поток 2. Поток 2 выполняет запрос open, завершающийся неудачей, в результате чего значение errno изменяется и предыдущее значение теряется. После того как поток 1 вновь получит управление, он прочитает неверное значение errno и дальнейшие его действия будут неправильными.
Существует несколько различных решений проблемы. Одно из решений — запретить глобальные переменные вообще. Какой бы заманчивой ни была эта идея. она вступит в противоречие с большей частью существующего программного обеспечения. Другое решение — предоставить каждому потоку собственные глобальные переменные, как показано на рис. 2.13. В этом случае конфликт исключается, поскольку у каждого потока будет своя копия errno и остальных глобальных переменных. Это решение фактически приводит к появлению новых уровней видимости переменных: переменные, доступные всем процедурам потока (в дополнение
Обеспечить доступ к собственным глобальным переменным не очень просто, поскольку в большинстве языков программирования есть способы описания локальных и глобальных переменных, но не промежуточных разновидностей. Можно отвести под глобальные переменные отдельный участок памяти и рассматривать их как дополнительные параметры процедур. Несмотря на некоторую неуклюжесть, этот метод работает.
В качестве альтернативы можно написать новые библиотечные процедуры, которые будут создавать, записывать и считывать переменные, глобальные для потока. Первый запрос будет выглядеть примерно так:
Create_global (“bufptr"):
Этот запрос отводит участок памяти под указатель, называющийся bufptr, в динамической памяти или в отдельном участке памяти, зарезервированном для вызывающего потока. Не имеет значения, где именно расположен этот участок памяти, важно, что лишь вызывающий поток имеет к нему доступ. Если другой поток создаст глобальную переменную с таким же именем, она будет размещаться в другом участке памяти и конфликта потоков не будет.
Для доступа к глобальной переменной нужно два запроса: один чтобы написать ее значение, и другой — чтобы его считать. Для записи будет использоваться что-то вроде
set_g1obal ("bufptr",&buf);
Этот запрос сохраняет значение указателя в участке памяти, созданном запросом create_ global. Запрос на чтение может выглядеть как
bufptr= read_global("bufptr");
Запрос возвращает адрес для доступа к данным, хранящийся в глобальной переменной.
Другим препятствием может стать тот факт, что большинство библиотечных процедур не являются реентерабельными. Это означает, что при их написании не предполагалась ситуация, при которой процедуре будет необходимо ответить на второй запрос, не закончив ответа на первый. Например, пересылку сообщения по сети можно организовать следующим образом: сообщение помещается в буфер, затем эмулируется прерывание в ядро для его отсылки. Что произойдет, если один поток поместит сообщение в буфер, а затем прерывание по таймеру приведет к передаче управления второму потоку, который тут же поместит в этот буфер свое сообщение?
Подобная же проблема возникает с процедурами распределения памяти (malloc в UNIX), управляющими таблицами использования памяти (в виде связного списка доступных участков памяти). Пока процедура malloc занята переписыванием таблиц, таблицы могут временно находиться в несовместимом состоянии, с указателями, никуда не указывающими. Если в этот момент произойдет переключение потоков и от нового потока придет запрос, может быть использован неправильный указатель, что приведет к нарушению работы программы. Решение всех подобных проблем равнозначно полному переписыванию библиотеки.
Другим решением может быть снабжение каждой процедуры чехлом (jacket), устанавливающим бит, означающий, что эта процедура используется. Любая попытка использования процедуры другим потоком до окончания выполнения предыдущего запроса блокируется. Этот метод можно использовать, но он практически исключает параллелизм.
Теперь рассмотрим сигналы. Одни из них связаны с потоками, тогда как другие — нет. Например, если поток выполняет запрос alarm, результирующий сигнал по логике должен вернуться к этому потоку. Однако если потоки реализованы в пространстве пользователя, ядро ничего не знает об их существовании и вряд ли направит сигнал к правильному потоку. Ситуация еще больше усложняется, если одновременно у процесса может быть только один необработанный аварийный сигнал, а несколько потоков выполняют запрос alarm независимо друг от друга. Другие сигналы, такие как прерывание с клавиатуры, не связаны с потоками. Кто должен их перехватывать? Один назначенный поток? Все потоки? Специально созданным всплывающий поток? Что случится, если один поток изменит обработчик сигнала, не сообщив об этом остальным потокам? А что если одни поток хочет перехватить определенный сигнал (например, CTRL+C с клавиатуры), а другому потоку этот сигнал нужен, чтобы прервать процесс? Подобная ситуация может возникнуть, если один или более потоков пользуются стандартными библиотечными процедурами, а остальные — процедурами, написанными пользователем, Эти потоки абсолютно несовместимы. Вообще говоря, управлять сигналами даже в однопоточной среде достаточно сложно. При переходе к многопоточному окружению обработка сигналов проще не становится.
Последняя проблема, связанная с потоками, — управление стеками. Во многих системах при переполнении стека процесса ядро автоматически увеличивает его. Если у процесса несколько потоков, стеков тоже должно быть несколько. Если ядро не знает о существовании этих стеков, оно не может их автоматически увеличивать при переполнении. Ядро может даже не связать ошибки памяти с переполнением стеков.
Разумеется, эти проблемы не являются непреодолимыми, но на их примере хорошо видно, что введение потоков в существующую систему невозможно без тщательной и продуманной реконструкции всей системы. По крайней мере, придется изменить семантику системных запросов и переписать библиотеки. И результат ваших трудов должен быть совместим с существующими программами для процессов с одним потоком.
Межпроцессное взаимодействие
Процессам часто бывает необходимо взаимодействовать между собой. Например, в конвейере ядра выходные данные первого процесса должны передаваться второму и т. д. по цепочке. Поэтому необходимо правильно организованное взаимодействие между процессами, по возможности не использующее прерываний. В этом разделе мы рассмотрим некоторые аспекты межпроцессного взаимодействия (IPC, interprocess communication).
Проблема разбивается на три пункта. Первый мы уже упомянули: передача информации от одного процесса другому. Второй связан с контролем над деятельностью процессов: как гарантировать, что два процесса не пересекутся в критических ситуациях (представьте себе два процесса, каждый из которых пытается завладеть последним мегабайтом памяти). Третий касается согласования действий процессов: если процесс А должен поставлять данные, а процесс В выводить их на печать, то процесс В должен подождать и не начинать печатать, пока не поступят данные от процесса А. Мы рассмотрим все три случая в следующем подразделе.
Важно понимать, что два из трех описанных пунктов и равной мере относятся и к потокам. Первый — передача информации — в случае потоков проблемой не является, поскольку у потоков общее адресное пространство (передача информации между потоками с разным адресным пространством уже является проблемой передачи информации между процессами). Остальные два с тем же успехом касаются потоков: те же проблемы, и те же решения. Мы будем рассматривать эти ситуации в контексте процессов, но имейте в виду, что эти же рассуждения применимы и для потоков.
Состояние состязания
В некоторых операционных системах процессы, работающие совместно, могут сообща использовать некое общее хранилище данных. Каждый из процессов может считывать из общего хранилища данных и записывать туда информацию. Это хранилище представляет собой участок в основной памяти (возможно, в структуре данных ядра) или файл общего доступа. Местоположение совместно используемой памяти не влияет на суть взаимодействия и возникающие проблемы. Рассмотрим межпроцессное взаимодействие на простом, но очень распространенном примере:
спулер печати. Если процессу требуется вывести на печать файл, он помещает имя файла в специальный каталог спулера. Другой процесс, демон печати, периодически проверяет наличие файлов, которые нужно печатать, печатает файл и удаляет его имя из каталога.