Wiki

volatile - лучший друг программиста работающего с потоками

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_;
};

Основное предназначение 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:
    ... как и выше ...
private:
    volatile bool flag_;
};

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

Использование 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;

If you think volatile is not that useful with objects, prepare for some surprise.
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!

The conversion from a non-qualified type to its volatile counterpart is trivial. However, just as with const, you cannot make the trip back from volatile to non-qualified. You must use a cast:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

A volatile-qualified class gives access only to a subset of its interface, a subset that is under the control of the class implementer. Users can gain full access to that type's interface only by using a const_cast. In addition, just like constness, volatileness propagates from the class to its members (for example, volatileGadget.name_ and volatileGadget.state_ are volatile variables).

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.

  • Outside a critical section, any thread might interrupt any other at any time; there is no control, so consequently variables accessible from multiple threads are volatile. This is in keeping with the original intent of volatile — that of preventing the compiler from unwittingly caching values used by multiple threads at once.
  • Inside a critical section defined by a mutex, only one thread has access. Consequently, inside a critical section, the executing code has single-threaded semantics. The controlled variable is not volatile anymore — you can remove the volatile qualifier.

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();
    ...    
};

To use LockingPtr, you implement Mutex using your operating system's native data structures and primitive functions. LockingPtr is templated with the type of the controlled variable. For example, if you want to control a Widget, you use a LockingPtr that you initialize with a variable of type volatile Widget. LockingPtr's definition is very simple. LockingPtr implements an unsophisticated smart pointer. It focuses solely on collecting a const_cast and a critical section.
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&);
};

In spite of its simplicity, LockingPtr is a very useful aid in writing correct multithreaded code. You should define objects that are shared between threads as volatile and never use const_cast with them — always use LockingPtr automatic objects. Let's illustrate this with an example. Say you have two threads that share a vector object:
class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Inside a thread function, you simply use a LockingPtr to get controlled access to the buffer_ member variable:
void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

The code is very easy to write and understand — whenever you need to use buffer_, you must create a LockingPtr pointing to it. Once you do that, you have access to vector's entire interface. The nice part is that if you make a mistake, the compiler will point it out:
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 ...
    }
}

You cannot access any function of buffer_ until you either apply a const_cast or use LockingPtr. The difference is that LockingPtr offers an ordered way of applying const_cast to volatile variables. LockingPtr is remarkably expressive. If you only need to call one function, you can create an unnamed temporary LockingPtr object and use it directly:
unsigned int SyncBuf::Size() {
    return LockingPtr<BufT>(buffer_, mtx_)->size();
}

..to be continued...

Перевод: Andrew Selivanov for crossplatform.ru