C# 프로그래밍을 하면서 컴파일러 최적화와 하드웨어 최적화가 일어난다는 것을 처음 알았습니다.
컴파일러는 컴파일 과정을 통해 기계어로 번역 해주는 작업 외에도 코드 최적화를 통해 성능을 개선합니다.
그리고 하드웨어 최적화의 예시로는 캐시 메모리를 활용하거나, 병렬 처리를 향상시켜 멀티스레딩 작업을 빠르게 처리할 수 있도록 하는 것이 있습니다.
여기서 메모리 배리어는 하드웨어 최적화와 밀접한 관계를 나타냅니다.
메모리에 대한 이해와 하드웨어에 대한 이해를 동시에 잡을 수 있는 키워드여서 이렇게 정리를 하게 되었습니다.
메모리 배리어는 중앙 처리 장치나 컴파일러에게 특정 연산의 순서를 강제하도록 하는 기능이다.
중앙 처리 장치에서는 비순차적 명령어 처리 기법을 통해 연산 결과에 영향이 가지 않도록 연산의 순서를 뒤바꿀 수 있으며, 컴파일러에서도 역시 비슷한 최적화를 수행한다. 하지만, 이러한 기능은 여러 스레드가 동시에 돌아가는 경우, 코드의 실행 순서가 바뀌어 실행되는 동안 다른 스레드에서 그 부분에 대한 메모리를 접근하여 잘못된 결과를 내놓을 수 있다. 따라서 특정 부분에 대하여 실행 순서를 강제하는 메모리 배리어를 놓아야 한다.
<위키백과 - 메모리 배리어>
간단하게 정리하면, 이 메모리 배리어(장벽)을 만나면 그 전까지 CPU의 레지스터나 캐시값의 변경을 메인 메모리로 반영하는 것이라고 할 수 있습니다.
메인 메모리로 Flush 한다고도 합니다.
이렇게 함으로써 다른 CPU에서 변경된 값을 읽을 수 있도록 하는 것입니다.
메모리 배리어를 올바르게 사용하면 다중 스레드에서 발생하는 데이터 무결성 및 가시성 문제를 방지할 수 있으며, 이는 병렬 처리 성능을 향상시키는데 중요합니다.
특정 스레드에서 변경한 메모리의 값이 다른 스레드에서 제대로 읽어지는지에 대한 것입니다.
Visibility라는 단어의 뜻 그대로 눈에 보이고 읽히는 지에 대한 것을 의미합니다.
멀티 스레드 환경에서 스레드 간에 작업이 겹칠 수 있으므로 가시성을 관리하지 않으면 예상치 못한 결과와 버그가 발생할 수 있습니다.
Volatile 키워드가 변수 앞에 붙으면 해당 변수에 대한 최적화를 방지하도록 컴파일러에 지시합니다. 즉, 컴파일러는 volatile 변수를 상 메모리에서 읽거나 쓸 수 있도록 최적화하지 않아야 합니다.
정리하면 volatile은 최적화를 제한하고, 메모리 배리어는 접근 순서와 동기화를 제어해 데이터의 일관성을 조절하는게 목적입니다.
첫 번째로 메모리 배리어를 사용하지 않은 예제 코드입니다.
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} 번 후 반복문 탈출");
}
}
스레드 2개를 생성해서 공유 자원에 x, y, r1, r2에 접근하고 있습니다.
r1과 r2에 1이 할당하고 무한 반복문에 빠지도록 의도한 코드입니다.
하지만 출력을 하게 되면 아래와 같습니다.
의도와는 전혀 다르게 반복문을 탈출하게 됩니다.
위와 같은 이유가 하드웨어 최적화가 거쳐지기 때문입니다.
위와 같은 상황에서 의도된 순서대로 실행하기 위해서 메모리 배리어를 사용하면 됩니다.
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
// 메모리 배리어 관련 코드
Thread.MemoryBarrier();
r1 = x; // Load x
}
static void Thread_2()
{
x = 1; // Store x
// 메모리 배리어 관련 코드
Thread.MemoryBarrier();
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} 번 후 반복문 탈출");
}
}
그러면 위와 같이 무한 반복문에 빠지는 것을 볼 수 있습니다.
컴파일러와 하드웨어 최적화에 대한 키워드를 접했습니다.
어떻게 컴파일러가 최적화를 하는지는 아직 모르지만,
멀티 스레드 프로그래밍이 의도와 다르게 결과가 발생하는 이유 중 하나를 알게 되었습니다.
하지만 아직은 메모리 배리어를 실제로 어디에 사용해야 하는지 감이 잡히지는 않습니다.
지금 공부하고 있는 게임 서버에서도 사용하는지 아직 모르겠습니다.
volatile도 C#에서는 사용하지 않는다는 의견도 있다.
상상 이상으로 volatile이라는 것이 훨씬 복잡하다고 합니다.
단편적인 부분만 바라보고 사용하기에는 쉽지 않은 내용 같습니다.