Библиотека сайта rus-linux.net
калинин.ru / программирование / unix / | 03.07.01 |
Потоки
Хочется наконец-то выполнить данное мною еще полгода назад обещание и рассказать о том, что такое потоки в Unix и каких типов они бывают. Предполагается, что читатель хотя бы в общих чертах представляет себе что такое процесс и поток.
В Unix изначально существовала только одна модель одновременного выполнения нескольких задач --- разделение их на несколько процессов, каждому из которых выделялось бы некоторое количество процессорного времени. Изначально эта схема была чрезвычайно проста и эффективна, в частности из-за того, что Unix в своем первоначальном варианте был рассчитан на работу только с одним процессором. Это значительно упрощало всю схему обеспечения многозадачности, потому что при ориентированности на один процессор синхронизация в пределах ядра не требовалась (учитывая то, что вызовы ядра всегда минимизировались по времени и не могли вытесняться другими вызовами ядра).
═ | ══ | Лирическое отступление: | |
═ |
Распространено заблуждение, что "свободные" варианты Unix ни в чем не уступают коммерческим и что единственный плюс, который можно получить приобретая коммерческий вариант Unix, это техническая поддержка со стороны фирмы-производителя. Так вот это не так: коммерческие операционные системы отличаются во многом, в частности, в алгоритмах, поддерживающих многозадачность, методах распределения оперативной памяти или сетевых средствах. Другое дело, что в большинстве ситуаций возможностей той же FreeBSD хватает с избытком. |
С другой стороны, именно развитие этого подхода в разных направлениях определило множество сложных коммерческих и свободно-распространяемых операционных систем, которые имеются на сегодняшний момент.
Фактически, с процессом связывается его адресное пространство (данные), код выполняемой программы, стек, содержимое регистров и еще некоторое количество структур данных ядра (таких как идентификаторы группы и пользователя, присоединенный терминал, приоритет выполнения и прочее). В тот момент, когда происходит переключение процессора с одного процесса на другой, ядро должно каким-то образом сохранить состояние одного процесса и потом восстановить состояние другого. Мало того, когда один процесс вызывает какой-то вызов ядра, выполняются примерно такие же действия: сохраняется состояние процесса, затем происходит переключение регистров таким образом, чтобы было возможно выполнить код, находящийся внутри ядра, после чего состояние процесса (обращавшегося к ядру или иного, если время выделенное процессу уже истекло), восстанавливается.
Эта схема очень проста, но у нее есть много недостатков. Например, значительно усложняется межпроцессное взаимодействие. Конечно же, в современных вариантах Unix существуют такие способы, как разделяемые пространства оперативной памяти, но было время, когда единственным способом оперативно передать большое количество данных от одного процесса к другому было использование программного канала. В то же время, очень часто является удобным разделить одну и ту же задачу на несколько маленьких подзадач так, чтобы они разделяли все свои данные.
Кроме того, описанный выше порядок смены контекста процесса слишком дорогостоящий и не позволяет эффективно разбить один поток управления на несколько, потому что при этом возникают накладные расходы на сохранение и восстановление состояний процесса.
Потоки появились как логическое продолжение понятия процесса. Во-первых, появились некоторые задачи внутри ядра, которые точно так же требовалось выполнять параллельно с остальными работающими процессами. Эти задачи не имеют своего собственного адресного пространства и работают внутри пространства ядра. Фактически, они представлены лишь стеком данных и набором регистров, что позволяет ядру очень быстро переключаться между ними. Понятно, что ядро современной операционной системы посредством разделения некоторых частей внутри себя на такие потоки управления, может работать одновременно на нескольких процессорах, в случае необходимости.
Подобные потоки (своеобразные внутренние процессы ядра) называются потоками ядра. В современных операционных системах традиционные процессы реализуются именно на них.
Потоки внутри пользовательской программы обычно служат для того, чтобы дать возможность программисту разделить задачу на несколько подзадач для более удобного и логичного внутреннего представления программы. Обычно программист не особенно заботится о том, сколько процессоров есть на компьютере, где будет запущена его программа, и действительно ли затребованные им десять потоков будут работать одновременно на десяти процессорах. Поэтому потоки, предоставляемые в распоряжение клиентских программ, на самом деле, могут вообще не иметь никакого отношения к потокам ядра и быть ограничены только одним процессором!
Мало того, переключение потоков может быть реализовано и без поддержки потоков ядром исключительно на пользовательском уровне. То есть, если знать о том, как на конкретной архитектуре микропроцессора устроен стек, то, опять же, имея доступ к регистрам процессора, можно переключать выполнение команд из одного потока в другой, сохраняя при этом всю "историю" вызова подпрограмм в каждом потоке. Тем самым получается многопоточная программа, не требующая поддержки потоков со стороны ядра. Вообще, ядро в этом случае может быть честно уверенно, что выполняется обычный процесс --- с точки зрения операционной системы подобный многопоточный процесс ничем не отличается от однопоточного.
Такой подход обеспечения многопоточности получил название "пользовательских потоков" и он имеет некоторые преимущества по сравнению с использованием нескольких процессов: например, совершенно не требует сохранения всего состояния процесса при переключении от одного потока к другому, только модификацию нескольких регистров (командного и стека). К недостаткам можно отнести то, что даже на нескольких процессорах такие потоки не могут выйти за пределы одного, то есть они могут вместе с процессом переходить с одного процессора на другой, но не могут выполняться одновременно на двух.
В некоторых операционных системах ядро все-таки предоставляет доступ пользовательским процессам к потокам ядра. При этом, понятно, что такие потоки требуют значительно меньше затрат на переключение, чем процессы и позволяют при этом использовать столько процессоров, сколько имеется на данном компьютере, но все операции с потоками требуют переключения контекста процесса в контекст ядра, включая такие часто используемые процедуры в многопоточных приложениях, как синхронизацию. Это значит, что эффективно их можно использовать только в случаях практически независимых потоков, которые очень редко используют общие переменные.
Существуют комбинированные подходы. При этом ядро операционной системы все-таки знает о том, сколько потоков имеется у процесса и может выделить несколько потоков ядра на один процесс так, чтобы пользовательские потоки можно было распределить между потоками ядра. При этом, понятно, потоков ядра может быть выделено много меньше, чем пользовательских потоков.
Практически все известные коммерческие Unix-подобные операционные системы поддерживают такие "комбинированные" потоки. Мало того, почти выработан общий стандарт на программный интерфейс к пользовательским потокам под названием pthreads, используя который программисты могут писать многопоточные приложения почти не задумываясь о том, под какой операционной системой будут выполняться их программы.
"Почти", это потому, что существует много тонкостей. Например, в известной операционной системе FreeBSD библиотека потоков pthreads является целиком пользовательской (то есть, несколько потоков будут всегда выполняться в пределах одного процессора). Связано это с несколькими ограничениями внутри ядра, которые не позволяют сделать целиком совместимую со стандартом библиотеку pthreads, использующую процессы ядра. Если об этих ограничениях знать, то под FreeBSD можно воспользоваться "портом" linuxthreads.
Использование процессов ядра зачастую очень полезно. Например, если существует цикл вида:
for(int i = 0; i < 1000000; i++) A[i] = B[i]*C[i];
где A[i], B[i] и C[i] --- квадратные матрицы достаточно большого размера, то разделив этот цикл на, допустим, имеющиеся в наличии четыре процессора функциями вида:
void* foo(void* start) { for(int i = *(int*)arg; i < 1000000; i += 4) A[i] = B[i]*C[i]; return NULL; }
где через arg
передается число от 0 до 3, то можно
получить ускорение данного куска программного кода в четыре раза
(если, конечно же, операция умножения матриц может выполняться
параллельно).
С другой стороны, если просто взять такой код и перенести его на компьютер с, допустим, двумя процессорами, то он будет выполняться медленнее, чем если бы использовалось только два потока ядра, потому что много времени уйдет на переключение из одного потока в другой.
Кстати сказать, некоторые современные коммерческие компиляторы фортрана умеют выделять подобные циклы и самостоятельно распараллеливать на заказанное при выполнении программы количество процессов ядра.
В то же самое время, использование в подобной ситуации пользовательских потоков будет совершенно неоправданно. Зато пользовательские потоки позволяют программисту достаточно простым способом организовывать ожидание некоторых редких событий, к примеру, нажатие пользователем клавиши на клавиатуре или получения данных из сетевого устройства. Использование потоков в этих случаях позволяет зачастую значительно упростить программу внутри.
Тем не менее, я не рекомендую использовать пользовательские потоки для программ, работающих под большими нагрузками. Опыт показывает, что библиотеки пользовательских потоков очень часто содержат в себе ошибки, а "реентерабельные" версии используемых подпрограмм значительно меньше оттестированы, чем обычные. Кроме того, становятся существенными затраты на переключение пользовательских потоков и, в общем не нужную синхронизацию. В то же время, обычно программу, использующую пользовательские потоки можно не менее эффективно переписать при помощи использования неблокирующих, асинхронных или событийных средств ввода-вывода, предоставляемых операционной системой.
Не стоит забывать и о традиционном для Unix'а разделения задач: процессах. Во многих ситуациях последовательной обработки данных их использование позволяет эффективно загрузить все используемые ресурсы ЭВМ не сталкиваясь при этом с потенциально опасными "реентерабельными" версиями программных библиотек.
Резюме
Необходимо понимать различие между потоками ядра и пользовательскими потоками. Они отличаются друг от друга и применяются в совершенно разных ситуациях. Одно из основных отличий: потоки ядра могут работать одновременно на нескольких процессорах, а пользовательские --- нет.
2000-2002, Andrey L. Kalinin mailto:andrey@kalinin.ru |