16.1. Реализация потоков в пользовательском пространстве
Есть два основных места реализации набора потоков: в пользовательском пространстве и в ядре. Это утверждение носит несколько спорный характер, поскольку возможна еще и гибридная реализация. А теперь мы опишем эти способы со всеми их достоинствами и недостатками.
Первый способ — это поместить весь набор потоков в пользовательском пространстве. И об этом наборе ядру ничего не известно. Что касается ядра, оно управляет обычными, однопотоковыми процессами. Первое и самое очевидное преимущество состоит в том, что набор потоков на пользовательском уровне может быть реализован в операционной системе, которая не поддерживает потоки. Под эту категорию подпадают все операционные системы, даже те, которые еще находятся в разработке. При этом подходе потоки реализованы с помощью библиотеки.
У всех этих реализаций одна и та же общая структура (рис. 41, а). Потоки запускаются поверх системы поддержки исполнения программ (run-time system), которая представляет собой набор процедур, управляющих потоками. Четыре из них: pthread_create, pthread_exit, pthread_join и pthread_yield — мы уже рассмотрели, но обычно в наборе есть и другие процедуры.

Когда потоки управляются в пользовательском пространстве, каждому процессу необходимо иметь собственную таблицу потоков, чтобы отслеживать потоки, имеющиеся в этом процессе. Эта таблица является аналогом таблицы процессов, имеющейся в ядре, за исключением того, что в ней содержатся лишь свойства, принадлежащие каждому потоку, такие как счетчик команд потока, указатель стека, регистры, состояние и т. д. Таблица потоков управляется системой поддержки исполнения программ. Когда поток переводится в состояние готовности или блокируется, информация, необходимая для возобновления его выполнения, сохраняется в таблице потоков, точно так же, как ядро хранит информацию о процессах в таблице процессов.
Когда поток совершает какие-то действия, которые могут вызвать его локальную блокировку, например ожидание, пока другой поток его процесса не завершит какую-нибудь работу, он вызывает процедуру системы поддержки исполнения программ. Эта процедура проверяет, может ли поток быть переведен в состояние блокировки. Если может, она сохраняет регистры потока (то есть собственные регистры) в таблице потоков, находит в таблице поток, готовый к выполнению, и перезагружает регистры машины сохраненными значениями нового потока. Как только будут переключены указатель стека и счетчик команд, автоматически возобновится выполнение нового потока. Если машине дается инструкция сохранить все регистры и следующая инструкция — загрузить все регистры, то полное переключение потока может быть осуществлено за счет всего лишь нескольких инструкций. Переключение потоков, осуществленное таким образом, по крайней мере на порядок, а может быть, и больше, быстрее, чем перехват управления ядром, что является веским аргументом в пользу набора потоков, реализуемого на пользовательском уровне.
Но у потоков есть одно основное отличие от процессов. Когда поток на время останавливает свое выполнение, например когда он вызывает thread_yield, код процедуры thread_yield может самостоятельно сохранять информацию о потоке в таблице потоков. Более того, он может затем вызвать планировщик потоков, чтобы тот выбрал для выполнения другой поток. Процедура, которая сохраняет состояние потока, и планировщик — это всего лишь локальные процедуры, поэтому их вызов намного более эффективен, чем вызов ядра. Помимо всего прочего, не требуется перехват управления ядром, осуществляемый инструкцией trap, не требуется переключение контекста, кэш в памяти не нужно сбрасывать на диск и т. д. Благодаря этому планировщик потоков работает очень быстро.
У потоков, реализованных на пользовательском уровне, есть и другие преимущества. Они позволяют каждому процессу иметь собственные настройки алгоритма планирования. Например, для некоторых приложений, которые имеют поток сборщика мусора, есть еще один плюс — им не следует беспокоиться о потоках, остановленных в неподходящий момент. Эти потоки также лучше масштабируются, поскольку потоки в памяти ядра безусловно требуют в ядре пространства для таблицы и стека, что при очень большом количестве потоков может вызвать затруднения.
Но несмотря на лучшую производительность, у потоков, реализованных на пользовательском уровне, есть ряд существенных проблем. Первая из них — как реализовать блокирующие системные вызовы. Представьте, что поток считывает информацию с клавиатуры перед нажатием какой-нибудь клавиши. Мы не можем разрешить потоку осуществить настоящий системный вызов, поскольку это остановит выполнение всех потоков. Одна из главных целей организации потоков в первую очередь состояла в том, чтобы позволить каждому потоку использовать блокирующие вызовы, но при этом предотвратить влияние одного заблокированного потока на выполнение других потоков. Работая с блокирующими системными вызовами, довольно трудно понять, как можно достичь этой цели без особого труда.
Все системные вызовы могут быть изменены и превращены в неблокирующие (например, считывание с клавиатуры будет просто возвращать нуль байтов, если в буфере на данный момент отсутствуют символы), но изменения, которые для этого необходимо внести в операционную систему, не вызывают энтузиазма. Кроме того, одним из аргументов за использование потоков, реализованных на пользовательском уровне, было именно то, что они могут выполняться под управлением существующих операционных систем. Вдобавок ко всему изменение семантики системного вызова read потребует изменения множества пользовательских программ.
В том случае, если есть возможность заранее сообщить, будет ли вызов блокирующим, существует и другая альтернатива. В большинстве версий UNIX существует системный вызов select, позволяющий сообщить вызывающей программе, будет ли предполагаемый системный вызов read блокирующим. Если такой вызов имеется, библиотечная процедура read может быть заменена новой процедурой, которая сначала осуществляет вызов процедуры select и только потом — вызов read, если он безопасен (то есть не будет выполнять блокировку). Если вызов read будет блокирующим, он не осуществляется. Вместо этого запускается выполнение другого потока. В следующий раз, когда система поддержки исполнения программ получает управление, она может опять проверить, будет ли на этот раз вызов read безопасен. Для реализации такого подхода требуется переписать некоторые части библиотеки системных вызовов, что нельзя рассматривать в качестве эффективного и элегантного решения, но все же это тоже один из вариантов. Код, который помещается вокруг системного вызова с целью проверки, называется конвертом (jacket), или оболочкой, или оберткой (wrapper).
Достаточно сказать, что компьютеры могут иметь такую настройку, что в одно и то же время в оперативной памяти находятся не все программы. Если программа вызывает инструкции (или переходит к инструкциям), отсутствующие в памяти, возникает ошибка обращения к отсутствующей странице и операционная система обращается к диску и получает отсутствующие инструкции (и их соседей). Это называется ошибкой вызова отсутствующей страницы. Процесс блокируется до тех пор, пока не будет найдена и считана необходимая инструкция. Если ошибка обращения к отсутствующей странице возникает при выполнении потока, ядро, которое даже не знает о существовании потоков, как и следовало ожидать, блокирует весь процесс до тех пор, пока не завершится дисковая операция ввода-вывода, даже если другие потоки будут готовы к выполнению.
Использование набора потоков, реализованного на пользовательском уровне, связано еще с одной проблемой: если начинается выполнение одного из потоков, то никакой другой поток, принадлежащий этому процессу, не сможет выполняться до тех пор, пока первый поток добровольно не уступит центральный процессор. В рамках единого процесса нет прерываний по таймеру, позволяющих планировать работу процессов по круговому циклу (поочередно). Если поток не войдет в систему поддержки выполнения программ по доброй воле, у планировщика не будет никаких шансов на работу.
Проблему бесконечного выполнения потоков можно решить также путем передачи управления системе поддержки выполнения программ за счет запроса сигнала (прерывания) по таймеру с периодичностью один раз в секунду, но для программы это далеко не самое лучшее решение. Возможность периодических и довольно частых прерываний по таймеру предоставляется не всегда, но даже если она и предоставляется, общие издержки могут быть весьма существенными. Более того, поток может также нуждаться в прерываниях по таймеру, мешая использовать таймер системе поддержки выполнения программ.
Другой наиболее сильный аргумент против потоков, реализованных на пользовательском уровне, состоит в том, что программистам потоки обычно требуются именно в тех приложениях, где они часто блокируются, как, к примеру, в многопоточном веб-сервере. Эти потоки часто совершают системные вызовы. Как только для выполнения системного вызова ядро осуществит перехват управления, ему не составит особого труда заняться переключением потоков, если прежний поток заблокирован, а когда ядро займется решением этой задачи, отпадет необходимость постоянного обращения к системному вызову select, чтобы определить безопасность системного вызова read. Зачем вообще использовать потоки в тех приложениях, которые, по существу, полностью завязаны на скорость работы центрального процессора и редко используют блокировку? Никто не станет всерьез предлагать использование потоков при вычислении первых n простых чисел или при игре в шахматы, поскольку в данных случаях от них будет мало проку.
16.2. Реализация потоков в ядре
Теперь давайте рассмотрим, что произойдет, если ядро будет знать о потоках и управлять ими. Как показано на рис. 41, б, здесь уже не нужна система поддержки исполнения программ. Также здесь нет и таблицы процессов в каждом потоке. Вместо этого у ядра есть таблица потоков, в которой отслеживаются все потоки, имеющиеся в системе. Когда потоку необходимо создать новый или уничтожить существующий поток, он обращается к ядру, которое и создает или разрушает путем обновления таблицы потоков в ядре.
В таблице потоков, находящейся в ядре, содержатся регистры каждого потока, состояние и другая информация. Вся информация аналогична той, которая использовалась для потоков, создаваемых на пользовательском уровне, но теперь она содержится в ядре, а не в пространстве пользователя (внутри системы поддержки исполнения программ). Эта информация является подмножеством той информации, которую поддерживают традиционные ядра в отношении своих однопоточных процессов, то есть подмножеством состояния процесса. Вдобавок к этому ядро поддерживает также традиционную таблицу процессов с целью их отслеживания.
Все вызовы, способные заблокировать поток, реализованы как системные, с более существенными затратами, чем вызов процедуры в системе поддержки исполнения программ. Когда поток блокируется, ядро по своему выбору может запустить либо другой поток из этого же самого процесса (если имеется готовый к выполнению поток), либо поток из другого процесса. Когда потоки реализуются на пользовательском уровне, система поддержки исполнения программ работает с запущенными потоками собственного процесса до тех пор, пока ядро не заберет у нее центральный процессор (или не останется ни одного готового к выполнению потока).
Поскольку создание и уничтожение потоков в ядре требует относительно более весомых затрат, некоторые системы с учетом складывающейся ситуации применяют более правильный подход и используют свои потоки повторно. При уничтожении потока он помечается как неспособный к выполнению, но это не влияет на его структуру данных, имеющуюся в ядре. Чуть позже, когда должен быть создан новый поток, вместо этого повторно активируется старый поток, что приводит к экономии времени. Повторное использование потоков допустимо и на пользовательском уровне, но для этого нет достаточно веских оснований, поскольку издержки на управление потоками там значительно меньше.
Для потоков, реализованных на уровне ядра, не требуется никаких новых, неблокирующих системных вызовов. Более того, если один из выполняемых потоков столкнется с ошибкой обращения к отсутствующей странице, ядро может с легкостью проверить наличие у процесса любых других готовых к выполнению потоков и при наличии таковых запустить один из них на выполнение, пока будет длиться ожидание извлечения запрошенной страницы с диска. Главный недостаток этих потоков состоит в весьма существенных затратах времени на системный вызов, поэтому, если операции над потоками (создание, удаление и т. п.) выполняются довольно часто, это влечет за собой более существенные издержки.
Хотя потоки, создаваемые на уровне ядра, и позволяют решить ряд проблем, но справиться со всеми существующими проблемами они не в состоянии. Что будет, к примеру, когда произойдет разветвление многопоточного процесса? Будет ли у нового процесса столько же потоков, сколько у старого, или только один поток? Во многих случаях наилучший выбор зависит от того, выполнение какого процесса запланировано следующим. Если он собирается вызвать команду exec, чтобы запустить новую программу, то, наверное, правильным выбором будет наличие только одного потока. Но если он продолжит выполнение, то лучше всего было бы, наверное, воспроизвести все имеющиеся потоки.
Другой проблемой являются сигналы. Стоит вспомнить, что сигналы посылаются процессам, а не потокам, по крайней мере, так делается в классической модели. Какой из потоков должен обработать поступающий сигнал? Может быть, потоки должны зарегистрировать свои интересы в конкретных сигналах, чтобы при поступлении сигнала он передавался потоку, который заявил о своей заинтересованности в этом сигнале? Тогда возникает вопрос: что будет, если на один и тот же сигнал зарегистрировались два или более двух потоков? И это только две проблемы, создаваемые потоками, а ведь на самом деле их значительно больше.
16.3. Гибридная реализация
В попытках объединить преимущества создания потоков на уровне пользователя и на уровне ядра была исследована масса раз¬личных путей. Один из них (рис. 42) заключается в использовании потоков на уровне ядра, а затем нескольких потоков на уровне пользователя в рамках некоторых или всех потоков на уровне ядра. При использовании такого подхода программист может опреде¬лить, сколько потоков использовать на уровне ядра и на сколько потоков разделить каждый из них на уровне пользователя. Эта мо¬дель обладает максимальной гибкостью.

При таком подходе ядру известно только о потоках самого ядра, работу которых оно и планирует. У некоторых из этих пото¬ков могут быть несколько потоков на пользовательском уровне, которые расходятся от их вершины. Создание, удаление и планиро¬вание выполнения этих потоков осуществляется точно так же, как и у пользовательских потоков, принадлежащих процессу, запущен¬ному под управлением операционной системы, не способной на многопоточную работу. В этой модели каждый поток на уровне ядра обладает определенным набором потоков на уровне пользова¬теля, которые используют его по очереди.
16.4. Активация планировщика
Хотя потоки на уровне ядра по ряду ключевых позиций превосходят потоки на уровне пользователя, они, несомненно, бо¬лее медлительны. Поэтому исследователи искали способы улучше¬ния ситуации без потери их положительных свойств. Далее мы опишем один из таких способов, изобретенный Андерсоном (Anderson et al., 1992), который называется активацией планиров¬щика. Родственная работа рассматривается Элдером и Скоттом (Edler et al.,1988; Scott et al., 1990).
Цель работы по активации планировщика заключается в имитации функциональных возможностей потоков на уровне ядра, но при лучшей производительности и более высокой гибкости, свойственной пакетам потоков, реализуемых в пользовательском пространстве. В частности, пользовательские потоки не должны выполнять специальные неблокирующие системные вызовы или заранее проверять, будет ли безопасным осуществление конкрет¬ного системного вызова. Тем не менее, когда поток блокируется на системном вызове или на ошибке обращения к отсутствующей странице, должна оставаться возможность выполнения другого по¬тока в рамках того же процесса, если есть хоть один готовый к вы¬полнению поток.
Эффективность достигается путем уклонения от ненужных переходов между пространствами пользователя и ядра. Если, к примеру, поток блокируется в ожидании каких-либо действий дру¬гого потока, то обращаться к ядру не имеет смысла, благодаря чему снижаются издержки на переходах между пространствами ядра и пользователя. Имеющаяся в пространстве пользователя система поддержки исполнения программ может заблокировать синхрони¬зирующийся поток и самостоятельно спланировать работу другого потока.
При использовании активации планировщика ядро назна¬чает каждому процессу определенное количество виртуальных процессоров, а системе поддержки исполняемых программ (в поль¬зовательском пространстве) разрешается распределять потоки по процессорам. Этот механизм также может быть использован на мультипроцессорной системе, где виртуальные процессоры могут быть представлены настоящими центральными процессорами. Из¬начально процессу назначается только один виртуальный процес¬сор, но процесс может запросить дополнительное количество про¬цессоров, а также вернуть уже не используемые процессоры. Ядро также может забрать назад уже распределенные виртуальные про¬цессоры с целью переназначения их более нуждающимся процес¬сам.
Работоспособность этой схемы определяется следующей основной идеей: когда ядро знает, что поток заблокирован (напри¬мер, из-за выполнения блокирующего системного вызова или воз¬никновения ошибки обращения к несуществующей странице), оно уведомляет принадлежащую процессу систему поддержки испол¬нения программ, передавая через стек в качестве параметров номер данного потока и описание произошедшего события. Уведомление осуществляется за счет того, что ядро активирует систему под¬держки исполнения программ с заранее известного стартового ад¬реса, примерно так же, как действуют сигналы в UNIX. Этот меха¬низм называется upcall (вызов наверх).
Активированная таким образом система поддержки испол¬нения программ может перепланировать работу своих потоков, как правило, переводя текущий поток в заблокированное состояние, выбирая другой поток из списка готовых к выполнению, устанав¬ливая значения его регистров и возобновляя его выполнение. Чуть позже, когда ядро узнает, что исходный поток может возобновить свою работу (например, заполнился канал, из которого он пытался считать данные, или была извлечена из диска ранее не существо¬вавшая страница), оно выполняет еще один вызов наверх (upcall) в адрес системы поддержки исполнения программ, чтобы уведомить ее об этом событии. Система поддержки исполнения программ мо¬жет либо немедленно возобновить выполнение заблокированного потока, либо поместить его в список ожидающих потоков для по¬следующего выполнения.
При возникновении в период выполнения пользователь¬ского потока аппаратного прерывания центральный процессор, в котором произошло прерывание, переключается в режим ядра. Если прерывание вызвано событием, не интересующим прерван¬ный процесс, например завершением операции ввода-вывода, отно¬сящейся к другому процессу, то при завершении работы обработ¬чика прерывания прерванный поток возвращается назад, в то со-стояние, в котором он был до возникновения прерывания
Если же процесс заинтересован в этом прерывании — например, доставлена страница, необходимая одному из потоков, принадлежащих этому процессу, — выполнение прерванного по¬тока не возобновляется. Вместо этого прерванный поток приоста¬навливается и на виртуальном процессоре запускается система поддержки исполнения программ, имеющая в стеке состояние пре¬рванного потока. Затем на систему поддержки исполнения про¬грамм возлагается решение, выполнение какого именно потока спланировать на этом центральном процессоре: прерванного, по¬следнего ставшего готовым к выполнению или какого-нибудь тре¬тьего.
Недостатком активаций планировщика является полная за¬висимость этой технологии от вызовов наверх (upcall) — концеп¬ции, нарушающей структуру, свойственную любой многоуровневой системе. Как правило, уровень n предоставляет определенные услуги, которые могут быть запрошены уровнем n + 1, но уровень n не может вызывать процедуры, имеющиеся на уровне n + 1. Вы¬зовы наверх (upcall) этому фундаментальному принципу не сле¬дуют.
16.5. Всплывающие потоки
Потоки часто используются в распределенных системах. Хорошим примером может послужить обработка входящих сооб¬щений, к примеру запросов на обслуживание. Традиционный под¬ход заключается в использовании процесса или потока, блокирую¬щегося системным вызовом receive в ожидании входящего сообще¬ния. По прибытии сообщения он его принимает, распаковывает, проверяет его содержимое и проводит дальнейшую обработку.
Возможен и совершенно иной подход, при котором поступ¬ление сообщения вынуждает систему создать новый поток для его обработки. Такой поток (рис. 43) называется всплывающим. Ос¬новное преимущество всплывающих потоков заключается в том, что они создаются заново и не имеют прошлого — никаких реги¬стров, стека и всего остального, что должно быть восстановлено. Каждый такой поток начинается с чистого листа, и каждый их них идентичен всем остальным. Это позволяет создавать такие потоки довольно быстро. Новый поток получает сообщение для последу¬ющей обработки. В результате использования всплывающих пото¬ков задержку между поступлением и началом обработки сообщения можно свести к минимуму.

При использовании всплывающих потоков требуется пред¬варительное планирование. К примеру, возникает вопрос: в каком процессе следует запускать поток? Если система поддерживает по¬токи, выполняемые в пространстве ядра, поток может быть запу¬щен в этом пространстве (именно поэтому ядро на рис. 43 не пока¬зано). Как правило, запустить всплывающий поток в пространстве ядра легче и быстрее, чем поместить его в пользовательское про¬странство. К тому же всплывающий поток в пространстве ядра по-лучает простой доступ ко всем таблицам ядра и к устройствам ввода-вывода, которые могут понадобиться для обработки преры¬вания. В то же время дефектный поток в пространстве ядра может нанести более существенный урон, чем такой же поток в простран¬стве пользователя. К примеру, если он выполняется слишком долго, а способов его вытеснения не существует, входные данные могут быть утрачены навсегда.
16.6. Превращение однопоточного кода в многопоточный
Многие из существующих программ создавались под одно¬поточные процессы. Превратить их в многопоточные куда сложнее, чем может показаться на первый взгляд. Далее мы рассмотрим лишь некоторые из имеющихся подводных камней.
Начнем с того, что код потока, как и код процесса, обычно содержит несколько процедур. У этих процедур могут быть ло¬кальные и глобальные переменные, а также параметры. Локальные переменные и параметры проблем не создают, проблемы возни¬кают с теми переменными, которые носят глобальный характер для потока, но не для всей программы. Глобальность этих переменных заключается в том, что их использует множество процедур внутри потока (поскольку они могут использовать любую глобальную пе-ременную), но другие потоки логически должны их оставить в по¬кое.
Рассмотрим в качестве примера переменную errno, поддер¬живаемую UNIX. Когда процесс (или поток) осуществляет систем¬ный вызов, терпящий неудачу, код ошибки помещается в errno. На рис. 44 поток 1 выполняет системный вызов access, чтобы опреде-лить, разрешен ли доступ к конкретному файлу. Операционная си¬стема возвращает ответ в глобальной переменной errno. После воз¬вращения управления потоку 1, но перед тем, как он получает воз¬можность прочитать значение errno, планировщик решает, что по¬ток 1 на данный момент времени вполне достаточно использовал время центрального процессора и следует переключиться на вы¬полнение потока 2. Поток 2 выполняет вызов open, который терпит неудачу, что вызывает переписывание значения переменной errno, и код access первого потока утрачивается навсегда. Когда чуть позже возобновится выполнение потока 1, он считает неверное зна¬чение и поведет себя некорректно.

Существуют разные способы решения этой проблемы. Можно вообще запретить использование глобальных переменных. Какой бы заманчивой ни была эта идея, она вступает в конфликт со многими существующими программами. Другой способ заключа¬ется в назначении каждому потоку собственных глобальных пере¬менных (рис. 45) В этом случае у каждого потока есть своя закры¬тая копия errno и других глобальных переменных, позволяющая избежать возникновения конфликтов. В результате такого решения создается новый уровень области определения, где переменные видны всем процедурам потока (но не видны другим потокам), вдо¬бавок к уже существующим областям определений, где переменные видны только одной процедуре и где переменные видны из любого места программы.

Однако доступ к закрытым глобальным переменным не¬сколько затруднен, поскольку большинство языков программиро¬вания имеют способ выражения локальных и глобальных перемен¬ных, но не содержат промежуточных форм. Есть возможность рас¬пределить часть памяти для глобальных переменных и передать ее каждой процедуре потока в качестве дополнительного параметра. Решение не самое изящное, но работоспособное.
В качестве альтернативы можно ввести новые библиотеч¬ные процедуры для создания, установки и чтения глобальных пе¬ременных, видимых только внутри потока. Первый вызов проце¬дуры может иметь следующий вид:
create_global(«bufptr»);
Он выделяет хранилище для указателя по имени bufptr в ди¬намически распределяемой области памяти или в специальной об¬ласти памяти, зарезервированной для вызывающего потока. Неза¬висимо от того, где именно размещено хранилище, к глобальным переменным имеет доступ только вызывающий поток. Если другой поток создает глобальную переменную с таким же именем, она по¬лучает другое место хранения и не конфликтует с уже существую¬щей переменной.
Для доступа к глобальным переменным нужны два вызова: один для записи, а другой для чтения. Процедура для записи может иметь следующий вид:
set global(«bufptr», &buf);
Она сохраняет значение указателя в хранилище, ранее со¬зданном вызовом процедуры create_global. Процедура для чтения глобальной переменной может иметь следующий вид:
bufptr = read_global(«bufptr»);
Она возвращает адрес для доступа к данным, хранящимся в глобальной переменной.
Другой проблемой, возникающей при превращении однопо¬точной программы в многопоточную, является отсутствие возмож¬ности повторного входа во многие библиотечные процедуры. То есть они не создавались с расчетом на то, что каждая отдельно взя¬тая процедура будет вызываться повторно еще до того, как завер¬шился ее предыдущий вызов. К примеру, отправка сообщения по сети может быть запрограммирована на то, чтобы предварительно собрать сообщение в фиксированном буфере внутри библиотеки, а затем для его отправки осуществить перехват управления ядром. Представляете, что произойдет, если один поток собрал свое сооб¬щение в буфере, а затем прерывание по таймеру вызвало переклю¬чение на выполнение второго потока, который тут же переписал буфер своим собственным сообщением?
Подобная проблема возникает и с процедурами распределе¬ния памяти, к примеру с процедурой malloc в UNIX, работающей с весьма важными таблицами использования памяти, например со связанным списком доступных участков памяти. Когда процедура malloc занята обновлением этих списков, они могут временно пре¬бывать в несообразном состоянии, с указателями, которые указы¬вают в никуда. Если в момент такого несообразного состояния про¬изойдет переключение потоков и из другого потока поступит новый вызов, будут использованы неверные указатели, что приведет к сбою программы. Для эффективного устранения всех этих проблем потребуется переписать всю библиотеку. А это далеко не самое простое занятие с реальной возможностью внесения труднообна¬руживаемых ошибок.
Другим решением может стать предоставление каждой про¬цедуре оболочки, которая устанавливает бит, отмечающий, что библиотека уже используется. Любая попытка другого потока вос¬пользоваться библиотечной процедурой до завершения предыду¬щего вызова будет заблокирована. Хотя этот подход вполне осуще¬ствим, он существенно снижает потенциальную возможность па¬раллельных вычислений.
А теперь рассмотрим сигналы. Некоторые сигналы по своей логике имеют отношение к потокам, а некоторые не имеют к ним никакого отношения. К примеру, если поток осуществляет систем¬ный вызов alarm, появляется смысл направить результирующий сигнал к вызывающему потоку. Но когда потоки целиком реализо¬ваны в пользовательском пространстве, ядро даже не знает о пото¬ках и вряд ли сможет направить сигнал к нужному потоку. Допол¬нительные сложности возникают в том случае, если у процессана данный момент есть лишь один необработанный аварийный сигнал и несколько потоков осуществляют системный вызов alarm незави¬симо друг от друга.
Другие сигналы, например прерывания клавиатуры, не имеют определенного отношения к потокам. Кто их должен пере¬хватывать? Один специально назначенный поток? Или все потоки? А может быть, заново создаваемый всплывающий поток? Кроме того, что произойдет, если один из потоков вносит изменения в об¬работчики сигналов, не уведомляя об этом другие потоки? А что случится, если одному потоку потребуется перехватить конкретный сигнал (например, когда пользователь нажмет CTRL+C), а другому потоку этот сигнал понадобится для завершения процесса? Подоб¬ная ситуация может сложиться, если в одном или нескольких пото¬ках выполняются стандартные библиотечные процедуры, а в дру-гих — процедуры, созданные пользователем. Совершенно оче¬видно, что такие требования потоков несовместимы. В общем, с сигналами не так-то легко справиться и при наличии лишь одного потока, а переход к многопоточной среде отнюдь не облегчает их обработку.
Остается еще одна проблема, создаваемая потоками, — управление стеком. Во многих системах при переполнении стека процесса ядро автоматически предоставляет ему дополнительное пространство памяти. Когда у процесса несколько потоков, у него должно быть и несколько стеков. Если ядро ничего не знает о су¬ществовании этих стеков, оно не может автоматически наращивать их пространство при ошибке стека. Фактически оно даже не сможет понять, что ошибка памяти связана с разрастанием стека какого-нибудь потока.
Разумеется, эти проблемы не являются непреодолимыми, но они наглядно демонстрируют, что простое введение потоков в су¬ществующую систему без существенной доработки приведет к ее полной неработоспособности. Возможно, необходимый минимум будет состоять в переопределении семантики системных вызовов и переписывании библиотек. И все это должно быть сделано так, чтобы сохранялась обратная совместимость с существующими про¬граммами, когда все ограничивается процессом, имеющим только один поток.

Оставить комментарий