전에 배운 메모리 베리어를 사용하지 않더라도 다양한 방법이 있다.
<Interlocked>
1. 공유 변수 접근 문제점
1) 하나의 공유 변수 number를 두고 Thread_1은 for 루프 1000번동안 number++하는 동작을 하고 Thread_2는 for 루프 1000번 동안 number--을 하는동작을 한다.
2) 메인 함수에 Task 2개를 만들어서 각각 Thread_1과 Thread_2를 동작하는 스레드를 실행하도록 한다.
3) 해당 스레드가 끝날 때까지 Task.WaitAll로 기다린 후 콘솔에 number을 출력하면, 직관적으로 생각한 것과 같이 0이 나온다.
* 하지만, for문의 횟수를 늘리면 이상한 값이 나온다.
namespace SeverCore
{
class Program
{
// 공유 변수 - 전역 변수
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 1000; i++)
number++;
}
static void Thread_2()
{
for (int i = 0; i < 1000; i++)
number--;
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
4) volatile을 통해 가시성 문제인지 확인해보면 가시성 문제가 아니라는 것을 확인할 수 있다.
2. 경합 조건 (Race Condition)
: race condition이란 두 개 이상의 프로세스가 공통 자원을 병행적으로 읽거나 쓰는 동작을 할 때, 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 같지 않고 달라지는 상황을 말함
1) 위의 코드에서 number++의 동작을 확인하기 위해 메인 함수에 number++; 명령어를 적고 breakingpoint를 아래 아무곳이나 하여 F5 로 디버깅을 한다. 디버그 -> 창 -> 디스어셈블리 를 통해 기계어인 어셈블리어를 확인해보면
number++의 동작이 세가지로 분할된다.
a) number가 저장된 메모리에서 데이터를 가져와 레지스터에 저장한다.
b) 레지스터의 저장된 데이터를 +1한다
c) 레지스터의 데이터를 다시 number가 저장도니 메모리에 저장한다.
코드로 확인해보면 다음과 같이 동작한다고 할 수 있다.
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
int temp = number;
temp += 1;
number = temp;
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
int temp = number;
temp -= 1;
number = temp;
}
}
2) 1 번과 같은 결과가 나오는 이유가 2번의 Race Condition 때문이다.
a) 처음, Thread_1과 Thread_2가 동시에 실행이 될 때 number를 temp로 불러오면 각각 0을 불러온다.
b) 그 후에 temp += 1 과 temp -= 1연산을 각각 실행하면, 1, -1이 된다
c) number에 각각의 값을 저장할 때, 어떤 스레드가 먼저 실행되느냐에 따라서 number에 저장된 값이 달라진다.
=> 이렇게 하나의 명령어가 쪼개져서 실행되기 때문에 Race Condition 발생
=> 원자성 (Atomic) : 쪼개지지않고 한번에 원자적으로 실행되어야한다. 원자성이 보장되면 위와 같은 문제가 발생하지 않는다.
3. Interlocked
: 위와 같은 Race Condition 문제를 해결하기 위해 Interlocked 사용 가능
-> 특정 변수를 Atomic하게 사용하겠다. CPU 내부에 Atomic하게 실행하도록 하는 명령어가 존재한다. 그것을 이용하여 실행하겠다
but, 성능적인 측면에서 굉장히 좋지않다.
아래의 코드는 C#에서 제공하는 Interlocked.Increment와 Interlocked.Decrement를 사용
이렇게 하면, 아무리 for 문의 횟수를 늘려도 0이 나온다.
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
Interlocked.Increment(ref number);
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
Interlocked.Decrement(ref number);
}
}
a) 왜 Interlocked를 이용하면 Race Condition이 해결되는가?
Interlocked는 All or Nothing으로 하거나 하지않거나 둘 중 하나로 동작한다. 즉, 한번 동작을 하면 해당 동작이 완료될 때까지 기다린다.
스레드끼리 경쟁을 하여 어떤 한 스레드가 실행되면 해당 동작에 대한 결과를 보장하고 다음 스레드는 최신 결과를 업데이트받아서 동작을 할 수 있다.
무조건, 원자적으로 실행되어 결과가 보장된다.
rf> but, 이런식으로 원자적으로 실행하면 캐시의 개념이 필요없어진다.
b) Interlocked 함수에서 인자가 무조건 ref 형태로 가져옴 -> 즉, 해당 변수의 주소값을 가져온다.
그냥 int number 값을 가져오면 다른 스레드에 의해 수정된 값을 가져올 수 있으므로 무조건 Interlocked에서는 ref 값을 가져온다는 것은 메인 메모리에 저장되어있는 해당 변수를 가져온다는 뜻이다.
* number가 ++ 연산을 하기 전에 값과 ++ 연산을 한 이후의 값을 추출하기 위해 아래와 같이 한 다음 콘솔로 number의 값을 추출하면,
내가 의도한 값이 출력되는 것이 아닌 다른 스레드에 의해 수정된 캐시 number의 값이 추출된다.
실제로 number++ 연산에 의해 나온 결과 값을 출력하기 위해서는 Interlocked.Increment 함수의 reuturn값을 변수로 받아서 출력해야한다.
for (int i = 0; i < 1000000; i++)
{
int before = number;
Interlocked.Increment(ref number);
int after = number;
}
'Development > 게임 서버' 카테고리의 다른 글
[Part 4 게임 서버] 멀티쓰레드 프로그래밍 5(Lock 구현 이론 ~ AutoResetEvent) (0) | 2022.01.10 |
---|---|
[Part 4 게임 서버] 멀티쓰레드 프로그래밍 4(Lock 기초, DeadLock) (0) | 2022.01.07 |
[Part4 게임 서버] 멀티쓰레드 프로그래밍 2(컴파일러 최적화, 캐시 이론, 메모리 베리어) (0) | 2022.01.07 |
[Part 4 - 게임 서버] 멀티쓰레드 프로그래밍1(개론과 스레드 생성) (0) | 2022.01.06 |
[Part 4 게임 서버 - 개론] (0) | 2022.01.04 |