'멀티쓰레드'에 해당되는 글 3건

  1. 2010.12.18 동기화 기법: Read-Copy-Update (2)
  2. 2010.04.28 멀티 쓰레드 프로그래밍이 어려운 까닭
  3. 2009.11.11 volatile과 메모리 배리어 (2)

동기화 기법: Read-Copy-Update

컴퓨터 과학 2010.12.18 02:55

2.6 시대에 리눅스 커널을 뜯어본 사람이라면 RCU라는 것을 알지도 모르겠다. RCU란 리눅스 커널 내에서 주로 읽기 연산만 일어나고 쓰기 연산의 비중은 매우 작은 객체에 주로 쓰이는 동기화 기법이다. Reader-Writer lock과 비슷한 동기화 기법인데, RW lock에 대해 RCU가 가지는 상대적 강점으로는 읽기 연산이 Wait-free이며 (다시 말해 블럭이 일어나지 않으며) 그 오버헤드가 극도로 작다는 점 등이 있다. (리눅스에서는 kernel preemption을 끄고 컴파일하면 RCU로 보호되는 메모리를 읽을 때 동기화 오버헤드가 제로다!) 그 대신 쓰기 연산의 오버헤드가 꽤 큰데다 무슨 일을 하건 쓰기에 필요한 동기화의 시간 복잡도가 최소한 RCU로 보호되는 객체의 크기에 비례한다.


RCU의 아이디어는 꽤 심플하다. 그 기저에 깔린 전제들만 알면 쉽게 이해할 수 있는데, 내 생각엔 그 목록은 대충 아래와 같다.

  • 불변 Immutable 객체는 쓰레드 안전하다. 좀 더 구체적으로, 어떤 코드 영역을 수행하는 동안 공유되는 메모리 구간이 불변이라면 해당 코드로 인한 상태의 변화는 항상 예측가능하다. 다시 말해 코드 수행의 결과가 결정적 Deterministic 이다.
  • 메모리 배리어 등을 통해 가시성이 확보한 경우 단일 워드에 대한 쓰기 연산은 대개 원자적이다. 적어도 다른 쓰레드 입장에선 그렇게 보인다.
  • 어떤 객체의 상태를 변경하는 코드를 수행할 때, 그 변경이 다른 쓰레드의 입장에서 한 순간에 원자적으로 이루어지는 것처럼 보인다면 연관된 코드들의 수행 내역은 단일 쓰레드로도 재현 가능하다. 쓰레드 간 코드 수행 순서에 대한 구체적인 합의가 필요한 경우는 드믈기 때문에 대부분은 이 정도만 보장되어도 동기화로써 충분한 역할을 할 수 있다.

이러한 전제들을 사용하면 흥미로운 결과를 도출해 낼 수 있다. 우선 가비지 컬렉션을 지원하는 요즘 언어들에서는 객체를 직접 변경하는 대신 불변 객체를 새로 만들어서 쓰는 것은 드믈지 않은 일이다. 이를테면 문자열 객체를 불변으로 다루는 Java 같은 언어에서는 문자열을 변경하려 하는 경우 전역적으로 관리되는 문자열 객체 풀에서 변경된 결과의 불변 문자열을 가져와서/새로 만들어서 사용한다. (이를 보통 String Internalization이라 한다.) 굳이 객체 풀을 만들 필요까지도 없고 이미 노출된 객체를 변경하는 대신 새로운 불변 객체를 만드는 방법을 이용하면 쓰레드 안전성을 쉽게 확보할 수 있다.

그렇다면 이를 안전하게 노출시키기만 하면 된다. 세 번째 전제에 따르면 객체의 갱신 과정이 원자적으로 보이기만 하면 실용적인 동기화 방식으로 활용할 수 있는데, 두 번째 전제에 따라 레퍼런스 역할을 하는 포인터의 값으로 새로운 객체의 주소를 집어 넣기만 하면 된다. 다만 그냥 대입하면 새로운 객체가 완성되기 전에 CPU나 컴파일러의 비순차 실행 최적화에 의해 포인터의 대입 연산이 먼저 수행될 수 있다. 이 경우 객체의 변경이 완료되기 전에쓰레드 외부로노출될 우려가 있으므로 메모리 배리어로 포인터의 대입이 객체의 생성 이후에 이루어지도록 보장할 필요가 있다.


여기까지 설명한 것만으로도 이미 RCU의 기본적인 동작 방식을 절반 이상 설명했다. RCU로 보호되는 객체에 대한 읽기를 하려는 경우 평소처럼 포인터를 가져와서 읽기 전용으로 해당 객체를 다루면 된다. RCU로 보호되는 객체를 변경하려는 경우 객체를 직접 변경하는 대신 새로 할당한 메모리 영역에 객체를 복사한 뒤 새로 만든 객체를 변경한다. 기존의 객체는 불변이므로 이 과정은 안전하다. 변경이 완료되면 RCU로 보호되는 포인터에 새로운 객체의 포인터를 대입한다. 이러면 대입 이전에 이 객체를 읽어서 사용하던 쓰레드는 이전의 객체를 계속 사용하게 될 것이고, 대입 이후에 사용하는 쓰레드는 새로운 버젼의 객체를 읽어 사용하게 될 것이다. 각각의 쓰레드에서 여러 버젼의 객체가 동시에 사용된다는 점에서 데이터베이스에서 사용되는 동시성 관리 기법인 MVCC[각주:1]와도 비슷하다. 아래는 RCU를 사용하는 방식을 보여주는 가짜 코드이다.

SomeObject object;

 // RCU로 보호되는 객체
RCU<SomeObject> rcu(object);

...

// reader thread
rcu.beginRead();

// Wait-free로 구현됨.
SomeObject* ptr = rcu.getPointer();
ReadOnlyOperation(ptr);
rcu.endRead();

...

// writer thread
rcu.beginWrite();

// 일반적인 락과 유사. 어차피 객체의 갱신은 직렬적으로 처리되어야 한다.
SomeObject* ptr = rcu.copyObject();

// 객체를 복사함.
WriteOperation(ptr);
rcu.endWrite();
rcu.setPointer(ptr);

이 쯤에서 자원 관리가 어떻게 될까 의문을 가질 것이다. 사실 RCU 구현의 핵심도 바로 이 부분이다. 자원 관리에 신경을 쓰지 않아도 되는 언어라면 굳이 이런 개념을 도입할 필요도 없었을 것이다. 하지만 수동으로 자원을 관리해줘야 하는 언어를 사용하는 경우라면 RCU 차원에서 더 이상 사용되지 않는 객체를 적절하게 해제할 수 있도록 도와줘야 할 필요가 있다. reader 입장에서 더 이상 접근할 방도가 없는 객체인 경우만 해제해주면 되므로 위의 가짜 코드에서 setPointer를 한 이후 예전 객체를 읽는 reader들의 코드 수행이 전부 종료되는 시점에 해제를 해주면 된다. 물론 이를 구현하는 방법은 여러 가지가 있겠지만, 가장 직관적인 방법으로는 레퍼런스 카운팅이 떠오른다.


이 정도면 누구라도 쉽고 재미있게 구현할 수 있을 것 같다. ... 정말 그럴까?

아쉽게도 그렇지 않다. -_- 만약 레퍼런스 카운팅을 한다 치면 읽기 영역에 진입/이탈 시 원자적인 정수 연산이 적어도 한 번씩은 일어날 것이다. 그런데 일반적인 RW lock도 경쟁이 없다는 전제 하에선 읽기에 대해 원자적인 연산 두어개의 오버 헤드만을 가지도록 구현하는 것이 가능... 아마 가능할 것이다 -_-; 즉 RW lock과 차별화되는 강점 하나가 사라진다. 물론 읽기가 Non-blocking이라는 매력적인 조건이 있지만 이 것만 가지고 쓰기 시의 추가적인 오버헤드를 용납하기는 어렵다. RCU 자체가 읽기에 수반되는 동기화의 Critical path를 최소화하려는 시도이므로 읽기의 오버헤드는 최소화되어야 한다.

리눅스 커널에서는 이걸 아주 간단하게 해결하고 있다. RCU는 커널 코드 내에서만 쓰이며, 커널 모드에서의 수행이 끝나면 RCU의 읽기 구간 수행 역시 끝날 것이다. 따라서 위 가짜 코드의 setPointer를 수행하는 시점에 돌아가고 있는 모든 CPU들에 대해 해당 CPU로의 문맥 전환을 한번씩 요청한다. 커널 모드에서 문맥 전환이 일어나지 않는 비선점형 커널을 사용하고 있는 경우 이 과정이 끝나면 해당 시점에 커널 모드에서 돌아가던 모든 동작들이 종료된 것이므로 (= 기존 객체에 대한 읽기가 끝난 것이므로) 메모리를 안전하게 해제할 수 있다. 선점형 커널인 경우는 읽기 구간에 들어간 동안에만 커널 선점을 꺼버리는 방법을 사용하고 있다고 한다. (요즘의 구현은 다를지도 모르겠다. 논문들을 보면 Hazard pointer 등을 이용한 구현도 있는 모양이다.)

헌데 유저 모드에서 적은 오버헤드로 RCU를 구현하려고 생각해보면 암담하다. -_-; OS의 지원을 받지 못하니 선점 모드를 끄는 등의 작업은 명백하게 월권이다. 특정 쓰레드가 읽기 구간에 있다는 것을 알리는 변수를 세팅한다고 해도 해제하는 쓰레드가 이에 대한 가시성을 얻고 코드 수행의 순서 일관성을 보장하기 위해서는 메모리 배리어가 필요하다. 허나 메모리 배리어의 사용 역시 만만치 않은 오버헤드를 가지고 있기 때문에 이를 사용하려 들면 문제는 원점으로 돌아가 버린다. 결론은 답이 없다 되겠다. -_-;


그런데 사실 오버헤드가 좀 있다손 치더라도 읽기의 동기화 과정이 Wait-free라는 것만으로도 RCU는 상당히 매력적인 모델이다. 이 말은 곧 읽기에 대해서는 최대한의 병행적인 확장성을 제공한다는 이야기이기 때문이다. 또한 읽기를 위한 동기화는 넌블럭이고 쓰기 역시 하나의 락만 사용하므로 데드락의 가능성이 거의 없다는 것 역시 강점이다. 또한 쓰기에 걸리는 락 역시 쓰기 동기화를 위해서는 당연히 필요한 오버헤드이기 때문에 결국 RCU로 인해 생기는 오버헤드는 객체 복사 및 추가적인 할당/해제 정도가 전부이다.

개인적으로는 이렇게 시점 별로 여러 버젼의 불변객체를 유지하는 MVCC 류의 동시성 제어 기법은 가비지 컬렉터가 지원되는 언어에서 더욱 빛을 발할 것이라 생각한다. 자원 해제 관련된 부분이 완전히 투명해지므로 (심지어는 성능적인 부분에 있어서도) 사용하기가 훨씬 간편해지며, 메모리 압축을 지원하는 언어에서는 할당 오버 헤드 역시 매우 작으니 큰 문제가 되지 않는다. 복사로 인한 오버헤드는 읽기에서 락을 사용하지 않음으로 얻는 퍼포먼스 이득 및 데드락으로부터의 해방을 생각해보면 충분히 감수할만한 가치가 있다.

물론 이 방식이 은탄환인 건 절대 아니다. 일단 원하는 데이터가 연속된 메모리 공간에 위치하지 않은 경우에는 이 기법을 적용할 수 없다. 즉, 이 기법만을 사용해서는 임의의 메모리 공간들에 대해 원자적인 갱신을 할 수 없다.졸면서 쓴 부분을 수정한다. -_-; 정확히는 다루는 객체가 의미적으로 불변인 객체여야 한다는 제약이다. 이러면 포인터를 바꿔칠 수 없으므로 RCU로 동기화되는 객체들을 조합해서 마찬가지로 RCU로 동기화되는 더 큰 객체를 만드는 등의 작업은 불가능하고, 그냥 불변 객체를 생으로 만들고 복사하는 방식으로 써야 한다. 다르게 말하자면 객체 간의 합성이 까다로운 동기화 기법인 셈이다. 방법이 있는데 내가 모르는 것일지도 모르겠지만... 하지만 이 방법은 높은 병행성을 가지는 자료구조나 알고리즘을 구현하기 위한 빌딩 블럭으로써 꽤 괜찮은 기법이다. 실제로 리눅스에서는 RCU를 이용해서 리스트를 관리하기도 한다. 개인적으로는 RCU를 잘 정제해서 동시성을 제어하는 기본 요소로 언어에 도입해보는 것도 좋은 시도가 아닐까 생각한다. (이미 있을지도 모르겠다)
  1. Multi version concurrency control [본문으로]
Trackback 0 : Comments 2

멀티 쓰레드 프로그래밍이 어려운 까닭

컴퓨터 과학 2010.04.28 21:48

보통 멀티 쓰레드 프로그래밍 하면 손사레부터 치는 사람들이 대단히 많다. 이 쪽에 대해 나름 공부를 하고 있다 생각하지만 아직까지 버그가 없으면서 높은 병렬성을 가진, 어느 정도 이상 규모를 가진 프로그램을 일반적인 순차적 프로그래밍을 짜듯 쉽게 만들 자신은 없다. 팀 스위니 같은 천재조차도 멀티 쓰레드 프로그래밍은 쉽지 않다고 고백하는 것을 보면 현재 주된 개발 방식 어딘가에 동시성과 맞지 않는 근본적인 한계가 존재한다는 추측을 하게 된다.

 
 멀티 쓰레드 프로그래밍이 어려운 까닭을 파고 들어가면 현재 가장 주류를 이루고 있고 또 성공적으로 적용 중인 구조적 프로그래밍이라는 개념 자체가 동시성 프로그래밍에 적합하지 않다는 점에 그 근본적인 원인이 있음을 알게 된다.[각주:1] 이러한 원인을 알기 위해서는 우선 구조적 프로그래밍에 대한 이해가 필요하다.


 구조적 프로그래밍이란 프로그램의 논리를 작은 단위로 나누어 생각할 수 있도록 하위 구조(sub-structure)라는 논리적인 단위를 제공한다. 이러한 하위 구조의 가장 작은 단위는 일반적으로 문장(statement)인데, 구조적 프로그래밍에서는 이들을 순차적으로, 혹은 필요에 따라 비순차적으로 수행하도록 적합한 제어 구조를 제공함으로써 작은 하위 구조로 부터 더 큰 프로그램을 조립해나간다.

 여기에서 중요한 것은 프로그램의 문맥이 특정한 상태에서 어떤 하위 구조로 진입했을 때, 프로그램 작성자가 그 결과를 결정적으로(deterministic) 예측할 수 있다는 점에 있다. 하위 구조의 동작 결과를 알고 있기 때문에 이러한 하위 구조를 조립한 상위 구조들의 동작 결과도 미리 예측 가능하며, 이러한 전제 하에 상방식(bottom-up), 혹은 하방식(top-down) 설계가 가능해진다. 이 때 각각의 하위 구조가 다른 구조에 영향을 적게 줄 수록 유지 보수가 쉬워지는 경향이 있는데, 이를 위해 그 범위를 가능한 하나의 객체로 좁히기 위한 노력들이 훗날 객체 지향 패러다임에 상당한 영향을 주었다고 한다.

 이 구조적 프로그래밍에서 가장 강력한 도구를 들라면 역시 서브루틴, 혹은 메쏘드이다. 잘 짜여진 서브루틴이라면 전제 조건(pre-condition)과 사후 조건(post-condition), 부가 효과(side-effect)가 명확하게 정의될 수 있다. 전제 조건이란 서브루틴에 진입하기 이전 프로세스[각주:2]의 상태가 가져야 하는 전제 조건들을 의미하며, 사후 조건이란 서브루틴을 수행한 이후 반환되는 결과 값 및 변화한 프로세스의 상태이다. 부가 효과란 해당 서브루틴의 수행으로 인해 발생한 프로세스의 변화 자체를 의미한다. 현재의 컴퓨터 모델에서는 서브루틴을 수행하는 사이에 발생한 메모리 영역의 변화 일체가 서브루틴 수행의 부가 효과라고 볼 수 있다.[각주:3]


 헌데 기존의 프로그램에 동시성이라는 개념이 등장하는 순간 기존의 이런 도구들이 전부 의미가 없어진다. 두 개 이상의 실행 문맥이 동시에 한 메모리 영역을 읽고 쓰는데에 아무런 제약이 가해지지 않기 때문이다. 헌데 서브루틴이건 하위 구조이건 수행 자체에는 시간이 필요하고, 그 사이에 한 메모리 영역을 두 쓰레드가 동시에 조작하려 시도하면 비결정적(non-deterministic)인 결과, 이른바 데이터 레이스가 나오게 된다.

 문제는 이 뿐만이 아니다. 설령 데이터 레이스가 존재하지 않는다고 하여도 싱글 쓰레드와 멀티 쓰레드 사이에는 프로그래밍 방식에 있어 현격한 차이가 있다. 만약 단일 쓰레드가 특정 객체를 수정하는 서브루틴를 수행한다 하면 일반적으로 진입 이전과 이후의 객체 상태는 모두 의미 있는 상태일 것이다. 이러한 가정 하에 구조적 프로그래밍이 가능해지는 것이다. 그러나 두 개 이상의 쓰레드가 그러한 서브루틴을 수행한다면 수정이 완전히 이루어지지 않는 불완전한 상태의 객체에 접근하게 될 가능성이 있다. 동시에 접근될 가능성이 있는 모든 객체에 대해 가능한 모든 불완전한 상태에 대한 대비를 해야 한다는 것이다. 이러면 당연히 프로그램의 복잡도가 현격하게 상승하게 되며, 이는 구조적 프로그래밍의 강점 하나가 그대로 사라지는 것을 의미한다.

 여기에서 구조적 프로그래밍의 가장 큰 전제가 무너지는 것이다. 구조적 프로그래밍에 있어 그 결과를 신뢰할 수 없는 하위 구조는 존재 가치가 없으며, 그러한 하위 구조를 단 하나 가지고 있는 것 만으로도 해당 프로그램은 전혀 신뢰할 수 없는 프로그램이 되고 만다. 그렇지 않은 하위 구조를 짜는 것은 싱글 쓰레드에 비해 몇 배의 노력이 들어가며, 뒤에서도 설명하겠지만 특정 조건을 만족하지 않는 이상 이러한 하위 구조들은 서로 조합이 불가능하다는 치명적인 문제가 존재한다.


 그간 연구자들이 이에 대해 손을 놓고만 있었던 것은 아니다. 컴퓨터 과학계에서는 수십년 전부터 동시성에 대한 연구가 시작되었고, 특히나 CPU 자원의 공평한 분배가 중요한 운영체제론에서는 동시성에 대한 연구가 광범위하게 진행되어 왔다. 쓰레드나 락, 데이터 레이스 등의 개념을 프로그래밍 자체보다는 운영체제나 시스템 프로그래밍 등의 테마를 공부하며 배우는 사람이 많은 것은 바로 이런 까닭이다.


 가장 먼저 나온 방안은 (그리고 현재까지 가장 널리 통용되는 방안은) 필요한 경우 락을 이용하여 프로그램 수행을 직렬화하는 것이다. 간단히 말해 의미 없는, 불완전한 상태의 객체에 관한 서브루틴이 수행될 가능성이 있는 경우 진입 지점에 적당히 락을 걸어 두 개 이상의 쓰레드가 동시에 객체를 수정하지 못하도록 막는 것이다. 이 방법은 구현이 쉽고, 가장 기계 친화적인 방식이기 때문에 아직까지 널리 쓰인다.

 그러나 구조적 프로그래밍과 이 방식이 잘 맞느냐를 묻는다면 회의적이다. 구조적 프로그래밍은 말 그대로 하위 구조의 조합을 통해 프로그램을 만들어가지만, 일반적으로 락 기반 프로그래밍은 객체 단위로 락을 할당한다는 점을 생각해보면 이 방식은 런타임에 존재하는 실제 객체의 상태에도 다분히 의존적이다. 기존에는 주로 하위 구조들이 이루는 프로그램의 구조에 대해서만 신경쓰면 됐다면 [각주:4] 이제는 여기에 런타임의 프로세스 상태라는 새로운 차원까지 치밀하게 신경써야 한다. 이로 인해 락을 이용한 하위 구조들끼리는 별 다른 조치 없이 그대로 조합하는 것은 불가능하며, 악명 높은 동시성 버그인 데드락이 발생하는 까닭의 99%는 여기에 있다. [각주:5]

 이런 방식 대신, 프로세서가 제공하는 원자적 명령어[각주:6]를 통해 서브루틴을 수행하는 구간에 대해 해당 객체가 항시 유효한 상태임을 보장하는 방식인 이른 바 Lock-free, Wait-free로 대변되는 non-blocking 동기화 기법도 존재한다. 여기에서는 보통 프로그램의 복잡도를 제어 가능한 수준으로 낮추기 위해 선형화(Linearization)라는 개념을 도입한다. 선형화의 아이디어는 간단히 말해 특정 쓰레드가 특정 객체에 대한 작업를 수행한다 치면 다른 쓰레드에 있어서는 이 작업이 한 순간에 일어난 것처럼 보이도록 하자는 것이다. 데이터베이스의 트랜잭션과 어느 정도 유사한데, 이런 방식으로 접근을 하면 동시적으로 수행되는 각 작업들 사이의 선후 관계를 판별하는게 가능해지고, 또한 락을 이용한 방법과는 달리 하위 구조끼리의 조합도 가능해진다.


 허나 non-blocking 동기화 기법은 기존의 알고리즘을 선형화시켜야 한다는 제한이 있기 때문에 코딩하기가 대단히 까다롭다. 이는 현대 프로세서들이 제공하는 원자적 연산 명령들의 한계 때문인데, 대부분의 경우 근접해있는 64비트 변수 두 개를 원자적으로 바꾸는 명령 정도가 한계이다. 이러한 명령만을 이용하여 프로그래밍하는 것은 락을 이용한 프로그래밍에 비해 절대 쉽다고 할 수 없다. 그렇기 때문에 대개의 경우는 아주 기초적이고 자주 사용되는 자료구조에 한해 이러한 프로그래밍 기법을 사용하는 것이 대부분이다.

 그러나 선형화라는 아이디어 자체는 상당히 유용하기 때문에 동시성 모델을 만들고 사고하는데 있어 (락을 이용하는 경우에도) 쓸만하며, 또한 하위 구조간 조합이 아무 제약 없이 가능하다는 강점 덕분에 구조적 프로그래밍과 상당히 궁합이 잘 맞는다. 여기에서 더 나아가 특정 코드 구간에서 일어나는 모든 연산이 원자적으로 일어난다는 것을 보장할 수 있으면 어떨까? 이런 아이디어에서 Transactional memory라는 개념이 등장한다. Transactional memory란 간단히 말해 특정 수행 구간에서 일어난 메모리 변화를 원자적으로 반영시키는 개념으로, 메모리 버젼의 트랜잭션이라고 생각하면 된다. Transactional memory가 구현되면 모든 코드를 아주 쉽게 선형화 할 수 있게 되므로 동기화에 대한 시름거리를 하나 덜어버리는 셈이 되나, 안타깝게도 아직까지는 이를 실용적인 수준까지 구현한 사례는 존재하지 않는다.


 현재까지는 어느 쪽이든 그 난이도가 낮지 않다. 이 외에도 수많은 방법들이 제안되었고 또 제안되고 있지만, 아직까지는 확실하게 은탄환이라 불릴 만한 해법은 나오지 않은 상태이다. 병렬, 동시성 프로그래밍은 로직 자체를 고민하는 것도 만만치 않은데 여기에 이런 다양한 문제들이 엮이면서 그 난이도가 살인적인 수준까지 올라간다. 마땅한 방법이 없는 현재로써는 이를 하나 하나 공부하며 그때 그때 맞는 방법론을 찾아 적용하는 수 밖에 없어 보인다.

  1. 물론 이게 goto를 쓴다고 해결된다는 의미는 절대 아니다. [본문으로]
  2. 여기에서 프로세스란 시스템 프로그래밍적 관점에서의 그 용어가 아닌, 프로그램의 인스턴스로써의 프로세스를 의미한다. [본문으로]
  3. 물론 이에 한정되지는 않는다. I/O도 부가 효과라고 볼 수 있으니까. [본문으로]
  4. 꼭 그렇지만은 않지만, 대부분은 좋은 설계에 의해 해결된다. [본문으로]
  5. 이 뿐만 아니라 lock contention, lock convoying등 락으로 인한 문제점은 셀 수도 없이 많다. [본문으로]
  6. Compare and swap등. [본문으로]
Trackback 1 : Comment 0

volatile과 메모리 배리어

개발 2009.11.11 19:36

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

  

 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번째 줄의 코드 수행 순서가 뒤바뀐다고 가정해보자. 우리가 이 코드를 믿고 쓸 수 있을까? 메모리 접근 순서가 제대로 보장되지 않는다면 이런 간단한 코드조차 사용할 수 없게 된다.

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

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

 

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

  

 - 결론 

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

 

'개발' 카테고리의 다른 글

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
Trackback 0 : Comments 2

티스토리 툴바