📄 원자성
스레드 환경에서 원자성이란, 간단히 말해 어떠한 계산을 수행할 때 한번에 끝난다(한 줄에 끝난다)라고 표현할 수 있습니다. 계산을 위한 연산이 여러단계에 걸치면, 여러 스레드에서 공유변수에대해 서로 참조를하다보면 어긋날 수 있는 문제(경합조건)가 발생할 수 있습니다.
📑 예제
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread1()
{
for (int i = 0; i < 10000000; ++i)
{
number++;
//int temp = number;
//temp += 1;
//number = temp;
}
}
static void Thread2()
{
for (int i = 0; i < 10000000; ++i)
{
number--;
//int temp = number;
//temp -= 1;
//number = temp;
}
}
static void Main(string[] args)
{
Task task1 = new Task(Thread1);
Task task2 = new Task(Thread2);
//각 스레드마다 number을 +10000000, -10000000씩 연산
//당연히 0이 나와야 정상이라고 판단
task1.Start();
task2.Start();
Task.WaitAll(task1, task2);
Console.WriteLine(number);
}
}
}
- +10000000, -10000000 연산을 수행하면 당연히 0이 나와야 정상이지만, 멀티스레드 환경에서는 이것이 보장되지 않습니다. 연산이 한번에 끝난다는 속성인 '원자성'이 없는 number++연산은 아래 주석처럼 세가지 단계로 계산이 이루어집니다.
- 이러한 상태에서 보장된 계산을 하기위해서 Interlocked라는 키워드를 이용합니다.
- n++, --n와 같은 계산을 Interlocked.Increment, Decrement를 이용하여 호출할 수 있습니다.
- 원자성을 보장해주며, 예측 가능한 계산 결과가 나올 수 있게 해줍니다.
📑 Interlocked
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread1()
{
//원자성 (더 이상 쪼개지지 않는 성질)
//number++은 봤을때 한번에 수행되어 보이지만
//실제로는 세 줄로 쪼개어져 계산이 된다.
for (int i = 0; i < 10000000; ++i)
{
//1. 원자성을 보장하는 계산을 하여 해결
//원자성을 보장하지만, 성능에서는 손해를 본다.
//순서 보장을 해줌(실행하거나, 기다리거나)
Interlocked.Increment(ref number);
}
}
static void Thread2()
{
for (int i = 0; i < 10000000; ++i)
{
//1. 원자성을 보장하는 계산을 하여 해결
//원자성을 보장하지만, 성능에서는 손해를 본다.
Interlocked.Decrement(ref number);
}
}
static void Main(string[] args)
{
Task task1 = new Task(Thread1);
Task task2 = new Task(Thread2);
task1.Start();
task2.Start();
Task.WaitAll(task1, task2);
Console.WriteLine(number);
}
}
}
- Interlockced를 사용하여 ++, --연산을 호출할 수 있습니다.
- 원자성을 보장해줘 예측한대로 동작하도록 구현할 수 있습니다.
- 하지만 기본적인 계산식 (n++, n--)보다 성능에서는 뒤쳐집니다.
📑 Monitor
namespace ServerCore
{
class Program
{
static int number = 0;
static object _obj = new object();
static void Thread1()
{
for (int i = 0; i < 1000000; ++i)
{
//상호 배제: 공유 불가능한 자원의 동시 사용을 피하기 위해 사용되는 알고리즘
//문을 잠그는 행위
Monitor.Enter(_obj);
number++;
//잠금을 풀어준다(꼭 Exit를 해줘야함!)
//Monitor.Exit을 하지 않으면, 대기상태가 풀리지 않음(DeadLock)
//짝을 꼭 맞추어 잘 사용해야한다
//짝을 꼭 맞춰야하기에 Try-Catch문을 이용하여 사용하는것이 좋다(예외처리)
Monitor.Exit(_obj);
}
}
static void Thread2()
{
for (int i = 0; i < 1000000; ++i)
{
Monitor.Enter(_obj);
number--;
Monitor.Exit(_obj);
}
}
static void Main(string[] args)
{
Task task1 = new Task(Thread1);
Task task2 = new Task(Thread2);
task1.Start();
task2.Start();
Task.WaitAll(task1, task2);
Console.WriteLine(number);
}
}
}
- 원자성을 보장하여 의도한대로 동작하게 하는 또 다른 방법은 Monitor의 사용입니다.
- Monitor.Enter(...), Monitor.Exit(...)을 사용하여 Enter <> Exit 사이의 코드가 실행될때까지 다른 스레드에서 같은 인자에 대한 함수를 대기하도록 합니다.
- Interlocked와 달리 개발자가 작성한 코드를 원하는만큼 범위를 지정할 수 있습니다.
- 하지만 Enter을 하고, Exit을 꼭 해줘야하며, 오류나 실수로 Exit를 하지 못한경우에는 동일한 인자를 사용하는 Monitor는 무한 대기에 빠지게되는 치명적인 문제(DeadLock, 데드락)가 있습니다.
📑 lock
namespace ServerCore
{
class Program
{
static int number = 0;
static object _obj = new object();
static void Thread1()
{
for (int i = 0; i < 1000000; ++i)
{
lock(_obj)
{
number++;
}
}
}
static void Thread2()
{
for (int i = 0; i < 1000000; ++i)
{
lock (_obj)
{
number--;
}
}
}
static void Main(string[] args)
{
Task task1 = new Task(Thread1);
Task task2 = new Task(Thread2);
task1.Start();
task2.Start();
Task.WaitAll(task1, task2);
Console.WriteLine(number);
}
}
}
- Monitor와 동일한 기능을 수행하지만, 더욱 간결하고 관리하기 쉬운 lock이 있습니다.
lock (_obj)
{
number--;
}
- Monitor.Enter(_obj)와 동일하게 매개변수를 사용하며, 같은 매개변수에 대해 lock이 걸리면, 다른 스레드는 대기상태에 들어갑니다.
'server > socket server' 카테고리의 다른 글
[C# 서버] Context Switching (0) | 2023.01.04 |
---|---|
[C# 서버] 스핀락 (0) | 2023.01.04 |
[C# 서버] 메모리 배리어 (0) | 2023.01.02 |
[C# 서버] volatile (0) | 2022.12.30 |
[C# 서버] 멀티스레드(Multi-thread) 기초 (0) | 2022.12.30 |