Превращение однопоточного кода в многопоточный

Многие из существующих программ создавались под однопоточные процессы. Превратить их в многопоточные куда сложнее, чем может показаться на первый взгляд. Далее мы рассмотрим лишь некоторые из имеющихся подводных камней.

Начнем с того, что код потока, как и код процесса, обычно содержит несколько процедур. У этих процедур могут быть локальные и глобальные переменные, а также параметры. Локальные переменные и параметры проблем не создают, проблемы возникают с теми переменными, которые носят глобальный характер для потока, но не для всей программы. Глобальность этих переменных заключается в том, что их использует множество процедур внутри потока (поскольку они могут использовать любую глобальную переменную), но другие потоки логически должны их оставить в покое.

Рассмотрим в качестве примера переменную errno, поддерживаемую UNIX. Когда процесс (или поток) осуществляет системный вызов, терпящий неудачу, код ошибки помещается в errno. На рис. 2.13 поток 1 выполняет системный вызов access, чтобы определить, разрешен ли доступ к конкретному файлу. Операционная система возвращает ответ в глобальной переменной errno. После возвращения управления потоку 1, но перед тем, как он получает возможность прочитать значение errno, планировщик решает, что поток 1 на данный момент времени вполне достаточно использовал время центрального процессора и следует переключиться на выполнение потока 2. Поток 2 выполняет вызов open, который терпит неудачу, что вызывает переписывание значения переменной errno, и код access первого потока утрачивается навсегда. Когда чуть позже возобновится выполнение потока 1, он считает неверное значение и поведет себя некорректно.

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


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


Однако доступ к закрытым глобальным переменным несколько затруднен, поскольку большинство языков программирования имеют способ выражения локальных и глобальных переменных, но не содержат промежуточных форм. Есть возможность распределить часть памяти для глобальных переменных и передать ее каждой процедуре потока в качестве дополнительного параметра. Решение не самое изящное, но работоспособное.

В качестве альтернативы можно ввести новые библиотечные процедуры для создания, установки и чтения глобальных переменных, видимых только внутри потока. Первый вызов процедуры может иметь следующий вид:

create_global("bufptr");

Он выделяет хранилище для указателя по имени bufptr в динамически распределяемой области памяти или в специальной области памяти, зарезервированной для вызывающего потока. Независимо от того, где именно размещено хранилище, к глобальным переменным имеет доступ только вызывающий поток. Если другой поток создает глобальную переменную с таким же именем, она получает другое место хранения и не конфликтует с уже существующей переменной.

Для доступа к глобальным переменным нужны два вызова: один для записи, а другой для чтения. Процедура для записи может иметь следующий вид:

set global("bufptr", &buf);

Она сохраняет значение указателя в хранилище, ранее созданном вызовом процедуры create global. Процедура для чтения глобальной переменной может иметь следующий вид:

bufptr = read_global("bufptr");

Она возвращает адрес для доступа к данным, хранящимся в глобальной переменной.

Другой проблемой, возникающей при превращении однопоточной программы в многопоточную, является отсутствие возможности повторного входа во многие библиотечные процедуры. То есть они не создавались с расчетом на то, что каждая отдельно взятая процедура будет вызываться повторно еще до того, как завершился ее предыдущий вызов.

К примеру, отправка сообщения по сети может быть запрограммирована на то, чтобы предварительно собрать сообщение в фиксированном буфере внутри библиотеки, а затем для его отправки осуществить перехват управления ядром. Представляете, что произойдет, если один поток собрал свое сообщение в буфере, а затем прерывание по таймеру вызвало переключение на выполнение второго потока, который тут же переписал буфер своим собственным сообщением?

Подобная проблема возникает и с процедурами распределения памяти, к примеру с процедурой malloc в UNIX, работающей с весьма важными таблицами использования памяти, например со связанным списком доступных участков памяти. Когда процедура malloc занята обновлением этих списков, они могут временно пребывать в несообразном состоянии, с указателями, которые указывают в никуда. Если в момент такого несообразного состояния произойдет переключение потоков и из другого потока поступит новый вызов, будут использованы неверные указатели, что приведет к сбою программы. Для эффективного устранения всех этих проблем потребуется переписать всю библиотеку. А это далеко не самое простое занятие с реальной возможностью внесения труднообнаруживаемых ошибок.

Другим решением может стать предоставление каждой процедуре оболочки, которая устанавливает бит, отмечающий, что библиотека уже используется. Любая попытка другого потока воспользоваться библиотечной процедурой до завершения предыдущего вызова будет заблокирована. Хотя этот подход вполне осуществим, он существенно снижает потенциальную возможность параллельных вычислений.

А теперь рассмотрим сигналы. Некоторые сигналы по своей логике имеют отношение к потокам, а некоторые не имеют к ним никакого отношения. К примеру, если поток

осуществляет системный вызов alarm, появляется смысл направить результирующий сигнал к вызывающему потоку. Но когда потоки целиком реализованы в пользовательском пространстве, ядро даже не знает о потоках и вряд ли сможет направить сигнал к нужному потоку. Дополнительные сложности возникают в том случае, если у процесса на данный момент есть лишь один необработанный аварийный сигнал и несколько потоков осуществляют системный вызов alarm независимо друг от друга.

Другие сигналы, например прерывания клавиатуры, не имеют определенного отношения к потокам. Кто их должен перехватывать? Один специально назначенный поток? Или все потоки? А может быть, заново создаваемый всплывающий поток? Кроме того, что произойдет, если один из потоков вносит изменения в обработчики сигналов, не уведомляя об этом другие потоки? А что случится, если одному потоку потребуется перехватить конкретный сигнал (например, когда пользователь нажмет CTRL+C), а другому потоку этот сигнал понадобится для завершения процесса? Подобная ситуация может сложиться, если в одном или нескольких потоках выполняются стандартные библиотечные процедуры, а в других — процедуры, созданные пользователем. Совершенно очевидно, что такие требования потоков несовместимы. В общем, с сигналами не так-то легко справиться и при наличии лишь одного потока, а переход к многопоточной среде отнюдь не облегчает их обработку.

Остается еще одна проблема, создаваемая потоками, — управление стеком. Во многих системах при переполнении стека процесса ядро автоматически предоставляет ему дополнительное пространство памяти. Когда у процесса несколько потоков, у него должно быть и несколько стеков. Если ядро ничего не знает о существовании этих стеков, оно не может автоматически наращивать их пространство при ошибке стека. Фактически оно даже не сможет понять, что ошибка памяти связана с разрастанием стека какого-нибудь потока.

Разумеется, эти проблемы не являются непреодолимыми, но они наглядно демонстрируют, что простое введение потоков в существующую систему без существенной доработки приведет к ее полной неработоспособности. Возможно, необходимый минимум будет состоять в переопределении семантики системных вызовов и переписывании библиотек. И все это должно быть сделано так, чтобы сохранялась обратная совместимость с существующими программами, когда все ограничивается процессом, имеющим только один поток. Дополнительную информацию о потоках можно найти в трудах Хаузера (Hauser et al., 1993), Марша (Marsh et al., 1991) и Родригеса (Rodrigues et al., 2010).

2.3.

<< | >>
Источник: Э. ТАНЕНБАУМ Х. БОС. СОВРЕМЕННЫЕ ОПЕРАЦИОННЫЕ СИСТЕМ Ы 4-е ИЗДАНИЕ. 2015

Еще по теме Превращение однопоточного кода в многопоточный:

  1. Глава 6 ПОНИМАНИЕ ГОЛОСОВОГО КОДА АНАЛИЗ ГОЛОСОВОГО КОДА
  2. Глава 6 ПОНИМАНИЕ ГОЛОСОВОГО КОДА АНАЛИЗ ГОЛОСОВОГО КОДА
  3. Глава 5 ПОНИМАНИЕ РЕЧЕВОГО КОДА АНАЛИЗ РЕЧЕВОГО КОДА
  4. Глава 5 ПОНИМАНИЕ РЕЧЕВОГО КОДА АНАЛИЗ РЕЧЕВОГО КОДА
  5. МЕХАНИЗМ ПРЕВРАЩЕНИЯ ЦЕЛИ
  6. Превращения
  7. Превращения
  8. Часть II ПРЕВРАЩЕНИЕ СОЦИОЛОГИЧЕСКОЙ ТЕОРИИ В ПРИКЛАДНЫЕ ФОРМЫ
  9. § 2. ПРЕВРАЩЕННЫЕ ФОРМЫ В ПРИКЛАДНОМ СОЦИОЛОГИЧЕСКОМ ИССЛЕДОВАНИИ
  10. Волчица и вынос из ВД (Рассказ о превращении)
  11. Первый секрет превращения человека в личность:
  12. История превращения целеголика в человека, живущего без целей
  13. Основные части нумерологического кода
  14. Основные формулы нумерологического кода
  15. Мандала нумерологического кода
  16. Хислей Филипп. Генерация высококачественного кода для программ, написанных на СИ, 2010
  17. ОБЪЕДИНЯЯ ВСЕ ЧЕТЫРЕ КОДА ОБЩЕНИЯ