C++

Volatile : 멀티쓰레드 프로그래밍 시 거의 쓸모 없는 그 것

Sorting 2020. 12. 12. 10:03
반응형

Volatile : 멀티쓰레드 프로그래밍 시 거의 쓸모 없는 그 것


 

원본 : http://software.intel.com/en-us/blogs/2007/11/30/volatile-almost-useless-for-multi-threaded-programming/

 

의역, 오역 주의.

한 줄 요약 : volatile은 컴파일러 최적화를 꺼주는 역할이다. 메모리 일관성을 유지하려면 mfence를 사용하자.

 

volatile 키워드가 멀티쓰레드 프로그래밍에 유용하다는 소문이 널리 퍼졌다. 내가 처음 volatile 수식어를 봤을 때, 당연히 "멀티 쓰레드 프로그래밍 할 때 유용하겠는데?"라고 생각했다. 몇 주 전까지는 유용하다고 생각했다. 몇 주 전, volatile이 멀티쓰레드 프로그래밍에 거의 쓸모가 없다는 것이 분명해지기 전까진. 지금부터 왜 당신의 멀티 쓰레드 코드를 뜯어 고쳐야 하는지 설명해 주겠다.

 

한스 보헴은 volatile을 사용하는 세가지 경우가 있다고 했다. 그것을 요약해보면 이렇다. 

 

  • setjmp의 스코프 안에 ​로컬 변수를 만들 때 
     : 이로인해 그 변수는 longjmp 뒤에도 롤백되지 않는다.
  • 외부 에이전트에 의해 이미 수정되었거나, 수정될 메모리 
     : 메모리 매핑 이상현상을 막기 위해
  • 신호 핸들러(signal handler) 오작동 시

 

이것들 중 멀티쓰레딩에 관한 언급은 하나도 없다. 게다가 보헴은  comp.programming.threads discussion에서 직설적으로 다음과 같이 말했다.

당신의 변수를 volatile로 정의하는 것은 아무런 효과가 없습니다. 그리고 그것은 컴파일러의 최적화 옵션을 켰을 때보다 당신의 코드를 더 느리게 만듭니다.

volatile의 사용은 아무 쓸모도 없습니다. 컴파일러의 유용하고 가치 있는 최적화를 막는 것을 빼고는. 당신의 코드를 thread safe로 만드는 데 어떠한 도움도 제공하지 않습니다.

(이렇게 절망적으로 말할 것 까지야..)

 

당신이 멀티쓰레딩을 하는 이유가 성능을 높이기 위해서라면, 당신은 더 느린 코드를 원하지 않을 것이다. 멀티 쓰레드 프로그래밍에 있어서, volatile이 종종 잘못 생각되는 두 가지 핵심 이슈가 있다.

  • ​atomicity
  • memory consistency. 

1번에 대해 알아보자. volatile은 원자적 입출력에 아무런 소용이 없다. 예를 들어, 129bit volatile 구조체의 읽기 혹은 쓰기는 오늘날의 대부분의 하드웨어에서 원자적이지 않을 것이다. 반면, 32bit volatile int형 데이터의 입출력은 대부분 원자적이지만, 여기서 volatile 이 하는 역할은 아무것도 없다. 그것은 volatile 이 붙지 않아도 그렇게 동작한다. 원자성은 컴파일러에 있어 일종의 우연이다. C 혹은 C++ 표준에서 원자성을 가져야 한다는 말은 어디에도 없다. (c++11이 나오기 이전의 글입니다.)

 

자 이제 2번 이슈에 대해 생각해보자. 가끔 프로그래머들은 volatile이 volatile로 정의된 데이터 접근에 대한 최적화를 끈다고 생각한다. 이 생각은 지당하다. 하지만 그것은 volatile로 정의된 데이터 접근에 한해서만이지, non-volatile로 선언된 변수까지 영향을 미치는 것이 아니다. 다음 코드 조각을 보자.

1
2
3
4
5
6
7
8
volatile int ready;  
 
int message[100]; 
 
void foo( int i ) { 
    message[i/10= 42
    ready = 1
}
cs

멀티쓰레드 프로그래밍에서 매우 의미있는 동작을 하고 있는 코드이다. 메세지를 쓴 후, 다른 쓰레드에 그것을 전달하고 있다. 다른 쓰레드는 ready가 0이 아닐때까지 기다렸다가 메세지를 읽을 것이다. 이 코드를 "gcc -O2 -S" using gcc 4.0, 또는 icc 로 컴파일 해보자. 두 컴파일러 모두 ready = 1을 먼저 저장하여, i/10 계산과 중첩(순서가 뒤바뀜)될 수 있다. 이 순서 재배치는 컴파일러 버그가 아니다. 이것은 극적인 최적화를 위해 컴파일러가 하는 일일 뿐이다. 

 

당신이 모든 메모리 참조에 volatile로 떡칠을 하는 것이 해결책이라고 생각할 수 있다. 그건 바보같은 계획이다. 아까 말했듯, 단지 당신의 코드가 느려질 뿐이다. 아직까지는 이 문제는 고쳐지지 않을 것으로 보인다. 심지어 당신의 컴파일러가 최적화를 하지 않는다면, 당신의 하드웨어(CPU의 out of order를 말하는 듯)가 그렇게 할 것이다. 이 예제에서는, x86 cpu는 순서를 뒤바꾸지 않는다. Itanium 프로세서도 마찬가지로 명령어 순서가 뒤바뀌지 않는다. 왜냐하면 Itanium 컴파일러는 volatile 데이터에 대해 메모리 펜스를 삽입해 주기 때문이다. (올ㅋ) 하지만 그렇지 않은 컴파일러도 있다. 

당신이 명령어 재배치에 진짜로 필요한 것은 memory barrier(메모리 장벽)이라고 불리우는 memory fence다. 메모리 펜스는 메모리 입출력 작업 순서가 펜스 너머로 뒤바뀌는 현상을 막아준다. 혹은 몇가지 경우에서 명령어 자신의 순서가 바뀌지 않도록 해준다. 폴 맥커니의 Memory Ordering in Modern Microprocessors  에서 이것들을 설명한다. 이 내용을 한줄로 요약하자면, volatile은 memory fence완 달리 아무것도 할 수 없다는 것이다.

 

그렇다면, 멀티쓰레드 프로그래밍의 해결법은 무엇인가? atomic과 fence 의미론이 구현된 확장된 언어 라이브러리를 사용해라. 라이브러리의 연산들이 펜스를 삽입해 줄 것이다. 

 

몇가지 예제들 : 

 

  • POSIX threads
  • Windows™ threads
  • OpenMP
  • TBB

 

멀티스레드 환경에서 volatile 이 필요한 경우


그렇다면 멀티스레드 환경에서 volatile 이 필요한 상황은 정말 없는 걸까?

 

컴파일러 최적화는 단일 스레드 기준으로 진행되기 때문에,

최적화 때문에 멀티스레드 환경에서 의도하지 않은 결과가 나오는 케이스가 존재한다.

 

위 예제와 관련한 예제 코드를 작성해보자면 다음과 같다.

 

1
2
3
4
5
6
7
8
 
bool ready = false;
 
void threadEntry()
{
    while(!ready);
    my_data = message[0];
}
cs

출처 : www.slideshare.net/naihoonjung/naver-3

위 코드를 디스어셈블리 해보면, 

ready 는 싱글스레드 기준으로는 threadEntry 함수 안에서 변하지 않으므로

루프 전에 한번만 값을 읽어 레지스트리에 저장한 뒤, 그 값으로만 루프 체크를 진행한다.

즉, while 은 무한루프를 돌게 된다.

최적화 때문에 발생하는 멀티스레드 이슈인 것이다.

 

이러한 케이스에서는 volatile 을 사용하는 것이 적절한 해결책이 될 수 있다.

 

다만 C++11 이후의 권장 해결책은, ready 변수의 타입을 std::atomic<bool> 로 변경하는 것이다.

 

atomic 에 대한 조금 더 자세한 설명은 다음 슬라이드를 참조.

www.slideshare.net/seao/c-atomic

 

반응형