공부/게임서버

하드웨어 최적화 문제 / 메모리 배리어

samosa 2024. 11. 9. 21:34
internal class Program
{
    static int x = 0;
    static int y = 0;
    static int r1 = 0;
    static int r2 = 0;

    static void Thread1()
    {
        y = 1;
        r1 = x;
    }

    static void Thread2()
    {
        x = 1;
        r2 = y;
    }

    static void Main()
    {
        int count = 0;

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

            Task t1 = new(Thread1);
            Task t2 = new(Thread2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            if (r1 == 0 && r2 == 0)
            {
                Console.WriteLine($"{count}번째 시도에서 빠져나옴");
                break;
            }
        }
    }

위의 코드에서

y = 1;

r1 = x;

두 줄의 실행 순서는 뒤바뀔 수 있다.

싱글 스레드에서는 전혀 문제될 것이 없다. 전혀 다른 변수 영역을 건들고 있기 때문에. 따라서 r1 == 0 && r2 == 0 조건은 참일리가 없다.

그러나 멀티쓰레드 환경에서는 놀랍게도 r1 == 0 && r2 == 0 가 참일 수 있다.

이 문제는 멀티스레드 환경에서의 메모리 재정렬(memory reordering) 때문. CPU나 컴파일러가 코드 실행 순서를 최적화하면서 예상하지 못한 실행 순서를 가져오게 된다. 이러한 최적화는 성능을 향상시키지만, 다중 스레드에서 공유되는 변수의 읽기/쓰기 순서가 바뀌어 레이스 컨디션과 같은 문제가 발생할 수 있다.

위 코드의 주요 문제를 다시 정리하면, Thread1Thread2에서 y = 1x = 1을 설정하는 명령어와 r1 = x, r2 = y를 읽는 명령어가 최적화에 의해 순서가 바뀌거나 병렬로 처리될 수 있다는 점. 이는 특정 실행 시점에서 r1 == 0이고 r2 == 0인 상황을 만들 수 있다.

해결 방법: 메모리 배리어 사용

메모리 배리어(memory barrier)는 코드의 재정렬을 방지하는 기법이다. C#에서는 Thread.MemoryBarrier()를 사용하여 명시적으로 메모리 배리어를 삽입할 수 있다. 아래는 코드의 수정 예제:

internal class Program
{
    static int x = 0;
    static int y = 0;
    static int r1 = 0;
    static int r2 = 0;

    static void Thread1()
    {
        y = 1;
        Thread.MemoryBarrier(); // 메모리 배리어 삽입
        r1 = x;
    }

    static void Thread2()
    {
        x = 1;
        Thread.MemoryBarrier(); // 메모리 배리어 삽입
        r2 = y;
    }

    static void Main()
    {
        int count = 0;

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

            Task t1 = new(Task.Run(Thread1));
            Task t2 = new(Task.Run(Thread2));

            Task.WaitAll(t1, t2);

            if (r1 == 0 && r2 == 0)
            {
                Console.WriteLine($"{count}번째 시도에서 빠져나옴");
                break;
            }
        }
    }
}

메모리 배리어는 CPU나 컴파일러의 명령어 재정렬을 방지하고 메모리 접근 순서를 제어하기 위해 사용된다. 이를 통해 멀티스레드 프로그램에서 예상하지 못한 실행 결과를 방지할 수 있다. 메모리 배리어는 크게 네 가지 주요 유형으로 나뉜다:

1. 로드 배리어 (Load Barrier):

  • 설명: 로드 배리어는 메모리 읽기 명령어가 재정렬되지 않도록 보장한다. 이 배리어는 앞선 읽기 명령어가 모두 완료된 후에야 뒤의 읽기 명령어가 수행되도록 보장한다.
  • 사용 시점: 멀티스레드 환경에서 변수 읽기의 순서를 보장해야 할 때 사용한다.

2. 스토어 배리어 (Store Barrier):

  • 설명: 스토어 배리어는 메모리 쓰기 명령어가 재정렬되지 않도록 보장한다. 이 배리어는 앞선 쓰기 명령어가 완료된 후에야 뒤의 쓰기 명령어가 실행되도록 한다.
  • 사용 시점: 메모리에 데이터를 쓰는 순서를 보장하고 싶을 때 사용한다.

3. 전체 배리어 (Full Barrier / Memory Fence):

  • 설명: 전체 배리어는 로드와 스토어를 포함한 모든 메모리 접근이 재정렬되지 않도록 보장한다. 즉, 앞의 모든 읽기 및 쓰기 명령어가 완료된 후에야 뒤의 읽기 및 쓰기 명령어가 수행된다.
  • 사용 시점: 읽기 및 쓰기 순서가 모두 중요할 때 사용한다. 강력한 보장으로 코드 실행 순서의 정확성을 보장한다.

4. Acquire와 Release 배리어:

  • Acquire Barrier:
    • 설명: 이 배리어는 현재의 읽기/쓰기 이후의 명령어가 이전의 읽기/쓰기 명령어를 앞서 실행되지 않도록 한다. Acquire는 보통 읽기와 관련이 있다.
    • 사용 시점: 잠금(lock)이나 공유 리소스를 획득할 때 사용하여 이전 메모리 연산이 완료된 후 후속 연산이 실행되도록 한다.
  • Release Barrier:
    • 설명: 이 배리어는 현재의 읽기/쓰기 이전의 명령어가 이후의 읽기/쓰기 명령어를 넘어 실행되지 않도록 한다. Release는 보통 쓰기와 관련이 있다.
    • 사용 시점: 공유 리소스를 해제하거나 잠금을 해제할 때 사용하여 후속 메모리 연산이 이전 연산 이후에 실행되도록 한다.

언어 및 플랫폼별 구현:

  • C#: Thread.MemoryBarrier() 메서드는 기본적으로 전체 배리어로 작동하여 메모리 명령어의 재정렬을 방지한다.
  • C/C++: std::atomic_thread_fence(std::memory_order)와 같은 표준 라이브러리를 사용하여 다양한 배리어를 지정할 수 있다.
  • Java: synchronized 블록이나 volatile 키워드는 메모리 배리어의 역할을 한다.

각 배리어는 멀티스레드 프로그래밍에서 코드의 메모리 일관성을 보장하는 데 필수적이다. 이를 통해 각 스레드 간에 데이터의 동기화를 보장하고 레이스 컨디션을 방지할 수 있다.