본문 바로가기

Programing/멀티코어,병렬,멀티스레드

volatile과 메모리 배리어

이글은 출처글입니다
출처 : http://summerlight.tistory.com/entry/volatile과-메모리-배리어


 이전 글에서 volatile 키워드에 대해 간단하게 언급했는데, 핵심은 간단하다. volatile 속성을 가진 변수는 프로그램 밖의 다른 문맥들에 의해서도 비동기적으로 접근될 수 있다. 따라서 특정 쓰레드가 해당 변수에 하는 작업들은 다른 모든 문맥들 역시 볼 수 있어야 한다는 것이다. 하드웨어를 직접 제어하기 위해 Memory-mapped I/O를 하는 경우가 가장 대표적인 예이다.[각주:1] 고로, 프로그램 문맥 상에서는 레지스터만을 이용해서 똑같은 일을 할 수 있는 경우라 해도 가시성의 확보를 위해 컴파일러는 해당 작업을 메모리에도 저장하도록 코드를 만든다.

  

 volatile 속성을 가진 변수는 그 정의대로 동작하기 위해 컴파일러 최적화 기법 중 하나인 명령어 재배치(instruction reordering)의 대상에서 제외된다. 명령어 재배치란 빠른 연산을 위해 일부 연산의 순서를 바꾸어 파이프라인을 최대한 활용하는 최적화 기법인데, 프로그램 밖의 다른 문맥들이 접근할 때 연산의 순서가 뒤바뀐 상태라면 큰 문제가 될 수 있으므로 이러한 조치를 취하는 것이다. 명령어 재배치로 인해 프로그램이 오동작할 수 있는 유명한 예로는 double-checked locking pattern이 있다.

Singleton* getInstance()
{
    if (instance == NULL) {
        lock.lock();
        if (instance == NULL) {
            instance = new Singleton
        }
        lock.unlock();
    }
    return instance;
}

 DCLP는 프로그램 전체에서 한 번만 이루어지는 생성자 호출을 위해 객체가 생성이 된 이후에도 매 번 불필요하게 락을 얻는 오버헤드를 줄이려는 의도에서 나온 패턴이다. 이는 우선 instance가 비어 있는가부터 체크한 뒤 락을 얻어 객체가 생성되는 순간에만 락을 얻는다. 이를 제시된 코드의 흐름대로만 보면 아무런 문제가 없다. 그러나 여기에서 명령어가 재배치되기 시작하는 순간 문제가 꼬여버리게 된다. 6번째 줄을 더 잘게 쪼개어 본다면 

  1. 메모리를 할당한 뒤
  2. 생성자의 논리에 따라 할당된 메모리를 초기화하고
  3. 해당 메모리 주소를 instance에 대입한다.

 이런 순서가 될 것이다. 그런데 2번과 3번 사이에는 의존성이 없으므로 이 둘을 서로 뒤집어도 단일 프로그램 문맥 상으로는 아무런 문제가 없다. 컴파일러에 따라서는 이 둘의 순서를 뒤집는게 성능 상 더 낫다고 판단, 명령어 재배치를 하자는 결론을 내릴 수도 있다. 이렇게 되면 멀티 쓰레드 환경에서는 아래와 같은 비극이 발생할 가능성도 있다. 

  1. 쓰레드 A가 진입하여 메모리를 할당 받고 이를 instance에 대입한다.
  2. 그 뒤 생성자를 통해 메모리를 초기화하기 시작한다.
  3. 그런데 쓰레드 B가 들어와 2번째 줄을 검사한다. 이 때 instance는 NULL이 아니다.
  4. 초기화가 완료되지 않은 객체가 쓰레드 B에 의해 사용된다.

 이를 막으려면 명령어가 재배치되지 않도록 해야 한다. 이를 위해 instance에 volatile 속성을 넣으면 컴파일러에 의한 재배치는 막을 수 있을 것 같다. 그러면 이걸로 모든게 완벽하게 해결된 것일까? 안타깝게도 그런 것 같지는 않다. 명령어를 재배치하는 것은 컴파일러만이 아니라 CPU 레벨에서도 이루어지기 때문이다. 현대 CPU 중 상당수는 파이프라인 및 명령어 단위 병렬성 등을 최대한으로 활용하기 위해 명령어 간 의존성을 동적으로 분석, 수행 순서를 임의로 바꾸는 비순차 실행(Out of order execution) 기법을 적극 활용한다. 이는 컴파일과는 무관하게 런타임에 이루어지는 것으로, 단순히 생성되는 코드의 순서와 메모리 접근 여부에만 영향을 줄 수 있는 volatile 키워드로는 해결할 수 없는 문제이다.

  

 사실 따지고 보면 컴파일러에 의한 것이건 CPU에 의한 것이건 비순차적 실행이 문제가 될 수 있는 경우는 어렵지 않게 상상해 볼 수 있다. 이를테면 아래와 같은 코드를 생각해보자. 

lock.lock();
a++;
lock.unlock();

 위는 동기화 객체를 사용하는 전형적인 예이다. 그런데 만에 하나라도 비순차 실행에 의해 1번째 줄과 2번째 줄의 코드 수행 순서가 뒤바뀐다고 가정해보자.[각주:2] 우리가 이 코드를 믿고 쓸 수 있을까? 메모리 접근 순서가 제대로 보장되지 않는다면 이런 간단한 코드조차 사용할 수 없게 된다.

 크리티컬 섹션과 같은 동기화 객체에서 중요한 것은 동기화 객체에 의해 보호되는 코드 혹은 객체는 무슨 일이 있어도 동시에 한 쓰레드만이 사용할 수 있어야 한다는 것이다. 이러한 목적을 달성하려면 동기화 객체 사용 이전과 이후를 기준으로 메모리 읽기/쓰기가 구분되어야 한다. 이를 위해 프로세서 내부의 메모리 읽기/쓰기의 순서를 코드에 명시된 순서대로 하도록 제약하는 메모리 배리어(Memory barrier)라는 개념이 도입된다. 메모리 배리어의 종류에도 몇 가지가 있으나, 위와 같은 목적으로는 특정 시점을 기준으로 이전의 모든 읽기/쓰기를 완료한 뒤 이후의 읽기/쓰기를 재개하는 풀 메모리 배리어가 사용된다.

 MSDN에 나온 바에 따르면 Win32 API에서는 각종 동기화 객체와 연관된 함수, 원자적인 연산인 Interlocked 계열 함수, 쓰레드를 블럭시키는 함수에서 메모리 배리어가 사용되며, POSIX쪽의 메모리 배리어에 대해서는 알아보진 않았지만 아마 상식적으로 볼 때 비슷할 것이다. 거기에 C++0x에서는 메모리 배리어가 강제되는 원자적인 연산 관련 함수들이 추가된다. VS2005 이후의 VC++에서는 volatile 키워드에 메모리 배리어를 추가했다지만,[각주:3] 표준 구현이 아니니 volatile을 동기화 목적으로는 사용하지 않는게 좋을 것 같다.

 

 멀티 쓰레드 프로그래밍이 어려운 까닭은 다른게 아니라 이런 로우 레벨의 개념들이 제대로 추상화가 되지 않은 상황이라 이들을 모르고 사용하면 쉽게 잡아내기 어려운 버그가 속출할 수도 있다는 것이다. 게다가 이를 부정확하게 알고서 동기화에 volatile을 함부로 쓴다거나 하는 경우 퍼포먼스가 낮아지는 것은 둘째치고 잡아낼 수 없는 버그가 속출할 가능성이 무척 높다. 자기가 잘 모르는 내용은 아예 쓰지 말도록 하자. 지금 이 말 쓰면서 스스로가 찔리긴 하지만 ;

  

 - 결론 

  • volatile considered harmful - 동기화에는 명시적으로 동기화 객체나 atomic operation만 쓰자.
  • 컴파일러와 프로세서에 의한 명령어 재배치는 엄연히 다른 개념이니 구분하자.
  1. 사실 Memory-mapped I/O 때문에 volatile 키워드가 생긴 것이라고 봐도 과언이 아니다. [본문으로]
  2. 물론 제대로 된 동기화 객체라면 이럴 일은 절대 없다. [본문으로]
  3. http://msdn.microsoft.com/en-us/library/ms686355(VS.85).aspx [본문으로]