2009년 11월 11일 수요일

volatile과 메모리 배리어

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

 

 

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

 

[code cpp]Singleton* getInstance()<br>{<br>    if (instance == NULL) {<br>        lock.lock();<br><br>        if (instance == NULL) {<br>            instance = new Singleton;<br>        }<br>        lock.unlock();<br>   }<br>    return instance;<br>}[/code]

 

 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에 의한 것이건 비순차적 실행이 문제가 될 수 있는 경우는 어렵지 않게 상상해 볼 수 있다. 이를테면 아래와 같은 코드를 생각해보자.

 

 [code cpp]lock.lock();<br>a++;<br>lock.unlock();<br>[/code]

 

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

 

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

 

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

 

 

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

 

 

 - 결론

 

  • volatile considered harmful - 동기화에는 명시적으로 동기화 객체나 atomic operation만 쓰자.
  • 컴파일러와 프로세서에 의한 명령어 재배치는 엄연히 다른 개념이니 구분하자.

 

2009년 11월 7일 토요일

C/C++의 몇 가지 키워드들

C++에서는 의외로 사람들이 잘 모르는 키워드가 많다. 이를테면 autoregister 같이 존재 의미부터가 희미한 키워드부터[footnote]이 중 auto는 C++의 다음 표준인 C++0x에서 다른 용도로 사용되는 것으로 결정되었다. 참고로 x는 16진수 A임이 유력하다.[/footnote] mutable 같이 잘만 쓰면 유용할 수도 있는 키워드, volatile 같이 알려지긴 했지만 사람들이 잘못 이해하고 있는 경우가 많은 키워드, export 같이 컴파일러들에게 외면 당한 키워드 등등 알아보면 C++의 세계는 크고 아름답다무궁무진하다. 그래서 세상에서 제일 익히기 어려운 프로그래밍 언어 타이틀을 땄다.

 

 

 C/C++에서는 변수를 선언할 때 보통 자료형 이름 앞에 해당 변수의 유효 기간과 가시 영역에 영향을 주는 storage class specifier와 변수의 상수성, 일시성을 지정하는 cv-qualifier, 이 두 분류의 속성들이 붙을 수 있다. 그 외에도 class, struct, enum 등의 키워드를 이용, 즉석에서 자료형을 정의하고 사용하는 것도 가능하지만 자료형 선언과 변수 선언은 서로 분리시키는 것이 보통이므로 변수가 가질 수 있는 속성은 실질적으로 위 두 가지가 전부라고 볼 수 있다.

 

 

 현재 C/C++ storage class specifier에는 auto, register, static, extern 네 종류가 있고, 멤버 변수에 한해서 mutable이 있다. 대부분의 프로그래머들은 static과 extern 키워드가 무슨 역할을 하는지 잘 알고 있으나 auto와 register는 사실상 사장된 키워드들이라 모르는 경우가 많다.

 

 우선 auto 키워드는 해당 변수의 가시 영역을 변수가 초기화되는 지점의 scope로 한정시키는 역할을 한다. 즉, 지역 변수를 선언하는데 사용되는 키워드이다. 그러나 C++ 컴파일러는 storage class specifier가 지정되지 않은 모든 변수에는 암시적으로 auto 키워드를 붙여 지역 변수로 분류하기 때문에 이를 명시적으로 사용할 이유는 전혀 없다. 그렇기 때문에 C++0x에서는 가능한 경우에 한해 컴파일러가 타입을 자동으로 유추하는데에 사용하는 키워드로 그 목적이 바뀌었다.

 

 register 키워드는 해당 변수가 굳이 메모리에 기록될 필요가 없을 때 속도 향상을 위해 가급적 레지스터에만 쓰도록 권유하는 키워드이다. 그러나 대부분의 컴파일러들은 충분히 똑똑하기 때문에 이러한 키워드를 쓰지 않아도 레지스터를 최대한 활용하도록 알아서 최적화를 해준다. 그런 이유로 register는 사실상 거의 쓰이지 않는 키워드이고, 상당수의 컴파일러에서는 이 키워드 자체를 그냥 무시한다.

 

 static 키워드는 익히 알려진 대로 정적 변수를 선언하는데 쓰이나 예외적인 용법이 있다. 전역 변수에 static을 붙일 경우 해당 변수는 속한 번역 단위[footnote]번역 단위란 #include, #ifdef 등 전처리 과정이 끝난 cpp 파일 하나를 의미한다.[/footnote] 밖으로 변수가 노출되지 않도록 보장한다. 허나 C++의 익명 네임스페이스 역시 똑같은 기능을 제공하므로 C++을 사용한다면 이를 굳이 사용할 필요는 없을 것이다.

 

 mutable 키워드는 상수 객체에서도 변경할 수 있는 멤버 변수를 지정하는데 사용되는 키워드이다. 이 속성이 지정된 변수는 해당 객체가 상수 객체이거나 상수 멤버 함수에서도 수정이 가능해진다. 이 키워드는 대개 객체의 실제 상태와는 직접적인 연관이 없는 변수에 사용한다. (그다지 좋은 예는 아니라 생각하지만) 이를테면 그래프 객체를 만든다 할 때 현재 객체가 가리키고 있는 노드를 mutable 속성을 지닌 내부 변수로 지정한면 iterator가 따로 없더라도 상수 멤버 함수들을 통해 상수 그래프 객체의 순회를 쉽게 구현할 수 있게 된다.

 

 

 cv-qualifier에는 constvolatile이 있는데, const는 익히 알려진대로 변수에 상수성을 추가하는 키워드이다. 이는 아주 널리 쓰이고 있으므로 별다른 추가적인 설명은 필요 없을 것이다. 그러나 volatile은 많은 사람들이 그 기능에 대해 오해를 하고 있다.

 

 큰 오해 중 하나가 "volatile은 해당 객체의 최적화를 막는 키워드"라는 것이다. 결과적으로 본다면 맞는 말이지만, 이는 키워드의 본래 목적을 왜곡할 수 있다. 기본적으로 volatile은 프로그램 문맥 외의 요인으로 인해 해당 객체가 비동기적으로 변경될 가능성이 있음을 컴파일러에게 알려주는 키워드이다. 따라서 컴파일러는 이를 참조하여 해당 객체에 대한 접근을 할 때 매 번 레지스터가 아니라 메모리에서 읽고 쓰도록 바이너리를 작성하며, 또한 병렬성 극대화를 위해 명령들의 수행 순서를 바꾼다거나 하는 공격적인 최적화를 하지 않는다.[footnote]물론 이는 하드웨어 레벨에서 이루어지는 비순차 실행까지 막지는 못한다.[/footnote] 그러나 프로그램의 흐름을 바꾸지 않는 최적화까지 막는 것은 아니다. 이를테면

 

[code cpp]volatile int a = 1;<BR>a = a * 4; // Equivalent to a = a << 2;[/code]

 

이러한 코드가 있을 때 곱하기보다는 쉬프트 연산이 훨씬 저렴하므로 가능한 경우 곱하기를 쉬프트 연산으로 최적화하는데, 이렇게 프로그램의 흐름을 바꾸지 않는 정도에 한해서는 최적화가 이루어질 수도 있다. 물론 이는 컴파일러 의존적이므로 반드시 이렇다고 단언할 수는 없다.

 

 또 한 가지의 오해 중 하나는 "멀티 쓰레드 프로그래밍에서 동기화 용도로 사용될 수 있다는 것"이다. 물론 바쁜 대기(busy-waiting) 등에서 CPU 레지스터와 실제 메모리 사이에서 생긴 괴리로 인한 문제 정도라면 volatile을 사용하여 해결할 수도 있으나 이 역시 CPU의 명령어 비순차 실행으로 인한 오류 가능성을 고려해보면 좋은 선택은 아니다. 게다가 data race 등의 문제를 volatile로 해결할 수 있는 방법은 없으며, 이는 atomic operation이나 동기화 객체를 사용하여 해결하는 수 밖에 없다. 예외는 있으나[footnote]Java나 C#, 혹은 VC++과 같이 volatile 키워드를 사용하면 메모리 배리어가 보장되는 메모리 모델에서는 부분적으로 사용 가능하다. 그런데 VC++은 메모리 배리어가 보장되는게 맞는지 좀 모호하다.[/footnote] 대부분의 경우 멀티 쓰레드 프로그래밍과 volatile은 아무 상관 없다고 생각하는 것이 속 편하다.

 

 그 외에 캐시를 사용하지 않게 만든다거나 하는 등의 오해도 있지만, 이는 컴퓨터 구조에 대한 기본적인 지식만 있어도 풀릴 오해이다. 캐시를 사용하고 말고는 프로그램 레벨에서 결정되는 것이 아니라 하드웨어 레벨에서 결정되는 문제이며, 응용 프로그램 수준에서 이를 바꾸려면 특별한 인스트럭션을 써야 하지만 이는 volatile 키워드의 목적을 달성하는데 있어서는 아무런 의미도 없는 일이기 때문이다.

 

 

 export 키워드는 템플릿을 사용했을 때 클래스 선언과 구현을 분리할 수 있도록 도와주는 키워드이다. 이 키워드를 쓴 템플릿 함수는 다른 번역 단위들에 노출이 되어 다른 번역 단위에서도 사용할 수 있게 된다... 는 것이 당시 표준에 export 키워드를 넣은 목적이었다.

 

 그러나 안타깝게도 이는 현재 C++의 컴파일 방식에 정면으로 배치되기 때문에 컴파일러 입장에서는 구현하기가 무척 어렵다. C++은 각각의 번역 단위를 따로 목적 코드로 컴파일한 뒤 목적 코드끼리 서로 링크하여 최종적인 실행 파일을 생성해낸다. 그런데 템플릿 함수나 클래스는 구체화를 하기 전까지는 목적 코드를 생성할 수 없고, 구체화는 링크 단계가 아니라 컴파일 초기 단계에서 이루어진다. 다시 말해 링크 단계가 아니라 컴파일 단계에서 모든 번역 단위에게 템플릿의 정의를 알려야 하는데 이는 무척 비효율적일 뿐만 아니라 기존 컴파일러의 구조와도 정면으로 배치되기 때문에 메이저 컴파일러들은 전부 export의 구현을 포기했다. 대부분의 컴파일러가 지원하지 않는 기능이기에 사실상 용도 폐기된 셈이다.

 

 

 이외에도 C++에는 다양한 키워드가 있는데, 이 정도가 사람들이 잘 모르거나 잘못 알고 있는 키워드가 아닌가 싶다. 나 역시 얼마 전까지는 이 중 상당수를 잘못 알고 있었다. 이러한 키워드가 많다는 것은 C++이 무척 어려운 언어라는 것을 반증하는 것이 아닌가 싶은데, 이도 모자라 내년에는 C++ 확장팩 C++0x가 발매될 예정이라고 한다. 과연 따라갈 수 있을까?

 

2009년 11월 1일 일요일

Local search

내용 보기

2009년 10월 22일 목요일

Informed search

내용 보기

2009년 10월 21일 수요일

Uninformed search


내용 보기

2009년 10월 20일 화요일

중간 고사.

이번 학기에 전공 중 하나로 인공지능을 듣고 있는데, 중간 고사가 코 앞으로 다가왔다.

 

개인적으로 인공지능이라는 단어에서 풍기는 "인간적으로 생각한다"는 뉘앙스 때문인지 편견을 가지고 있었는데, 그보다는 오히려 "이성적으로 행동한다"는 방향, 즉 문제를 수학적으로 정의하고 그걸 효율적으로 푸는 알고리즘을 가르치는 쪽으로 접근하더라. 역시 편견은 좋지 않다. 그런 것도 있고, 또 교수님의 수업 방향 때문인지는 모르겠으나 인공지능 과목에서 나는 냄새가 고급 알고리즘 과목 같달까, 그런 부분이 마음에 들어 수업은 그대로 나름 열심히 들었다.

 

고로 한번 훑어보기만 하면 되지 않을까, 그런 안이한 생각으로 다시 수업 자료를 보는데 신세계가 펼쳐지는 것이다. 난 이런 내용을 듣지 못한 것 같은데 수업 자료에는 있고, 그래서 곰곰히 생각해보면 '아 들었었... 나?'라며 애매하게 떠오르는 상황.

 

일단 준비는 해야 겠는데, 그냥 공부만 하는 것보다는 내 언어로 소화도 시킬 겸, 관련 내용을 글로 정리해보는 것이 더 좋을 것 같아 그간 다룬 내용 중 몇 가지 주요 이슈들을 다음 포스트들에 정리해보기로 했다.

블로그 시작

블로그 시작.

 

아마도 전공이 전공이니만큼 프로그래밍 관련 이야기들이 주가 되지 않을까 싶군요.