volatile - лучший друг программистов, разрабатывающих многопотоковые программы

(Оригинал статьи: www.ddj.com/cpp/184403766)

Andrei Alexandrescu

Development Manager at RealNetworks Inc.

Перевод: Г. Берман (genberm@gmail.com)

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


Я не хочу испортить настроение, но мы рассмотрим ужасную тему многопотокового программирования. То о чем говорилось в предыдущей статье Generic<Programming>, а именно exception-safe считалось трудной темой, но это игра ребенка по сравнению с многопотоковым программированием.  

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

Нет необходимости говорить о том, что программист, разрабатывающий многопотоковый код должен получить максимум помощи. Эта статья фокусируется на совместном использовании – общем источнике проблем в многопотоковых программах, и обеспечит идеями и инструментами, позволяющими их избежать, и помочь компилятору в его тяжелой работе.

Просто маленькое ключевое слово

Хотя стандарты как С, так и C++ явно не многословны, когда они касаются потоков, то делают небольшую уступку в виде ключевого слова volatile .

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

Рассмотрим следующий код:

class Gadget { public:     void Wait() {         while (!flag_)         {             Sleep(1000); // спим 1000 миллисекунд         }     }     Wakeup()     {         flag_ = true;     }     ... private:     bool flag_; };

Назначение Gadget::Wait заключается в проверке каждую секунду переменной flag_ и возврате, когда эта переменная установлена в true другим потоком. По крайней мере, это программист имел в виду, но, увы, Wait некорректна.

Предположим, компилятор полагает, что Sleep(1000) — это обращение к внешней библиотеке, которая не может изменять переменную flag_. Затем компилятор приходит к выводу, что он может кэшировать flag_ в регистр и использовать этот регистр вместо доступа к более медленной оперативной памяти. Это отличная оптимизация для однопоточного кода, но в данном случае, она вносит некорректность. После вызова Wait для некоторого объекта Gadget, хотя другой поток вызовет Wakeup, Wait будет постоянно в цикле. Это произойдет из-за того, что изменение flag_ не будет отражено в регистре, в котором кэширован flag_. Оптимизация является слишком ... оптимистичной.

Кэширование переменных в регистрах, является весьма полезной оптимизацией, которая применяется достаточно давно, и было бы жаль потерять ее. C и C++ предоставляют возможность явно отключать такое кэширование. Если вы используете для переменной  модификатор volatile, компилятор не будет кэшировать эту переменную в регистрах. При каждом обращении к этой переменной будет указано фактическое место в памяти. Поэтому, все что все вам нужно сделать для совместной работы Wait/Wakeup Gadget'а – это квалифицировать flag_:

class Gadget { public:     ... as above ... private:     volatile bool flag_; };

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

Использование volatile с типами, определенными пользователями

Вы можете использовать volatile квалификацию не только с простыми типами, но и с типами, определенными пользователем. В этом случае volatile модифицирует тип подобно const. (Вы также можете применить const и volatile одновременно для одного и того же типа.)

В отличие от const, volatile по-разному действует с простыми типами и типами, определенными пользователем. А именно, в отличие от классов, простые типы, квалифицируемые volatile, по-прежнему поддерживают все свои операций (сложение, умножение, присваивание и т.д.). Например, можно volatile int присвоить обычный (не volatile) int, но нельзя назначить обычный (не volatile) объект volatile объекту.

Давайте рассмотрим пример как volatile работает с типами, определенными пользователем.

class Gadget { public:     void Foo() volatile;     void Bar();     ... private:     String name_;     int state_; }; ... Gadget regularGadget; volatile Gadget volatileGadget;

Если вы считаете, что с данным объектом volatile не полезен, то подготовьтесь к ряду сюрпризов.

volatileGadget.Foo(); // ok, volatile функция вызывается для volatile объекта regularGadget.Foo();  // ok, volatile функция вызывается для не volatile объекта volatileGadget.Bar(); // ошибка! Не volatile функция вызывается для volatile объекта

Преобразование от не квалифицированного типа к его volatile двойнику является тривиальным. Однако подобно const обратное действие от volatile к не квалифицированному типу сделать нельзя. Необходимо использовать приведение типов:

Gadget& ref = const_cast<Gadget&>(volatileGadget); ref.Bar(); // ok

Квалифицированный volatile класс предоставляет доступ только к подмножеству своего интерфейса, подмножество находится под контролем разработчика класса. Пользователи могут получить полный доступ к интерфейсу такого типа только с помощью const_cast. Кроме того, подобно const, квалификация volatile в классах распространяется от класса к своим членам (например, volatileGadget.name_ и volatileGadget.state_ являются volatile переменными).

volatile, критические секции и совместное использование

Наиболее простым и часто используемым способом синхронизации в многопотоковых программах является мьютекс. Мьютекс предоставляет примитивы Acquire и Release. Когда вы вызываете в каком-либо потоке Acquire, любой другой поток, вызвавший Acquire, будет блокироваться. Позднее, когда для первого потока будет вызван Release, точно один поток с заблокированным вызовом Acquire будет освобожден. Другими словами, для данного мьютекса, только один поток может получить время процессора между вызовом Acquire и вызовом Release. Исполняемый код между вызовом Acquire и вызовом Release называется критической секцией. (Терминология Windows является немного запутанной. Она понимает под мьютексом собственно критическую секцию, в то время как на самом деле "мьютекс" это межпроцессная блокировка. Было бы хорошо, если бы они разделяли термины потоковый мьютекс и процессорный мьютекс.)

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

Опытные разработчики мультипотоковых приложений могли бы позевывать, читая предыдущие два параграфа, но их цель – это интеллектуальная тренировка, потому что сейчас мы обозначим связь, обеспечиваемую volatile, соединяя мир типов C++ и мир семантики потоков.

Иными словами, данные, разделяемые между потоками, концептуально являются изменчивыми вне критической секции и устойчивым внутри критической секции.  

Задайте критическую секцию, заблокировав ее мьютексом. Удалите  квалификатор volatile из типа, применяя const_cast. Если мы поместим эти две операции вместе, то мы создадим соединение между системой типов C++ и семантиками потоков приложения. Мы можем заставить компилятор проверить для нас совместное использование.  

LockingPtr

Нам нужен инструмент, который соберет применяемые мьютексы и const_cast. Давайте разработаем шаблонный класс LockingPtr , который инициализируется с объектом volatile obj и мьютексом mtx. Во время своего существования LockingPtr хранит  полученный mtx. Кроме того, LockingPtr предоставляет доступ к volatile obj. Доступ предоставляется на основе смарт-указателя, через operator -> и operator *. Const_cast выполняется внутри LockingPtr. Все семантически верно, потому что LockingPtr содержит мьютекс, полученный для своего существования.

Во-первых, давайте определим основу класса Mutex, с которым будет работать LockingPtr:

class Mutex { public:     void Acquire();     void Release();     ...    };

Для использования LockingPtr реализуем Mutex с помощью структуры  данных операционной системы и примитивных функций.

LockingPtr — шаблонный класс с типом контролируемой переменной. Например, если вы хотите управлять Widget'ом, вы используете LockingPtr <Widget> , который инициализируется с переменной типа volatile Widget.  

Определение LockingPtr является очень простым. LockingPtr реализует простой смарт-указатель. Он сосредоточен исключительно на сборе const_cast и критической секции.

template <typename T> class LockingPtr { public:    // Конструкторы/деструкторы    LockingPtr(volatile T& obj, Mutex& mtx)        : pObj_(const_cast<T*>(&obj, pMtx_(&mtx)    {    mtx.Lock();    }    ~LockingPtr()    {    pMtx_->Unlock();    }    T& operator*()    {    return *pObj_;    }    T* operator->()    {   return pObj_;   } private:    T* pObj_;    Mutex* pMtx_;    LockingPtr(const LockingPtr&);    LockingPtr& operator=(const LockingPtr&); };

Несмотря на свою простоту, LockingPtr является весьма полезным помощником при написании корректного многопотокового кода. Вы должны определить объекты, которые распределяются между потоками, как volatile и никогда не использовать const_cast с ними. Всегда используйте автоматические объекты LockingPtr . Давайте рассмотрим это на примере.  

Предположим, у вас есть два потока, использующие объект vector <char>:

class SyncBuf { public:     void Thread1();     void Thread2(); private:     typedef  vector<char> BufT;     volatile BufT buffer_;     Mutex mtx_; // управляет доступом к buffer_ };

Внутри функции потока вы просто используете LockingPtr <BufT> для получения управляемого доступа к переменной buffer_:

void SyncBuf::Thread1(){     LockingPtr<BufT> lpBuf(buffer_, mtx_);     BufT::iterator i = lpBuf->begin();     for (; i != lpBuf->end(); ++i) {         ... use *i ...     } }

Код очень простой в написании и понимании. Всякий раз, когда вам нужно использовать buffer_, вы должны создать LockingPtr <BufT> , указывающий на него. Когда вы это сделаете, имеется доступ ко всему интерфейсу вектора.

Приятное здесь то, что если вы сделаете ошибку, компилятор укажет на нее:

void SyncBuf::Thread2() {     // Ошибка! Нет доступа 'begin' для volatile объекта     BufT::iterator i = buffer_.begin();     // Ошибка! Нет доступа 'end' для volatile объекта     for (; i != lpBuf->end(); ++i) {         ... use *i ...     } }

Вы не сможете получать доступ к любой функции buffer_, до тех пор, пока вы либо не примените const_cast, либо не используете LockingPtr. Разница заключается в том, что LockingPtr предлагает упорядоченный способ применения const_cast для изменчевых переменных.

LockingPtr является удивительно выразительным. Если вам требуется вызвать только одну функцию, можно создать неименнованный временный объект LockingPtr и использовать непосредственно:

unsigned int SyncBuf::Size() {     return LockingPtr<BufT>(buffer_, mtx_)->size(); }

Назад к простым типам

Мы видели, как красиво volatile защищает объекты от неконтролируемого доступа и как LockingPtr обеспечивает простой и эффективный способ написания потокозащищенного кода. Теперь вернемся к простым типам, которые по-разному трактуются  volatile.

Давайте рассмотрим пример, где несколько потоков разделяют переменную типа int.

class Counter { public:     ...     void Increment() { ++ctr_; }     void Decrement() { --ctr_; } private:     int ctr_; };

Если Increment и Decrement будет вызываться из разных потоков, представленный выше фрагмент не корректен. Во-первых, ctr_ должна быть volatile. Во-вторых, даже казалось бы атомарная операция, такая как ++ ctr_, на самом выполняется в три шага. Сама память не обеспечивает арифметических функций. Когда увеличивается значение переменной, процессор:

Это трехшаговая операция называется RMW (Read-Modify-Write). Во время Modify шага RMW операции большинство процессоров освобождают шину памяти, с тем чтобы дать другим процессорам доступ к памяти.

Если в это время другой процессор выполняет операцию RMW с этой же переменной, у нас будет совместное использование. Вторая запись перезапишет результат первой.

Чтобы избежать этого, вы снова можете положиться на LockingPtr:

class Counter { public:     ...     void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }     void Decrement() { --*LockingPtr<int>(ctr_, mtx_); } private:     volatile int ctr_;     Mutex mtx_; };

Теперь код является корректным, но его качество хуже по сравнению с кодом SyncBuf. Почему? Потому что при использовании Counter, компилятор не будет предупреждать вас, если вы ошибочно обратитесь к ctr_ напрямую (без его блокировки). Компилятор скомпилирует ++ ctr_, если ctr_ является volatile, хотя сгенерированный код просто неверен. Компилятор больше не является вашим союзником, и только ваше внимание может помочь вам избежать совместного использования.

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

volatile функции-члены

До этого момента мы имели классы, которые включали volatile данные-члены. Теперь давайте подумаем о разработке классов, которые в свою очередь будет частью более крупных объектов и будут совместно использоваться между потоками. Здесь volatile функции-члены могут оказать огромную помощь.

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

Например, вы определяете класс Widget, который реализует два варианта Operation -  один потокобезопасный, другой быстрый и незащищенный.

class Widget { public:     void Operation() volatile;     void Operation();     ... private:     Mutex mtx_; };

Обратите внимание на использование перегрузки. Здесь пользователь Widget может вызвать Operation, используя единообразный синтаксис либо как volatile объект и получить безопасный поток, либо как обычный объект и получить скорость. Пользователь должен быть осторожным при определении общих объектов Widget, как volatile.

При реализации volatile функции-члена, первая операция обычно блокирует this с LockingPtr. Далее работа выполняется с использованием не-volatile функции-члена:

void Widget::Operation() volatile {     LockingPtr<Widget> lpThis(*this, mtx_);     lpThis->Operation(); // запускаем не-volatile функцию }

Резюме

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

Если вы выполните это, и если используете простой общий компонент LockingPtr, то сможете написать потокобезопасный код. Будет гораздо меньше беспокойства по поводу совместного использования, так как компилятор будет беспокоиться за вас и будет неустанно указывать места, где вы не правы.

Несколько проектов, в которых я принимал участие и использовал volatile и LockingPtr были очень эффективны. Код является чистым и понятным. Я помню несколько взаимных блокировок, но я предпочитаю взаимные блокировки при совместном использовании, потому что они гораздо проще для отладки. Проблем, связанных с совместным использованием, не было.

Благодарности

Большое спасибо James Kanze и Sorin Jianu, за серьезные идеи.

Сайт создан в системе uCoz