Видео: YouTube
В этой статье мы продолжим рассмотрение вопроса многозадачности. Чуть ранее мы поговорили о процессной многозадачности. Это когда в памяти компьютера существуют одновременно несколько участков исполняемого кода, данных и стека. На этот раз речь пойдет параллельном выполнении задач в рамках одного процесса.
Что такое поток?
Теперь о том, что такое поток. Это единица многозадачности меньшего размера в сравнении с процессом. Наравне с процессной многозадачностью получила развитие поточная многозадачность. Эта концепция позволяет в одном монолитном процессе реализовать все функции сложного приложения. При вызове функций ядра, требующих длительного времени ожидания или просто при вызове функций, требующих длительного вычислительного процесса можно организовать параллельные ветви исполнения программы в которых будут выполняться эти функции. При этом ожидание результата не затормозит работу всего приложения. Как же это организовано? Да все просто.
Технические подробности
Манипуляции с регистрами
Как мы ранее узнали, в состав процесса входят значения адресных регистров, они определяют места страниц в физической памяти.
В регистрах атрибутов страниц (ATTR) содержатся границы страниц и права на чтение и запись. Отдельные регистры (CACHE, RAM, SWAP) содержат информацию о местонахождении страниц в кэш памяти, оперативной памяти или на диске если ранее пришлось туда их откачать. Регистры общего назначения, флаги (Flags), указатель стека (SP), указатель инструкции (PC) завершают общую картину процесса. А теперь самое интересное. Называем информацию в последней секции регистров потоком.
Сейчас в популярных операционных системах исполняются потоки, а не процессы. В каких-то приложениях один поток, в каких-то их несколько. Вот в данном случае приложение простое, оно содержит один поток. Этот поток определяет адрес текущей инструкции (PC) в секции исполняемого машинного кода, вершину стека (SP).
Еще не забудем упомянуть, что поток определяется состоянием флагов (Flags) и состояние всех регистров общего назначения (R0 - R4).
Запуск второго потока
Предлагаю запустить еще один поток в нашем процессе. И произойдет тут вот что.
В структуре данных процесса появится еще одна запись об исполняемом потоке. В этой новой записи еще один комплект информации о состоянии регистров. Регистры общего назначения, флаги, указатель инструкций и особое внимание на регистр указателя стека (SP). Поскольку выполнение программы пойдет по совсем другой ветке, то в ней будут вызываться свои функции, им будут передаваться свои параметры. Для всего этого необходим отдельный стек. При вызове потока под стек выделяется место в незанятом ранее пространстве.
Такой стек называется стеком потока.
Какую особенность можно заметить в поточной многозадачности?
Циклическое выполнение потоков
Поскольку все происходит в одном логическом адресном пространстве процесса, то потоки в равной степени могут пользоваться всеми ресурсами процесса. Это могут быть глобальные переменные, а также ресурсы, предоставляемые ядром операционной системы. К ресурсам ядра можно отнести структуры описатели устройств ввода вывода и их разновидность это файлы на диске. Все довольно неплохо, когда ресурсы предназначены только для чтения, это работает просто, быстро и идеально точно. Но вся радость заканчивается тогда, когда потоки пользуются ресурсами, в которые нужно кроме того еще и записывать данные. Но не будем пока портить себе настроение. Как это работает? Каждый из запущенных процессов обладает одним или несколькими потоками, вся информация об этом содержится в дескрипторе процесса.
При поступлении процесса на обработку в регистры процессора заносятся все необходимые данные. Поток процесса это довольно небольшой набор регистров.
Поработав квант времени, поток будет заморожен операционной системой, которая сохранит содержимое регистров потока. Следующий поток после разморозки попадает на исполнение процессором где и проработает следующий квант времени. Механизм, по которому все это функционирует уже нам хорошо известен из прошлых статей и называется он аппаратное прерывание. Таймер компьютера подает сигналы на линию прерывания процессора, он, в свою очередь определяет что это за прерывание и вызывает для этого сигнала свою функцию обработчик. В нашем случае функция обработчик прерывания от таймера занимается циклической отправкой всех потоков на исполнение. Все это очень напоминает работу процессной многозадачности. Она, кстати, никуда не исчезла. Процессы все также в большом количестве находятся в системе, но происходит лишь смена их потоков. Они попадают в очередь на исполнение, выполняются на процессоре квант времени, при окончании кванта времени попадают снова в очередь. Часть потоков могут вызывать функции ядра, требующие длительного времени ответа. При этом чтобы не занимать процессорное время они помещаются в очередь с состоянием блокировки до наступления какого-либо события. Как только блокировка закончилась, поток направляется в общую очередь готовности наравне со всеми.
Проблемы многопоточности
В целом необходимо отметить, что многопоточность это своего рода высший пилотаж в программировании. И тот, кто хорошо понимает суть происходящего в недрах операционной системы становится востребованным специалистом. Пораскинуть мозгами тут есть над чем. Как уже ранее отмечалось, если ресурсы процесса только для чтения, то проблем нет. Но это довольно абстрактная ситуация. Кроме чтения необходима и запись и вот тут все осложняется. Современные популярные операционные системы работают с многоядерными процессорами. У каждого ядра своя локальная кэш память.
Потоки из очереди попадают на обработку в ядра процессоров. А вот теперь представьте, что есть какой-то ресурс, в который потоки пишут данные и какие-то потоки читают эти данные из ресурсов. Из общей памяти в локальный кэш попадают копии страниц памяти. Это значит, что каждый поток может делать со своей копией все что угодно и при перекачке страниц обратно в общую память нет никакой гарантии корректности результата. Кто последний перекачает свою копию обратно, тот и прав.
Такое положение дел никуда не годится. Решением проблемы являются так называемые блокировки ресурсов. По сути это еще одни из ресурсов ядра, предотвращающие множественный доступ к основному ресурсу. Прежде чем что-то менять в своей копии, нужно вызвать функцию ядра, где спрашивается у блокировки, а не сделал ли кто-то обращение чуть раньше. Если уже кто-то занял ресурс, то нужно подождать освобождения. Тот кто занял ресурс когда-то его освободит, вызвав функцию ядра. После этого другие потоки могут попробовать занять нужный ресурс.
Для облегчения труда программистов придумываются все новые и новые языки программирования, а также библиотеки к ним. Обычному программисту предоставляются удобные абстракции для работы в условиях поточной многозадачности.
Подход к безопасной многопоточности
Одним из новых языков программирования является Rust. Особенный его подход к сохранению высокой производительности в том, чтобы не дать программисту скомпилировать программу с проблемами доступа к общим ресурсам. К ресурсам на чтение можно обращаться без ограничений, а вот правом на запись в отдельно взятый момент времени обладает только один поток. Это позволяет обходиться без блокировок, пожирающих производительность, но можете поверить, образ мышления программиста должен быть изменен до неузнаваемости.
По счастью, такие подробности можно упаковать в более менее удобные абстракции, например в канал обмена данными. На разные его концы можно повесить потоки чтения и записи и никаких вам специальных обращений к блокировкам. Это позволяет облегчить написание сложных и все еще производительных приложений.
К сожалению, формат статьи не позволяет более-менее глубоко рассказать о многопоточности. Разные тонкости так или иначе будут появляться при рассмотрении других вопросов.