게임을 개발 할 당시에는 Debug 모드로 보통 개발을 진행한다.
그리고 출시 당시 Release 모드로 한다. 그리고 이 모드로 진행 시 멀티 스레드를 사용하면 오류가 발생하기 쉬워진다.
그 이유는 Release 모드로 할 경우 컴파일러가 자동으로 최적화를 진행시켜준다. 그러면서 변수 할당 위치까지 바뀌면서 데드락이 발생할 수 있다.
이러한 문제를 해결하기 위해서 동기화 이전에 몇 가지 최적화 예방법에 대해서 설명하겠다.
volatile 키워드 : C#에서 최적화를 금지시키는데 사용하는 키워드이다.
C++에서도 존재하는 키워드이지만, C++에서는 최적화 금지 + 최신 캐시 값을 갖고와서 캐시메모리에서만 계산하도록 하는 것이다. 즉, RAM 메모리에 저장을 하지않으므로 멀티스레딩 환경에서 C++은 volatile을 쓰면 안된다.
따라서 C#에서만 volatile로 최적화 금지를 통해 멀티스레딩 환경에서 예방할 수 있다.
여러 단계별로 Step을 나눠서 이전 Step에서 모든 결과 데이터를 처리가 완료되면 다음 Step으로 넘어간다. 특정 위치에서 모든 Task가 완료 될 때까지 대기하는 것이다.
C#에서는 동기화를 위한 메커니즘으로 여러가지가 존재한다.
동기화를 하는 이유는 각각의 스레드가 한 변수에 수정하는데 ++과 같은 연산자에서 실제로 하드웨어에서는 다음과 같은 순서로 작업하기 때문이다.
1. Read 'Ram Data' to 'Resgister Data'
2. Register Data ++ 연산 실행
3. Save Register Data to Ram Data
2개의 스레드에서 ++ 연산을 한다고 하자.
그런데 각각의 스레드에서 스케줄링에 의해서 1번이 실행된 후 각각 저장하면 ++가 2번 되어야 하는데 1번만 실행되는 문제가 발생한다.
즉, 원자성이 성립하지 않기 때문이다.
Interlocked.Increment(ref val);
Interlocked.Decrement(ref val);
Interlocked는 C#에서 제공하는 원자성이 성립하는 ++과 -- 함수이다.
C#에서는 Object를 통해서 원자성을 보장하게끔 도와주는 도구 중 하나이다.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp2
{
class Program
{
static Object key = new object();
static void newThreading()
{
Monitor.Enter(key);
Console.WriteLine("First Line");
Thread.Sleep(1000);
Console.WriteLine("1 sec later");
Monitor.Exit(key);
}
static void Main(string[] args)
{
Task task = new Task(newThreading);
Console.WriteLine("Main Function Start");
task.Start();
Thread.Sleep(100);
Monitor.Enter(key);
Console.WriteLine("Main Function is Down");
Monitor.Exit(key);
}
}
}
어떤 스레드가 key를 들고서 Mointer.Enter()을 하면 다른 스레드가 Monitor.Enter() 시 이전에 들어간 스레드가 Moniter.Exit()을 하기 전까지 대기를 하게 된다.
하지만 이 함수는 Exit()를 유의해야 한다. 만약 Exit을 하지않고 return을 할 경우 교착상태에 들어가는 문제가 발생한다.
물론 이를 잠시나마 해결하고자 아래와 같은 방식으로 사용이 가능하다.
2번째 인자로 준 milliseconds를 넘을 경우 그냥 실행한다.
Monitor.TryEnter(key, 10000);
class Program
{
static Object key = new object();
static void newThreading()
{
lock (key)
{
Console.WriteLine("First Line");
Thread.Sleep(1000);
Console.WriteLine("1 sec later");
}
}
static void Main(string[] args)
{
Task task = new Task(newThreading);
Console.WriteLine("Main Function Start");
task.Start();
Thread.Sleep(100);
lock (key)
{
Console.WriteLine("Main Function is Down");
}
}
}
따로 Exit할 필요없이 lock 키워드를 이용하면 조금 더 편하게 사용할 수 있다.
class Program
{
static Mutex _lock = new Mutex();
static void newThreading()
{
_lock.WaitOne();
Console.WriteLine("First Line");
Thread.Sleep(1000);
Console.WriteLine("1 sec later");
_lock.ReleaseMutex();
}
static void Main(string[] args)
{
Task task = new Task(newThreading);
Console.WriteLine("Main Function Start");
task.Start();
Thread.Sleep(100);
_lock.WaitOne();
Console.WriteLine("Main Function is Down");
_lock.ReleaseMutex();
}
}
Mutex 클래스를 이용해서 잠금과 품을 할 수 있다.
WaitOne()은 접근 요청이고, ReleaseMutex는 접근 해제를 하는 것이다.
Mutex클래스는 커널을 이용하는 방법이다.
SpinLock은 상호배제 잠금을 획득할 때까지 반복저으로 확인하면서 루프에서 대기하는 방법이다.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApp2
{
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int origin = Interlocked.Exchange(ref _locked, 1);
if(origin == 0)
break;
}
}
public void Release()
{ _locked = 0; }
}
class Program
{
static SpinLock spinLock = new SpinLock();
static void newThreading()
{
spinLock.Acquire();
Console.WriteLine("First Line");
Thread.Sleep(1000);
Console.WriteLine("1 sec later");
spinLock.Release();
}
static void Main(string[] args)
{
Task task = new Task(newThreading);
Console.WriteLine("Main Function Start");
task.Start();
Thread.Sleep(100);
spinLock.Acquire();
Console.WriteLine("Main Function is Down");
spinLock.Release();
}
}
}
Interlocked.Exchange은 위에서 봤듯이 기본적으로 원자성을 제공해준다.
그리고 리턴 값으로는 접근하기 이전의 변수의 값을 리턴해준다.
즉, 아무도 접근한 상대가 없을 경우 실행되고, 어떤 스레드가 Acquire로 실행할 경우 다른 스레드는 while문에서 계속 1을 1로 바꾸면서 대기하다, 다른 스레드가 Release() 함수로 0으로 바꾸면 그제서야 break해주며 권한을 얻는다.
참고로 C#에서는 이미 구현된 SpinLock 클래스가 존재한다.
C#의 SpinLock 클래스는 가끔 너무 오래 걸리면 양보를 해주는 특성이 있다.
뮤텍스가 보통 자료형에 대한 보호(동기화)를 위해서이다.
Event 또한 자료형 보호에 대해서 사용할 수 있지만, 보통 하나 이상의 스레드가 다른 스레드의 신호를 주는데 사용한다. (통신용으로)
Event로 두가지 정도 존재한다.
AutoResetEvent : 자동으로 상태를 false 상태로 만들어준다. (자동)
ManualResetEvent : 수동으로 상태를 false 상태로 만들어야 한다. (수동)
True 상태 : 들어올 수 있는 상태
False 상태 : 들어올 수 없는 상태
WaitOne() 함수는 입장을 시도하는 함수이다. 만약 True 상태면 접근할 수 있다.
Reset() 함수는 False 상태로 만들어 준다.
Set() 함수는 True 상태로 만들어 준다.
즉, ManualResetEvent는 WaitOne()과 Reset()을 모두 호출해야 하는 반면에, AutoResetEvnet는 WaitOne() 함수만 호출해야 한다.
문제는 ManualResetEvent의 WaitOne()과 Reset()은 원자성이 보장되지 않는다.
그래서 보통 초기값으로 false 상태로 만들어준 후에 1회용으로 Set()함수로 신호를 줘서 다른 스레드에게 신호를 준다.
class Program
{
static AutoResetEvent available = new AutoResetEvent(true);
static void newThreading()
{
available.WaitOne();
Console.WriteLine("First Line");
Thread.Sleep(1000);
Console.WriteLine("1 sec later");
available.Set();
}
static void Main(string[] args)
{
Task task = new Task(newThreading);
Console.WriteLine("Main Function Start");
task.Start();
Thread.Sleep(100);
available.WaitOne();
Console.WriteLine("Main Function is Down");
available.Set();
}
}
해당 클래스는 C#에서 제공하는 동기화 방법으로 Read와 Write를 각각 잠글 수 있게 설정하는 방법이다.
조금 더 최적화를 하기 위한 방법으로 결국 동기화를 하는 이유는 Write를 하는 것이기 때문에 Read는 허용하기 위해서이다.
EnterReadLock()은 Read를 진행할 것임을 나타낸다.
ExitReadLock()은 Read가 끝났음을 얘기한다.
EnterWriteLock()은 단독으로 Write를 진행할 것이므로 Read하는 것 혹은 Write하는게 없으면 잠그고 들어가는 것을 얘기한다.
ExitWriteLock()은 단독 Write Lock을 풀고 나가는 것을 얘기한다.
ThreadLocal는 각 스레드 별로 개인 힙 영역을 만드는 것이다.
멀티 쓰레드의 static은 모두 공유된다. 그런데 개인 쓰레드 별로 힙 공간을 공유하고, 같은 변수로 접근하고 싶을 때 사용한다.
인자로 함수를 줄 수 있는데 이는 처음 만들 경우에 실행 할 함수를 줄 수 있다.
Dispose() 함수로 해제할 수 있다.
해당 ThreadLocal의 사용법 예시로는 다음과 같다.
메인 큐에서 정해진 mass만큼 que를 해와서 ThreadLocal에 저장해놓고 평소에는 ThreadLocal queue에서 que를 해서 처리하고 모두 처리하면 메인 큐에서 mass만큼 que해오는 식으로 응용할 수 있다.