멀티 쓰레드
이전 시간에서는 컴파일러가 소스 코드를 최적화하여 우리가 원하던 방향으로 정상적으로 작동하지 않는 문제가 발생하였다. 그러나 사실 컴파일러 뿐만 아니라 소스 코드를 최적화 하는 또 다른 존재가 있었으니 바로 하드웨어(HW)이다. 따라서 오늘 실습할 것은 하드웨어가 진행하는 최적화 메모리 배리어에 대해서 학습해보겠다. 🤠
코드는 아래와 같다.
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}번만에 빠져나옴!");
}
}
위 소스코드를 보면 메인메서드 내부에서 if 문이 동작할 일이 없다. 왜냐하면 테스크에서 y, x의 값을 모두 1로 바꿔주기 때문이다. 그러나 막상 프로그램을 돌려서 테스트를 진행해보면 예상외로 whilte() 문을 잘 탈출한다. 왜 그런 것일까? 🤔..
이 결과가 도대체 어떻게 나오는 것일까? 아래 이미지와 같이 우리는 X와 Y의 값을 1로 설정하고 있고 R1과 R2는 각각 X와 Y의 값을 물고 있기 때문이다. 사실 알고보면 하드웨어(CPU)도 우리를 위해서 최적화를 진행하는 것이다. 의존성이 없는 명령어라고 판단이 들면, 순서를 CPU 마음대로 뒤바꿀 수 있다.
따라서 하드웨어 최적화가 진행된다면 메인 메서드에서 while 문을 정상적으로 빠져나올 수 있다. 그렇다면 우리가 작성한 소스코드의 순서대로 실행되게끔 하기 위해서는 어떻게 해야할까? 답은 '강제 처리'이다. 이를 메모리 배리어라고 한다.
메모리 배리어는 크게 코드 재배치 억제 기능과, 가시성의 기능을 담당한다. 코드 재배치를 억제한다는 말은 말 그대로 하드웨어가 우리가 작성한 소스코드를 재배치 하는 행위를 막는다는 뜻이다. 따라서 아래와 같이 소스코드를 수정하면 코드 재배치 억제 기능을 활성화 할 수 있다.
코드 재배치 억제 기능
static void Thread_1()
{
y = 1; // Store y
// Store/Load가 재배치 되는 것을 막는다.
Thread.MemoryBarrier();
r1 = x; // Load x
}
static void Thread_2()
{
x = 1; // Store x
// Store/Load가 재배치 되는 것을 막는다.
Thread.MemoryBarrier();
r2 = y; // Load y
}
위와 같이 소스코드를 수정하면 메모리 베리어 메서드가 38선을 친것과 같이 경계선을 만들어 주는 것과 같다. 또한 메모리 배리어의 종류는 크게 3가지가 있다.
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가 재배치 되는 것을 막는다.
각각의 메모리 베리어는 어셈블리어 명렁어도 포함하고 있다. 사실 우리가 코드를 작성할 때 2번과 3번을 따로 작성할 일 없이 1번 기능을 이용하는 것도 충분하다.
가시성
다시 아래 이미지와 같이 고급식당으로 돌아간다고 생각해보자.
이전에 문제가 되었던 상황을 복기해보자. 우리는 A 알바생이 콜라라는 주문을 받았지만, 이를 주문 현황판에 작성하지 않고 다른 일을 처리하러 갔는데 B 알바생이 2번 테이블에서 콜라를 사이다로 변경해달라는 주문을 받았었다. 하지만 B 알바생의 경우, 주문 현황판에 2번 테이블에서 콜라를 주문 받았던 것이 없기 때문에 일을 정상적으로 처리할 수 없다.
따라서 우리가 얘기하고자 하는 가시성은, 결국 A 알바생이 주문을 받은 행위 자체를 우리가 바로 볼 수 있느냐의 문제인 것이다. 콜라를 주문 받은 내용을 수첩에 적는 것이 아닌, 주문 현황판에 옮겨놔야 하는 것이다. 이렇게 처리하면 A 알바생이 주문을 받고, 현황판에 바로 작성하기 때문에 B 알바생도 이를 보고 업무를 처리할 수 있게 된다. (루키스님은 이를 화장실로 비유하셨는데 글쎄... 🤦♂️ 나는 잘 모르겠다..)
또한 메모리 베리어는 볼라티에도 있고, 락, 아토믹 문법에도 모두 내부적으로 베리어가 들어있다.
메모리 베리어 예제
다른 책에서 사용하는 메모리 베리어 예제인데, 위 소스 코드를 이해한다면 메모리 베리어를 정상적으로 이해했다고 볼 수 있다. 다만, 우리가 작성했던 소스 코드와는 다르게 메모리 베리어가 값들 사이에 모두 작성되어 있다는 것이다. 차이점은 Read와 Write의 차이이다. 우리가 작성한 소스코드는 Read를 하고, 바로 Write를 해주었기 때문에 상관이 없었지만, 위 예제에서는 Write만 해주었기 때문에, B 메서드에서도 베리어를 두 번 사용한다.
'공부 > 인프런 - Rookiss' 카테고리의 다른 글
Part 4-2-7. 멀티쓰레드 프로그래밍 : Lock 기초 (0) | 2023.09.12 |
---|---|
Part 4-2-6. 멀티쓰레드 프로그래밍 : interlocked (0) | 2023.09.12 |
Part 4-2-4. 멀티쓰레드 프로그래밍 : 캐시 이론 (1) | 2023.09.06 |
Part 4-2-3. 멀티쓰레드 프로그래밍 : 컴파일러 최적화 (0) | 2023.09.06 |
Part 4-2-2. 멀티쓰레드 프로그래밍 : 쓰레드 생성 (0) | 2023.09.04 |