Memory Visibility (메모리 가시성)

Memory Fence(메모리 장벽)

C++ 11 이후부터 표준에 memory_order 이라는 개념이 추가되었다고 한다.

  • 메모리 가시성에 대해 간단히 말하자면 어떠한 Thread 에서 변경한 메모리의 값이 다른 Thread 에서도 제대로 (관찰)읽어지느냐에 대한 것이다

이 메모리 가시성은 다중코어 CPU에서 중요한 이슈이다. 싱글코어 CPU 에서는 쓰레드가 컨텍스트 스위칭 될때에 레지스터와 Program Counter(PC), 스택프레 임 등등 하드웨어 정보가 교체되겠지만 캐쉬 메모리는 교체될 필요가 없을 것이다. (교체되는 쓰레드가 같은 프로세스내에 있다면)

그렇다면 프로그래머가 신경써야할 주제는 쓰레드가 메모리에 값을 변경할때에 레지스터의 값을 메모리에 반영하는 그 순간으로 좁혀진다. 해당 연산을 atomic 하게 수행하게 하거나 공유리소스를 건드는 코드블록에 임계영역을 걸어버리면 문제는 간단히 해결된다. 다중코어 CPU 에서는 쓰레드간 독립적인 캐쉬메모리가 존재하기 때문에 해당 이슈에 대해서 조금 더 생각해봐야 한다 (정확히는 여러개의 코어들의 캐쉬메모리가 독립적인것이다)

각각의 코어(CPU) 들은 메인 메모리는 공유하지만 각각의 레지스터와 캐쉬를 가지고 있다 (이 레지스터 정보가 쓰레드마다 다르기 때문에 싱글코어에서도 Race condition 문제가 발생하는 것이라는 것을 짚고 넘어가고 싶다.)

또한 컴파일러의 최적화 덕분에 CPU는 메인메모리가 아닌 레지스터와 캐쉬를 최대한 사용하도록 동작되며 dependent 한 관계가 아닌 코드들 이라면 컴파일러는 효율성을 위해 명령어를 재배치 할 수 있 #### 이것이 의미하는 바가 무엇일까?

  • 각 코어(쓰레드) 마다 캐쉬가 독립적이므로 코어마다 같은 시각의 관찰한 변수의 값이 다를 수 있다 (!!!!) .

  • 컴파일러의 최적화 (명령어 재배치) 때문에 어떠한 문맥이후에 반드시 이뤄어졌어야할 어떠한 작업은 사실 실행되지 않았을 수도 있다.

두번째 문장은 설명이 좀 필요할 것 같은데

1
2
3
4
5
6
7
 void foo(void)
 {
	  bool _flag = false ;
	  int value =0;
	  _flag = true;
	  value =77;
 }

이러한 함수가 있다고 해보자. flag 의 값이 true 가 되었다고 했을때 value 의 값이 77이라고 확신할 수 있을까?

직관적으로 흐름을 파악할 수 있는 사람 입장에서는 해당 함수의 변수 둘 사이의 관계가 논리적으로 종속적이라고 생각한다. 하지만 컴퓨터 입장에선 서로 값을 참조하는 일이 없기 때문에 전혀 종속적이지 않은 변수들이며 때문에 컴파일러의 명령어 재배치로 인해 해당 문장의 순서는 .

value =77;
_flag = true;

이렇게 바뀔 수 있다.

결국 프로그래머는 멀티쓰레드 프로그래밍에서 변수의 값을 수정한 이후에 동기화가 필요한지 아닌지 적절히 판단해야 하며 종속적이지 않은 명령어는 재배치 될수 있다는 사실을 머릿속에 가지고 프로그래밍 해야한다.

여기서 동기화가 필요할때 사용하는 개념이 바로 메모리 장벽(Memory Fence) 이다.

결론부터 얘기하자면 메모리 장벽을 만나면 해당 명령어를 수행하면 코어는 메모리 장벽을 만나기전 수행했던 작업들을 모든 코어가 제대로 값을 볼 수 있도록 공유메모리에 반영한다.

여기서 언급하는 메인메모리는 L3 캐쉬 이거나 RAM 이다.

좀 귀찮아지는 소리이지만 당연히 공유메모리에 값을 반영했다면 그 값을 읽는 코어들도 독립적인 캐쉬나 레지스터가 아닌 공유메모리에서 값을 읽어오도록 해야한다. (기껏 메모리에 반영했거늘 그것도 모른채 자기 캐쉬에서 읽어오면 망하는거임…)

이 사실로부터 유추해볼수 있는 점은 익숙하게 사용해왔던 세마포어, 뮤텍스의 Lock 또한 암시적으로 메모리 장벽을 사용한다고 할 수 있다. Lock 으로 Critical Section 을 획득한 쓰레드는 당연히 해당 사실을 알려야할 의무가 있으며 다른 쓰레드들 또한 이것을 실시간으로 알아야 한다.

정리하자면 여러 코어에서 값을 관찰했을때 동일한 시간에 서로 똑같은 값을 관찰할 필요가 있다면 동기화를 해야 한다는 것이고 메모리 장벽을 사용해야 할것이다.

C++ 에 atomic 라는 개념이 등장하는데 atomic 는 변수의 값을 말그래도 원자적으로 수행한다. 값을 읽거나 수행하는 것을 캐쉬나 레지스터에서 하는것이 아니고 메인메모리에 수행되도록 해준다는 것이다. (당연히 값을 변경할때는 레지스터를 거치겠지만)

메모리 장벽은 명령어 재배치와도 관련한 내용이 있다. 컴파일러는 메모리 장벽을 넘어서까지 재배치를 하지는 않는다. 메모리 장벽이란 메모리 장벽 이전 작업을 메모리에 반영하는것이라 하였다.

메모리 장벽 이전 의 기준은 메모리 장벽을 실행하는 코드의 이전에 해당하는 코드흐름을 의미한다.

따라서 컴파일러는 이 메모리 장벽을 넘어서 명령어를 재배치 하지 않는 것이다. 이것이 이해가 안간다면 메모리 장벽 이후로 이전 코드들을 옮겼을때의 상황을 상상해보자

C++ 에서 Memory Order 이라는 개념이 등장하는데 atomic 변수에 값을 변경할떄에 옵션으로 acquire 와 release 라는 옵션이 존재한다. 컴파일러는 acquire 옵션 이후에 코드들을 acquire 옵션 이전으로 재배치하지 않고 release 이전의 코드들을 release 이후로 재배치 하지 않는다.

1
2
3
4
5
6
7
8
9
void bar()
{

// 획득 이후 코드들은 절대 이쪽으로 배치 될 수 없다.
   acquire !! 
	cmd a....
	cmd b.....
	cmd c.....	
};
1
2
3
4
5
6
7
8
void foo()
{
	cmd a....
    cmd b.....
    cmd c.....	
	release !!
// 배포 이후 코드들은 절대 이쪽으로 배치 될 수 없다.
};

배포(Release) 하기 이전의 메모리 변경사항을 메모리로 Flush 하겠다는 것이고 그것을 획득(Acquire) 한 이후에 메모리에 반영된 값들을 Read 하겠다는 것이다.