휘발성 : 멀티 스레드 프로그래머의 베스트 프렌드
Andrei Alexandrescu, 2001 년 2 월 1 일
volatile 키워드는 특정 비동기 이벤트가있을 때 코드를 잘못 렌더링 할 수있는 컴파일러 최적화를 방지하기 위해 고안되었습니다.
기분을 망치고 싶지는 않지만이 칼럼은 다중 스레드 프로그래밍이라는 두려운 주제를 다룹니다. Generic의 이전 기사에서 언급했듯이 예외 안전 프로그래밍이 어렵다면 다중 스레드 프로그래밍에 비해 어린이 놀이입니다.
여러 스레드를 사용하는 프로그램은 일반적으로 작성, 올바른 증명, 디버그, 유지 관리 및 길들이기가 어렵습니다. 잘못된 멀티 스레드 프로그램은 몇 년 동안 결함없이 실행될 수 있지만 일부 중요한 타이밍 조건이 충족 되었기 때문에 예기치 않게 실행될뿐입니다.
말할 필요도없이 멀티 스레드 코드를 작성하는 프로그래머는 얻을 수있는 모든 도움이 필요합니다. 이 칼럼은 경쟁 조건 (멀티 스레드 프로그램의 일반적인 문제 원인)에 초점을 맞추고이를 방지하는 방법에 대한 통찰력과 도구를 제공하고 놀랍게도 컴파일러가이를 해결하기 위해 열심히 노력하도록합니다.
약간의 키워드
비록 C와 C ++ 표준 모두 쓰레드에 관해서는 눈에 띄게 침묵하지만, volatile 키워드의 형태로 멀티 스레딩에 약간의 양보를합니다.
잘 알려진 const와 마찬가지로 volatile은 유형 수정 자입니다. 다른 스레드에서 액세스 및 수정되는 변수와 함께 사용하기위한 것입니다. 기본적으로 휘발성이 없으면 다중 스레드 프로그램 작성이 불가능하거나 컴파일러가 막대한 최적화 기회를 낭비합니다. 설명이 순서대로 있습니다.
다음 코드를 고려하십시오.
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
위의 Gadget :: Wait의 목적은 매초마다 flag_ 멤버 변수를 확인하고 다른 스레드에서 해당 변수가 true로 설정되었을 때 반환하는 것입니다. 적어도 그것은 프로그래머가 의도 한 것이지만, 아아, Wait는 틀 렸습니다.
컴파일러가 Sleep (1000)이 멤버 변수 flag_를 수정할 수없는 외부 라이브러리에 대한 호출이라고 판단한다고 가정합니다. 그런 다음 컴파일러는 레지스터에 flag_를 캐시하고 더 느린 온보드 메모리에 액세스하는 대신 해당 레지스터를 사용할 수 있다고 결론을 내립니다. 이것은 단일 스레드 코드에 대한 훌륭한 최적화이지만,이 경우 정확성에 해를 끼칩니다. Wait for some Gadget object를 호출 한 후에 다른 스레드가 Wakeup을 호출하더라도 Wait는 영원히 반복됩니다. 이는 flag_의 변경이 flag_를 캐시하는 레지스터에 반영되지 않기 때문입니다. 최적화도 낙관적입니다.
레지스터에 변수를 캐싱하는 것은 대부분의 시간에 적용되는 매우 가치있는 최적화이므로 낭비하는 것이 아쉽습니다. C 및 C ++는 이러한 캐싱을 명시 적으로 비활성화 할 수있는 기회를 제공합니다. 변수에 volatile 한정자를 사용하면 컴파일러는 해당 변수를 레지스터에 캐시하지 않습니다. 각 액세스는 해당 변수의 실제 메모리 위치에 도달합니다. 따라서 Gadget의 Wait / Wakeup 콤보 작업을 수행하기 위해해야 할 일은 flag_를 적절하게 한정하는 것입니다.
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
휘발성 정지의 이론적 근거와 사용법에 대한 대부분의 설명은 여기에서 여러 스레드에서 사용하는 기본 유형을 휘발성으로 한정하도록 권장합니다. 그러나 C ++의 멋진 유형 시스템의 일부이기 때문에 volatile로 할 수있는 작업이 훨씬 더 많습니다.
사용자 정의 유형에 휘발성 사용
기본 유형뿐만 아니라 사용자 정의 유형도 volatile-qualify 할 수 있습니다. 이 경우 volatile은 const와 유사한 방식으로 유형을 수정합니다. (동일한 유형에 const와 volatile을 동시에 적용 할 수도 있습니다.)
const와 달리 volatile은 기본 유형과 사용자 정의 유형을 구분합니다. 즉, 클래스와 달리 기본 유형은 volatile로 한정 될 때 모든 연산 (더하기, 곱하기, 할당 등)을 계속 지원합니다. 예를 들어, 비 휘발성 int를 volatile int에 할당 할 수 있지만 비 휘발성 개체를 volatile 개체에 할당 할 수는 없습니다.
예제에서 volatile이 사용자 정의 유형에서 작동하는 방식을 설명하겠습니다.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
휘발성이 물체에 그다지 유용하지 않다고 생각한다면 놀라움에 대비하십시오.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
정규화되지 않은 유형에서 휘발성 대응 유형으로의 변환은 사소합니다. 그러나 const와 마찬가지로 휘발성에서 비정규로 되돌아 갈 수 없습니다. 캐스트를 사용해야합니다.
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
volatile로 한정된 클래스는 인터페이스의 하위 집합, 클래스 구현자가 제어하는 하위 집합에만 액세스를 제공합니다. 사용자는 const_cast를 사용해야 만 해당 유형의 인터페이스에 대한 전체 액세스 권한을 얻을 수 있습니다. 또한 constness와 마찬가지로 volatileness는 클래스에서 해당 멤버로 전파됩니다 (예 : volatileGadget.name_ 및 volatileGadget.state_는 휘발성 변수 임).
휘발성, 중요 섹션 및 경쟁 조건
다중 스레드 프로그램에서 가장 간단하고 가장 자주 사용되는 동기화 장치는 뮤텍스입니다. 뮤텍스는 Acquire 및 Release 프리미티브를 노출합니다. 일부 스레드에서 Acquire를 호출하면 Acquire를 호출하는 다른 스레드가 차단됩니다. 나중에 해당 스레드가 Release를 호출하면 Acquire 호출에서 차단 된 정확히 하나의 스레드가 해제됩니다. 즉, 주어진 뮤텍스에 대해 Acquire 호출과 Release 호출 사이에 하나의 스레드 만 프로세서 시간을 가져올 수 있습니다. Acquire 호출과 Release 호출 사이의 실행 코드를 중요 섹션이라고합니다. (Windows 용어는 뮤텍스 자체를 중요한 섹션이라고 부르는 반면 "뮤텍스"는 실제로 프로세스 간 뮤텍스라고 부르기 때문에 약간 혼란 스럽습니다. 스레드 뮤텍스 및 프로세스 뮤텍스라고 부르면 좋았을 것입니다.)
뮤텍스는 경쟁 조건으로부터 데이터를 보호하는 데 사용됩니다. 정의에 따라 데이터에 대한 더 많은 스레드의 영향이 스레드가 예약 된 방식에 따라 달라지는 경우 경쟁 조건이 발생합니다. 두 개 이상의 스레드가 동일한 데이터를 사용하기 위해 경쟁 할 때 경쟁 조건이 나타납니다. 스레드는 임의의 순간에 서로를 인터럽트 할 수 있기 때문에 데이터가 손상되거나 잘못 해석 될 수 있습니다. 결과적으로 데이터에 대한 변경 및 때때로 액세스는 중요한 섹션으로 신중하게 보호되어야합니다. 객체 지향 프로그래밍에서 이것은 일반적으로 뮤텍스를 멤버 변수로 클래스에 저장하고 해당 클래스의 상태에 액세스 할 때마다 사용함을 의미합니다.
경험 많은 멀티 스레드 프로그래머는 위의 두 단락을 읽었을지 모르지만 그들의 목적은 지적 운동을 제공하는 것입니다. 이제 우리는 불안정한 연결과 연결될 것이기 때문입니다. 우리는 C ++ 유형의 세계와 스레딩 시맨틱 세계 사이에 병렬을 그려이를 수행합니다.
- 중요 섹션 외부에서 스레드는 언제든지 다른 스레드를 인터럽트 할 수 있습니다. 제어가 없으므로 여러 스레드에서 액세스 할 수있는 변수는 휘발성입니다. 이는 컴파일러가 여러 스레드에서 사용하는 값을 한 번에 무의식적으로 캐싱하는 것을 방지하는 휘발성의 원래 의도와 일치합니다.
- 뮤텍스에 의해 정의 된 중요 섹션 내에서는 하나의 스레드 만 액세스 할 수 있습니다. 따라서 중요한 섹션 내에서 실행 코드는 단일 스레드 의미 체계를 갖습니다. 제어 변수는 더 이상 휘발성이 아닙니다. 휘발성 한정자를 제거 할 수 있습니다.
간단히 말해, 스레드간에 공유되는 데이터는 개념적으로 중요 섹션 외부에서는 휘발성이고 중요 섹션 내부에서는 비 휘발성입니다.
뮤텍스를 잠그면 중요 섹션에 들어갑니다. const_cast를 적용하여 유형에서 휘발성 한정자를 제거합니다. 이 두 작업을 합치면 C ++의 유형 시스템과 응용 프로그램의 스레딩 의미 체계 사이에 연결이 생성됩니다. 컴파일러가 경쟁 조건을 확인하도록 만들 수 있습니다.
LockingPtr
뮤텍스 획득과 const_cast를 수집하는 도구가 필요합니다. 휘발성 객체 obj 및 뮤텍스 mtx로 초기화하는 LockingPtr 클래스 템플릿을 개발해 보겠습니다. 수명 동안 LockingPtr은 mtx를 획득 한 상태로 유지합니다. 또한 LockingPtr은 휘발성 제거 obj에 대한 액세스를 제공합니다. 운영자-> 및 운영자 *를 통해 스마트 포인터 방식으로 액세스가 제공됩니다. const_cast는 LockingPtr 내에서 수행됩니다. LockingPtr이 획득 한 뮤텍스를 수명 동안 유지하므로 캐스트는 의미 상 유효합니다.
먼저 LockingPtr이 작동 할 Mutex 클래스의 골격을 정의 해 보겠습니다.
class Mutex {
public:
void Acquire();
void Release();
...
};
LockingPtr을 사용하려면 운영 체제의 기본 데이터 구조와 기본 함수를 사용하여 Mutex를 구현합니다.
LockingPtr은 제어 변수의 유형으로 템플릿 화됩니다. 예를 들어 위젯을 제어하려면 휘발성 위젯 유형의 변수로 초기화하는 LockingPtr을 사용합니다.
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은 올바른 다중 스레드 코드를 작성하는 데 매우 유용한 도구입니다. 스레드간에 공유되는 객체를 휘발성으로 정의하고 const_cast를 함께 사용하지 않아야합니다. 항상 LockingPtr 자동 객체를 사용하십시오. 예를 들어 설명해 봅시다.
벡터 객체를 공유하는 두 개의 스레드가 있다고 가정합니다.
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
스레드 함수 내에서 단순히 LockingPtr을 사용하여 buffer_ 멤버 변수에 대한 액세스를 제어합니다.
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
코드는 작성하고 이해하기가 매우 쉽습니다. buffer_를 사용해야 할 때마다이를 가리키는 LockingPtr을 만들어야합니다. 그렇게하면 벡터의 전체 인터페이스에 액세스 할 수 있습니다.
좋은 점은 실수하면 컴파일러가이를 지적한다는 것입니다.
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
const_cast를 적용하거나 LockingPtr을 사용할 때까지 buffer_의 기능에 액세스 할 수 없습니다. 차이점은 LockingPtr이 const_cast를 휘발성 변수에 적용하는 정렬 된 방법을 제공한다는 것입니다.
LockingPtr은 놀랍도록 표현력이 뛰어납니다. 하나의 함수 만 호출해야하는 경우 이름이 지정되지 않은 임시 LockingPtr 개체를 만들어 직접 사용할 수 있습니다.
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
원시 유형으로 돌아 가기
우리는 얼마나 휘발성이 제어되지 않는 액세스로부터 객체를 보호하는지와 LockingPtr이 스레드로부터 안전한 코드를 작성하는 간단하고 효과적인 방법을 제공하는 방법을 보았습니다. 이제 volatile로 다르게 처리되는 기본 유형으로 돌아가 보겠습니다.
여러 스레드가 int 유형의 변수를 공유하는 예를 살펴 보겠습니다.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Increment 및 Decrement가 다른 스레드에서 호출되는 경우 위의 조각은 버그가 있습니다. 첫째, ctr_은 휘발성이어야합니다. 둘째, ++ ctr_과 같이 원자 적으로 보이는 작업조차 실제로는 3 단계 작업입니다. 메모리 자체에는 산술 기능이 없습니다. 변수를 증가시킬 때 프로세서는 다음을 수행합니다.
- 레지스터에서 해당 변수를 읽습니다.
- 레지스터의 값을 증가시킵니다.
- 결과를 다시 메모리에 씁니다.
이 3 단계 작업을 RMW (Read-Modify-Write)라고합니다. 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_을 컴파일하지만 생성 된 코드는 단순히 올바르지 않습니다. 컴파일러는 더 이상 당신의 동맹이 아니며 오직 당신의 관심 만이 경쟁 조건을 피하는 데 도움이 될 수 있습니다.
그러면 어떻게해야합니까? 상위 레벨 구조에서 사용하는 기본 데이터를 캡슐화하고 해당 구조에 휘발성을 사용하십시오. 역설적이게도, 처음에는 이것이 휘발성의 사용 의도 였음에도 불구하고 빌트인과 함께 휘발성을 직접 사용하는 것이 더 나쁩니다!
휘발성 멤버 함수
지금까지 휘발성 데이터 멤버를 집계하는 클래스가 있습니다. 이제 더 큰 객체의 일부가되고 스레드간에 공유되는 클래스를 설계하는 것을 생각해 봅시다. 휘발성 멤버 함수가 큰 도움이 될 수있는 곳입니다.
클래스를 디자인 할 때 스레드로부터 안전한 멤버 함수 만 휘발성 한정합니다. 외부의 코드가 언제든지 모든 코드에서 휘발성 함수를 호출한다고 가정해야합니다. 잊지 마세요 : 휘발성은 자유 멀티 스레드 코드이며 중요 섹션이 없습니다. 비 휘발성은 단일 스레드 시나리오 또는 중요 섹션 내부와 같습니다.
예를 들어, 스레드로부터 안전한 것과 빠르고 보호되지 않는 것의 두 가지 변형으로 작업을 구현하는 위젯 클래스를 정의합니다.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
오버로딩의 사용에 주목하십시오. 이제 Widget의 사용자는 휘발성 객체와 스레드 안전성을 확보하거나 일반 객체에 대해 균일 한 구문을 사용하여 Operation을 호출하고 속도를 얻을 수 있습니다. 사용자는 공유 위젯 객체를 휘발성으로 정의 할 때주의해야합니다.
휘발성 멤버 함수를 구현할 때 첫 번째 작업은 일반적으로 LockingPtr로이를 잠그는 것입니다. 그런 다음 비 휘발성 형제를 사용하여 작업이 수행됩니다.
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
요약
다중 스레드 프로그램을 작성할 때 휘발성을 유리하게 사용할 수 있습니다. 다음 규칙을 준수해야합니다.
- 모든 공유 객체를 휘발성으로 정의하십시오.
- 기본 유형에 volatile을 직접 사용하지 마십시오.
- 공유 클래스를 정의 할 때 휘발성 멤버 함수를 사용하여 스레드 안전성을 표현하십시오.
이렇게하면 간단한 제네릭 구성 요소 LockingPtr을 사용하면 스레드로부터 안전한 코드를 작성하고 경쟁 조건에 대해 훨씬 덜 걱정할 수 있습니다. 컴파일러가 사용자를 걱정하고 부지런히 잘못된 부분을 지적하기 때문입니다.
내가 참여한 몇 가지 프로젝트는 volatile 및 LockingPtr을 사용하여 큰 효과를 냈습니다. 코드는 깨끗하고 이해하기 쉽습니다. 몇 가지 교착 상태가 기억 나지만 디버그가 훨씬 쉽기 때문에 경쟁 조건보다 교착 상태를 선호합니다. 경쟁 조건과 관련된 문제는 거의 없었습니다. 그러나 당신은 결코 알지 못합니다.
감사의 말
통찰력있는 아이디어를 제공하는 James Kanze와 Sorin Jianu에게 감사드립니다.
Andrei Alexandrescu는 워싱턴 주 시애틀에 기반을 둔 RealNetworks Inc. (www.realnetworks.com)의 개발 관리자이며 유명한 책 Modern C ++ Design의 저자입니다. 그는 www.moderncppdesign.com에서 연락 할 수 있습니다. Andrei는 또한 The C ++ Seminar (www.gotw.ca/cpp_seminar)의 주요 강사 중 한 명입니다.
이 기사는 약간 구식 일 수 있지만, 컴파일러가 경쟁 조건을 확인하는 동안 이벤트를 비동기 적으로 유지하는 데 도움이되는 다중 스레드 프로그래밍을 사용할 때 volatile 한정자를 사용하는 데있어 좋은 통찰력을 제공합니다. 이것은 메모리 펜스 생성에 대한 OP의 원래 질문에 직접적으로 대답하지 않을 수 있지만, 멀티 스레드 응용 프로그램으로 작업 할 때 휘발성을 잘 사용하는 것에 대한 훌륭한 참조로 다른 사람들을위한 대답으로 게시하기로 선택했습니다.