가시성(visibility)의 개념은 멀티스레드 프로그래밍에서 스레드 간의 메모리 상태가 어떻게 공유되고 관찰되는지를 설명하는 중요한 개념이다. 가시성 문제는 특정 스레드에서 업데이트된 메모리 상태가 다른 스레드에서 즉각적으로 보이지 않을 때 발생할 수 있다.
가시성 문제의 원인
- 캐시: 각 CPU 코어는 자신의 캐시를 사용하여 메모리 접근을 최적화한다. 이로 인해 한 스레드가 변경한 변수가 다른 스레드에서 참조할 때 캐시된 이전 값을 읽을 수 있다.
- 명령어 재정렬: 컴파일러나 CPU가 성능 최적화를 위해 명령어의 순서를 재배치할 수 있다. 이는 스레드 간 메모리 접근이 프로그램 코드에서 작성된 순서대로 이루어지지 않게 만들 수 있다.
- 메모리 모델: 각 프로그래밍 언어는 자체 메모리 모델을 가지고 있어, 메모리 접근의 일관성과 가시성 보장 수준이 다를 수 있다.
예시
두 개의 스레드가 있다고 가정해보자. 스레드 A가 어떤 변수 x
를 1
로 설정하고, 스레드 B가 x
의 값을 읽는다고 할 때, 스레드 B가 x
의 변경된 값을 즉시 볼 수 있는지 여부는 가시성의 문제이다. 만약 스레드 A의 변경이 메인 메모리에 즉시 반영되지 않거나 스레드 B가 메모리 캐시에서 이전 값을 읽는다면, 스레드 B는 변경된 값을 보지 못할 수 있다.
가시성 보장 방법
volatile
키워드: Java나 C#에서는volatile
키워드를 사용하여 변수의 가시성을 보장할 수 있다.volatile
로 선언된 변수는 읽기나 쓰기 연산 시 항상 메인 메모리에서 직접 접근하도록 보장한다.- 동기화:
synchronized
블록(Java) 또는lock
구문(C#)과 같은 동기화 기법을 사용하면 한 스레드가 변경한 메모리가 다른 스레드에 즉시 반영되도록 할 수 있다. 동기화는 메모리 배리어의 역할을 하여 명령어 재정렬을 방지하고 가시성을 보장한다. - 메모리 배리어: 메모리 배리어를 명시적으로 삽입하여 명령어 재정렬을 방지하고 메모리 쓰기와 읽기의 순서를 제어함으로써 가시성을 개선할 수 있다.
결론
가시성은 멀티스레드 프로그래밍에서 데이터의 일관성과 동기화와 깊이 연관된 개념이다. 이를 제대로 다루지 않으면 예상치 못한 동작이나 레이스 컨디션이 발생할 수 있다. 적절한 키워드, 동기화 방법, 또는 메모리 배리어를 사용하여 가시성 문제를 해결할 수 있다.
그럼 이 코드에서, Thread2가 y를 읽기 전에 Thread1에서 y가 1로 초기화되었다는 걸 어떻게 보장하나?
이 코드에서 Thread1
이 y = 1
로 초기화한 후 Thread2
가 r2 = y
를 읽을 때 y
의 업데이트된 값을 보장받기 위해서는 두 스레드 간의 메모리 가시성을 확보해야 한다. 단순히 Thread.MemoryBarrier()
만으로는 한 스레드의 메모리 쓰기가 다른 스레드의 메모리 읽기에서 즉시 관찰될 수 있다고 보장하지 않는다. 메모리 배리어는 코드 순서 재정렬을 방지하지만, 스레드 간의 동기화 문제를 완벽하게 해결하지 못한다.
메모리 배리어의 한계
Thread.MemoryBarrier()
는 해당 스레드에서의 메모리 명령어 재정렬을 방지하지만, 다른 스레드에서 그 변경이 즉시 보이는 것을 보장하지는 않는다. 스레드 간의 데이터 가시성 보장을 위해서는 추가적인 동기화 메커니즘이 필요하다.
해결 방법: lock
이나 동기화 기법 사용
이 문제를 해결하려면 lock
구문이나 다른 동기화 기법을 사용하여 두 스레드 간의 메모리 변경 사항이 즉시 반영되도록 보장해야 한다.
private static readonly object lockObj = new();
static void Thread1()
{
lock (lockObj)
{
y = 1;
r1 = x;
}
}
static void Thread2()
{
lock (lockObj)
{
x = 1;
r2 = y;
}
}
설명
lock
구문은 두 스레드가 동일한 자원에 동시에 접근하지 못하도록 하여 메모리 가시성과 일관성을 보장한다.lock
은 메모리 배리어의 기능을 내포하고 있어, 코드 블록 내의 메모리 쓰기가 다른 스레드에 즉시 반영되도록 보장한다.- 이 코드를 통해
Thread1
이y = 1
로 설정한 후Thread2
가r2 = y
를 읽을 때 올바른 값을 볼 수 있게 된다.
추가적인 설명: volatile
의 사용
volatile
키워드도 가시성 문제를 일부 해결할 수 있지만, volatile
은 단순히 읽기 및 쓰기 순서를 보장할 뿐이다. 다중 변수에 대한 복잡한 동기화를 다루기 위해서는 lock
이나 다른 고급 동기화 메커니즘이 필요하다.
마지막 예시:
namespace ServerCore;
internal class Program
{
private int _answer;
private bool _complete;
void A()
{
_answer = 123;
Thread.MemoryBarrier();
_complete = true;
Thread.MemoryBarrier();
}
void B()
{
Thread.MemoryBarrier();
if (_complete)
{
Thread.MemoryBarrier();
Console.WriteLine(_answer);
}
}
static void Main(string[] args)
{
Program p = new Program();
Task t1 = new Task(p.A);
Task t2 = new Task(p.B);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
}
}
이 코드에서 각 Thread.MemoryBarrier()
의 역할을 설명하겠다. MemoryBarrier()
는 CPU나 컴파일러의 명령어 재정렬을 방지하여 코드 실행 순서를 보장하는 역할을 한다. 이로 인해 코드의 메모리 읽기 및 쓰기 순서가 정확하게 유지되며, 멀티스레드 환경에서 데이터의 가시성을 높일 수 있다.
A()
메서드 내의 MemoryBarrier()
- 첫 번째
MemoryBarrier()
(_answer = 123;
이후):_answer = 123
을 메모리에 쓰는 작업이 완료된 후에야 그 뒤의_complete = true;
가 실행되도록 보장한다.- 이 배리어는
_answer
를 설정한 이후 그 값을 메인 메모리에 즉시 쓰도록 강제하여, 다른 스레드에서_answer
를 읽을 때 최신 값을 보도록 한다.
- 두 번째
MemoryBarrier()
(_complete = true;
이후):_complete = true;
를 메모리에 쓰는 작업이 메모리 배리어 이전에 반드시 완료되도록 보장한다.- 이 배리어는
_complete
의 변경이 다른 스레드에서 관찰될 수 있도록 보장하여, 이후의 명령어가 이 변경을 반영하게 한다.
B()
메서드 내의 MemoryBarrier()
- 첫 번째
MemoryBarrier()
(if (_complete)
이전):- 이 배리어는
if (_complete)
이전에 어떤 읽기/쓰기 작업이 있었다면, 그 작업이 완료된 후에_complete
의 상태를 확인하도록 보장한다. _complete
를 읽기 전에 이전 명령어의 완료를 보장해, 정확한 메모리 읽기를 수행하게 한다.
- 이 배리어는
- 두 번째
MemoryBarrier()
(Console.WriteLine(_answer);
이전):_complete
가true
일 때만 실행되는 코드 블록의 시작 부분에 위치해 있다.- 이 배리어는
_complete
가true
로 판별된 후,_answer
의 읽기 작업이 그 이전의 읽기/쓰기 명령어 이후에 실행되도록 보장한다. 이는_answer
의 최신 값이 읽히게끔 하여 이전에 쓰인 값을 확실히 읽도록 한다.
전체적인 역할
A()
메서드의 배리어들은_answer
를 먼저 설정하고 그 값이 메인 메모리에 반영된 후_complete
가true
로 설정되도록 하여 다른 스레드에서 이 순서대로 보게 만든다.B()
메서드의 배리어들은_complete
가true
로 확인된 후에_answer
를 읽을 때 최신 값을 보장하도록 만든다.- 각각의 배리어는 코드 순서가 재정렬되지 않도록 하여, 멀티스레드 프로그램에서 변수의 쓰기 및 읽기 순서가 정확하게 보이게 하고, 코드의 일관성을 유지한다.
이 메모리 배리어들은 스레드 간 데이터의 일관성을 높이지만, 코드의 복잡성을 줄이고 더 높은 수준의 동기화를 보장하려면 lock
이나 다른 동기화 메커니즘을 사용하는 것이 일반적이다.
'공부 > 게임서버' 카테고리의 다른 글
재귀적 lock을 허용하지 않는 ReaderWriterLock 구현 예시 (0) | 2024.11.11 |
---|---|
임계영역 (Critical Section) (0) | 2024.11.10 |
SpinLock / Interlocked.Exchange (0) | 2024.11.10 |
컨텍스트 스위칭 (0) | 2024.11.10 |
교착 상태 (Deadlock) (1) | 2024.11.09 |