Await && Locks Programming Guide

Содержание


Введение

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


Макрос await и его родственники

Базовая конструкция (await)

Простейшая конструкция, которую предлагает Await && Locks, - это конструкция await.

await(условие) {
    действие
}

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

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

Пример 1 Целочисленный семафор

Класс, представляющий целочисленный семафор.

class Semaphore {
private:
    int m_nCount;
public:
    Semaphore(int nInitial): m_nCount(nInitial) {}
    ~Semaphore() {}

    // операция занятия семафора
    // уменьшает счетчик семафора, как только он становится положительным
    void acquire() {
	await(m_nCount>0)
	    --m_nCount;
    }

    // операция освобождения семафора
    // увеличивает счетчик семафора
    void release() {
	await(true)
	    ++m_nCount;
    }
};

Пример 2 Группа двоичных семафоров

Класс, представляющий группу двоичных семафоров.

class GBS {
private:
    // каждый бит соответствует отдельному двоичному семафору
    unsigned long m_nMask;
public:
    GBS(): m_nMask(~0) {}
    ~GBS() {}

    // операция занятия семафора
    // можно занять несколько семафоров одновременно
    void acquire(unsigned long nMask) {
	await((m_nMask & nMask) == nMask)
	    m_nMask &= ~nMask;
    }

    // операция освобождения семафора
    void release(unsigned long nMask) {
	await(true)
	    m_nMask |= nMask;
    }
};

Пример 3 Рекурсивный мютекс

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

Замечание: Этот пример только для демонстрации. Вместо него лучше использовать конструкцию lock_it.

class Mutex {
private:
    threadid m_tOwner;
    int m_nLockCount;
public:
    Mutex(): m_nLockCount(0) {}
    ~Mutex() {}

    // операция занятия мютекса
    void lock() {
	await((m_nLockCount == 0) || (m_tOwner == getCurrentThreadId())) {
	    ++m_nLockCount;
	    m_tOwner = getCurrentThreadId();
	}
    }

    // операция освобождения мютекса
    void unlock() {
	await(true)
	    --m_nLockCount;
    }
};

Пример 4 Пайп

Это более сложный пример. Пайп служит для взаимодействия между двумя потоками. Первый посылает данные второму, используя метод Pipe::put, а второй получает эти данные, используя метод Pipe::get.

template<class T>
class Pipe {
private:
    std::queue<T> m_oContainer;
public:
    Pipe() {}
    ~Pipe() {}

    // операция get
    T get() {
	T result;
	// ждет наличие данных и берет первый
	await(!m_oContainer.empty()) {
	    result = m_oContainer.front();
	    m_oContainer.pop();
	}
	return result;
    }

    // операция put
    void put(T obj) {
	await(true)
	    m_oContainer.push(obj);
    }
};

Расширенная конструкция (await_switch и await_case)

Иногда возникает необходимость ждать несколько различных событий. Конечно, можно написать так:

await(событие 1 || событие 2) {
    if(событие 1) {
	действие 1
    }
    else if(событие 2) {
	действие 2
    }
}

Но это выглядит довольно некрасиво - одни и те же условия присутствуют в нескольких местах. Чтобы этого избежать Await && Locks предлагает другую конструкцию. Эта конструкция - await_switch.

await_switch(FOREVER) {
    await_case(событие 1) {
	действие 1
    }
    await_case(событие 2) {
	действие 2
    }
}

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

Пример 5 Производитель и Потребитель

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

// производитель
for(int i=0; i<N; ++i) {
    // ожидание запроса от потребителя
    await(g_bCanWrite)
	g_bCanWrite = false;
    // ПРОИЗВОДСТВО . . .
    // оповещение потребителя о готовности данных
    await(true)
	g_bCanRead = true;
}
// оповещение потребителя о выходе
await(true)
    g_bCanQuit = true;
// потребитель
while(true) {
    // посылка запроса производителю
    await(true)
	g_bCanWrite = true;
    bool bQuit = false;
    // ожидание любого события от производителя
    await_switch(FOREVER) {
	await_case(g_bCanRead)
	    g_bCanRead = false;
	await_case(!g_bCanRead && g_bCanQuit)
	    bQuit = true;
    }
    if(bQuit)
	break;
    // ПОТРЕБЛЕНИЕ . . .
}

Обработка таймаутов (await_timeout)

В конструкции await_switch можно указать таймаут.

await_switch(таймаут) {
    await_case(условие 1) {
	действие 1
    }
    await_case(условие 2) {
	действие 2
    }
. . . . . . .
    await_timeout() {
	действие по таймауту
    }
}

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

Пример 6 Производитель и Потребитель (с таймаутами)

Это слегка измененный предыдущий пример. Производитель не оповещает о выходе, и Потребитель выходит по таймауту.

// производитель
for(int i=0; i<N; ++i) {
    // ожидание запроса от потребителя
    await(g_bCanWrite)
	g_bCanWrite = false;
    // ПРОИЗВОДСТВО . . .
    // оповещение потребителя о готовности данных
    await(true)
	g_bCanRead = true;
}
// производитель НЕ ОПОВЕЩАЕТ потребителя о выходе
// потребитель
while(true) {
    // посылка запроса производителю
    await(true)
	g_bCanWrite = true;
    bool bQuit = false;
    // ожидание ответа от производителя
    await_switch(5000) { // ожидание не более 5 секунд
	await_case(g_bCanRead)
	    g_bCanRead = false;
	await_timeout()
	    bQuit = true;
    }
    if(bQuit)
	break;
    // ПОТРЕБЛЕНИЕ . . .
}

Макрос lock_it и его родственники

Локировки чтения, записи и их комбинация (lock_it, rlock и wlock)

Одним из наиболее важных видов синхронизации потоков являются локировки. Их можно выразить и при помощи конструкции await, но Await && Locks предлагает отдельные конструкции специально для локировок. Простейшая из них - конструкция lock_it.

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

lock_it(wlock(g_oResource)) {
    // здесь поток имеет исключительный доступ к g_oResource
}

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

lock_it(rlock(g_oResource)) {
    // здесь поток имеет доступ к g_oResource только для чтения
}

Можно также составлять различные комбинации из локировок при помощи оператора &&.

lock_it(rlock(g_oRes0) && wlock(g_oRes1)) {
    // здесь поток может читать g_oRes0 и модифицировать g_oRes1
}

В общем случае конструкция lock_it выглядит так:

lock_it(комбинация локировок) {
    действие
}

где комбинация локировок состоит из произвольного количества локировок записи и чтения, связанных оператором &&. Для локировок чтения используется rlock, а для записи - wlock. Параметром для rlock и wlock должно быть l-value, так как Await && Locks различает ресурсы по адресу.

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

Конструкции lock_it могут быть вложенными. Возможно локировать один и тот же ресурс несколько раз.

lock_it(wlock(g_oRes0)) {
    lock_it(rlock(g_oRes1)) {
	lock_it(rlock(g_oRes0)) {
	    // . . .
	}
    }
}

Выбор локировок (lock_switch, lock_case and lock_timeout)

Теперь, используя аналогию, легко представить, как работает конструкция lock_switch. :) Которая выглядит так:

lock_switch(таймаут) {
    lock_case(комбинация локировок 1) {
	действие 1
    }
    lock_case(комбинация локировок 2) {
	действие 1
    }
. . . . . . .
    lock_timeout() {
	действие по таймауту
    }
}

Обнаружение дедлоков (lock_victim)

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

// поток 1
lock_it(wlock(g_oRes0)) {
    // точка 1
    lock_it(wlock(g_oRes1)) {
	// точка 2
    }
}
// поток 2
lock_it(wlock(g_oRes1)) {
    // точка 1
    lock_it(wlock(g_oRes0)) {
	// точка 2
    }
}

Если оба потока достигли точки 1 одновременно, они не могут достичь точки 2. Потому что поток 1 ждет ресурс g_oRes1, который уже занят потоком 2, а поток 2, в свою очередь, ждет g_oRes0, который занят потоком 1. Так, потоки вовлечены в дедлок.

Дедлок - плохая ситуация и существуют несколько путей для его предотвращения. Это

  1. использовать верифицирующую программу и если она обнаружит дедлок попробовать переупорядочить локировки
  2. использовать определенные правила, например: никогда не занимать ресурсы последовательно - локировать все и сразу, или локировать ресурсы в четко определенном порядке
  3. использовать таймауты, и если поток не смог в течение определенного промежутка времени залочить какой-либо ресурс, он должен освободить все занятые и начать прерванную операцию заново
  4. не предотвращать, но обнаруживать и разрешать дедлоки (например, выбирая поток жертву)
  5. вовсе не использовать многопоточность
  6. и т.д.
Можно использовать любой из этих способов. В свою очередь Await && Locks автоматически обнаруживает и разрешает дедлоки. Под разрешением дедлока подразумевается выбор потока-жертвы из потоков, вовлеченных в дедлок, и отказ этому потоку в требуемых локировках. Поток может узнать выбран ли он на роль жертвы при помощи выражения lock_victim в конструкции lock_switch. Теперь полная конструкция lock_switch выглядит так:

lock_switch(таймаут) {
    lock_case(комбинация локировок 1) {
	действие 1
    }
    lock_case(комбинация локировок 2) {
	действие 1
    }
. . . . . . .
    lock_timeout() {
	действие по таймауту
    }
    lock_victim() {
	действие при возникновении дедлока
    }
}

Указания об использовании (как можно делать и как нельзя)

Убедитесь, что условие в конструкции await не равно тождественно false. Такой код:

await(false) {
    // . . .
}

приведет к дедлоку, который не будет распознан Await && Locks.

Всегда объявляйте переменные, которые используются в конструкции await как volatile. Программа может правильно работать в Debug конфигурации и без volatile, но в Release, когда вы включите оптимизацию, volatile избавит вас от труднообнаруживаемых ошибок.

Не используйте ExitThread, pthread_exit или другое API прерывающее выполнение потока. Await && Locks не предоставляет средств для создания и удаления потоков потому, что не зависит от того, как потоки были созданы. Поэтому для этой цели вы должны использовать родное API операционной системы или любимую поточную библиотеку. Но если вы напишите так:

void ThreadProc() {
    SomeClass oLocalVar;
    lock_it(rlock(g_oGlobalVar)) {
	ExitThread(0);
    }
}

локировка чтения для g_oGlobalVar не будет освобождена. Эта проблема внезапного завершения также коснется oLocalVar. Ее деструктор не будет вызван. Но эта проблема легко разрешима, если немного изменить код:

void ThreadProc() {
    try {
	SomeClass oLocalVar;
	lock_it(rlock(g_oGlobalVar)) {
	    throw ThreadDeath();
	}
    }
    catch(ThreadDeath) {
	// просто словить исключение
    }
}

Не используйте TerminateThread, pthread_cancel или другое API, которое отменяет работу потока извне. Это из-за проблемы внезапного завершения, описанной ранее. В данном случае нет общего рецепта. Подходящее решение должно быть найдено в каждом конкретном случае.

Используйте конструкции lock_it и lock_switch везде, где они нужны. Такой код:

await(!g_bVarLocked)
    g_bVarLocked = true;
doSomethingWithVar(g_oVar);
await(true)
    g_bVarLocked = false;

лучше переписать так:

lock_it(wlock(g_oVar)) {
    doSomethingWithVar(g_oVar);
}

по следующим причинам. Во-первых, если doSomethingWithVar бросает исключение, то в первом случае, g_bVarLocked не станет false, следовательно, g_oVar не будет разлокировано. А второй пример корректно работает в этом случае. Во-вторых, использование конструкций lock_it and lock_switch гарантирует, что возможный дедлок будет разрешен.

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

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

lock_victim() {
    assert(("Deadlock detected!", 0));
}

Заключение

Итак, Await && Locks - сильная вещь. Простая, выразительная, платформонезависимая, способная обнаруживать дедлоки. Но она имеет также и недостатки. Она подразумевает использование макросов. Препроцессор был унаследован в C++ от C. И C++ не смог от него избавиться. Препроцессор делает язык более гибким, но он меняет язык. И язык теряет свою изначальную чистоту и красоту. Что касается Await && Locks, если вы случайно сделаете опечатку в ее конструкциях, вы получите странные ошибки компиляции, которые ничего вам не скажут о неправильном использовании этих конструкций. Так что, будьте готовы. Await && Locks также полагается на некоторые особенности C++ такие как конструкция for(;;) и автоматический вызов деструктора. Конструкции Await && Locks выражены нетривиально и поэтому реализованы не настолько оптимально как если бы они были встроены в язык.

Несмотря на все сказанное в предыдущем параграфе, Await && Locks все-таки полезна и удобна. И я намерен расширить ее локировкой областей памяти и расширяемыми локировками (которые позволят программисту не ограничиваться только локировками чтения и записи, а определять свою семантику для локировок).


SourceForge.net Logo