[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 메모리 배리어

참치와돌고래·2022년 11월 19일
0

서버개발

목록 보기
1/1

컴파일러 최적화
지난 시간 컴파일러가 우리가 작성한 코드를 멋대로 최적화하다가 원하지 않는 결과를 얻는(멀티쓰레드 환경에서) 컴파일러 최적화에 대해 알아보았다. 사실 우리도 모르게 이러한 장난을 치는건 컴파일러 뿐만이 아니다.

메모리 배리어

다음 예제를 살펴보자.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{

    class MemoryBarrier
    {
        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;
                //모두 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}번만에 빠져나왔다");
        }
    }
}

해당 예제에선 두 개의 Task(멀티 쓰레드 환경)를 만들어 놓고,r1과 r2 둘 다 0인 경우 While문을 빠져나오는 간단한 코드이다. 이론 상 과연 둘 다 0인 경우가 나올 수 있을까? 아무리 경우의 수를 생각해봐도 그럴 수 없다는 점을 알 수 있을 것이다.

그렇다면 무한 루프가 지속되어 끝이 안나는게 정상일 것이다. 그럼에도 불구하고 해당 코드는 몇 번을 실행해봐도 N번 만에 빠져나왔다는 듯, 아무 이상이 없는 것마냥 결과가 나와버린다.

하지만 Thread 함수 내 코드 순서가 바뀌어진다면 말은 달라진다.

만약 Thread_1에서 y=1보다 r2=y가 먼저 실행되고, Thread_2에서 x=1보다 r2=y가 먼저 실행된다면?

while문 이전 우리가 모든 변수를 0으로 초기화하였기 때문에, r1, r2에 x=1, y=1인 값이 load 되기 전에 둘 다 0으로 초기화 되버려 While문을 빠져나오기 때문이다.

이런 현상이 벌어지는 이유는 Thread_1에서 y=1인 store와 r1=x인 load가 서로 연관이 없다고 판단(적어도 해당 쓰레드에선)하여 r1=x를 먼저 실행하고, 그다음 y=1를 하드웨어가 실행하기 때문이다.

그렇기 때문에 메모리 배리어라는, 특수한 코드로 이를 사전에 막아줄 필요가 있다. 다음 수정된 코드를 살펴보자.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class MemoryBarrier
    {
        static int x = 0;
        static int y = 0;
        static int r1 = 0;
        static int r2 = 0;
        static void Thread_1()
        {
            y = 1;
            //Store y
            Thread.MemoryBarrier();
            //더이상 r1=x 먼저 실행 안됨.
            r1 = x;//Load x
        }
        static void Thread_2()
        {
            x = 1;//store x
            Thread.MemoryBarrier();
            //이렇게하면 둘다 0이 될 일이 아예 없다.
            r2 = y;//Load y

        }
        static void Main(string[] args)
        {
            int count = 0;
            while (true)
            {
                count++;
                x = y = r1 = r2 = 0;
                //모두 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}번만에 빠져나왔다");
        }
    }
}

해당 쓰레드 함수들의 문제가 되는 코드 사이에 Memory Barrier를 넣고 실행해주면 우리의 의도대로 무한 루프가 돌아갈 것이다. 메모리 배리어의 역할은 다음과 같이 정리할 수 있다.

  1. 코드 재배치 억제
  2. 가시성

여기서 가시성이란 기능이 무엇을 의미하는지 알려면, 저번에 캐시 이론에서 비유한 식당 내 상황을 예로 들어보자.

캐시 이론

해당 비유에서 종업원1이 수첩에 콜라를 메모하고 주문 현황표에 적으러 가는 동안, 어느 순간 주문이 바뀌어 또 다른 종업원2이 사실 콜라가 아니라 사이다로 바꿔야 하는 사실을 깨달았다면, 종원원1은 그 사실을 모를 것이다.

가시성이란 여기서, 사실 사이다로 바뀌어야 하는 사실을 종업원2 뿐만 아니라 종업원1도 즉시 그 사실을 깨닫게 해주는 것이라고 생각하면 된다.

한마디로, 새로고침이라고 이해(?)하면 되겠다.

덕분에 Thread_1, Thread_2에서 MemoryBarrier 이후의 r1=x, r2=y 코드에서 각자 따끈따끈하게 최신으로 갱신된 x, y를 Load할 수 있게되는 것이다.

MemoryBarrier는 별도로 쓰이기 보단, 해당 기능은 이미 lock 등의 메소드에서 구현되어 있으므로, 이러한 개념을 설명하기 위해 존재하는 메소드라고 생각하면 된다.

0개의 댓글