Блокировки в MS SQL Server 2000
Алексей Ширшов
Обычно блокировки рассматривают совместно с транзакциями. В данной статье упор делается в основном на механизм блокировок, его внутреннее устройство и использование в СУБД MS SQL Server 2000. Предполагается, что читатель хорошо знаком с транзакциями и их свойствами. Давайте вспомним вкратце, какими свойствами должны обладать транзакции в современных СУБД (эти требования носят название ACID – Atomicity, Consistency, Isolation и Durability):
Atomicity (атомарность). Это требование заключается в том, что все данные, с которыми работает транзакция, должны быть либо подтверждены (commit), либо отменены (rollback). Не должно быть ситуации, когда часть изменений подтверждается, а часть – отменяется. Это правило автоматически выполняется для простых данных.
Consistency (согласованность). После выполнения транзакции все данные должны остаться в согласованном состоянии. Другими словами, транзакция либо не изменит данных, и они останутся в прежнем состоянии, либо измененные данные будут удовлетворять ограничениям целостности, правилам (rules) и другим критериям согласованности данных.
Isolation (изолированность). Транзакции должны выполнятся автономно и независимо от других транзакций. При одновременном выполнении множества конкурирующих друг с другом транзакций, любое обновление определенной транзакции будет скрыто от остальных до тех пор, пока эта транзакция не будет зафиксирована. Существуют несколько уровней изолированности (изоляции) транзакций, которые позволяют выбрать наиболее оптимальное решение с точки зрения производительности и целостности данных. Основным методом реализации этих уровней и являются блокировки, о которых пойдет речь в этой статье.
Durability (долговечность или устойчивость). Это требование заключается в том, что после подтверждения изменения данных (фиксации транзакции) система переходит в новое состояние и возврат к старому состоянию не возможен, т.е. не возможен откат в предыдущее состояние. Для вложенных транзакций это требование нарушается.
В данной статье рассматриваются механизмы реализации уровней изолированности транзакции. Стандартом ANSI были определены четыре уровня изоляции транзакций. Первый – это нулевой уровень изоляции, второй – первый уровень и так далее. Эти уровни помогают решать различные проблемы, которые будут рассматриваться подробно далее в процессе написания демонстрационной программы на С++. Определения уровней будут даны в конце раздела.
Итак, чтобы лучше понять проблемы изоляции транзакций, рассмотрим их сначала с точки зрения программирования на С++. Так как наша программа будет оперировать простыми данными (значение типа int), будем считать, что требования атомарности выполняются автоматически. Кроме того, мы не будем налагать каких-либо логических ограничений на значение переменной и не будем использовать вложенных транзакций, так что требования согласованности и устойчивости также будут опущены.
Наша программа содержит всего два класса: CObject и CProxy. Класс CObject - это объект-одиночка (singleton), который содержит переменную value (доступ к этой переменной мы и будем защищать), и некоторый набор служебных функций. Класс CProxy представляет собой посредника для объекта CObject; именно с ним будет работать клиент. Вот первоначальный набросок (в классе CProxy используется нестандартная конструкция __declspec(property), поддерживаемая только компиляторами от Microsoft):
class CObject;class CProxy{ friend class CObject;public: __declspec(property(get=get_Value,put=put_Value)) int value; int get_Value(int level = -1) const; void put_Value(int i); void Commit(); void Rollback();private: int _level; int _value; bool fUpd; CProxy(CObject* par,int level) { fUpd = false; parent = par; _level = level; } CObject* parent;};class CObject{ friend class CProxy;public: static CProxy& GetObject(int level = -1); ~CObject() { if (hMutex) CloseHandle(hMutex); }protected: CProxy& BeginTran(int level) { return *(new CProxy(this,level)); } void RequestExclusive(int level) { } void RequestShared(int level) { } void RemoveShared(int level) { } void RemoveLocks() { }private: CObject() { value = 0; } int value; static HANDLE hMutex;};__declspec(selectany) HANDLE CObject::hMutex = NULL;CProxy& CObject::GetObject(int level){ HANDLE hLocMutex = CreateMutex(NULL,TRUE,_T("Guard-Lock-Mutex")); bool flg = GetLastError() == ERROR_ALREADY_EXISTS; if (flg) WaitForSingleObject(hLocMutex,INFINITE); else CObject::hMutex = hLocMutex; static CObject obj; ReleaseMutex(hLocMutex); if (flg) CloseHandle(hLocMutex); return obj.BeginTran(level);}void CProxy::Commit(){ parent->RemoveLocks(); delete this;}void CProxy::Rollback(){ if (fUpd) parent->value = _value; parent->RemoveLocks(); delete this;}void CProxy::put_Value(int i){ parent->RequestExclusive(_level); if (!fUpd) _value = parent->value; parent->value = i; fUpd = true;}int CProxy::get_Value(int level) const{ if (level == -1) level = _level; parent->RequestShared(level); int v = parent->value; parent->RemoveShared(level);return v;} |
Клиент никогда не имеет дела непосредственно с экземпляром класса CObject. Экземпляры класса CProxy – представляют копию данных объекта CObject и делегируют запросы на чтение и запись переменной value. Код получился несколько громоздким: к чему такие сложности? Я заранее определил довольно широкий интерфейс, чтобы потом меньше исправлять. :)
Прошу обратить внимание на довольно сложный механизм создания экземпляра CObject в функции GetObject. Обычно в программах используется более простой код, типа:
CProxy& CObject::GetObject(int level){ static CObject obj; return obj.BeginTran(level);} |
Чем он плох? Дело в том, что если несколько потоков попытаются одновременно вызвать функцию GetObject, конструктор класса CObject может быть вызван более одного раза, так как компилятор (возможно, это его ошибка) не генерирует безопасный код проверки с использованием ассемблерной инструкции cmpxchg. Хотя вероятность возникновения такой ситуации довольно низка, я рекомендую все же не игнорировать ее. Самое простое решение проблемы заключается в использовании недорогого ресурса критической секции, например, так:
CProxy& CObject::GetObject(int level){ ::EnterCriticalSection(&g_cs); static CObject obj; ::LeaveCriticalSection(&g_cs); return obj.BeginTran(level);} |
Однако встает вопрос: где ее инициализировать? Можно в конструкторе глобального объекта, но если у нас будет такой же глобальный клиент, мы не сможем гарантировать, что инициализация критической секции произойдет раньше вызова функции GetObject. Нам нужно что-то, что создается, инициализируется и захватывает ресурс непосредственно в функции GetObject. В качестве этого «чего-то» я выбрал объект исполнительной системы «Мьютекс». Его использование вы и можете наблюдать в первоначальном коде.
Теперь рассмотрим пример с использованием этих классов, который сразу вскрывает первую проблему.
unsigned __stdcall thread_proc(void*){// Началотранзакции CProxy& prx = CObject::GetObject(); prx.value = 20; prx.Commit(); return 0;}int main(int argc, char* argv[]){// Началотранзакции CProxy& prx = CObject::GetObject(); prx.value = 10;// Началоновойсессии _beginthreadex(0,0,thread_proc,0,0,0);// Эмулируемработу// Sleep(1000); printf("%d\n",prx.value);prx.Commit(); return 0;} |
Здесь я в двух параллельных потоках изменяю значение переменной value объекта CObject: в одном – на 10, во втором – на 20. Что выведется на консоль? Определенно сказать нельзя: если раскомментировать строчку Sleep(1000), выведется 20. С закомментированной строчкой выводится 10. Эта проблема носит название «проблема потери последнего изменения» (lost update problem) или проблема «грязной» записи. Она заключается в том, что при одновременном выполнении транзакций, в которых производится изменение данных, невозможно сказать заранее, какое конечное значение примут данные после фиксирования обеих транзакций. В случае «грязной» записи только одна из всех параллельно выполняющихся транзакций будет работать с действительными данными, остальные – нет. Другими словами, хотя данные и будут находиться в согласованном состоянии, логическая их целостность будет нарушена.
Для того чтобы наш объект удовлетворял первому уровню изоляции транзакций, на котором запрещается «загрязнение» данных, перепишем его следующим образом (изменения касаются только класса CObject):
class CObject{ friend class CProxy;public: enum {READ_UNCOMMITTED}; static CProxy& GetObject(int level = -1); ~CObject() { DeleteCriticalSection(&exclusive); if (hMutex) CloseHandle(hMutex); }protected: CProxy& BeginTran(int level) { return *(new CProxy(this,level)); } void RequestExclusive(int level) { if (level >= READ_UNCOMMITTED) TestExclusive(); } void RequestShared(int level) { } void RemoveShared(int level) { } void RemoveLocks() { RemoveAllLocks(); }private: CObject() { value = 0; InitializeCriticalSection(&exclusive);} void TestExclusive() { //Проверка на монопольную блокировкуEnterCriticalSection(&exclusive); //Вошлибольшеодногораза if (exclusive.RecursionCount > 1) LeaveCriticalSection(&exclusive);} void RemoveAllLocks() { //Если была установлена монопольная блокировка - снимаемif (exclusive.OwningThread == (HANDLE)GetCurrentThreadId()) LeaveCriticalSection(&exclusive); } int value; CRITICAL_SECTION exclusive;static HANDLE hMutex;}; |
Добавленный код выделен. Хочется пояснить одну деталь: так как изменять значение переменной value можно несколько раз, а Commit (или Rollback) вызывать только раз, налицо тот факт, что функция EnterCriticalSection вызывается бОльшее количество раз, нежели LeaveCriticalSection. Это ошибка – в соответствии с документацией количество вызовов функций EnterCriticalSection и LeaveCriticalSection должно совпадать. Поэтому после входа в критическую секцию я проверяю поле RecursionCount, которое устанавливает количество повторных входов потока в критическую секцию.
ПРИМЕЧАНИЕПри работе под ОС семейства Windows 9x это поле не используется и всегда содержит 0, так что приводимый здесь и далее код будет работать только на операционных системах семейства NT. |
Теперь можно определенно сказать, что выведется на консоль в следующем примере:
unsigned __stdcall thread_proc(void*){ // Началовторойтранзакции CProxy& prx = CObject::GetObject(CObject::READ_UNCOMMITTED);// Здесь поток будет ожидать примерно 1 сек. До тех пор, пока // в главном потоке не будет выполнена строчка prx.Commit();prx.value = 20; prx.Commit(); return 0;}int main(int argc, char* argv[]){ //Началотранзакциис 0 уровнемизоляции CProxy& prx = CObject::GetObject(CObject::READ_UNCOMMITTED);//Изменение данных prx.value = 10; //Открываем новую сессию_beginthreadex(0,0,thread_proc,0,0,0); //Print CObject::value variable printf("%d\n",prx.value);prx.Commit(); return 0;} |
На экран будет выведено число 10, а второй поток изменит данные только после фиксирования транзакции в главном потоке.