на домашнюю страницу автора

к списку статей

предыдущая часть

Многопоточность и Синхронизация. Часть 4. Дополнительные инструменты синхронизации.

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

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

Критические секции.

Кроме событий, мьютексов и семафоров в Win32 есть ещё один объект синхронизации: критическая секция. По своей функциональности она практически полностью соответствует мьютексу, о котором я рассказывал в предыдущей части. Напомню, что мьютекс используется главным образом для синхронизации доступа потоков к общему ресурсу. Критическая секция имеет то же самое назначение и применяется очень похожим образом. Поэтому, чтобы не пересказывать всё, что я уже говорил про мьютекс, остановимся только на их отличиях. Главное из них заключается в том, что критическая секция не является объектом ядра. Поэтому, с ней нельзя работать используя WaitForMultipleObjects и другие функции ожидания. Вместо неё для критической секции есть своя специальная функция: EnterCriticalSection. По сравнению с WaitForMultipleObjects она предельно упрощена: ей можно передать указатель только на одну критическую секцию и нельзя даже указать таймаут. Функции ReleaseMutex тоже соответствует своя функция: LeaveCriticalSection. Таким образом, чтобы защитить участок кода, где ведётся работа с общими данными, от вмешательства других потоков, надо окружить его парой вызовов: EnterCriticalSection - LeaveCriticalSection.

Что же получается? Критическая секция – это тот же самый мьютекс, только со значительно урезанными возможностями. Зачем же она вообще нужна? Единственное, но важное преимущество критической секции заключается в том, что обращение к ней происходит примерно в 100 раз быстрее, чем к объекту ядра. С другой стороны, синхронизация потоков при работе с общими данными и ресурсами является пожалуй самой распространённой задачей синхронизации в многопоточных приложениях. При этом расширенные возможности мьютекса требуются не слишком часто. Обычно надо просто оградить участок кода от одновременного исполнения несколькими потоками, и всё! Здесь как раз идеально подойдёт критическая секция.

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

Атомарные операции. («interlocked» функции)

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

Уверен, подавляющее большинство читателей, даже новичков, знакомо со счётчиком ссылок. И именно он является наиболее типичным случаем, когда в пользовательской программе используют атомарные операции. Пусть мы строим, скажем COM-объект, или что-то ещё, где нужна система определения времени жизни объекта. В подобных случаях обычно появляется примерно такой класс:

class CReferenceCounter {

public: // functions

CReferenceCounter( void ) : m_nReferences( 0 ) {}

virtual ~CReferenceCounter( void ) { ASSERT( !m_nReferences ); }

void AddRef( void ) { ++m_nReferences; }

void Release( void ) { if( !--m_nReferences ) delete this; }

private: // data

unsigned long m_nReferences;

};

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

Каждый, кто работает с объектом, вызывает в начале AddRef, увеличивая счётчик, а по окончании работы – Release, уменьшая его. Если счётчик при очередном вызове Release стал равен нулю, значит объект никому больше не нужен и Release удаляет его. (Предполагается, что все такие объекты создаются оператором new.)

Примечание. Об использовании счётчиков ссылок я подробно рассказывал также в статье «Поучительный CString».

Однако непосредственно в таком виде, как показано выше, класс непригоден для использования в многопоточной среде. Запись ++m_nReferences, хотя и выглядит коротко, транслируется компилятором в целую серию команд, когда операнд сначала считывается из памяти, затем увеличивается на единичку, затем записывается обратно... Если в это время процессор переключится на другой поток, который как раз тоже вызовет AddRef или Release, значение счётчика ссылок в результате наверняка окажется неверным. Чем это грозит, понятно. В лучшем случае объект останется жить, когда он уже не нужен, в худшем будет уничтожен раньше времени. И если первое обычно грозит лишь утечками памяти, второе неизбежно приведёт программу к полному краху.

Чтобы решить эту проблему, проще всего вместо ++ и -- использовать атомарные функции InterlockedIncrement и InterlockedDecrement. Они гарантируют, что между чтением величины счётчика из памяти и записью туда нового значения поток не будет прерван. Метод AddRef, например, должен будет выглядеть так:

void CReferenceCounter::AddRef( void ) {

::IntrlockedIncrement( &m_nReferences );

}

С методом Release дело обстоит немного сложнее. Он должен не только поправить значение счётчика, но проверить, не обнулился ли он. К счастью, функции IntrlockedDecrement и IntrlockedIncrement позволяют сделать и это. Если в результате уменьшения или увеличения счётчика он станет нулём, возвращаемое значение тоже будет нулём. Благодаря этому метод Release получается таким же простым:

void CReferenceCounter::Release( void ) {

if( !::IntrlockedDecrement( &m_nReferences ) ) {

delete this;

}

}

Таким образом, IntrlockedIncrement и IntrlockedDecrement выполняет атомарно сразу две операции: изменяют счётчик на единичку и сравнивают результат с нулём. Если бы эти функции не позволяли делать операцию сравнения, они вряд ли имели бы такую ценность, поскольку для сравнения с нулём нам пришлось бы снова прочитать значение счётчика из памяти, а в это время его мог бы изменить другой поток. Это общее свойство всех Interlocked функций: все они позволяют атомарно выполнить не одну, а как минимум две или даже три простых операции.

В принципе, счётчик ссылок – обычный случай синхронизации доступа нескольких потоков к общим данным. Ничто не мешает использовать здесь и мьютекс, и критическую секцию. Достоинство атомарных операций в том, что они выполняются почти мгновенно, практически так же быстро, как и обычная операция ++ или --. Всё дело в том, что атомарные операции поддерживаются на уровне «железа». Так, на привычной нам x86 платформе, каждой из Interlocked функций соответствует специальная команда процессора. Таким образом, атомарные операции – это самый эффективный с точки зрения производительности и других накладных расходов инструмент синхронизации.

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

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

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

class CSimpleCriticalSection {

public: // methods

CSimpleCriticalSection( void );

bool TryToEnter( void );

void Leave( void );

private: // data

LONG m_idOwnerThread;

};

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

Метод TryToEnter должен возвращать true, если потоку удалось захватить объект, и false, если он в данный момент захвачен другим потоком. Для этого, он должен проверить, равен ли нулю m_idOwnerThread, и если да, записать туда идентификатор вызвавшего его потока. Но возникает проблема: если поток будет прерван как раз в тот момент, когда он уже выполнил сравнение, но ещё не успел записать свой идентификатор, тогда если другой поток в это время тоже вызовет TryToEnter, произойдёт ошибка.

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

inline bool CSimpleCriticalSection::TryToEnter( void )

{

LONG idPreviousOwnerThread = ::InterlockedCompareExchange( &m_idOwnerThread, ::GetCurrentThreadId(), 0 );

return idPreviousOwnerThread == 0;

}

Чтобы освободить нашу секцию, достаточно просто обнулить идентификатор потока-владельца:

inline void CSimpleCriticalSection::Leave( void )

{

ASSERT( m_idOwnerThread == ::GetCurrentThreadId() );

m_idOwnerThread = 0;

}

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

В заключении хочу обратить ваше внимание, что часть Interlocked функций, в частности упомянутая InterlockedCompareExchange, поддерживаются лишь начиная с Windows 98 и NT4. Связано это с тем, что соответствующие им команды появились только в 486, но отсутствовали в 386 процессоре. Старые версии Windows, в частности 95, были рассчитаны на совместимость с 386 процессором, потому не могли использовать новые команды. С этим связаны и упомянуты различия в поведении InterlockedIncrement и InterlockedDecrement. Так что, если вашей программе требуется совместимость со старыми версиями Windows, будьте внимательны.

Дополнение

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

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

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

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

Кстати, мне встречалась рекомендация размещать данные, с которыми производятся атомарные операции (к примеру, те же счётчики ссылок) в специальной некэшируемой области памяти. Её можно выделить, вызвав функцию VirtualAlloc, указав в параметре flProtect флаг PAGE_NOCACHE. При работе с такой областью памяти операции чтения/записи всегда производятся напрямую в системную память, миную кэш процессора. Утверждается, что поскольку при этом процессорам не требуется осуществлять сложную процедуру синхронизации кэшей, атомарные операции выполняются чуть быстрее. Сам я этот факт не проверял, и подозреваю, он может сильно зависеть от конкретной модели процессора.

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

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

Функция окна, функция DllMain.

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

Функция окна входит в азы программирования под Windows. Её свойства, в том числе однопоточность, хорошо известны даже начинающим программистам. Напомню, есть два принципиально разных способа передачи сообщений. Функция PostMessage помещает сообщение в очередь сообщений, обработку которой может вести только поток-хозяин окна. Таким образом, эти сообщения обрабатываются в однопоточном режиме «по определению». Функция SendMessage отправляет сообщение напрямую, в обход очереди. Но для тех кто «лезет без очереди», система образует другую, дополнительную очередь. Таким образом, система заботится, чтобы обработка этих сообщения всё равно не пересекаясь с сообщениями из основной очереди и друг с другом, даже если их отправляют разные потоки. Поэтому PostMessage, SendMessage очень удобно использовать, когда потоки должны обмениваться небольшими порциями информации, даже если эти потоки принадлежат разным процессам. О синхронизации при этом позаботится система. Впрочем, не теряйте бдительность! Хотя функция SendMessage и выглядит совершенно безобидно, в некоторых специфических ситуациях она может вызвать взаимоблокировку (deadlock).

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

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

Ноги у функции DllMain растут всё из тех же проблем с многопоточностью стандартной библиотеки языка С, (также её называют «библиотекой времени исполнения» или RTL, run-time library), о которых я рассказывал ещё в первой части статьи. Язык C и пришедший ему на смену C++ (что к сожалению не помешало C++ использовать всё туже стандартную библиотеку C) был и остаётся основным средством разработки под Windows. К сожалению, язык C создавался ещё в те времена, когда многопоточность не поддерживалась господствовавшими тогда UNIX-системами. Поэтому создатели языка не позаботились о том, чтобы стандартная библиотека языка была бы совместима с многопоточностью (thread safe). Например, в ней активно используется глобальные переменные, что категорически противопоказано многопоточной программе. Что поделаешь, такой стиль программирования, в те далёкие времена считался нормой.

Естественно с приходом многопоточности потребовались изменения. Полностью выкинуть струю библиотеку и создать новую никто не решился. Вместо этого RTL адаптировали, поместили глобальные переменные в TLS (tread local storage, специальный кусок адресного пространства, где каждый поток имеет свой независимый блок памяти, подобно процессам) и т.п. Однако возникла необходимость следить за появлением и завершением работы потоков. (Например, чтобы инициализировать те самые глобальные переменные в TLS). Очевидно, это и стало основным поводом для разработчиков Win32 придумать функцию DllMain.

Напомню, DllMain вызывается, во-первых, сразу после загрузки и перед самой выгрузкой динамической библиотеки (DLL), а во-вторых, при запуске и завершении работы каждого нового потока в процессе. Кстати, та функция DllMain, с которой вы знакомы, на самом деле вызывается не системой, а RTL (если явно не указать линковщику другое). Стандартная библиотека содержит свою функцию DllMain (в RTL Visual C++ она называется _DllMainCRTStartup), которая по умолчанию встраивается в каждый DLL модуль. Она-то и производит все необходимые для RTL действия, а уж затем вызывает вашу DllMain, если таковая присутствует.

Отсюда понятно, почему разработчики сделали так, чтобы система вызывала DllMain всегда в однопоточном режиме. И вот мы подходим к тому, ради чего я затеял весь этот исторический экскурс в историю DllMain. Оказывается, иногда однопоточность DllMain является не только достоинством, но и одновременно её же недостатком. Вас никогда не удивляло, почему не смотря на существование DllMain различные «DLLины» так часто содержат специальные функции инициализации и остановки, которые нужно явно вызвать перед началом и по завершению работы с библитекой? В качестве конкретного примера приведу функции WSAStartup и WSACleanup библиотеки Winsock. Оказывается, далеко не всю инициализацию можно провести в DllMain, и причина тому – именно её однопоточность! Основная трудность возникает, когда процедура инициализации должна запускать рабочие потоки. Как правило, рабочий поток должен сам провести некую инициализацию и сообщить об этом главному потоку, чтобы когда мы начнём работать с библиотекой, мы были уверены, что все потоки готовы к работе. Поэтому обычно функция инициализации строится так, чтобы она возвращала управления только после того, как все рабочие потоки гарантированно завершили инициализацию. О том, как это сделать, я уже подробно рассказывал в предыдущей части статьи.

Что же будет, если мы вставим в функцию DllMain код из примера, который я приводил в предыдущей части, где управляющий поток ожидает завершения инициализации рабочего? Оказывается, ничего хорошего, она просто зависнет! Первое, что должен сделать новый поток, это вызвать функции DllMain всех загруженных динамических библиотек, чтобы сообщить им о своём появлении (делает это системный код, ещё до передачи управления функции, которая была указана в CreateThread). В том числе система должна вызвать и DllMain нашей библиотеки. Но она как раз занята главным потоком! Чтобы не нарушать принцип однопоточности для DllMain, разработчики Win32 поступили просто: если CreateThread вызывается из DllMain, то новый поток создаётся, но не начинает исполняться до тех пор, пока DllMain не вернёт управление системе. Вот и придётся главному потоку ожидать рабочий до бесконечности. Аналогичная проблема может возникнуть и при завершении работы библиотеки, когда рабочие потоки должны быть остановлены.

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

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

Заключение.

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

Где искать более детальную информацию? Очень подробно эта тема освещена в знаменитейшей книге Джеффри Рихтера «Windows для профессионалов». За справочной информацией как всегда следует обращаться к MSDN. Кроме того, я настоятельно рекомендую начинающим использовать MSDN не только в качестве справочника, но и изучить обзорные статьи, особенно разделы «About Processes and Threads» и «About Synchronization». Начинающих иногда пугает английский язык. К счастью, именно эти части MSDN написаны, на мой взгляд, очень хорошо, подробно и понятно. Так что их чтение послужит вам, кроме всего ещё и дополнительной практикой в английском языке, без которого современному разработчику никак не обойтись.

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

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