Q1. MemoryBarrier의 기능 두가지를 설명해 보세요.
컴파일러가 최적화 한게 오히려 독이 되었던 걸 지난 시간에 살펴봤어.
컴파일러 뿐만 아니라 하드웨어도 그런 짓을 하고 있어.
오늘은 하드웨어 최적화 실습을 해보자.
일단 지난 시간 코드를 다 지우고 만들어 보자.
namespace ServerCore
{
internal class Program
{
static int x = 0;
static int y = 0;
static int r1 = 0;
static int r2 = 0;
static void Thread_1()
{
y = 1; // Store y
r1 = x; // Load x
}
static void Thread_2()
{
x = 1; // Store x
r2 = y; // Load y
}
static void Main(string[] args)
{
int count = 0;
while(true)
{
count++;
x = y = r1 = r2 = 0;
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
if (r1 == 0 && r2 == 0)
break;
}
Console.WriteLine($"{count}번만에 빠져나옴!");
}
}
}
이러면 r1과 r2가 0이 되어서 빠져 나올 수 있을까?
ctrl+f5로 실행해 보면
은근히 잘 빠져나온 걸 볼 수 있다.
r1도 r2가 일어나는 일이 일어난다.
혹시 최적화를 시킨게 아닐까 x,y,r1,r2에 volatile을 붙여서 해보면
static volatile int x = 0;
static volatile int y = 0;
static volatile int r1 = 0;
static volatile int r2 = 0;
여전히 0, 0인 상황이 나온다.
이 결과가 어떻게 나온걸까?
어떤 경우의 수를 조합해 보아도 둘다 0, 0이 나오는 걸 조합할 수 없어.
캐시와 관련된 문제일까 싶지만 당장은 그 문제가 아니다.
지금 문제가 되는 건 하드웨어도 최적화를 해주고 있다.
CPU같은 경우 일련의 명령어들을 시키면 서로 의존성이 없는 명령어라 판단이 되면 순서를 멋대로 뒤바꿀 수 있다.
Y=1과 R1=X는 CPU 입장에서는 아무런 연관성이 없다. 빨리 실행할 수 있는 최적의 수를 찾다가
이런 식으로 뒤집어서 실행해 줄 수 있다.
그러면 아까 상태가 이해가 간다. 이 상태에서는 0, 0 이 동시에 튀어날 수 있다.
이게 말도 안되는 소리 같은데 싱글 스레드에서는 이렇게 해도 문제가 되지 않았어. 애당초 Y=1과 R1=X는 연관 성이 없기 때문에 속도 향상을 위해 뒤바꿔서 하는게 좋았어. 그런데 멀티 스레드 환경에서는 쿨하게 멋대로 해버리면 예상한 로직이 꼬이게 되어 버린다.
결국에는 문제가 됐던데 명령한 순서대로 실행되지 않고 멋대로 순서를 뒤바꿔서 실행한게 문제가 되었다는 건데
static void Thread_1()
{
y = 1; // Store y
//////////////////////
r1 = x; // Load x
}
이런식으로 선을 그어서 얘 먼저 하고 얘를 해라 라고 강제를 시킬 수 있는 수단이 있으면 해결할 수 있어.
// 메모리 배리어
// A) 코드 재배치 억제
// B) 가시성
가시성은 이따가 설명하고, 코드 재배치 억제가 더 직관적으로 이해되니 먼저 설명한다.
static void Thread_1()
{
y = 1; // Store y
// ------------------------ //
Thread.MemoryBarrier();
r1 = x; // Load x
}
static void Thread_2()
{
x = 1; // Store x
// ------------------------ //
Thread.MemoryBarrier();
r2 = y; // Load y
}
이렇게 해주면 선을 넘어 올 수 없다고 경계선을 그은 거다.
y=1이 밑으로 넘어올 수 없고, r1=x도 위로 넘어 올 수 없게 된 거.
하드웨어가 멋대로 먼저 실행할 수 없게 된거.
Thread_2도 똑같이 해준다.
이상태에서 ctrl+f5로 실행을 해보면
무한루프를 돈다.
이제 r1=0, r2=0인 상황이 나타나지 않는다는 거.
메모리 배리어가 종류별로 여러개 있다.
// 1) Full Memory Barrier (ASM MFENCE, C# Thread.MemoryBarrier) : Store/Load 둘다 막는다
// 2) Store Memory Barrier (ASM SFENCE) : Store만 막는다
// 3) Load Memory Barrier (ASM LFENCE) : Load만 막는다
이렇게 있는데 프로그래밍 할 때 이렇게 세분화할 일은 없으니 Full MemoryBarrier만 이해하면 된다.
이번에는 가시성에 대한 얘기를 해보자.
가시성은 1번 직원에 주문을 받은 거 자체를 다른 직원도 바로 볼 수 있느냐 없느냐야.
가시성을 보장하기 위해서는 1번 직원이 콜라를 주문 받은걸 수첩에만 갖고 있으면 안되고 실제 주문 형황에 옮겨놔야 한다는 얘기가 된다.
그 다음에 2번 직원은 그대로 주문 현황의 정보를 가져와 수첩에 있는 정보를 갱신을 하면 현재 상황과 맞게 업데이트가 된다.
뭔가를 자기 수첩에 쓴 다음에 커밋을 동기화 작업을 해야 하는거고, 실제로 사용할 사람은 일단 동기화를 먼저 한다음에 read를 해서 데이터를 불러 읽어 들여야 정상적인 방식으로 똑같은 정보를 보게 된다.
다시 메모리 배리어로 돌아오면
// 메모리 배리어
// A) 코드 재배치 억제
// B) 가시성
첫번쨰 역할은 코드 재배치 억제라고 했어. 그건 직관적으로 이해하기 쉬웠어.
가시성의 역할도 MemoryBarrier가 간접적으로 같이 해준다.
static int x = 0;
static int y = 0;
static int r1 = 0;
static int r2 = 0;
volatile을 안쓰는 일반적인 상황이라 가정을 하면
메모리 베리어가 코드 재배치 억제만 하는 애였다고 하면 가시성까지는 못해줄거야.
얘가 하는 건 화장실에서 물을 내리는 거라 생각하면 된다.
static void Thread_1()
{
y = 1; // Store y
// ------------------------ //
Thread.MemoryBarrier();
r1 = x; // Load x
}
y=1을 한 상태에서 MemoryBarrier를 호출하면 물을 내리는 작업처럼 실제 메모리에 y=1을 올리게 된다는 그런 느낌이야.
마찬가지로 r1=x 로드 하기 전에 MemoryBarrier가 실행 됐다는 건 x의 값도 따끈따끈한 실제의 값으로 갖고 온다는 얘기가 된다.
반대쪽 쓰레드도 똑같이 작업을 해줘야 한다. 첫번째 직원이 수첩에 기록한걸 주문 현황에 올렸다 해서 두번째 직원이 그걸 바로 볼 수 있는 건 아냐, 두번째 직원도 동기화 작업을 해야 즉 주문 현황을 자기 수첩에 옮겨야지만 똑같은 정보를 보고 있을 수 있게 된다.
어째든 가시성도 MemoryBarrier로 같이 해결할 수 있다.
volatile 키워도도 사실 일종의 Barrier 두개가 간접적으로 동작을 하고 있다 보면 된다. 그래서 Volatile을 모른다 하더라도 그냥 MemoryBarrier하나만 알아도 사실은 기능을 같이 사용할 수 있어.
굳이 MemoryBarrier를 하나하나 하지 않더라도 간접적으로 들어가 있는 경우가 많다. volatile도 그렇고, lock같은 경우도 그렇고, 나중에 배울 atomic 문법도 이런식으로 내부적으로 배리어로 구현이 되어 있다.
MemoryBarrier는 실제로 사용하기 위해 배우는 건 아니고 이렇게 원리가 작동한다고 이해하기 위해 학습을 한다고 보면 된다.
기억할 건
// 메모리 배리어
// A) 코드 재배치 억제
// B) 가시성
마지막으로 마치기 전에 유명한 예제를 보자.
A라는 함수와 B라는 함수가 두개의 쓰레드에서 각각 실행이 된다 가정을 하면
그 때 Barrier 1,2,3,4를 다 해줘야 우리가 예상하는 대로 나온다 설명하는거
Barrier안해주면 순서가 뒤바뀔 수 있고, 가시성에도 피해를 입는다. _anser =123, _complete = true로 해도 B에서 갱신이 안 될 수 있다.
A를 보면 마지막에도 Barrier를 하나 넣은 걸 볼 수 있어. B에도 처음에 하나를 넣고 있는데 왜 그러냐면, 아까는 Store한 다음에 바로 Load를 했었어. 근데 지금은 A에서 첫번째_answer = 123도 Store, 두번째도 _complete = true도 Store야. Store가 연속적으로 두번 이니까 첫번째 Barrier는 _answer의 가시성을 챙겨준거야. 쓴다음에 물을 내리는 작업 한거고 다음에도 뭔가 썼으니까 물 내리는 작업을 해줘야 가시성이 확실히 보장이 되는거. B에서는 읽기 전에 가시성이 보이도록 한번 물을 내려 주는 거다. B의 두번째도 _anser라는 걸 Read를 하고 싶은데 그게 확실히 최신 정보를 긁어온게 맞는지 확실하게 하기 위해 MemoryBarrier를 넣어 준 것을 알 수 있다.
메모리 배리어가 코드 재배치를 막는 것도 훌륭히 해주고 있었고, 가시성도 열심히 챙겨주고 있는 것 까지 알아 봤다.
'Server programming' 카테고리의 다른 글
01_07_멀티쓰레드_Lock 기초 (0) | 2023.03.13 |
---|---|
01_06_멀티쓰레드_Interlocked (0) | 2023.03.13 |
01_04_멀티쓰레드_캐시이론 (0) | 2023.03.12 |
01_03_멀티쓰레드_컴파일러 최적화 (0) | 2023.03.12 |
01_02_멀티쓰레드_쓰레드 생성 (0) | 2023.03.12 |
댓글