본문 바로가기

진리는어디에

[C++] 멀티스레드 환경에서 volatile의 유용한 사용법

얼마전 싱글톤 패턴 관련 책을 보다 volatile에 관한 내용을 읽고, 보다 정확한 용도에 대해 알기 위해 인터넷을 떠돌던중 Dr.Dobb's에서 volatile: The Multithreaded Programmer's Best Friend라는 흥미로은 아티클을 발견하여 한글로 옮겨 봅니다.

간단하게 내용을 요약하자면 volatile이라는 키워드의 특성을 이용하여 쓰레드간에 공유 되는 변수에 강제적으로 lock을 잡고 사용하도록하는 방법을 제시하고 있습니다. 스레드간 공유 되는 자원에 lock을 잡지 않고 사용하고자 한다면 컴파일 타임에 에러를 발생 시켜 런타임에 프로그램이 예측 되지 않는 동작을 하는 것을 방지해 줍니다.

들어가며

volatile 키워드는 비동기 이벤트 환경에서 컴파일러 최적화를 통해 코드가 꼬이는 것을 방지 하기 위해 만들어 졌습니다. 어떻게 해서 코드가 꼬여 버리는지에 알고 싶으시다면 Debug lapvolatile에 관련된 페이지를 참고 할 것을 추천 드립니다. 암튼 결론은 일반적으로 여러 쓰레드 간에 공유되는 primitive의 변수를 사용한다면 그 변수를 volatile 키워드로 하세요. 그리고 volatile 키워드로는 앞에서 말한 것 뿐만 아니라, 다른 용도로도 더 다양하게 사용될 수 있습니다. 여러분은 thread safe하지 않은 코드를 찾아 내기 위해 volatile을 사용 할 수도 있습니다. 그리고 그걸 컴파일 타임에 이루어 낼 수 있지요. (놀랍지 않습니까? 프로그램을 실행 시켜서 터지는지 않터지는지 확인하기 위해 기다리지 않아도 컴파일 타임에 thread safe 여부를 검증 할 수 있습니다!!) 이 문서는 크리티컬 섹션 안에서 보다 쉽게 동기화를 이루어 낼 수 있는 간단한 스마트 포인터를 구현함으로써 어떻게 그것을 이루어 가는지 보여 드릴겁니다.

멀티 쓰레드 환경에서의 프로그래밍은 어렵기로(?) 유명합니다. 제대로 만들어지지 않은 멀티 쓰레드 프로그램이 몇 년동안 아무런 말성 없이 돌아가다, 결정적인 순간에 미쳐 날뛴다는 것은 여러분도 아마 경험해 보셨을 수도 있습니다.

말할 필요도 없이 멀티 스레드 프로그래밍을 하는 프로그래머라면 모든 가능한 방법들을 사용 할 필요가 있습니다. 이 컬럼은 주로 레이스 컨디셔(race condition)에 관하여 포커스를 맞춰 여러분에게 인사이트를 제공할 것이며, 그것을 컴파일 타임에 피해 갈수 있는 도구를 소개 할 것입니다.

일단 키워드에서 부터 시작

const, volatile은 타입 변환자로써 잘 알려져 있습니다. 이 키워드들의 목적은 다른 여러 쓰레드에서 접근되고 수정될 수 있는 변수 들에게 사용 되는 것입니다. 기본적으로 volatile 없이 멀티 쓰레드 프로그래밍을 하는 것은 불가능하거나 컴파일러는 대략난감할 정도의 최적화 기회를 낭비하게 됩니다.

아래의 코드를 살펴 보도록 하겠습니다 :

class Gadget 
{ 
public: 
    void Wait() 
    { 
        while (!flag_) 
        { 
            Sleep(1000); // sleeps for 1000 milliseconds 
        }
    } 
    
    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이라는 키워드 하나만 더 붙여 주면 됩니다. 그럼 컴파일러는 절대 그 변수를 레지스터 않에 두지 않을 것이고, 모든 참조는 직접 메모리를 참조 할 것이며, 그렇게 되면 Gadget의 Wait/Wakeup 함수는 정상적으로 동작하게 되겠지요.

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

대부분의 설명이 여기서 끝나지요. 하지만 volatile 키워드로 할 수 있는 재밌는 일들이 더 남았습니다(후훗~).

사용자 정의 타입에 volatile 사용하기

primitive 타입 뿐만 아니라 사용자 정의 타입(class, struct..etc)에도 volatile을 사용 할 수 있습니다. 예를 들면, volatile은 const와도 비슷하게 사용 됩니다(더욱 놀라운 것은 같은 타입에 const와 volatile을 동시에 지정 할 수 있습니다).

하지만 const와는 다르게 volatile은 primitive 타입과 user-defined 타입을 차별 합니다. 다시 말하자면, 클래스와 다르게 primitive 타입은 여전히 모든 오퍼레이션(더하기, 빼기, 나누기, 할당 등..)을 지원합니다. 예를 들어 volatile이 아닌 int를 volatile인 int에 할당 할 수 있습니다. 하지만 volatile이 아닌 user-defined 객체를 volatile객체에 집에 넣을 수는 없습니다. Volatile이 어떻게 user-defined 타입에 대해 동작하는 지를 살펴 보도록 합시다.

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

만일 volatile이 객체에는 별 유용하지 않다라고 느끼시는 분들은..놀랄 준비 하세요.

volatileGadget.Foo(); // ok, volatile function called for
                      // volatile object 
regularGadget.Foo();  // ok, volatile function called for 
                      // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for 
                      // volatile object!​

volatile이 적용되지 않은 객체가 volatile 멤버 함수를 호출하는 것은 괜찮습니다. 하지만 const에서와는 다르게 volatile 적용된 객체가 volatile로 꾸며지지 않은 함수를 호출 하는 것은 안됩니다. 이것은 cast를 통해서만 이루어 질 수 있습니다.

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

volatile이 적용된 클래스는 오직 volatile이 적용된 인터페이스에만 억세스 할 수 있습니다. 사용자는 객체에 const_cast를 이용해야만 모든 인터페이스에 억세스 가능합니다. 추가적으로 const와 같이 volatile도 클래스에서 멤버 변수로 전파됩니다. volatileGadget.name_과 volatileGadget.state_도 volatile변수 입니다.

volatile, Critical Sections, and Race Conditions

멀티 쓰레드 프로그래밍에서 동기화를 위해 가장 많이 쓰이는 것이 바로 mutex 일겁니다. mutex는 Acquire와 Release 이렇게 두가지 인터페이스를 제공하고 있습니다. 만일 어떤 쓰레드(T1)에서 Acquire를 호출하고, 다른 쓰레드(T2)에서 다시 동일한 mutex에 대해 Acquire를 호출 한다면 두 번째 Acquire를 호출한 쓰레드(T2)는 블록킹 상태에 놓이게 될 겁니다. 그리고 처음에 Acquire를 호출했던 쓰레드(T1)가 Release를 호출 하고 난 후에야 그 쓰레드(T2)는 Acquire 다음으로 넘어 갈 수 있게 되지요. 다시 말해서, mutex의 Acquire와 Release의 사이에서는 딱 하나의 쓰레드만이 프로세서 타임을 얻을 수 있고, 이것을 '크리티컬 섹션(critical section)'이라고 합니다.

  • 크리티컬 섹션의 외부에서는 쓰레드 끼리 레이스 컨디션을 벌일 수 있습니다. 그래서 여러 쓰레드에서 접근 가능한 변수에는 volatile 키워드를 붙여 줍니다(컴파일러가 마음대로 레지스터에 집어 넣는 것을 방지 하도록 말이죠).
  • mutex로 보호되는 크리티컬 섹션 내부에서는 오직 한개의 쓰레드만이 변수에 접근 가능 합니다. 고로 크리티컬 섹션 안에서는 싱글 쓰레드 형식의 구문을 따라가면 되겠지요. 위에서 volatile로 수식 되던 변수는 더이상 volatile일 필요가 없습니다. const_cast를 이용해서 volatile을 떼어 버릴 수 있습니다.

간단하게 요약 하자면..(사실 이 부분이 가장 중요하다고 생각 됩니다만..), 쓰레드 간에 공유되는 데이터는 크리티컬 섹션 바깥 부분에서는 volatile 변수이어야 합니다. 반면에 크리티컬 섹션 안에서는 non-volatile변수가 되어야 합니다. 꼭 이렇게 해야만 컴파일이 되는 것은 아니지만 크리티컬 섹션 외부에서는 변수에 volatile을 적용하고, 크리티컬 섹션 내부에서는 const_cast를 이용하여 volatile 속성을 제거함으로써 컴파일 타임에 '레이스 컨디션(race condition)을 체크 할 수 있습니다.

잊지마세요. 약속입니다. '크리티컬 섹션 밖에서는 volatile, 크리티컬 섹션 안에서는 const_cast '입니다.

LockingPtr

LockingPtr 템플릿 클래스를 만들어 봅시다. LockingPtr 클래스는 마치 스마트 포인터와 비슷한 구조를 가지고 있습니다. 템플릿으로 지정된 객체의 포인터를 리턴하지요. 하지만 단순히 객체의 포인터만을 리턴하지는 않습니다. LockingPtr이라는 이름 답게 lock을 걸고 난후 객체의 포인터를 리턴합니다.

구현의 첫단계로 각 시스템에 걸맞게 제작된 mutex 클래스가 필요 합니다. mutex는 Acquire와 Release를 구현 해야만 합니다(뭐 이름이야 LockingPtr에서 호출 할 이름을 정해야 하지만, 교과서적인 관점에서 mutex는 Acqurie와 Release 두 가지 오퍼레이션을 제공하지요..).그리고 LockingPtr이 감싸고 있을 객체는 volatile로 초기화 되어야 합니다. LockingPtr의 생명주기동안 mutex의 Acquire가 계속 걸려 있다고 생각하시면 됩니다. 또한 LockingPtr이 감싸고 있는 객체 obj에 접근이 필요한 경우 volatile 속성을 제거 시켜 줍니다. 위에서도 언급했다 싶이 LockingPtr은 마치 스마트 포인터의 구조와 비슷하게 작동 하지요. 그래서 ->와 * 오퍼레이터도 제공 합니다.

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

LockingPtr은 템플릿 클래스 입니다. 예를 들어 Widget이라는 클래스가 각 쓰레드간에 공유되는 변수의 타입이라고 가정하면, LockingPtr<Widget> 이라고 쓸 수 있습니다.

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&); 
};

이런 간단한 구조임에도, LockingPtr은 정확한 멀티 쓰레드 코드를 작성하는데 있어 상당한 도움을 줍니다. 여러분은 쓰레드 간에 공유되는 객체를 반드시 'volatile'을 이용해서 정의해야 하고, 절대 'const_cast'를 사용해서는 안됩니다. 그것은 어디까지나 LockingPtr이 자동적으로 처리해야만 하지 여러분이 임의로 건드려서는 안됩니다. 이제는 여러분이 vector<char> 타입의 객체를 공유하는 두 개의 쓰레드를 사용한다는 가정하에 예를 들어 보도록 하지요.

class SyncBuf
{
public:
    void Thread1();
    void Thread2(); 
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_ 
};

쓰레드 함수를 보면 buffer_ 멤버 변수에 접근하기 위해 LockingPtr<BufT>를 사용하는 간단한 코드를 보실 수 있습니다.

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

그렇게 이해하기 여려운 코드는 아니지요? 지금 부터는 buffer_ 멤버 변수에 접근 할 때는 무조건 LockingPtr<BufT>를 사용 해야만 합니다. 그렇게 해야만 vector의 인터페이스에 접근이 가능하지요. 그리고 여기서 정말 기분 좋은 것은, 만일 여러분이 실수 할 때(쓰레드 엔트리 함수에서 공유되는 변수에 접근 할 때, lock을 걸지 않고 접근 하는 경우) 컴파일러가 그 사실을 지적해 준다는 것이지요.

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 ... 
    }
}

위의 코드에서 const_cast를 사용하거나 LockingPtr을 사용하기 전에는 어떠한 buffer_의 함수에도 접근이 불가능 합니다. 만일 함수 하나만 호출하고 종료하기를 원한다면 unnamed LockingPtr 객체를 생성하므로써 간단히 해결 할 수 있습니다.

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

Back to Primitive Types

지금 까지 volatile이 어떻게 통제되지 않는 멀티 쓰레드의 접근에서 객체를 보호해 줄 수 있는지 보았고, 또 어떻게 LockingPtr이 간단하고 효과적으로 thread-safe 코드를 작성하는데 도움을 주는지 보았습니다.

지금 부터는 다시 primitive 타입으로 돌아가 보도록 하겠습니다(위에서 volatile은 primitive 타입 변수와 user defined 타입 변수를 다르게 취급한다고 이미 언급한 바있습니다. 잊지 말아주세요^^).

멀티 쓰레드가 int 타입의 변수 하나를 공유한다고 가정해 보도록 하겠습니다.

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

만일 Increment와 Decrement가 다를 쓰레드에서 제각각 불린다면, 위의 코드는 버그를 일으킬 것입니다. 첫째, ctr_은 volatile이 되어야 합니다. 둘째, 겉으로 보기에는 ++ctr_ 이 atomic 오퍼레이션 처럼 보이지만 사실은 세개의 오퍼레이션을 가집니다. 메모리 자체는 아무런 산술적 기능이 없기 때문에 변수를 증가 시킬 때는 아래와 같은 과정을 거칩니다.

  • Reads that variable in a register (레지스터에서 변수를 읽어 옵니다)
  • Increments the value in the register (레지스터의 변수를 하나 증가 시킵니다)
  • Writes the result back to memory (결과를 메모리에 적습니다)

이 세 단계의 과정을 RMW(Read-Modify-Write)라고 하지요. RMW 오퍼레이션에서 2단계, 수정을 하는 단계서 대부분의 프로세서는 다른 프로세서가 메모리에 접근 할 수 있도록 해줍니다. 만일 그 때, 다른 프로세서가 같은 변수에 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의 코드와 비교 했을 때 조잡합니다. 왜냐구요?  ctr_변수가 volatile로 선언되어 있다고 하더라고 primitive 타입은 volatile과 non-volatile에 대해 오퍼레이션에 차별을 두지 않기 때문에, 실수로 Counter 클래스에서 ctr_ 변수에 lock을 걸지 않고 접근해도 컴파일러는 아무런 경고를 해주지 않습니다.

volatile Member Functions

지금 부터는 volatile 멤버 함수에 대해서 살펴 보도록 하겠습니다. 먼저 모든 코드에서는 volatile 함수만을 호출 한다고 가정합니다(잊지 마세요. volatile은 크리티걸 섹션 외부에서만 사용 됩니다. non-volatile은 싱글쓰레드, 즉 크리티컬 섹션 안에서만 사용됩니다.)

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

위에서는 두 개의 멤버 함수를 구현 했습니다. 하나는 volatile이 적용 되어진 느리지만 thread-safe한 함수, 다른 하나는 쓰레드에서 보호 되지 못하지만 빠른 함수 입니다.

오버로딩에 주의 해주세요. 지금 부터는 Widget의 Operation함수를 동일한 방법으로 호출이 가능합니다. 사용자는 공유되는 Widget 객체를 volatile로 선언해주는 것에 유의 해야 합니다. volatile 멤버 함수를 구현 할 때, LockingPtr을 이용해 this 포인터에 lock을 잡아 줍니다. 그리고 그 포인터를 이용하여 non-volatile Operation 멤버 함수를 호출 하는 것이지요.

void Widget::Operation() volatile 
{
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function 
}

Summary

멀티 쓰레드 프로그래밍을 할 때 volatile을 유용한 도구로써 사용 할 수 있습니다. 하지만 아래의 사항들을 꼭 지키셔야 합니다

  • Define all shared objects as volatile.
    (모든 공유 객체는 volatile을 이용해 선언 되어야 합니다.)
  • Don't use volatile directly with primitive types.
    (Primitive 타입에는 volatile을 직접적으로 사용해서는 안됩니다.)
  • When defining shared classes, use volatile member functions to express thread safety.
    (공유 되는 클래스를 선언 할 때는, thread-safe를 위해 volatile 멤버 함수를 사용합니다.)

이 사항들만 잘 지켜 주시고, LockingPtr 클래스를 적절히 사용하신다면 보다 thread-safe한 코드를 작성하시는데 많은 동움이 될 것입니다. 만일 여러분이 크리티컬 섹션 외부에서 공유 변수에 lock을 걸지 않고 접근하신다면(LockingPtr을 사용하지 않고 접근 하신다면) 컴파일러가 그 부분을 지적해 주니까요.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!