하드웨어 최적화 문제 / 메모리 배리어
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나 컴파일러가 코드 실행 순서를 최적화하면서 예상하지 못한 실행 순서를 가져오게 된다. 이러한 최적화는 성능을 향상시키지만, 다중 스레드에서 공유되는 변수의 읽기/쓰기 순서가 바뀌어 레이스 컨디션과 같은 문제가 발생할 수 있다.
위 코드의 주요 문제를 다시 정리하면, Thread1
과 Thread2
에서 y = 1
과 x = 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
키워드는 메모리 배리어의 역할을 한다.
각 배리어는 멀티스레드 프로그래밍에서 코드의 메모리 일관성을 보장하는 데 필수적이다. 이를 통해 각 스레드 간에 데이터의 동기화를 보장하고 레이스 컨디션을 방지할 수 있다.