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

Мне хочется предложить на суд читателей мое собственное понимание таких неудобных аспектов, связанных с применением многопоточности для практического программирования, которое накопилось у меня за пару десятилетий успешного применения этой самой многопоточности на всех уровнях разработки от Embedded и аппаратно-ориентированных уровней до C#, WPF, Java высокоуровневых фронт-ендов.


Если на Хабре поискать по ключевому слову thread, то найдется статья

 «Что такое нити (threads)?»

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

Далее, эта найденная статья отсылает нас к другой-предыдущей статье с гордым названием:

«Процессы и потоки in-depth. Обзор различных потоковых моделей»

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

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

Вопросы терминологии

Тема достаточно сложная поэтому я не буду отвлекаться на разные нюансы, связанные с использованием терминов «Процесс», «Адресное пространство» и с некоторой неоднозначностью, которая привносится при попытках раскрыть сразу все проблемы и особенности функционирования Операционных Систем в целях изоляции разных программ, служб, драйверов при этом оставляя/определяя возможности для их взаимодействия. В большинстве случаев я использую термины «программа» или «задача» как равнозначные, чтобы заменить все возможные сущности кода такие как процессы, службы, драйвера, … чтобы не вдаваться в специфику их использования, а сосредоточиться только на том, что это все куски кода каждый из которых должен исполняться последовательно процессором в однопроцессорной системе (в предельно простом случае, ведь начинать надо сначала) как некоторая законченная непрерывная программа. Я рассматриваю проблемы взаимодействия некоторой условной ОС и условных программ под управлением этой ОС только с точки зрения последовательности и/или порядка их исполнения (в основном). То есть все проблемы связанные с разделением памяти по возможности и намеренно выведены за пределы изложенной логики чтобы максимально упростить понимание и без того достаточно сложных нюансов связанных с многопоточностью. Операционная система (ОС) — это тоже программа (код), особенная (и поэтому выделенная) в том смысле, что она определяет порядок выполнения всех других программ, это определение дано исключительно с целью облегчить понимание того, что написано далее, это не значит, что все должны придерживаться этого определения всегда.

Важным понятием является «Поток исполнения» — это последовательность и/или порядок исполнения кода, или даже способ построения кусков кода из разных программ для исполнения на единственном процессоре (изначально), я надеюсь полный смысл этого термина будет вполне понятен из контекста, там, где этот термин применяется. Отдельные упоминания потоков всегда обозначают то, что обозначается словом thread. Если другое явно не указано предполагается исполнение программ в однопроцессорной системе.

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

Можно ли жить без потоков, концепция из прошлого столетия

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

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

  1. Считывание значения с датчика в память (звучит конечно слишком просто, но представьте, что датчик — это некоторая достаточно сложная система, и чтобы получить значение требуются некоторые расчеты, управление)

  2. Вывод-рисование графика из массива накопленных значений

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

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

«какой-то период основной итерации» — это период опроса датчика;

«время бездействия задачи» — это время до конца периода опроса датчика, с того момента как значение уже получено-рассчитано;

Очевидно, что в это «время бездействия задачи» мы можем выполнять код другой задачи, которая занимается рисованием графика на экране, например.

По аналогии с известной цитатой из Мастера и Маргариты, о том, что «Вопросы крови - самые сложные вопросы в мире» хочется заметить, что «Вопросы времени - самые сложные вопросы в программировании».

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

Very old schema
Very old schema

На картинке схематично изображен абстрактный «Поток Исполнения». Когда линия потока исполнения находится внутри прямоугольника программы или Операционной Системы (которая, вообще говоря тоже программа) это говорит о том что именно эта программа исполняется на этом отрезке Потока Исполнения, а все остальные программы находятся в сохраненном состоянии или в готовности к активации (вроде как не совсем корректно говорить что программы находятся в состоянии ожидания, потому что под ожиданием можно подразумевать некоторое действие, а действия никакого точно нет для неактивных программ). Многие заметят одну как бы неточность на схеме, блок для функции AllowSleep() изображен многократно, а на самом деле это одна и таже функция ОС. Изображенные блоки [AllowSleep] надо понимать как разные вызовы (обращения к) одной и той же реализации AllowSleep внутри операционной системы. Вообще говоря, после блока [ПрограммаN] поток исполнения вернется из ОС снова в блок [Программа1], то есть блоки программ будут также повторяться, как и блоки [AllowSleep]. Важно понимать разницу между прохождением Потока исполнения через блок [AllowSleep] и через блок [ПрограммаХ]: AllowSleep это функция, которая всегда начинается сначала, а ПрограммаХ будет продолжаться после очередного вызова системной функции и это могут быть разные позиции в ПрограммаХ.  Также нужно отметить, что на схеме не изображен отдельно список описателей программ для исполнения, список, который ОС использует для запуска следующей программы, но программы [Программа1] … [ПрограммаN] как раз и составляют этот список, нет необходимости его повторно рисовать внутри блока операционной системы.

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

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

Теперь рассмотрим ту же самую схему переключения потока исполнения между программами, когда операционная система использует концепцию потоков (threads).

Schema with threads
Schema with threads

Вы видите разницу: поток исполнения стал прерывистым, он распадается на независимые кусочки, теперь программы не должны вызывать специальную функцию чтобы вернуть управление в ОС этот возврат управления в любом случае происходит теперь некоторым «волшебным» образом о котором написано далее (хотя функции в которых поток по собственной инициативе возвращает управление в ОС тоже никуда не делись, это любого типа Sleep(), любые Wait<Something>()).

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

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

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

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

Какие проблемы создают потоки

Читаем в микрософтовской документации:

"Multithreaded applications must avoid two threading problems: deadlocks and races."

Переведем deadlocks как «взаимная блокировка» и races как «гонки». Посмотрим насколько такой перевод терминов соответствует их сути в практических примерах.

Начнем с примера для гонок.

Threading races

Пусть у нас есть два потока которые разделяют переменную INDEX.

Пусть эти потоки каким-то образом определяют и считают какие-то события и инкрементируют переменную INDEX в которой сохраняется этот счет таким образом. Звучит несколько надумано, но вполне подойдет чтобы рассказать о проблеме с потоками при обращении к общей переменной (если обобщать, то к памяти вообще).

Так вот в каждом из двух потоков присутствует код:

INDEX++;

Как я написал ранее концепция потоков предполагает прерывание текущего активного кода в произвольном месте машинного кода (ассемблера), поэтому нам придется понять, что представляет собой наш супер-пупер высокоуровневый код:

INDEX++;

На машинном языке, на ассемблере это (обычно) аж целых три операции:

Оп1. Загрузка значения из памяти с адреса, помеченного идентификатором INDEX в регистр процессора;

Оп2. Инкремент значения в регистре процессора;

Оп3. Загрузка значения из регистра процессора в память по адресу, помеченному идентификатором INDEX;

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

Соответственно мы имеем не нулевую вероятность того, что поток будет прерван между любыми двумя из этих операций: Оп1- Оп2, Оп2- Оп3, Оп3- Оп1. Здесь Оп3-Оп1 это длительность от последней операции текущего инкремента до первой операции следующего инкремента.

Тут нам придется оценить вероятности (мы же занимаемся программированием, программирование — это вычислительные науки, мы должны уметь считать… ну хотя бы качественно оценить :) Для наглядности предположим, что частота событий, которые считает каждый из двух потоков, измеренная в длительностях условных машинных операций равна

=1000 условных машинных операций

И пусть все перечисленные операции имеют длительность одной условной машинной операции. Вероятность получить разрыв программы-потока внутри операции

INDEX++;

Будет равна примерно 0.002 (разрыв или после первой операции, или после второй), как ни странно, этого вполне достаточно чтобы постоянно ловить прерывания именно в этом месте.

Я, примерно так, могу растолковать откуда взялся термин гонка: проблемная-составная-операция пытается успеть завершиться до переключения потоков что бы не быть разорванной прерыванием потока. Как видите суть не в том, что что-то ускоряется чтобы что-то успеть, суть в том, что просто не известно успеем или нет, на самом деле по-русски наверно лучше здесь подходит слово «лотерея» для описанной ситуации, мне кажется.

Да! Но я увлекся и забыл описать проблему, почему же
нельзя не успеть? Проблема в том что мы отрываем значение переменной INDEX от
ячейки памяти в которой оно хранится когда копируем его в регистр процессора и
если в этот момент нас прервет система, а второй поток тоже поймает событие и
выполнит инкремент значения в ячейке памяти INDEX, то когда мы вернемся в поток с прерванной операцией
инкремента то будет сохранено старое значение в переменной INDEX, а изменения
произведенные прервавшим операцию потоком будут потеряны. Это наверно
максимально короткая формулировка для описания такой проблемы. Интересно это
только мне напоминает рассуждения о том, как змея кусает себя за хвост и
становится короче, а что будет когда она укусит свою пасть? Страшно представить
:) !

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

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

Threading deadlocks 

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

Тут я возьму мой любимый пример с видео плеером-декодером.

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

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

Теперь представим, что поток декодера заблокирован ожиданием места в буфере для записи, а поток отображения принял событие о завершении работы и благополучно закрылся и теперь уже никогда не освободит место в буфере, то есть поток декодера остался заблокированным навсегда.

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

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

Заключение

Изначально я хотел написать статью про потоковые модели, которые упоминаются по ссылке.

Но оказалось, что придется начать с изложения некоторых базовых вещей.

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

Комментарии (1)


  1. kozlyuk
    18.07.2023 10:14

    Получилось у нас не совсем то, что можно назвать взаимной блокировкой [...]

    Но на основании рассмотренных примеров мы можем сделать вывод что использовать как термин «взаимная блокировка» для перевода термина deadlock не всегда корректно так как это только один частный случай блокировки, для которой нет или потеряна возможность снять эту блокировку.

    Некорректно использовать термин "взаимная блокировка" для "не совсем того, что можно назвать взаимной блокировкой"? :) Выделенное и не является определением deadlock, которое вы забыли ввести.

    Гонки можно объяснить без машинных инструкций:

    if (size < capacity) {
        // тут ОС отнимает управление у одного из потоков
        arr[size] = value;
        size++;
    }
    

    Это лучше тем, что нет путаницы с атомарными операциями, которые на отдельные инструкции не распадаются (и имеют другие особенности), но решают проблему гонки только в специфическом случае, описанном в статье.