캐시
지난 포스팅에서 얘기했던 식당이야기를 기억할 것이다. 이제 레스토랑이 성공해서 손님들이 많아졌다고 해보자.
그러자 식당 입장에서는 일 처리를 어떻게 해야할지 고민이 들 것이다.
만약 어떤 직원1이 손님으로부터 주문을 받을 때, 주문을 받자마자 바로 주문현황 테이블에 올리는 것은 효율적일까? 주문현황테이블은 동선상 멀리떨어져 있다고 해보자. 그렇다면 당연히 아니다. 직원1이 가지고 있는 수첩에 주문을 모았다가 한번에 주문현황테이블에 올리는 것이 효율적이다.
또한 장점으로 만약 2번 테이블에서 콜라를 주문해서 직원1이 수첩에 적어놓은 상태였는데, 2번 테이블에서 다시 직원을 호출해 콜라 대신 사이다를 달라고 했다면, 직원 1은 자신의 수첩에서 콜라를 지우고 사이다로 채워 넣기만 하면 된다.
그런데 여기서 문제가 있다. 만약 직원이 여러명이라고 생각해보자. 위와 같은 상황에서 콜라 주문을 취소하고 사이다를 주문하려고 하는데 두 번째 부른 직원이 직원1이 아닌 직원2였다고 하면 직원2의 수첩에는 콜라가 없을 것이다. 또한 주문현황테이블에도 콜라는 찾을 수 없다. 그러니 직원 2는 혼동이 찾아 올 것이다.
이 일은 우리 컴퓨터에서도 똑같이 일어나고 있다.
컴퓨터에는 여러 캐시 장치가 있는데 CPU에서 연산한 값을 바로 메모리에 올리는 것이 아닌 캐시 장치에 잠시 저장해 놨다가 한번에 메모리에 올리는 것이다.
그렇다면 무엇을 캐싱해야하는지가 중요하다.
1. Temporal locality
2. Spacial locality
멀티쓰레드 환경에서는 직원 1과 직원 2가 혼동되는 상황이 엄청 자주 나오게 된다.
캐싱이 잘 일어나고 있는지 확인해보자.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static void ThreadMain()
{
}
static void Main(string[] args)
{
int[,] arr = new int[10000, 10000];
{
long now = DateTime.Now.Ticks;
for (int i = 0; i < arr.GetLength(0); i++)
for (int j = 0; j < arr.GetLength(1); j++)
arr[i, j] = 1;
long end = DateTime.Now.Ticks;
Console.WriteLine(end - now);
}
{
long now = DateTime.Now.Ticks;
for (int i = 0; i < arr.GetLength(0); i++)
for (int j = 0; j < arr.GetLength(1); j++)
arr[j, i] = 1;
long end = DateTime.Now.Ticks;
Console.WriteLine(end - now);
}
}
}
}
수학적으로 보면 두 {} 코드 블럭의 시간은 동일해야만 한다. 하지만 결과는 다르다. 거의 2배 이상 차이가 난다.
그렇다면 왜 시간차이가 날까??
arr 라고하는 변수의 값에 접근할 때, 주변 주소를 사용하겠지 라는 생각으로 미리 그 주소를 캐싱해오기 때문이다. 하지만 밑에 버전은 i를 기준으로 점프하기 때문에 주변 주소를 캐싱해도 10000을 건너뛰어 계산하기 때문에 시간이 오래걸린다. spacial locality 상황에 해당된다. 멀티쓰레드 환경이 아니어도 캐싱은 잘 된다는 걸 확인해 볼 수 있었다.
메모리 배리어
저번 포스팅에서 컴파일러 최적화에 대해 조금 알아봤다. 사실 컴파일러 뿐만 아니라 하드웨어에서도 코드를 최적화하고 있다는 사실이다.
예제를 통해 알아보자.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int x = 0;
static int y = 0;
static int r1 = 0;
static int r2 = 0;
static void Thread1()
{
y = 1; // store y
r1 = x; // load x
}
static void Thread2()
{
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(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
if (r1 == 0 && r2 == 0)
break;
}
Console.WriteLine(count);
}
}
}
프로그램을 실행해보면 운이 좋다면 1번 길게는 100번정도 루프를 돌아 프로그램이 종료가 된다. 우리가 코드를 작성한대로 라면 프로그램이 종료되지 않아야 한다.
경우의 수를 따져봐도 x와 y가 0이 될 경우는 없다.
사실은 하드웨어도 최적화를 진행하는데 y = 1; 후에 r1 = x;를 하라는건 우리의 입장이고 하드웨어 입장에서는 둘의 순서가 크게 중요하지 않고 속도면에서 따져서 실행되는 것이 갑자기 r1 = x가 먼저 실행 될 수 있기 때문이다. 이게 어떻게 되나 싶긴 하다.
싱글쓰레드가 아닌 멀티쓰레드환경에서 만약 다음과 같은 문제가 발생한다면 엄청난 실수가 되어 프로그램을 망칠수도 있을 것이다.
이것을 막기 위해 MemoryBarrier를 사용한다. 코드상 경계를 그려 절대 침범할 수 없게(뒤집을 수 없게)하는 것이다.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int x = 0;
static int y = 0;
static int r1 = 0;
static int r2 = 0;
static void Thread1()
{
y = 1; // store y
//--------------
Thread.MemoryBarrier();
r1 = x; // load x
}
static void Thread2()
{
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(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
if (r1 == 0 && r2 == 0)
break;
}
Console.WriteLine(count);
}
}
}
이제야 무한루프를 하는것을 확인할 수 있다.
메모리 배리어는 하나가 아닌 여러 종류가 있다.
- Full Memory Barrier (ASM MFENCE, C# Thread.MemoryBarrier) : Store/Load 둘다 막는다.
- Store Memory Barrier (ASM SFENOE) : Store만 막는다.
- Load Memory Barrier (ASM LFENCE) : Load만 막는다.
첫 번째만 알고 있으면 된다.
메모리 배리어는 역할이 두가지가 있는데 첫번째로는 코드 재배치 억제 즉, 위의 상황을 방지하게 되는 거고 두 번째 역할은 가시성이다.
가시성이라는 말은 동기화라고 생각하면 된다. 직원 1이 자기 수첩에 적었던 콜라를 메모리 배리어를 실행하게 되면 직원 1의 수첩 내용을 주문현황테이블에 올리게 된다. 그리고 직원 2가 콜라를 가져올 수 있게 된다.
다음의 코드가 이해된다면 완벽하게 이해한 것이다.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
int answer;
bool complete;
void A()
{
// 내가 저장하고자 하는 공용 변수를 각 쓰레드의 캐싱이 아닌
// 최신 값으로 동기화
answer = 123;
Thread.MemoryBarrier(); // answer를 store 했으니 메모리 배리어 실행
complete = true;
Thread.MemoryBarrier(); // complete를 store 했으니 메모리 배리어 실행
}
void B()
{
// 내가 지금 사용하는 공용 변수가 최신 값으로 동기화
Thread.MemoryBarrier(); // complete를 load 하기 전에 메모리 배리어 실행
if (complete)
{
Thread.MemoryBarrier();
Console.WriteLine(answer); // answer를 load 하기 전에 메모리 배리어 실행
}
}
static void Main(string[] args)
{
}
}
}
Interlocked
우리가 지금까지 알아본 컴파일러 최적화나 하드웨어 최적화를 굳이 신경쓰지 않고 우아한 방법으로 (lock 등) 처리할 수 있다. 그것에 대해 슬슬 알아보기 위해 공유 변수 접근에 대한 문제점에 대해 알아보자.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread1()
{
for(int i = 0; i < 100000; i++)
number++;
}
static void Thread2()
{
for (int i = 0; i < 100000; i++)
number--;
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
이것을 실행해보면 사실상 0이 나와야 정상이다 10만번 더했고 10만번 뺐는데도 불구하고 값이 다르게 나온다 왜 그럴까?
문제는 바로 경합 조건이다.
위의 레스토랑을 생각해보자. 콜라를 가져다 달라는 주문현황테이블을 보고 직원1, 직원2 모두가 콜라를 2번 테이블에 가져다 준것이다. 누군가가 그 일을 하겠다고 하면 나머지 쓰레드(직원)은 그 일을 해서는 안된다.
그렇다면 위의 코드에서는 뭐가 문제인가
numer++은 어셈블리어로 보면 number라는 주소를 가져와서 ecx 레지스터로 옮긴다음 ecx를 증가 한 후 다시 그 주소에 넣는다.
즉 코드를 나타내보면
int temp = number;
temp += 1;
number = temp;
로 나타낼 수 있다. 이렇게 3단계로 나눠지다 보니 이 3단계가 한번에 실행되어야 문제가 없을터인데 그것이 아니기 때문에 문제가 발생한 것이다.
이런 것을 atomic(원자성)이라고 한다. 어떤 동작이 한번에 일어나야한다를 의미한다.
예를 들어 집행검이라는 매우 비싼 아이템이 있는데 유저 1과 유저 2가 교환한다고 해보자.
그렇다면 코드상으로 보면 우리는 이렇게 짤 수 있다.
1. 유저 1 인벤에서 집행검을 삭제한다.
2. 유저 2에 인벤에 집행검이 도착한다.
그런데 어떠한 이유에서 1번은 실행됐지만 2번은 실행이 안됐다고 하면 애꿎은 집행검만 사라지게 된 것이다.
원자성을 보존하기 위해 Interlocked를 사용할 수 있다.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread1()
{
for (int i = 0; i < 10000; i++)
{
int afterValue = Interlocked.Increment(ref number);
Console.WriteLine(afterValue);
}
}
static void Thread2()
{
for (int i = 0; i < 10000; i++)
{
int afterValue = Interlocked.Decrement(ref number);
Console.WriteLine(afterValue);
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread1);
Task t2 = new Task(Thread2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
그렇다면 우리는 무조건 Interlocked를 사용해야 하는 걸까??? 성능이 매우 안좋기 때문에 꼭 그렇지는 않다.
또 Interlocked는 내부적으로 메모리 배리어를 사용하고 있어 가시성을 보장한다.
'Unity > 온라인 RPG' 카테고리의 다른 글
[게임 서버] 네트워크 프로그래밍 - 네트워크 기초 및 소켓 프로그래밍 (0) | 2024.01.17 |
---|---|
[게임 서버] 멀티쓰레드 프로그래밍 - Context Switching, Event등 Lock의 다양한 기법 (1) | 2024.01.11 |
[게임 서버] 멀티쓰레드 프로그래밍 - Lock에 대해서 (1) | 2024.01.10 |
[게임 서버] 멀티쓰레드 프로그래밍에 대해 알아보자. (1) | 2024.01.09 |
[게임 제작] 온라인 rpg 제작 준비 (2) | 2024.01.08 |