Меню

Синхронизация потоков многопоточного приложения



Синхронизация потоков многопоточного приложения

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

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

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

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

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

Учебник [2] представляет необходимые сведения для работы на многопроцессорной системе. В нем уделяется большое внимание практическим вопросам создания параллельных программ.

Книга [3] рассказывает о поддержке многопоточности в С++. Она включает в себя описания библиотеки потоков, atomics-библиотеки, модели памяти С++, блокировок и мьютексов (взаимных исключений) вместе с распространенными проблемами дизайна и отладки многопоточных приложений.

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

Так, в курсах «Принцип многопоточного программирования» и «Основы разработки на C++» рассматривается в контексте разработки сетевых и высоконагруженных систем. Главной целью данных курсов является обучение межпроцессному взаимодействию (IPC) и синхронизации потоков.

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

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

Возможные варианты синхронизации потоков/процессов

Синхронизация типа старт-старт. Одна задача может начаться раньше другой, но не позже.

Синхронизация типа финиш-старт задача A не может завершиться до тех пор, пока не начнется задача B. Этот тип отношений типичен для процессов типа родитель-потомок.

Синхронизация типа старт-финиш может считаться обратным вариантом синхронизации типа финиш-старт.

Синхронизация типа финиш-финиш. Одна задача не может завершиться до тех пор, пока не завершится другая, т.е. задача A не может финишировать до задачи B.

Существует ряд программных инструментов, которые могут помочь разработчику защитить разделяемые данные и сделать код потокобезопасным, их называют примитивами синхронизации, среди которых наиболее распространенные – мьютексы, семафоры, условные переменные и спин-блокировки [4–7]. Все они защищают часть кода, давая только определенному потоку право получать доступ к данным и блокируя остальные.

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

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

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

Задача мьютекса – обеспечить такой механизм, чтобы доступ к объекту в определенное время был только у одного потока. Если поток 1 захватил мьютекс объекта А, остальные потоки не получат к нему доступ, чтобы что-то в нем менять. До тех пор, пока мьютекс объекта А не освободится, остальные потоки будут вынуждены ждать.

С++ предоставляет нам 3 типа операций над базовыми мьютексами [8]:

1. lock – если мьютекс не принадлежит никакому потоку, тогда поток, вызвавший lock, становится его обладателем. Если же некий поток уже владеет мьютексом, то текущий поток (который пытается овладеть им) блокируется до тех пор, пока мьютекс не будет освобожден и у него не появится шанса овладеть им.

2. try_lock – если мьютекс не принадлежит никакому потоку, тогда поток, вызвавший try_lock, становится его обладателем и метод возвращает true. В противном случае возвращает false. try_lock не блокирует текущий поток.

3. unlock – освобождает ранее захваченный мьютекс.

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

Есть две реализации условных переменных, доступных в заголовке:

1. condition_variable: требует от любого потока перед ожиданием сначала выполнить std::unique_lock;

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

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

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

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

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

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

Большинство методов синхронизации потоков сводится к одной простой схеме: пока один поток работает с каким-либо ресурсом, другим потокам доступ к этому ресурсу закрывается. Именно благодаря данной концепции и появились такие программные примитивы как мьютексы, семафоры, условные переменные и спин-блокировки.

Источник

Многопоточность и синхронизация. Часть 2. Зачем нужна синхронизация?

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

Читайте также:  Firebird синхронизация двух баз

Принципы построения многопоточных приложений

Существуют всего две цели, которые преследуют программисты, используя потоки:

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

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

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

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

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

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

Единственный способ справиться с этой проблемой – это отказаться от принципа: каждой задаче свой поток. Чтобы полностью задействовать все процессоры, но при этом избежать излишне частых переключений контекстов, создадим набор рабочих потоков по числу процессоров. (Считается, что оптимальное число рабочих потоков – примерно в два раза больше числа процессоров в системе.) Но при этом нам снова придется самостоятельно заботиться о распределения времени этих потоков между различными задачами. Фактически мы вернулись к началу, организации параллельной работы без потоков, своими руками, но на качественно новом уровне.

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

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

Здесь я не могу не упомянуть об особом средстве Windows, предназначенном специально для организации многопоточной обработки запросов. Это «порты завершения ввода/вывода» (I/O Completion Ports). Из всех объектов ядра этот – пожалуй самый сложный и многофункциональный. Его функция как раз и заключаются в том чтобы организовать набор рабочих потоков (обычно его называют пулом потоков), и очередь, в которую система помещает сообщения о завершении асинхронных операций ввода/вывода. Порты завершения ввода/вывода позволяют создавать высокопроизводительные серверные приложения. Замечу, что поддерживаются они лишь NT-системами.

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

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

Зачем нужна синхронизация?

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

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

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

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

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

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

В конце концов, хотя многопроцессорные машины и остаются редкостью, они уже давно вышли из разряда экзотики, и вполне могут встретиться. К тому же, недавно компания Intel объявила о новой технологии под названием HyperThreading. Суть ее в том, что одни процессор выполняет несколько потоков одновременно. Это все равно, что засунуть несколько процессоров в один чип. Таким образом, широкое распространение «подлинной» многопоточности не за горами.

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

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

Как нельзя делать синхронизацию!

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

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

// НИКОГДА ТАК НЕ ДЕЛАЙТЕ!

volatile bool g_bOperationComplete = false ;

// где-то в рабочем потоке, по окончанию длительной операции:

// где-то в управляющем потоке, ждущем завершения этой операции:

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

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

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

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

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

Таким образом, если вам пришлось использовать полупустой цикл, это – несомненная ошибка разработчика данного программного интерфейса. В связи с этим хочу обратить особое внимание: если вы конструируете свой собственный API, которым будут пользоваться другие программисты, вы обязательно должны предусмотреть специальные методы оповещения о происходящих событиях. Благо система предоставляет для этого множество механизмов: оконные сообщения, специальные объекты ядра, которые так и называются: «события», асинхронный вызов процедур (APC) и т.п. Образцом (возможно даже крайностью) я бы назвал Winsock2 API, где, к примеру, отследить приход по сети очередной порции данных можно чуть ли не десятком разных способов!

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

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

«Во-вторых, не стоит совсем уж отказываться от «ручной» синхронизации потоков. Накручивать пустой цикл в ожидании результатов работы, конечно, глупо; но вот если в это время заняться чем-нибудь другим, попутно контролируя состояние переменной флага. А собственно, почему это должен быть именно флаг?! Пусть один поток сообщает в этой переменной другому потоку процент выполненной им работы.»

К сожалению, автор критической статьи абсолютно не понял о чём идёт речь. Под «ручной синхронизацией» он понимает управление потоком с помощью переменной-флага. Действительно, такая переменная – это абсолютно легальное, и более того, широко используемое средство взаимодействия потоков. Отказываться от неё никто и не предлагал. Именно для этого потоки приложения и помещены в общее адресное пространство. Заблуждение автора заключается в том, что сама по себе такая переменная не может быть инструментом синхронизации. Хотя, она конечно может выступать, как часть схемы синхронизации. По определению, синхронизация – это согласование порядка действий потоков. В приведённом автором примере, с передачей через глобальную переменную процента выполнения, синхронизация просто не требуется благодаря простоте примера. Если бы требовалось передать уже две переменные о синхронизации уже пришлось бы позаботиться. Обычно для достижения синхронизации необходимо временно приостановить выполнение потока, до тех пор, пока не будет выполнено некоторое условие. Но в приведённом в книге примере поток, вместо того чтобы быть приостановленным, был загнан в пустой цикл, что как мы знаем, недопустимо. Именно пустой цикл, а не используемая в его условии переменная, является недопустимым приёмом управления потоками!

Еще одна ошибка: попытка «ручного управления» потоками

Раз уж речь пошла о «граблях», на которые так любят наступать неопытные программисты, расскажу еще об одних, на этот раз не столь очевидных. Как я уже неоднократно повторял, для синхронизации обычно требуется на время приостановить выполнение одного или сразу нескольких потоков. Уверен, кое-кто из тех читателей, что только начали знакомиться с многопоточностью, наверняка подумал на этом месте: «Знаю! Для этого есть функции SuspendThread и ResumeThread ». Действительно первая из этих функций приостанавливает, а вторая возобновляет выполнение указанного потока. Более того, у этих функций есть даже такое полезное свойство, как счетчик блокировок: если SuspendThread для одного потока была вызвана скажем два раза, то и ResumeThread тоже придется вызвать дважды, прежде чем поток будет разблокирован. Казалось бы, эта пара функций может стать хорошим инструментом синхронизации.

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

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

// ВНИМАНИЕ. ПРИВЕДЕННЫЙ ПРИМЕР СОДЕРЖИТ ОШИБКУ СИНХОНИЗАЦИИ!

// Получаем HANDLE главного потока

// GetCurrentThread возвращает «ненастоящий» HANDLE, чтобы передать рабочему потоку настоящий, придется сделать DuplicateHandle «самому себе»

HANDLE hMainThread = NULL ;

HANDLE hCurrentProcess = :: GetCurrentProcess ();

BOOL isOk = :: DuplicateHandle ( hCurrentProcess , :: GetCurrentThread (), hCurrentProcess , & hMainThread , 0 , FALSE , DUPLICATE_SAME_ACCESS );

// Обработка ошибки. Выбрасываем исключение или как-то еще завершаем работу. На вкус читателя.

/* Кстати! Рекомендую перед любыми глобальными (в частности Win32 API) функциями всегда ставить знак «::», явно указывающий, что вы вызываете именно глобальную функцию. Это, во-первых, помогает избежать случайной путаницы с функциями-членами классов, во-вторых выделяет такие глобальные вызовы. Этот прием подобен префиксам в именах переменных. Эти, казалось бы, мелочи обычно сильно недооцениваются начинающими программистами, но на практике улучшают читабельность и главное «понимабельность» кода! */

// создаем рабочий поток

DWORD idWorkerThread = 0 ;

// здесь может быть что-то еще

// приостанавливаем самого себя

// далее идет код, в котором надо быть уверенным, что рабочий поток завершил инициализацию

// функция рабочего потока

DWORD WINAPI WorkerThreadProc ( LPVOID _parameter )

HANDLE hTheMainThread = ( HANDLE ) _parameter ; // HANDLE главного потока мы передали через параметр потока, но можно и по-другому, например используя глобальную переменную

// разблокируем главный поток

Казалось бы, все очень просто. Главный поток запускает рабочий и приостанавливает самого себя (это возможно). Рабочий поток производит инициализацию и разблокирует главный. Где же грабли? Дело в том, что распределение квантов процессорного времени неисповедимо, и нельзя гарантировать, что не случится следующая ситуация: рабочий поток закончит инициализацию и вызовет ResumeThread раньше, чем главный поток успеет вызвать SuspendThread ! Думаете, спасет счетчик блокировок? Увы, отрицательные значения в нем не предусмотрены (не знаю почему, но вероятно, у разработчиков Windows были для этого свои основания). Поэтому ResumeThread просто пройдет незамеченным, а вот SuspendThread сработает, и главный поток останется заблокированным навсегда!

Подведем итог. Хотя лично мне такие примеры неизвестны, я, пожалуй, не буду категорически утверждать, что не может быть ситуации, где управлять потоками «вручную», то есть с помощью SuspendThread и ResumeThread было бы удобнее. Но по указанной причине (отсутствию у счетчика блокировок отрицательных значений), при этом крайне трудно избежать новых проблем с синхронизацией. Поэтому я настойчиво не рекомендую вам пытаться управлять потоками подобным образом. Тем более, что Windows предоставляет для этого гораздо более удобные и универсальные механизмы, которым и будут посвящены следующие части статьи.

Итак, мы обсудили общую структуру многопоточного приложения, некоторые характерные сценарии взаимодействия потоков и убедились, что при этом неизбежно возникает необходимость в их синхронизации. Как мы видели, различные попытки организовать синхронизацию потоков «своими руками» ни к чему хорошему не приводят. Для этого следует использовать специальные инструменты, предусмотренные в системе. В следующей части мы рассмотрим важнейший из них: функцию WaitForMultipleObjects и объекты синхронизации ядра Windows. А последняя, четвертая часть статьи, будет посвящена дополнительным средствам синхронизации.

© 2003 Алексей Курзенков

Внимание. Если вы хотите перепечатать эту статью или её часть на на своём сайте, в печтном издании, где либо ещё, большая просьба, согласуйте пожалуйста это с автором! Связаться со мной можно по адресу:

Источник