Andrei Alexandrescu
Оригинал статьи:
http://www.ddj.com/cpp/184403766
Ключевое слово volatile было разработано для отключения компиляторной оптимизации, которая могла бы привести к неверной работе кода в мультипоточном окружении. К примеру: если переменная базового типа объявлена как volatile, то компилятору не разрешается кэшировать ее в регистре - распостраненная оптимизация, которая может привести к катастрофическим результатам, если данная переменная используется в нескольких потоках. Так что общее правило - если у вас есть переменные базовых типов, которые нужно использовать в нескольких потоках - объявляйте их как volatile. Однако возможности volatile намного шире: вы можете использовать его для нахождения не thread-safe кода и делать это можно в compile time. В статье показано как это сделать; в решении используется простой smart pointer, который также облегчает сериализацию критических секций кода.
Не хочу никому портить настроение, но эта статья затрагивает ужасную тему многопоточного программирования. В соответствии с принципами generic programming, достаточно сложное само по себе exception-safe программирование в сравнении с многопоточным - детские игрушки.
Хорошо известно, что программы, использующие несколько потоков, тяжело писать, тестировать, отлаживать, поддерживать и вообще иметь с ними дело с ними достаточно скушно. Подобная программа, содержащая ошибку, может работать годы без сбоев и внезапно рухнуть только благодаря внезапно возникшим новым условиям.
Излишне говорить, что программисту, пишущему многопоточную программу необходима вся помощь которую он может получить. В этой статье основное внимание уделяется race conditions - распространенному источнику неприятностей в многопоточном программировании - и предлагаются идеи и инструменты, помогающие решить данную проблему и, что достаточно удивительно, заставить компилятор прийти на помощь в ее решении.
Всего лишь ключевое слово
Хотя в стандартах C и C++ почти ничего не сказано о потоках, они все же вносят небольшой вклад в многопоточность в форме ключевого слова volatile.
Так же как и его более известная противоположность const, volatile это модификатор типа. Он предназначен для использования с переменными, доступ к которым осуществляется из разных потоков. Проще говоря, без volatile написание многопоточных программ становится невозможным и кроме того компилятор теряет много возможностей к оптимизации. Объясним все по порядку.
Рассмотрим следующий код:
class Gadget { public: void Wait() { while (!flag_) { Sleep(1000); // ждать 1000 миллисекунд } } void Wakeup() { flag_ = true; } ... private: bool flag_; };
class Gadget { public: ... как и выше ... private: volatile bool flag_; };
Использование volatile с пользовательскими типами
You can volatile-qualify not only primitive types, but also user-defined types. In that case, volatile modifies the type in a way similar to const. (You can also apply const and volatile to the same type simultaneously.) Unlike const, volatile discriminates between primitive types and user-defined types. Namely, unlike classes, primitive types still support all of their operations (addition, multiplication, assignment, etc.) when volatile-qualified. For example, you can assign a non-volatile int to a volatile int, but you cannot assign a non-volatile object to a volatile object. Let's illustrate how volatile works on user-defined types on an example.
class Gadget { public: void Foo() volatile; void Bar(); ... private: String name_; int state_; }; ... Gadget regularGadget; volatile Gadget volatileGadget;
volatileGadget.Foo(); // ok, volatile fun called for // volatile object regularGadget.Foo(); // ok, volatile fun called for // non-volatile object volatileGadget.Bar(); // error! Non-volatile function called for // volatile object!
Gadget& ref = const_cast<Gadget&>(volatileGadget); ref.Bar(); // ok
volatile, Critical Sections, and Race Conditions
The simplest and the most often-used synchronization device in multithreaded programs is the mutex. A mutex exposes the Acquire and Release primitives. Once you call Acquire in some thread, any other thread calling Acquire will block. Later, when that thread calls Release, precisely one thread blocked in an Acquire call will be released. In other words, for a given mutex, only one thread can get processor time in between a call to Acquire and a call to Release. The executing code between a call to Acquire and a call to Release is called a critical section. (Windows terminology is a bit confusing because it calls the mutex itself a critical section, while "mutex" is actually an inter-process mutex. It would have been nice if they were called thread mutex and process mutex.) Mutexes are used to protect data against race conditions. By definition, a race condition occurs when the effect of more threads on data depends on how threads are scheduled. Race conditions appear when two or more threads compete for using the same data. Because threads can interrupt each other at arbitrary moments in time, data can be corrupted or misinterpreted. Consequently, changes and sometimes accesses to data must be carefully protected with critical sections. In object-oriented programming, this usually means that you store a mutex in a class as a member variable and use it whenever you access that class' state. Experienced multithreaded programmers might have yawned reading the two paragraphs above, but their purpose is to provide an intellectual workout, because now we will link with the volatile connection. We do this by drawing a parallel between the C++ types' world and the threading semantics world.
In short, data shared between threads is conceptually volatile outside a critical section, and non-volatile inside a critical section. You enter a critical section by locking a mutex. You remove the volatile qualifier from a type by applying a const_cast. If we manage to put these two operations together, we create a connection between C++'s type system and an application's threading semantics. We can make the compiler check race conditions for us.
LockingPtr
We need a tool that collects a mutex acquisition and a const_cast. Let's develop a LockingPtr class template that you initialize with a volatile object obj and a mutex mtx. During its lifetime, a LockingPtr keeps mtx acquired. Also, LockingPtr offers access to the volatile-stripped obj. The access is offered in a smart pointer fashion, through operator-> and operator*. The const_cast is performed inside LockingPtr. The cast is semantically valid because LockingPtr keeps the mutex acquired for its lifetime. First, let's define the skeleton of a class Mutex with which LockingPtr will work:
class Mutex { public: void Acquire(); void Release(); ... };
template <typename T> class LockingPtr { public: // Constructors/destructors LockingPtr(volatile T& obj, Mutex& mtx) : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) { mtx.Lock(); } ~LockingPtr() { pMtx_->Unlock(); } // Pointer behavior T& operator*() { return *pObj_; } T* operator->() { return pObj_; } private: T* pObj_; Mutex* pMtx_; LockingPtr(const LockingPtr&); LockingPtr& operator=(const LockingPtr&); };
class SyncBuf { public: void Thread1(); void Thread2(); private: typedef vector<char> BufT; volatile BufT buffer_; Mutex mtx_; // controls access to buffer_ };
void SyncBuf::Thread1() { LockingPtr<BufT> lpBuf(buffer_, mtx_); BufT::iterator i = lpBuf->begin(); for (; i != lpBuf->end(); ++i) { ... use *i ... } }
void SyncBuf::Thread2() { // Error! Cannot access 'begin' for a volatile object BufT::iterator i = buffer_.begin(); // Error! Cannot access 'end' for a volatile object for (; i != lpBuf->end(); ++i) { ... use *i ... } }
unsigned int SyncBuf::Size() { return LockingPtr<BufT>(buffer_, mtx_)->size(); }
Перевод: Andrew Selivanov for crossplatform.ru