Android Document  SDK old PDF 파일
Android SMP(4/7) - 사례:C에서 하지 말 것
작성자
작성일 2011-05-02 (월) 22:00
ㆍ추천: 0  ㆍ조회: 9578      
IP: 121.xxx.76

 
 
 
목차
 
1. 도입
2. 이론
   2.1. 메모리 일관성 모델(Memory Consistency Models)
   2.2. 데이터 메모리 장벽(Data Memory Barriers)
   2.3. 원자적 오퍼레이션들(Atomic Operations)
3. 사례
   3.1. C에서 하지 말 것
   3.2. Java에서 하지 말 것.
   3.3. 해야 할 것
4. 부록
 

 

3. 사례(Practice)
 

메모리 일관성 문제를 디버깅하는 것은 매우 어려울 수 있다. 만약 잘못된 메모리 장벽으로 인해 몇몇 코드가 
이전 데이터를 읽게 된다면, 여러분은 디버거를 사용해 메모리 덤프를 해 봄으로써 원인을 찾지 못할 수 있다.
여러분이 디버거 쿼리를 시작할 수 있을 때, CPU 코어는 모든 관측된 접근들의 전체 집합을 가질 것이다.
그리고 메모리의 내용물과 CPU 레지스터는 "불능impossible" 상태가 된 것으로 보일 것이다.


3.1 C에서 하지 (What Not To Do in C)
 

여기에서 우리는 약간의 잘못된 예제와 그것을 고치는 간단한 방법을 보여줄 것이다. 이에 앞서 기본적인
언어 특징에 대한 사용에 대해 논할 필요가 있다.

 

3.1.1 C/C++ “volatile”
 
 
단일 쓰레드 코드를 작성할 때, 변수를 “volatile” 선언하는 것은 매우 유용할 수 있다. 컴파일러는 volatile
위치에 대한 접근들을 생략하거나 재정렬하지 않을 것이다. 이것을 하드웨어에 의해 제공되는 순차적
일관성과 결합시켜라. 그러면 기대했던 순서대로 load와 store가 일어나는 것을 보장받을 것이다.
 
하지만, volatile 스토리지에 접근은 non-volatile 접근들에 의해 재정렬될 수도 있다. 그러므로 여러분은
멀티쓰레드 단일 프로세서 환경에서는 주의가 필요하다. (명시적인 컴파일러 재정렬 장벽이 필요할 수도
있다.)
volatile에는 어떤 원자성atomicity 보장도 없으며 어떤 메모리 장벽 제공도 없다. 그러므로
멀티쓰레드 SMP 환경에서 “volatile”은 도움이 안 된다. C와 C++ 언어 표준은 빌트인 원자적 오퍼레이션을
이용해서 이것을 처리하기 위해 업데이트되고 있는 중이다.
 
만약 여러분이 어떤 것을 volatile로 선언할 필요가 있다면, 이것은 여러분이 volatile 대신에 원자적
오퍼레이션들 중 하나를 사용하여야 한다는 강한 지시적 의미인 것이다.


3.1.2 예제
 
 
대부분이 경우에 여러분은 원자적 오퍼레이션 보다는 차라리 (pthread mutex와 같은) 동기화 원형
synchronization primitive을 사용하는 것이 더 좋다. 하지만 우리는 실제 상황에서 이것들이 어떻게
사용될 것인지를 설명하기 위해 나중에 다룰 것이다.
 
번잡함을 피하기 위해, 우리는 여기에서 컴파일러 최적화 효과는 무시하고 있다 - 아래의 코드 중
약간은 심지어 단일 프로세서에서 조차도 깨진다 - 그러므로 이 모든 예제에 대해 여러분은 컴파일러가
그대로의 코드(예를 들어, gcc -O0 로 컴파일)를 생성한다고 가정해야 한다. 여기서 보여진 고쳐진
것들은 컴파일러 재정렬과 메모리 접근 순서 이슈 모두를 해결한다. 하지만 우리는 나중에 논의할 것이다.



MyThing* gGlobalThing = NULL;

 

void initGlobalThing() // runs in thread 1

{

MyStruct* thing = malloc(sizeof(*thing));

memset(thing, 0, sizeof(*thing));

thing->x = 5;

thing->y = 10;

/* initialization complete, publish */

gGlobalThing = thing;

}

 

void useGlobalThing() // runs in thread 2

{

if (gGlobalThing != NULL) {

int i = gGlobalThing->x; // could be 5, 0, or uninitialized data

...

}

}

 
예제 C-1
 
여기에서 우리는 구조체를 할당하고, 그것의 필드들을 초기화하며, 그리고 제일 마지막에 글로벌 변수에
그것을 저장함으로써 “publish”를 한다. 그 시점에 다른 어떤 쓰레드가 그것을 볼 수 있지만, 그것이
완전하게 초기화 되었기 때문에 괜찮다. 옳은가? 적어도 이것은 x86 SMP나 단일 프로세서에서는 그렇다.
(다시금, 컴파일러가 우리가 소스에 해 놓은 대로 정확하게 코드를 만든다는 잘못된 가정을 만들면 그렇다.)
 
메모리 장벽이 없다면, gGlobalThing에 store는 ARM상에서 그 필드가 초기화되기 전에 관측될 수 있다.
thing->x에서 read하는 다른 쓰레드는 5, 0 또는 심지어 초기화 되지 않은 데이터를 볼 수 있다.

이것은 마지막 할당assignment을 다음과 같이 변경함으로써 고쳐질 수 있다 :

atomic_release_store(&gGlobalThing, thing);
이것은 모든 다른 쓰레드가 제대로 된 순서로 그 write를 관측하게끔 한다. 하지만 read는 어떻게 될까?
이 경우는 ARM 상에서는 okay여야 한다. 왜냐하면 어드레스 의존성 규칙이 gGlobalThing의 오프셋에서의
임의의 load가 gGlobalThing의 load 이후에 관측되게끔 할 것이기 때문이다. 하지만 이것은 아키텍처의
세부사항에 의존하기 때문에 현명하지 못하며, 따라서 여러분의 코드가 미묘하게 이식성이 없을 수 있다는 
것을 의미한다. 완벽하게 고치기 위해서는 다음과 같이 추가적으로 load 뒤의 장벽을 요구한다:
MyThing* thing = atomic_acquire_load(&gGlobalThing);
int i = thing->x;
 
이제 우리는 순서가 올바르게 될 거란은 것을 안다. 이것은 마치 코드를 작성하기 불편한 방법처럼 보일 수
있다. 하지만 그것은 lock을 사용하지 않고 멀티 쓰레드에서 데이터 구조체를 접근하기 위해 지불해야 하는
비용이다. 뿐만 아니라, 주소 의존성이 여러분을 항상 구원해 주지는 않을 것이다.
 

MyThing gGlobalThing;

 

void initGlobalThing() // runs in thread 1

{

gGlobalThing.x = 5;

gGlobalThing.y = 10;

/* initialization complete */

gGlobalThing.initialized = true;

}

 

void useGlobalThing() // runs in thread 2

{

if (gGlobalThing.initialized) {

int i = gGlobalThing.x; // could be 5 or 0

}

}

 


예제 C-2

“initialized” 필드와 그 밖의 것들간에는 아무런 관계가 없기 때문에, read와 write는 순서와 상관없이
관측될 수 있다. (global 데이터는 OS에 의해 0 으로 초기화된다. 그러므로 초기화 되지 않은 임의의
값이 read되는 것이 가능하지 않다는 것에 주의하라.)

 
우리는 다음과 같이 store를 대체할 필요가 있다:
atomic_release_store(&gGlobalThing.initialized, true);
그리고 다음과 같이 load를 대체하라:
int initialized = atomic_acquire_load(&gGlobalThing.initialized);
reference-counted 데이터 구조체들을 구현할 때 동일한 문제를 야기하는 또 다른 예제가 있다.
reference count 자체는 원자적 증가와 감소 오퍼레이션이 계속 사용되는 한 일관성이 있을 것이다.
그러나 여러분은 예제의 끝에서 여전히 문제에 봉착할 수 있다.

void RefCounted::release()

{

int oldCount = atomic_dec(&mRefCount);

if (oldCount == 1) { // was decremented to zero

recycleStorage();

}

}

void useSharedThing(RefCountedThing sharedThing)

{

int localVar = sharedThing->x;

sharedThing->release();

sharedThing = NULL; // can’t use this pointer any more

doStuff(localVar); // value of localVar might be wrong

}

 


예제 C-3
 
release() 호출은 장벽을 사용하지 않는barrier-free 원자적 감소 오퍼레이션을 사용해서 참조 카운트를
감소시킨다. 이것이 원자적 RMW 오퍼레이션이기 때문에, 우리는 그것이 올바르게 작동할 것이라는 것을
안다. 만약 참조 카운트가 0 이라면, 우리는 그 스토리지를 재활용한다.
 
useSharedThing 함수는 “sharedThing”에서 필요한 것을 추출한다. 그리고 그런 다음에 그것의 copy를
해제한다. 하지만, 우리가 메모리 장벽을 사용하지 않기 때문에, 그리고 원자적 비원자적 오퍼레이션이
재정렬될 수 있기 때문에, 다른 쓰레드가 재활용 오퍼레이션을 관측한 후에 sharedThing->x의 read를
관측하는 것이 가능하다. 그러므로 “localVar”가 재활용된 메모리의 값을 보유하는 것이 가능하다.
예를 들어, release()가 호출된 이후 다른 쓰레드에 의해 같은 위치에 신규 오브젝트가 생성되었을 때에
그렇다.
 
이것은 atomic_release_dec으로 atomic_dec 호출을 대체함으로써 해결될 수 있다.
이 장벽은 sharedThing에서 read가 오브젝트를 재활용하기 전에 관측되게끔 한다.
대부분의 경우에 있어서, 위의 것은 실제로는 실패하지 않는다. 왜냐하면 “재활용recycle” 함수가
장벽을 사용하는 함수들(libc heap free()/delete, 또는 mutex에 의해 보호되는 오브젝트 풀)에 의해
비슷하게 보호되기 때문이다. 만약 재활용 함수가 장벽을 사용하지 않고 구현된 lock-free 알고리즘을
사용했다면, 위의 코드는 ARM SMP 상에서 실패할 것이다.

계속 : 

덧글 쓰기 0
3500
※ 회원등급 레벨 0 이상 읽기가 가능한 게시판입니다.