하드웨워 최적화를 막아주는 메모리 베리어에 대해 알아보자

먼저 아래와 같은 코드를 살펴보자

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 
            r1 = x; // Load
        }

        static void Thread_2()
        {
            x = 1; // Store
            r2 = y; // Load 
        }

        static void Main(string[] args)
        {

            int count = 0;

            while(true)
            {
                x = y = r1 = r2 = 0;
                count++; 

                Task t1 = new Task(Thread_1);
                Task t2 = new Task(Thread_2);

                t1.Start();
                t2.Start();

                Task.WaitAll(t1, t2); // Task가 Background Thread 이므로 메인쓰레드한테 끝날 때 까지
                // 대기하라고 알려준다. 

                if(r1 == 0 && r2 == 0)
                {
                    break; 
                }
            }

            Console.WriteLine($"{count}번 만에 빠져 나옴!");

        }
    }

while문을 빠져 나오지 못하고 무한 루프를 돌 것 같다고 예상하지만 출력의 결과는 우리의 예상을 빗나간다.

2번만에 빠져 나왔다니? 2개의 쓰레드가 병렬적으로 실행되어도 r1과 r2가 모두 0이 될 수는 없다. 적어도 하나는 1을 할당 받아야 한다.

다시 한 번 출력 결과를 확인해보자.

159?? 어쨋든 빠져 나오긴 했다는 뜻이다.


이런 동작이 발생하는 이유는 하드웨어 최적화가 일어났기 때문이다.

구체적으로 말하자면 CPU가 최적화를 위해 서로 관계가 없다고 판단한 코드의 순서를 임의로 바꾸어 버린다.

위와 같이 코드가 수정되면 r1, r2가 모두 0이 되는 경우가 나온다.


사실 싱글 쓰레드 환경에서는 위와 같은 동작이 전혀 문제가 없다. 코드의 순서를 빠르게 할 수 있다는 것은 좋은 것이다.

허나 멀티 쓰레드 환경에서는 에러를 발생시킨다.


이를 해결할 수 있는 방법이 메모리 배리어(Memory Barrier)다.

코드를 다음과 같이 수정해보자.

static void Thread_1()
{
	y = 1; // Store
    
    	Thread.MemoryBarrier(); 

	r1 = x; // Load
}

static void Thread_2()
{
	x = 1; // Store

	Thread.MemoryBarrier(); 

	r2 = y; // Load 
}

메모리 배리어는 코드 재배치를 억제하는 기능이 있다.
즉, 경계선을 그어주어 하드웨어가 코드의 순서를 바꾸지 못하게 억제하는 것이다.

코드의 순서를 변경하지 못하여 while문을 빠져 나오지 못하는 것을 볼 수 있다.


메모리 배리어는 가시성(Visibility)을 보장해준다.

레스토랑의 관점에서 가시성을 비유하자면, 주문현황판에 새로 받은 주문을 업데이트 해주는 것이다.

메모리에 기록된 따끈따끈한 데이터를 가져올 수 있게 해준다.

static void Thread_1()
{
	y = 1; // Store
    
    	Thread.MemoryBarrier(); 

	r1 = x; // Load
}

static void Thread_2()
{
	x = 1; // Store

	Thread.MemoryBarrier(); 

	r2 = y; // Load 
}

Thread_1에서 r1 = x 를 실행할 때, Thread_2에서 x에 할당한 값을 꺼내올 수 있게 해주는 것이다.

말이 조금 어려운데, 업데이트를 반영한다는 관점에서 이해하면 쉽다


마지막으로 아래와 같은 코드를 추가로 살펴보자.

	void A()
        {
            _answer = 123; // Store(write)
            Thread.MemoryBarrier(); // Barrier 1 

            _complete = true; // Store(write) 
            Thread.MemoryBarrier(); // Barrier 2
        }
        
        void B()
        {
            Thread.MemoryBarrier(); // Barrier 3 
            if (_complete)
            {
                Thread.MemoryBarrier(); // Barrier 4
                Console.WriteLine(_answer);
            }
        }

Barrier 1은 코드 재배치를 억제하면서 _answer의 업데이트를 반영해준다.

Barrier 2는 _complete의 업데이트를 반영해준다.

Barrier 3은 _complete를 읽기 전에 업데이트를 반영해주고

Barrier 4sms _answer를 읽기 전에 업데이트를 반영해주는 것이다.

profile
POSTECH EE 18 / Living every minute of LIFE

0개의 댓글