Сообщений 3 Оценка 0 Оценить |
Введение Краткое описание Конструкция await ПримерыКонструкция await_switch Конструкция synch Конструкция lock_it Конструкция lock_switch Особенности реализации Внутреннее устройство Достоинства и недостатки |
Исходные тексты и примеры – 59K
Await && Locks – это библиотека синхронизации потоков. Необычная библиотека. Необычна она в том смысле, что она предоставляет не ряд классов или функций на все случаи жизни, – она предоставляет ряд конструкций, при помощи которых программист может более четко и ясно выражать свои намерения.
Конструкций всего 5 и они логически делятся 2 группы. Первая группа – это конструкции, основанные на мониторе: await, await_switch и synch. Это базовые конструкции и, в принципе, любое взаимодействие между потоками можно выразить через них.
Вторая группа – это более высокоуровневые конструкции, отвечающие за блокировки чтения и записи: lock_it и lock_switch. Важным достоинством этих конструкций (кроме выразительности) является то, что они способны обнаруживать и разрешать дедлоки (взаимоблокировки).
Простейшая конструкция, которую предоставляет Await && Lock, – это await. В общем случае она выглядит так:
await(условие) { действие } |
Семантика данной конструкции довольно проста. Выполняя ее, поток ждет истинности условия, и только после этого выполняет действие. И условие, и действие выполняются в единой критической секции, т.е. гарантируется, что пока вычисляется условие, ни один из потоков не может его изменить (если конечно, во всех потоках поддерживается соглашение о том, что доступ к данным осуществляется только в пределах критической секции). Соответственно, пока один поток выполняет действие, ни один из остальных потоков не будет ни читать данные, которые меняет первый (вычислять свое условие), ни менять их (выполнять свое действие).
Если условие уже истинно, поток, не останавливаясь, выполняет действие. Если же условие оказалось ложным, поток засыпает до тех пор, пока другой поток не изменит общие для всех потоков данные. При этом он на время освобождает единую критическую секцию, для того, чтобы в нее могли зайти другие потоки. Как только какой-либо из потоков поменяет общие данные (и завершит выполнение своей конструкции await или await_switch, что и является сигналом для остальных потоков), все ждущие потоки просыпаются, проверяют свои условия и действуют затем в соответствии его значением (либо продолжают свою работу, либо засыпают до лучших времен).
Конструкция await_switch является естественным расширением конструкции await. Она незаменима в тех случаях, когда необходимо задать таймаут, максимальное время ожидания истинности заданного условия, либо когда необходимо задать несколько независимых пар условие-действие. Эта конструкция выглядит так:
await_switch(таймаут) { await_case(условие 1) { действие 1 } await_case(условие 2) { действие 2 } . . . await_timeout() { действие по таймауту } } |
Здесь таймаут – относительное время в миллисекундах. В том случае, если не нужно ограничивать время ожидания, можно использовать константу FOREVER. Семантика конструкции await_switch аналогична семантике await c той лишь разницей, что await_switch ожидает истинности хотя бы одного условия в выражениях await_case. И как только какое-либо условие станет истинным, будет выполнено соответствующее ему действие. В том случае, когда истинными становятся несколько условий одновременно, действие для выполнения выбирается только одного из истинных условий. Если же, в течение заданного промежутка времени ни одно из условий не стало истинным, будет выполнено действие по таймауту. Действие по таймауту, как и действия связанные с условиями, выполняется в единой критической секции.
ПРИМЕЧАНИЕ Положение выражения await_timeout относительно выражений await_case может быть любым. Т.е. это выражение может находиться, как в начале и в конце, так и в середине списка. В конструкции await_switch можно опускать выражение await_timeout. В этом случае, по таймауту не будет выполняться никаких действий в критической секции. Но больше одного выражения await_timeout задавать не имеет смысла, и поведение потока в этом случае не определено. Количество выражений await_case в конструкции await_switch может быть любым. В том числе и равным нулю. В этом случае эта конструкция всегда будет завершаться по таймауту. |
Конструкция synch еще более простая, чем конструкция await. Выглядит она так:
synch() {
действие
}
|
Она просто выполняет действие в единой критической секции. В отличие от конструкций await и await_switch, конструкция synch не оповещает другие потоки о том, что общие для всех потоков данные изменились.
Специально для блокировок чтения и записи Await && Locks предлагает отдельные конструкции. Вот общий вид конструкции lock_it:
lock_it(комбинация блокировок) { действие } |
Комбинация блокировок – это произвольное количество выражений rlock и wlock, объединенных оператором &&.
Например, если нам нужно получить исключительный доступ к переменной g_nResource1, необходимо написать так:
lock_it(wlock(g_nResource1)) { g_nResource1 = 13; // здесь можно менять значение g_nResource1 } |
Если нам нужно получить доступ для чтения к переменной g_nResource2, необходимо написать так:
int nResource2; lock_it(rlock(g_nResource2)) { nResource = g_nResource2; // здесь можно только читать g_nResource2 } |
Если нам нужно получить доступ для чтения к переменной g_nResource2 и исключительный доступ к g_nResource1, необходимо написать так:
lock_it(rlock(g_nResource2) && wlock(g_nResource1)) {
g_nResource1 += g_nResource2; // здесь можно только читать g_nResource2 и менять g_nResource1
}
|
Параметром для выражений rlock и wlock должно быть l-value. Т.е. для того, чтобы получить доступ к объекту, на который указывает указатель, необходимо этот указатель разыменовать.
ПРИМЕЧАНИЕ Текущая версия Await && Locks поддерживает только два типа блокировок. Но, вероятно, этого достаточно для большинства задач. Также Await && Locks не проверяет, как на самом деле используются ресурсы, т.е. в принципе возможно (но крайне не рекомендуется) изменять переменные, заблокированные для чтения. Также текущая версия не блокирует области памяти, т.е. она не различает, что на самом деле блокируется, целый массив из 10 элементов или только первые 4, например. |
Конструкции lock_it (а также и lock_switch) могут быть вложенными. Разрешается также блокировать один и тот же ресурс несколько раз (как для записи, так и для чтения).
Несмотря на то, что в принципе разрешается использовать вложенные конструкции, предпочтительным вариантом, однако, является блокирование всех необходимых переменных одной конструкцией. Это предотвратвратит дедлоки, связанные с порядком занятия ресурсов. Конструкции lock_it и lock_switch блокируют ресурсы не последовательно, а сразу все (т.е. только в тот момент, когда можно заблокировать все ресурсы, перечисленные в комбинации блокировок).
Так же как и конструкция await_switch является естественным расширением конструкции await, конструкция lock_switch является естественным расширением lock_it.
lock_switch(таймаут) { lock_case(комбинация блокировок 1) { действие 1 } lock_case(комбинация блокировок 2) { действие 2 } . . . lock_timeout() { действие по таймауту } lock_victim() { действие при возникновении дедлока } } |
Здесь также можно задать таймаут в миллисекундах, произвольное количество пар комбинация блокировка-действие и действие по таймауту. Кроме этого можно задать действие при возникновении дедлока. Оно указывается в выражении lock_victim.
Остановимся на этом подробнее. Дело в том, что в дедлок, обычно, бывает вовлечено несколько потоков, которые и блокируют друг друга. Я вижу две стратегии, которым можно следовать, при разрешении дедлока. Первая, отказать в блокировках всем потокам, вовлеченным в дедлок (поскольку все они находятся в равных условиях); и вторая, выбрать один поток-жертву, и отказать в блокировках только ему. В Await && Locks выбрана вторая стратегия. Она позволяет программе «оставаться на плаву» даже после возникновения дедлока.
Так вот, действие, указанное в выражении lock_victim, выполняет только поток-жертва. Он может либо аварийно завершить всю программу, либо прервать текущую операцию, освободив при этом все занятые ресурсы и начать все заново.
ПРИМЕЧАНИЕ Как и выражение lock_timeout, выражение lock_victim можно опустить (что, кстати, не рекомендуется). Однако если в конструкции lock_switch не указать ни одного выражения lock_case, то сразу же произойдет дедлок, и выполнится не действие, указанное в lock_timeout, а действие, указанное в lock_victim. |
Для того чтобы иметь полное представление о том, как пользоваться возможностями Await && Locks, рассмотрим несколько примеров. Примеры будут в основном касаться использования конструкций, основанных на мониторе. Так как это, на мой взгляд, наиболее сложная, но, тем не менее, важная часть Await && Locks.
Допустим, у нас существует несколько ресурсов. Пусть количество их равно N. И эти ресурсы используются M потоками. Естественно, что M > N, в противном случае можно было бы каждому потоку дать по ресурсу и тем самым решить вопрос о доступе. Ресурсы эти неразличимы потоками, т.е. каждому потоку абсолютно безразлично какой ресурс использовать. Для того чтобы не было коллизий, каждый поток должен следовать определенному соглашению. Перед использованием ресурса он должен явно указать, что он хочет его использовать (т.е. занять его), а по окончании работы с ним – его освободить.
Обычно эта задача решается и использованием семафора. С использованием Await && Locks семафор может быть выражен таким образом:
// инициализация
// выполняется в самом начале работы программы при инициализации ресурсов
int g_nNumberOfFreeResources = N;
|
// произвольный поток // здесь поток собирается занять ресурс await(g_nNumberOfFreeResources > 0) --g_nNumberOfFreeResources; // здесь поток может использовать ресурс // . . . // по окончании работы - освобождаем await(true) ++g_nNumberOfFreeResources; |
// особых действий по удалению нет
|
К сожалению, приведенное решение далеко от реальной действительности. Как правило, мало просто занять какой-либо ресурс, необходимо еще знать какой ресурс был-таки занят. А для этого можно, например, просканировать все ресурсы, найти свободный и пометить его как занятый.
Расширим ресурс дополнительным полем m_bFree, при помощи которого можно будет определить, занят ресурс или нет. Для поиска свободного ресурса введем вспомогательную функцию.
// вспомагательная функция Resource *findFreeResource(Resource *pResources, int n) { for(int i=0; i<n; ++i) if(pResources[i].m_bFree) return pResources+i; return 0; } |
В случае Await && Locks поиск свободного ресурса и его реальное занятие можно выполнить как одну операцию.
// инициализация ресурсов Resource *g_pResources = new Resource[N]; // предполагается, что с каждым отдельным ресурсом связан флаг // который говорит, занят ресурс или нет for(int i=0; i<N; ++i) g_pResources[i].m_bFree = true; |
// произвольный поток Resource *pSought = 0; await(0 != (pSought = findFreeResource(g_pResources, N))) pSought->m_bFree = false; // здесь поток может использовать ресурс // . . . // по окончании работы - освобождаем await(true) pSought->m_bFree = true; |
// удаление ресурсов delete[] g_pResources; |
Недостатком такого решения, является то, что findFreeResource будет вызываться довольно часто. Но этого можно избежать, если занимать и освобождать ресурс таким образом.
// произвольный поток Resource *pSought = 0; await((g_nNumberOfFreeResources > 0) && (0 != (pSought = findFreeResource(g_pResources, N))) ) { --g_nNumberOfFreeResources; pSought->m_bFree = false; } // здесь поток может использовать ресурс // . . . // по окончании работы - освобождаем await(true) { ++g_nNumberOfFreeResources; pSought->m_bFree = true; } |
Хотелось бы отметить, что вариант с использованием Await && Locks работает даже в том случае, когда количество ресурсов непостоянно. Решение же с использованием Win32 и POSIX в случае постоянно меняющегося количества доступных ресурсов не так очевидно.
Но и это еще не предел совершенства. Можно вместо того, чтобы для каждого ресурса задавать флаг занятости (m_bFree), использовать список свободных ресурсов (связанных между собой при помощи поля m_pNext). При этом на поиск свободного ресурса будет тратиться O(1) времени вместо O(N). Вот как это можно было бы сделать
// инициализация ресурсов Resource *g_pResources = new Resource[N]; Resource *g_pFreeResourcesList = g_pResources; // заносим ресурсы в список свободных ресурсов for(int i=0; i<N-1; ++i) g_pResources[i].m_pNext = g_pResources+(i+1); |
// произвольный поток Resource *pSought = 0; // ждем, пока будет хотя бы один свободный ресурс await(g_pFreeResourcesList) { // берем первый в списке свободных pSought = g_pFreeResourcesList; // удаляем его из списка g_pFreeResourcesList = pSought->m_pNext; } // здесь поток может использовать ресурс // . . . // по окончании работы - освобождаем await(true) { pSought->m_pNext = g_pFreeResourcesList; g_pFreeResourcesList = pSought; } |
// удаление ресурсов delete[] g_pResources; |
Ну и в заключение примера приведу окончательный exception-safe вариант.
// произвольный поток Resource *pSought = 0; // ждем, пока будет хотя бы один свободный ресурс await(g_pFreeResourcesList) { // берем первый в списке свободных pSought = g_pFreeResourcesList; // удаляем его из списка g_pFreeResourcesList = pSought->m_pNext; } try { // здесь поток может использовать ресурс // . . . // по окончании работы - освобождаем await(true) { pSought->m_pNext = g_pFreeResourcesList; g_pFreeResourcesList = pSought; } } catch(...) { await(true) { pSought->m_pNext = g_pFreeResourcesList; g_pFreeResourcesList = pSought; } throw; } |
Одной из особенностей семафора является то, что он не «запоминает» какие потоки занимали ресурсы. Т.е. возможно такая ситуация, что один поток занял ресурс, а другой его освободил. В отличие от семафора, мютекс – примитив синхронизации «с памятью», но он обычно связывается не с группой ресурсов, а с каждым из ресурсов. Наличие памяти у мютекса позволяет сделать такую разновидность мютекса как рекурсивный мютекс. Он разрешает занимать один и тот же ресурс повторно. Сейчас мы выразим мютекс при помощи конструкций await.
Пусть ресурс кроме полезных данных имеет еще 2 поля: идентификатор потока, использующего его; и количество раз, которые поток занимал этот ресурс.
struct Resource { // полезные данные // . . . // дополнительные поля threadid m_thrOwner; int m_nLockCount; }; |
тогда использование ресурса будет иметь вид
threadid thrCurrent = getCurrentThreadId(); // ждем, пока освободится await((g_pResource->m_nLockCount == 0) || (g_pResource->m_thrOwner == thrCurrent)) { ++g_pResource->m_nLockCount; g_pResource->m_thrOwner = thrCurrent; } // используем // . . . // освобождаем await(true) --g_pResource->m_nLockCount; |
Здесь используется внешняя функция getCurrentThreadId, которая однозначно идентифицирует поток.
При помощи конструкции lock_it это выражается гораздо проще.
lock_it(xlock(g_pResource)) {
// используем
// . . .
}
|
ПРИМЕЧАНИЕ Вариант с использованием конструкции lock_it не только проще, но и лучше. Во-первых, он exception-safe, а во-вторых, он гарантирует, что возможные дедлоки будут обнаружены и устранены. |
Предположим, что один поток посылает данные другому потоку. Для этой цели можно разработать такой шаблонный класс.
template<class T> class Pipe { private: std::queue m_oContainer; public: void send(T obj) { await(true) m_oContainer.push(obj); } T get() { T result; await(!m_oContainer.empty()) { result = m_oContainer.front(); m_oContainer.pop(); } return result; } }; |
При этом поток получатель будет ждать данных от отправителя бесконечно. Однако можно реализовать Pipe таким образом, чтобы получатель мог задать таймаут.
template<class T> class Pipe { private: std::queue m_oContainer; public: struct TimeoutException {}; void send(T obj) { await(true) m_oContainer.push(obj); } T get(long nTimeout) throw (TimeoutException) { T result; await_switch(nTimeout) { await_case(!m_oContainer.empty()) { result = m_oContainer.front(); m_oContainer.pop(); } await_timeout() { throw TimeoutException(); } } return result; } }; |
Существует такой примитив синхронизации как барьер. Когда поток достигает барьера, он останавливается и ждет другие потоки. Как только количество потоков достигает определенного значения (которое задается при инициализации барьера), все потоки стартуют одновременно. Барьер можно выразить так.
class Barrier { private: int const m_nMaxNumber; int m_nCurNumber; bool m_bGo; public: Barrier(int n): m_nMaxNumber(n), m_nCurNumber(0), m_bGo(false) {} void barrier() { synch() { await(!m_bGo) { ++m_nCurNumber; // если количество потоков достигло максимального значения // оповещаем всех ждущих о том, что можно продолжать работу if(m_nCurNumber == m_nMaxNumber) m_bGo = true; } await(m_bGo) { --m_nCurNumber; // последний «гасит свет» if(m_nCurNumber == 0) m_bGo = false; } } } }; |
Сердцем конструкций, основанных на мониторе, является, как уже все, наверное, догадались… монитор. Интерфейс монитора объявлен в хеадере <await/monitor.h>
struct Monitor { virtual void enter() =0; virtual void leave() =0; virtual bool sleep(AbsoluteMoment) =0; virtual void awake() =0; }; |
enter – вход в единую критическую секцию, leave – выход из нее, sleep – «засыпание» до момента, указанного в параметре, при этом временно освобождается критическая секция. awake – «будит» все потоки, «заснувшие» на sleep. Если поток был «разбужен», sleep возвращает false, если же он проспал до последнего момента, то sleep возвращает true. Методы awake и sleep всегда должны вызываться внутри критической секции. Реализация монитора – рекурсивная. Т.е. можно несколько раз входить в критическую секцию. Но при этом необходимо столько же раз выйти. Когда метод sleep освобождает критическую секцию, он освобождает ее полностью, независимо от того, сколько раз текущий поток ее занял. В методе sleep освобождение критической секции и собственно «засыпание» выполняются гарантированно атомарно. Это предотвращает такую ошибочную ситуацию, когда первый поток освободил критическую секцию, но еще не успел заснуть, а второй, дождавшись, когда первый даст ему возможность самому войти в критическую секцию, быстренько войдет, изменит данные, и попытается разбудить первый поток; при этом этот сигнал будет потерян, так как первый еще не заснул.
Конструкции await, await_switch и synch работают с экземпляром, который можно получить при помощи функции getGlobalMonitor(), которая объявлена в <await/monitor.h>
Monitor *getGlobalMonitor(); |
Преобразовать относительное время (в миллисекундах) в абсолютное можно при помощи функции cvtRelativeToAbsolute().
AbsoluteMoment cvtRelativeToAbsolute(long); |
В целом, конструкция await эквивалентна следующему коду.
Monitor *pMonitor = getGlobalMonitor(); pMonitor->enter(); try { while(!(условие)) pMonitor->sleep(cvtRelativeToAbsolute(FOREVER)); действие; pMonitor->awake(); pMonitor->leave(); } catch(...) { pMonitor->awake(); pMonitor->leave(); throw; } |
Конструкции блокировок основаны на другом объекте, экземпляре класса LockManager. Этот класс объявлен в <await/lockmanager.h>
struct LockManager { virtual Lock *lock(LockQuery const &query, long timeout, ExtraInfo const &info) throw (LockException) =0; virtual void dumpLockTables() =0; }; |
Основной метод lock и блокирует требуемые ресурсы. Он бросает 2 типа исключений: TimeoutException и DeadlockException. И всегда возвращает объект типа Lock. Этот класс описан там же <await/lockmanager.h>
struct Lock { virtual long tag() =0; virtual void unlock() =0; }; |
Метод unlock разблокрует ресурсы и удаляет сам объект типа Lock. Т.е. не нужно вызывать для него delete.
Метод dumpLockTables служит для отладки – он выводит на консоль таблицу блокировок. Параметр ExtraInfo содержит информацию об имени файла и номере строки, на которой происходит блокировка. (Эта информация может быть полезна, если бы отлаживаете программу и вам необходимо знать, где какой поток, на каком месте сейчас завис).
Чтобы немного прояснить ситуацию рассмотрим пример конструкции lock_switch и соответствующий ему код, без использования этой конструкции.
lock_switch(100) { lock_case(wlock(g_oRes1)) { действие 1 } lock_case(rlock(g_oRes1) && wlock(g_oRes2)) { действие 2 } lock_case(rlock(g_oRes1) && rlock(g_oRes2) && rlock(g_oRes3)) { действие 3 } lock_timeout() { действие 4 } lock_victim() { действие 5 } } |
А теперь тоже самое, но без использования конструкций.
// готовим запрос для lock manager’а // создаем слот для первого выражения lock_case LockSlot slot1 = LockSlot::makeExclusiveLock(&g_oRes1); // создаем слот для второго выражения lock_case LockSlot slot2 = LockSlot::makeSharedLock(&g_oRes1) && LockSlot::makeExclusiveLock(&g_oRes2); // создаем слот для третьего выражения lock_case LockSlot slot3 = LockSlot::makeSharedLock(&g_oRes1) && LockSlot::makeSharedLock(&g_oRes2) && LockSlot::makeSharedLock(&g_oRes3); // a теперь все слоты объединяем в один запрос // добавляем первый слот в запрос (связываем с ним tag 1) LockQuery query = tagLock(1, slot1); // добавляем второй слот в запрос (связываем с ним tag 2) query |= tagLock(2, slot2); // добавляем третий слот в запрос (связываем с ним tag 3) query |= tagLock(3, slot3); // запрос готов, блокируем try { Lock *pLock = getLockManager()->lock(query, 100, ExtraInfo(__FILE__, __LINE__, __FUNCTION__)); // здесь мы получили экземпляр класса Lock, // т.е. что-то заблокировали try { // определяем, что именно заблокировали // и какое действие необходимо выполнить switch(pLock->tag()) { case 1: { // tag, связанный со слотом 1 действие 1 } break; case 2: { // tag, связанный со слотом 2 действие 2 } break; case 3: { // tag, связанный со слотом 3 действие 3 } break; } // разблокиуем и удаляем полученную блокировку pLock->unlock(); } catch(...) { // разблокиуем и в том случае, если какое-либо // из действие выбросило исключение // и перебрасываем исключение дальше pLock->unlock(); throw; } } catch(TimeoutException) { действие 4 } catch(DeadlockException) { действие 5 } |
Все конструкции, которые предоставляет Await && Locks, выражены при помощи макросов, которые объявлены в <await/await.h>. При этом использованы такие особенности C++ как автоматический вызов деструктора и синтаксис оператора for(;;). Для примера, рассмотрим, как реализована простейшая конструкция synch. Остальные используют тот же принцип.
Итак, конструкция synch объявлена так:
#define synch() FOR(await_n::SynchHandle theAuxObj; theAuxObj.cond(); theAuxObj.next()) |
Т.е. следующее выражение
synch() {
действие
}
|
разворачивается в
for(await_n::SynchHandle theAuxObj; theAuxObj.cond(); theAuxObj.next()) { действие } |
и выполняется так. Сначала вызывается конструктор SynchHandle, в этом конструкторе выполняется вход в критическую секцию. Затем выполняется проверка условия theAuxObj.cond(). Объект класса SynchHandle содержит переменную, которая указывает на то, сколько раз выполнилось тело цикла, и первый раз theAuxObj.cond() всегда возвращает true. Затем выполняется тело цикла, т.е. действие. После этого вызывается theAuxObj.next(), которое увеличивает значение внутренней переменной, указывающей на количество завершенных итераций. И снова проверяется условие theAuxObj.cond(). Во второй раз оно всегда возвращает false. Значит, мы выходим из цикла. Напоследок вызывается деструктор SynchHandle, в котором происходит выход из критической секции.
Надо отметить, что не все компиляторы воспринимают for(;;) правильно. Некоторые компиляторы (например, VC++ 5) не ограничивают область видимости циклом, т.е. они не будут генерить код, который будет вызывать деструктор при выходе из цикла. Но это попровимо, если вместо такого
for(await_n::SynchHandle theAuxObj; theAuxObj.cond(); theAuxObj.next())
|
написать так
if(false); else for(await_n::SynchHandle theAuxObj; theAuxObj.cond(); theAuxObj.next()) |
Это заставит компилятор работать правильно. И для VC макрос FOR объявлен так
#define FOR if(false); else for |
Для остальных так
#define FOR for |
См. Await && Locks - Внутреннее устройство
См. Await && Locks - Достоинства и недостатки
Сообщений 3 Оценка 0 Оценить |