Следуя совету хабрапублики, пробую новый вариант перевода термина "lock-free"
В прошлый раз мы видели «беззахватный по духу» алгоритм, где захват был реализован так, что поток, обращающийся к захваченным данным, не ждёт их освобождения, а отправляется «обходным путём» (вычисляет требуемый результат, не пользуясь услугами кэша). В своём следующем посте Реймонд объясняет, как данный алгоритм можно усовершенствовать на случай, когда «обходного пути» нет. Алгоритм, однако, остаётся беззахватным: каждый поток продолжает работать, не дожидаясь освобождения захваченных данных.
В общей переменной теперь нужны два служебных бита: вдобавок к флагу захвата, как в прошлом примере, — флаг «поручена новая работа»; а если порученная работа сложная, то кроме флага, нужно будет где-то хранить ещё и её параметры. Например, в общей переменной можно хранить указатель на (выравненный в памяти) объект с параметрами, а в свободных младших битах указателя — два названных флага.
Перед выполнением действия над объектом, первым делом мы его захватываем, атомарно устанавливая соответствующий флаг. Если окажется, что объект уже был захвачен, — поручим выполнение нашего действия захватившему потоку, установив второй флаг.
Если же объект удалось захватить, то после завершения работы с ним снимаем флаг захвата и одновременно проверяем, не поручили ли нам новую работу. (Т.е. не было ли обращений к объекту за то время, пока мы его держали захваченным.) Если есть работа, то мы выполним и её; и так далее, пока однажды при разблокировке объекта отложенной работы не окажется. Мы не вправе оставить объект в состоянии «не захвачен, но есть работа».
Получившаяся модель «сделай, запиши,(поручи другому)» сплетена из множества циклов на случай всевозможных неудач. Каждая атомарная операция заключается в цикл; выполнение отложенной работы осуществляется в цикле; и каждый раз, когда вызов
Итак, младшие два бита указателя мы задействуем для хранения двух флагов: флага захвата, указывающего, что некий поток выполняет операцию над списком; и флага работы, указывающего, что пользователь потребовал установку событий в то время, пока список был захвачен.
Поскольку объединяем в одном значении указатель на элемент списка и пару флагов, то для удобства стоит определить методы, преобразующие используемые типы один в другой:
Изображать наши значения будем как битовые поля вида
Например:
Изображён объект GroupWait, содержащий три хэндла. Сброшенный флаг S в голове списка означает, что никто не требовал установить событие hEvent1. Напротив, установленный флаг S в элементе A означает, что нужно обойти все элементы после A и установить соответствующие события — а именно, hEvent2 и hEvent3. В частности, значение флага S в элементах B и C не имеет значения; эти элементы будут обработаны в любом случае, потому что того требует флаг S в элементе A. Таким образом, значение флага S в последнем элементе списка вообще никогда не имеет значения.
Флаг L используется только в голове списка; во всех остальных элементах он не имеет значения.
Итак, приготовления завершены; добавляем в список новый хэндл.
Мы добавляем в начало списка новый элемент, и следим, чтобы флаг L из старого элемента перешёл в новый: иначе может получиться, что другой поток уже захватил список, а мы его ненароком «освободили». Флаг S в добавляемом элементе сброшен: никто ещё не требовал установить новое событие. Добавляем элемент в голову списка мы по знакомой модели «сделай, запиши,(попытайся снова)» — проверяя, что никто не изменил список за нашей спиной. Обратите внимание, что «проблемы ABA» не возникает: даже если неизменившееся значение
Простота метода AddWait необычна для модели «сделай, запиши,(поручи другому)»: в случае неудачи нам нечего перепоручить — вся работа заключается в одной записи в память. Расплачиваться за эту простоту будут остальные методы, в которых придётся предусмотреть обработку элементов, добавленных в список за то время, пока он был захвачен.
Метод SignalAll получается настолько сложным, что читать его лучше по частям.
Если список захвачен, то единственное, что от нас требуется — установить в его голове флаг S. Если же список свободен, то попытаемся его захватить, и одновременно «украдём» все элементы списка, записывая вместо головы «заглушку»
Установив флаг S, мы перепоручили нашу работу потоку, захватившему список. Установленный флаг S в голове списка означает, что обработать нужно все следующие за головой элементы списка — т.е. вообще все элементы списка; как раз то, что нам нужно. Поток, захвативший список, проверит флаг при освобождении списка, и выполнит работу за нас.
Если же список не был захвачен, то нашим действием мы его захватили. «Украденные» элементы не видны другим потокам, так что одновременный вызов SignalAll с разных потоков не приведёт к многократной установке событий.
Обход «украденного» списка реализован очень прямолинейно, безо всякой заботы о потоко-безопасности; просто берём элемент за элементом, устанавливаем событие, и удаляем элемент. Единственная особенность — вызов
Теперь список надо разблокировать. Для начала:
В начале метода мы записали
Вначале проверим, изменился ли список: если нет, достаточно его лишь разблокировать — и готово. Иначе начинаем новый цикл: нужно выполнить всю отложенную работу, накопившуюся, пока список был захвачен.
Чтобы выполнить отложенную работу, мы обходим список, пока не найдём установленный бит S. В первом элементе, в котором бит S установлен, мы обнуляем исходящий указатель, чтобы «украсть» остаток списка; затем, обходя этот остаток, мы устанавливаем каждое событие, и удаляем элемент. Как и прежде, мы «крадём» список затем, чтобы одновременный вызов с нескольких потоков не привёл к многократной установке события. В конце, когда работа выполнена, мы снова пытаемся разблокировать список — в расчёте на то, что однажды новой работы не окажется, и мы сможем с полным правом выйти из функции.
Как видите, основная идея модели «сделай, запиши,(поручи другому)» достаточно проста, но от её реализации с учётом всех тонкостей может разболеться голова. Лучше всего оставить реализацию таких штук системным программистам, у которых достаточно времени, терпения и способностей, чтобы со всем этим справиться. Например, в интервью "Arun Kishan: Inside Windows 7 — Farewell to the Windows Kernel Dispatcher Lock" архитектор, работавший над ядром Windows, рассказывают о применении именно этой модели.
В прошлый раз мы видели «беззахватный по духу» алгоритм, где захват был реализован так, что поток, обращающийся к захваченным данным, не ждёт их освобождения, а отправляется «обходным путём» (вычисляет требуемый результат, не пользуясь услугами кэша). В своём следующем посте Реймонд объясняет, как данный алгоритм можно усовершенствовать на случай, когда «обходного пути» нет. Алгоритм, однако, остаётся беззахватным: каждый поток продолжает работать, не дожидаясь освобождения захваченных данных.
В общей переменной теперь нужны два служебных бита: вдобавок к флагу захвата, как в прошлом примере, — флаг «поручена новая работа»; а если порученная работа сложная, то кроме флага, нужно будет где-то хранить ещё и её параметры. Например, в общей переменной можно хранить указатель на (выравненный в памяти) объект с параметрами, а в свободных младших битах указателя — два названных флага.
Перед выполнением действия над объектом, первым делом мы его захватываем, атомарно устанавливая соответствующий флаг. Если окажется, что объект уже был захвачен, — поручим выполнение нашего действия захватившему потоку, установив второй флаг.
Если же объект удалось захватить, то после завершения работы с ним снимаем флаг захвата и одновременно проверяем, не поручили ли нам новую работу. (Т.е. не было ли обращений к объекту за то время, пока мы его держали захваченным.) Если есть работа, то мы выполним и её; и так далее, пока однажды при разблокировке объекта отложенной работы не окажется. Мы не вправе оставить объект в состоянии «не захвачен, но есть работа».
Получившаяся модель «сделай, запиши,(поручи другому)» сплетена из множества циклов на случай всевозможных неудач. Каждая атомарная операция заключается в цикл; выполнение отложенной работы осуществляется в цикле; и каждый раз, когда вызов
InterlockedCompareExchange
выявляет перезапись использованных данных, нужно откатить всю сделанную работу, и выполнять её с начала. Модель выходит весьма запутанная. Реймонд характеризует её так: «Во всём мире, пожалуй, лишь пятеро смогут её верно реализовать, и я к этим пятерым не отношусь.» Тем не менее, в качестве иллюстрации он приводит пример объекта под названием GroupWait, который поддерживает две операции: - AddWait: Добавить в список новый хэндл события.
- SignalAll: Установить все события в списке, автоматически удаляя каждое событие одновременно с его установкой. Чтобы событие установилось при следующем вызове SignalAll, его нужно добавить в список снова.
Итак, младшие два бита указателя мы задействуем для хранения двух флагов: флага захвата, указывающего, что некий поток выполняет операцию над списком; и флага работы, указывающего, что пользователь потребовал установку событий в то время, пока список был захвачен.
// WARNING! IF YOU USE THIS CODE YOU ARE AN IDIOT - READ THE TEXT ABOVE struct NODE; NODE *Node(LONG_PTR key) { return reinterpret_cast<NODE*>(key); } enum { Locked = 1, Signalled = 2, }; struct NODE { NODE *pnNext; HANDLE hEvent; LONG_PTR Key() { return reinterpret_cast<LONG_PTR>(this); } NODE *Ptr() { return Node(Key() & ~(Locked | Signalled)); } }; #define NODE_INVALID Node(-1) class GroupWait { public: GroupWait() : m_pnRoot(NULL) { } ~GroupWait(); BOOL AddWait(HANDLE hEvent); void SignalAll(); private: NODE *m_pnRoot; };
Поскольку объединяем в одном значении указатель на элемент списка и пару флагов, то для удобства стоит определить методы, преобразующие используемые типы один в другой:
Node
возвращает указатель, Key
— числовое значение, и Ptr
— годный к использованию указатель (без флагов в младших битах).Изображать наши значения будем как битовые поля вида
p|S|L
: p — указатель на следующий элемент списка; S — флаг работы; и L — флаг захвата. Установленный флаг S означает, что нужно обработать все элементы списка, начиная со следующего — представьте себе его обозначенным не внутри элемента, а на исходящей стрелке.Например:
m_pnRoot +--------+-+-+ | * |0|1| +---|----+-+-+ | v +--------+-+-+---------+ A | * |1|?| hEvent1 | +---|----+-+-+---------+ | v +--------+-+-+---------+ B | * |?|?| hEvent2 | +---|----+-+-+---------+ | v +--------+-+-+---------+ C | NULL |?|?| hEvent3 | +--------+-+-+---------+
Изображён объект GroupWait, содержащий три хэндла. Сброшенный флаг S в голове списка означает, что никто не требовал установить событие hEvent1. Напротив, установленный флаг S в элементе A означает, что нужно обойти все элементы после A и установить соответствующие события — а именно, hEvent2 и hEvent3. В частности, значение флага S в элементах B и C не имеет значения; эти элементы будут обработаны в любом случае, потому что того требует флаг S в элементе A. Таким образом, значение флага S в последнем элементе списка вообще никогда не имеет значения.
Флаг L используется только в голове списка; во всех остальных элементах он не имеет значения.
Итак, приготовления завершены; добавляем в список новый хэндл.
BOOL GroupWait::AddWait(HANDLE hEvent) { NODE *pnInsert = new(nothrow) NODE; if (pnInsert == NULL) return FALSE; pnInsert->hEvent = hEvent; NODE *pn; NODE *pnNew; do { pn = InterlockedReadAcquire(&m_pnRoot, NODE_INVALID); pnInsert->pnNext = pn; pnNew = Node(pnInsert->Key() | (pn->Key() & Locked)); } while (InterlockedCompareExchangeRelease(&m_pnRoot, pnNew, pn) != pn); return TRUE; }
Мы добавляем в начало списка новый элемент, и следим, чтобы флаг L из старого элемента перешёл в новый: иначе может получиться, что другой поток уже захватил список, а мы его ненароком «освободили». Флаг S в добавляемом элементе сброшен: никто ещё не требовал установить новое событие. Добавляем элемент в голову списка мы по знакомой модели «сделай, запиши,(попытайся снова)» — проверяя, что никто не изменил список за нашей спиной. Обратите внимание, что «проблемы ABA» не возникает: даже если неизменившееся значение
m_pnRoot
указывает уже на другой объект, то мы всё равно используем только сам указатель, а не содержимое объекта.Простота метода AddWait необычна для модели «сделай, запиши,(поручи другому)»: в случае неудачи нам нечего перепоручить — вся работа заключается в одной записи в память. Расплачиваться за эту простоту будут остальные методы, в которых придётся предусмотреть обработку элементов, добавленных в список за то время, пока он был захвачен.
Метод SignalAll получается настолько сложным, что читать его лучше по частям.
void GroupWait::SignalAll() { NODE *pnCapture; NODE *pnNew; do { pnCapture = InterlockedReadAcquire(&m_pnRoot, NODE_INVALID); if (pnCapture->Key() & Locked) { pnNew = Node(pnCapture->Key() | Signaled); } else { pnNew = Node(Locked); } } while (InterlockedCompareExchangeAcquire(&m_pnRoot, pnNew, pnCapture) != pnCapture); if (pnCapture->Key() & Locked) return; ...
Если список захвачен, то единственное, что от нас требуется — установить в его голове флаг S. Если же список свободен, то попытаемся его захватить, и одновременно «украдём» все элементы списка, записывая вместо головы «заглушку»
NULL|0|1
. В обоих случаях перезапись головы списка осуществляется по модели «сделай, запиши,(попытайся снова)» — повторяем попытки, пока запись не удастся.Установив флаг S, мы перепоручили нашу работу потоку, захватившему список. Установленный флаг S в голове списка означает, что обработать нужно все следующие за головой элементы списка — т.е. вообще все элементы списка; как раз то, что нам нужно. Поток, захвативший список, проверит флаг при освобождении списка, и выполнит работу за нас.
Если же список не был захвачен, то нашим действием мы его захватили. «Украденные» элементы не видны другим потокам, так что одновременный вызов SignalAll с разных потоков не приведёт к многократной установке событий.
... NODE *pnNext; NODE *pn; for (pn = pnCapture->Ptr(); pn; pn = pnNext) { SetEvent(pn->hEvent); pnNext = pn->pnNext->Ptr(); delete pn; } ...
Обход «украденного» списка реализован очень прямолинейно, безо всякой заботы о потоко-безопасности; просто берём элемент за элементом, устанавливаем событие, и удаляем элемент. Единственная особенность — вызов
Ptr
для удаления флагов из указателя на следующий элемент.Теперь список надо разблокировать. Для начала:
... pnCapture = pnNew; ...
В начале метода мы записали
pnNew
в m_pnRoot
, и если это значение так и сохранилось в голове списка — значит, мы легко отделались: никто не обращался к списку за то время, пока мы с ним работали.... for (;;) { pnNew = Node(pnCapture->Key() & ~Locked); if (InterlockedCompareExchangeRelease(&m_pnRoot, pnNew, pnCapture) == pnCapture) { return; } ...
Вначале проверим, изменился ли список: если нет, достаточно его лишь разблокировать — и готово. Иначе начинаем новый цикл: нужно выполнить всю отложенную работу, накопившуюся, пока список был захвачен.
... pnCapture = InterlockedReadAcquire(&m_pnRoot, NODE_INVALID); NODE *pnNew = Node(pnCapture->Key() & ~(Locked | Signaled)); NODE **ppn = &pnNew; NODE *pn; NODE *pnNext; BOOL fSignalSeen = FALSE; for (pn = pnNew; pn->Ptr(); pn = pnNext) { pnNext = pn->Ptr()->pnNext; if (fSignalSeen) { SetEvent(pn->Ptr()->hEvent); delete pn->Ptr(); } else if (pn->Key() & Signaled) { fSignalSeen = TRUE; (*ppn) = Node(Locked); // украсть, оставив захваченным SetEvent(pn->Ptr()->hEvent); delete pn->Ptr(); } else { ppn = &pn->Ptr()->pnNext; } } } // снова попытаемся разблокировать } // конец функции
Чтобы выполнить отложенную работу, мы обходим список, пока не найдём установленный бит S. В первом элементе, в котором бит S установлен, мы обнуляем исходящий указатель, чтобы «украсть» остаток списка; затем, обходя этот остаток, мы устанавливаем каждое событие, и удаляем элемент. Как и прежде, мы «крадём» список затем, чтобы одновременный вызов с нескольких потоков не привёл к многократной установке события. В конце, когда работа выполнена, мы снова пытаемся разблокировать список — в расчёте на то, что однажды новой работы не окажется, и мы сможем с полным правом выйти из функции.
Как видите, основная идея модели «сделай, запиши,(поручи другому)» достаточно проста, но от её реализации с учётом всех тонкостей может разболеться голова. Лучше всего оставить реализацию таких штук системным программистам, у которых достаточно времени, терпения и способностей, чтобы со всем этим справиться. Например, в интервью "Arun Kishan: Inside Windows 7 — Farewell to the Windows Kernel Dispatcher Lock" архитектор, работавший над ядром Windows, рассказывают о применении именно этой модели.