Параллельные вычисления и многопоточное программирование

Процессы и потоки в операционной системе

Показывать лекцию целиком

Процессы

Появление у компьютера операционной системы (ОС) позволило перейти от однопрограммного режима работы к многопрограммному (мультипрограммному) режиму работы. Операционную систему часто называют многозадачной, полагая, что она выполняет одновременно несколько задач. То, что для ОС является задачей, с точки зрения C# программиста является приложением или проектом. В разных операционных системах для одних и тех же или схожих понятий используются разные термины. Далее, говоря об ОС, будем иметь в виду ОС Windows, и будем использовать терминологию, характерную для этой ОС.

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

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

При создании новых компьютеров, согласно закону Мура, каждые полтора года эти ресурсы удваиваются. В 1960 году оперативная память компьютера Урал, одного из лучших компьютеров на тот момент, составляла 2К, а быстродействие - 100 операций в секунду. Сегодня современный суперкомпьютер имеет быстродействие, измеряемое петафлопами - 1015 - тысяча триллионов операций с плавающей точкой. Аналогичным образом возросли и объемы оперативной памяти, примерно сто триллионов байтов. Казалось бы, можно не заботиться об экономии памяти и времени. Но это не так. Сложность появляющихся задач также растет по экспоненте. Считается, что всегда есть задачи, которые хотелось бы решить на компьютере, но мощности компьютеров не хватает для их решения.

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

Процесс - владелец ресурсов. Когда ОС создает процесс, то выделяет ему ресурсы. Процесс, несмотря на свое название, не выполняет код приложения, следовательно, время процессора непосредственно процессу не выделяется. Когда говорится, "процессы ядра ОС могут выполняться в привилегированном режиме, выполняя команды компьютера, недоступные другим процессам", то это некоторая условность. Код выполняют потоки. Именно потокам ОС выделяет процессорное время. При создании процесса ОС всегда создает поток, связывая его с процессом. В процессе выполнения потока могут создаваться и другие потоки, связанные с процессом. Подробнее об этом поговорим чуть позже, а сейчас рассмотрим стратегию управления памятью.

Процессы и стратегия управления памятью

Блестящая стратегическая идея в управлении памятью состоит в том, чтобы процессу выделять не реальную оперативную память, а виртуальную, которую уже потом некоторым образом связывать с реальной памятью. Для 32 разрядных компьютеров адресное пространство составляет 232 байтов, примерно 4 Гб. Оперативная память компьютера долгие годы была меньше виртуальной, теперь она практически сравнялась по объему. При желании можно приобрести 32-х разрядный ПК с 4 Гб оперативной памяти, хотя это и неэффективно, поскольку только 2 или 3 Гб будут использоваться в качестве оперативной памяти. По этой причине в ближайшие годы предстоит массовый переход на 64-х битную архитектуру, где виртуальная память становится практически неограниченной по нынешним меркам, так что любая реальная оперативная память будет составлять малую толику виртуального пространства.

Ноутбук, на котором я сейчас пишу этот текст, является 64-битным компьютером с оперативной памятью в 6 Гб, с 4-мя физическими ядрами и соответственно с 8-мю логическими процессорами.

Вернемся к 32-х битной архитектуре. Из 4-х Гб виртуальной памяти ОС отводит процессу 2 или 3 Гб виртуальной памяти, оставляя для себя оставшуюся часть пространства. Так что ни один из процессов не обижен, каждый получает виртуальную память одинакового размера. В то же время достаточное пространство отводится самой операционной системе, которая занимает постоянную часть виртуальной памяти, не пересекающееся с памятью, отводимой процессам. Следующая идея состоит в том, что виртуальная и оперативная память рассматривается как состоящая из страниц. Страницы могут быть большими и малыми. У тех и других есть свои преимущества. Малые страницы имеют сравнительно небольшой объем, обычно 4К.

При трансляции приложения - его программный код и необходимые данные размещаются в виртуальной памяти. На одной из виртуальных страниц находится точка входа в приложение - процедура Main, с которой начинается выполнение. Но процессор компьютера не может выполнять код и использовать данные, находящиеся в виртуальной памяти, они должны находиться в реальной оперативной памяти. Поэтому при создании процесса приложение загружается в оперативную память. Это означает, что соответствующие виртуальные страницы отображаются на страницы реальной оперативной памяти. Всякий раз, когда при выполнении требуется очередная виртуальная страница, менеджер операционной системы проверяет, загружен ли ее образ в оперативную память, и если нет, то происходит загрузка с диска (внешней памяти) соответствующей страницы в свободную страницу оперативной памяти. Но оперативная память ограничена по сравнению с виртуальной. Следует помнить, что ОС одновременно выполняет несколько приложений, все они претендуют на оперативную память, так что "пряников на всех может не хватить" - может оказаться, что свободных страниц оперативной памяти нет. Тогда наступает время свопинга - одна из занятых страниц оперативной памяти вытесняется на диск, и новая страница загружается на ее место. Какую страницу вытеснить - это проблема, решаемая операционной системой. У ОС есть свои критерии оценки того, какая из страниц наиболее вероятно не понадобится в ближайшее время. Как правило, эти критерии хорошо работают и свопинг происходит не часто, хотя встречаются "плохие" примеры, когда значительная часть времени уходит на свопинг - обмен страницами между внешней и оперативной памятью. Причина того, что свопинг происходит к счастью не часто, понятна - большую часть времени приложение проводит, выполняя в цикле некоторую часть программы, работая с фиксированным набором данных. В этом случае приложение локально работает с небольшим набором страниц, которые уже находятся в оперативной памяти. По ходу развития алгоритма точки локализации смещаются, используются новые страницы памяти, но изменение точек локализации происходит, как правило, не часто в сравнении с общим временем решения задачи.

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

Потоки и стратегия управления временем процессоров

Процесс - объект, владеющий памятью и другими ресурсами, но не выполняющий код. Поток - динамический объект, он может быть создан в процессе выполнения кода приложения и может быть удален по ходу выполнения. У процесса может быть несколько одновременно существующих потоков, выполняющих различные фрагменты кода. ОС планирует время процессоров между потоками, и для нее не имеет значение, какому процессу принадлежит тот или иной поток. Говоря о потоках в операционной системе, будем рассматривать общую схему, опуская многие детали, основываясь на стратегии распределения процессорного времени, характерной для ОС Windows. Эта стратегия носит название "вытесняющая приоритетная многозадачность". Многозадачность в данном контексте означает, что планировщик ОС, распределяет время процессора между многими потоками, присутствующими в ОС.

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

Значит ли это, что могут быть "обиженные" приложения с низким приоритетом, до выполнения которых никогда не дойдет очередь? Это не так. ОС старается никого не обидеть. Если некоторое приложение долго не выполнялось, то ОС временно повышает его приоритет, так что и оно начнет выполняться.

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

На Рис. 2.1 показаны возможные состояния потока и переходы из одного состояния в другое.

(рис 2.1) Состояния потока и переходы между ними

После создания потока и должной инициализации поток переходит в состояние "готовность", занимая в своей группе приоритетности место в конце очереди". Планировщик ОС в соответствии с описанной стратегией выбирает поток, переводя его в состояние "выполнение". По истечении отведенного кванта времени поток возвращается в состояние "готовность", становясь в хвост очереди в своей группе приоритетности. Из состояния "выполнение" поток может перейти в другие состояния и до завершения отведенного кванта времени. В состояние "готовность" он может перейти, если появился поток с большим приоритетом. В состояние "завершение" поток переходит, выполнив свою работу, завершив выполнение отведенного ему фрагмента кода. В состояние "ожидание" поток может перейти, если его дальнейшее выполнение возможно только после наступления некоторого события (например, ему требуются данные, а устройство компьютера, выполняющее ввод этих данных, еще не завершило свою работу). Из состояния "ожидание" поток может перейти в состояние "готовность", если наступило событие, ожидаемое потоком. За время жизни потока он многократно проходит цикл {готовность} -> {выполнение} -> {ожидание} -> {готовность}, иногда минуя переход в состояние "ожидания".

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

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

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

Причина исключения может быть как аппаратной, так и программной. Деление на ноль, это, конечно же, программная ошибка. Исключения, связанные с тем, что не прочитаны требуемые внешние данные, могут быть связаны как со сбоем аппаратуры, так и с неверно заданными адресами в программе. Если письмо не доставлено, то виноватой может быть почтовая служба, а возможно вы послали письмо "на деревню дедушке".

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

Современные компьютеры, настольные и портативные имеют несколько процессоров. Практически все продающиеся сегодня компьютеры, предназначенные для индивидуального использования, имеют от двух до четырех ядер. Это позволяет организовать параллельное выполнение фрагментов кода в одном приложении, ускоряя его работу. Для этого в приложении создаются несколько потоков, параллельно работающих, каждый в отдельном ядре процессора. Иногда удается при N ядрах примерно в N раз уменьшить общее время работы приложения. Но, конечно, это возможно не для всякого приложения, а если и возможно, то требует усилий со стороны программиста. Многопоточный параллельный алгоритм сложнее однопоточного последовательного алгоритма. Сложнее становится и отладка. Нужны ли программисту дополнительные сложности? Хотим мы того или нет, но параллельное программирование становится одним из важнейших направлений развития современного программирования. Современные суперкомпьютеры имеют сотни тысяч процессоров. Высокопроизводительные вычисления, требующие распараллеливания алгоритмов, становятся реальностью. Использовать многоядерный компьютер только для последовательных алгоритмов неэффективно, - все равно, что использовать телескоп в качестве лупы для чтения убористого текста.

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

Процессы, потоки и данные

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

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

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

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

В адресном пространстве ОС для каждого процесса в момент его создания выделяется специальный блок памяти, называемый EPROCESS, хранящий системную информацию о процессе. Еще один блок с системной информацией - PEB (Process Environment Block) хранится в адресном пространстве самого процесса. В страницах виртуального адресного пространства процесса хранится код приложения и данные, необходимые для работы. Данные хранятся в памяти, называемой стеком (stack) и кучей (heap). Куча создается в момент создания процесса. У процесса может быть несколько куч. Код приложения может храниться частично в закрытых страницах, частично разделяемых страни

цах памяти. Разделяемые страницы двух или более процессов могут отображаться на одни и те же страницы реальной оперативной памяти. За счет этого несколько процессов могут использовать один и тот же программный код в оперативной памяти. Разные приложения могут использовать одну и ту же библиотеку классов - DLL, расположенную в оперативной памяти без дублирования. Понятно, что это не касается данных, данные у каждого процесса свои. Для хранения данных процесса операционная система выделяет защищенные страницы, так что никакой процесс не может получить доступ к данным другого процесса. Есть исключение из этого правила, когда организуется взаимодействие между процессами, но эту ситуацию мы рассматривать не будем.

В адресном пространстве ОС для каждого потока в момент его создания выделяется специальный блок памяти, называемый TPROCESS, хранящий системную информацию о потоке, а в адресном пространстве процесса создается блок с системной информацией - TEB (Thread Environment Block). Для каждого потока создается контекст потока. Уже говорилось, что в ходе работы процессора компьютера с большой частотой происходит смена потоков - пользовательских и системных. Процессор прекращает выполнять один поток и начинает выполнять другой поток. Процесс переключения называется переключением контекстов. Понятно, что, если в любой момент выполнение потока может быть прервано, а затем продолжено через некоторое время, то контекст потока должен содержать всю информацию, необходимую для продолжения вычислений в точке прерывания. Поэтому контекст потока включает все локальные данные потока, адрес команды в программном коде, с которой продолжится вычисление, состояние всех системных регистров в момент прерывания, состояния всех файлов, с которыми работал поток.

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

Потоки. Взгляд программиста

Мы рассмотрели понятие потока с позиций операционной системы. Это понимание важно для программиста, но не менее важно рассмотрение потоков как обычных программных объектов, понимание того, когда эти объекты следует создавать в программах, и как корректно работать с ними.

Когда нужны потоки

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

  • последовательность (блок, составной оператор),
  • выбор (if, switch),
  • цикл (разнообразные формы).
  • В распоряжении программиста есть важные возможности обобщения - часть алгоритма можно выделить в отдельную подзадачу, дать ей имя и рассматривать как новый элемент управления. Такие подзадачи оформляются как процедуры или функции. C# программист в этих ситуациях создает метод класса. Классы являются более мощной формой обобщения, реализуя такое понятие как тип данных, где задана форма представления данных и операции над ними.

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

    A(); B(); C();

    то это означает, что вначале нужно вызвать и выполнить метод А, затем В, затем С. Такой подход существенно облегчает понимание того, что делает программа в процессе вычислений, производя мириады элементарных операций при выполнении вызванного метода. Но зачастую действия можно выполнять в любом порядке и параллельно. Когда исполнитель действий один (например, один процессор), то параллельное выполнение невозможно, а порядок выполнения возможно не важен. В сказке о Золушке, уезжая на бал, мачеха приказала ей начистить посуду, подмести и вымыть пол, рассортировать мешок с фасолью, отделяя цветные зерна от белых. Порядок выполнения работ для Золушки не важен, за тем исключением, что пол нужно вначале подмести, а затем уже вымыть. И ни при каком порядке бедной Золушке не справиться со всеми работами в отведенное время. Но если есть в мире чудеса, то, работая одновременно, мыши могут рассортировать фасоль, белка - начистить до блеска посуду, енот - подмести и вымыть пол, а Золушка в это время может поехать на бал.

    Вернемся, однако, из сказки к задачам программирования и рассмотрим такую прозаическую задачу как вычисление суммы элементов массива. Как нас учат находить сумму? Вначале переменной S нужно дать значение 0, а затем последовательно в цикле прибавлять к S очередной элемент массива. Выполнив N сложений, получим сумму N элементов. Время выполнения - величина порядка O(N). Но ведь этот последовательный классический алгоритм суммирования далеко не единственный. Суммирование можно вести в любом порядке, вычисления можно распараллелить. Если в нашем распоряжении имеется N/2 процессоров, то они параллельно могут вычислить суммы пар элементов, на следующем шаге можно получить суммы четверок. Продолжая этот процесс, можно вычислить сумму элементов за log(N) шагов и тогда за счет параллелизма время вычислений существенно сокращается, являясь величиной порядка O(Log(N)). Конечно, использовать потоки для распараллеливания таких задач, как вычисление суммы элементов, обычно не приходится. Но стоит заметить, что современные мощные процессоры, которые могут иметь несколько арифметических сопроцессоров, на уровне выполнения команд организуют конвейерную обработку, где частично осуществляется распараллеливание вычислений.

    Потоки обычно создаются программистом для параллельного выполнения подзадач, которые для C# программиста представлены методами класса. В рассматриваемой выше задаче вызова трех методов - A, B, C, если эти методы выполняют независимые вычисления, то разумно организовать три потока и тогда компьютер, у которого число ядер больше 2-х, будет параллельно выполнять вычисления, так что общее время вычислений будет определяться временем работы самого трудоемкого из трех запущенных методов.

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

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

    Отсутствие взаимодействия существенно облегчает организацию работы многопоточного приложения. Такие жизненные ситуации возможны, но чаще приходится иметь дело с взаимодействующими потоками. Одна из простейших форм взаимодействия - это ожидание. Выполнив свою часть работы поток должен приостановить выполнение, дожидаясь окончания работы другого потока или потоков, поскольку дальнейшие вычисления требуют данных, полученных от нескольких потоков. Вот пример из военной области. Наступление ведут две армии, у одной из них наступление идет успешно, но она вынуждена приостановить наступление, дожидаясь аналогичных результатов соседа, иначе это может грозить неприятностями, окружением и прочими сложностями. У потоков C# есть соответствующий механизм приостановки выполнения.

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

    Реентерабельные и потоко-безопасные модули

    Несколько слов о терминологии. То, что C# программист называет методом, с точки зрения потока ОС является модулем. Уже говорилось, что один и тот же модуль может быть вызван в разных потоках, так что код модуля будет одновременно выполняться на разных процессорах (ядрах) компьютера. Для гарантирования корректной работы модуля в такой ситуации на него накладываются определенные ограничения. Рассмотрим два важных понятия - реентерабельного и потоко-безопасного модуля.

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

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

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

    Итоги

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

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

    Создание многопоточных приложений сопряжено с двумя видами трудностей:

  • Велики накладные расходы. Как следует из краткого обзора, приведенного в этой главе, на создание и обслуживание потока ОС требуется память и время (создание соответствующих структур данных, поддержка их работы). Создание и уничтожение потока - это достаточно дорогие операции. Поэтому излишнее увлечение многопоточностью может привести к потере эффективности работы приложения вместо желаемого ее улучшения. Раздутый административный аппарат - зло, а не благо.
  • Реализация многопоточного приложения требует усложнения алгоритма. Алгоритм, допускающий распараллеливание, зачастую сложнее последовательного алгоритма, решающего одну и ту же задачу. Но есть приятные исключения. Более того, иногда распараллеливание может быть выполнено автоматически с минимальными затратами для программиста.
  • Говоря о принципах построения ОС и многопоточных приложений, подразумевалось, что речь идет о компьютерах с общей памятью. Для кластеров и суперкомпьютеров требуются операционные системы, учитывающие архитектуру таких комплексов, способы обмена сообщениями между компьютерами комплекса.

    Вернуться к учебному плану