경합 조건(Race Condition)과 원자성(atomic)
이번 글은 멀티쓰레드 프로그래밍에서 일어나는 경합조건Race Condition에 관하여 알아보고, 이를 해결하기 위해 원자atomic 단위의 코딩이 필요하다는 것을 알아보겠습니다.
경합 조건이란, 서로 경쟁하는 조건이라고 보시면 될 것 같습니다. 그리고 이 조건에서 일어날 것으로 예상할 수 있는 문제는 예를 들어 카페에서, 한 테이블에서 콜라를 하나만 시켰는데 직원들이 서로 경쟁해서 각자 하나씩 가져다 주어서 여러 개의 콜라가 한 테이블에 배달된 상황이 있을 수 있습니다. 그런데 직원들이 서빙을 두세개 하면서 이런 실수를 하지는 않겠죠? 컴퓨터도 마찬가지입니다. 아래 코드는 컴퓨터가 1000번 일을 반복수행하는 경우입니다. 어렵지 않기 때문에 실수는 없습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System;
using System.Data;
using System.Threading;
namespace ServerCore
{
internal class Program
{
static volatile 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);
}
}
}
정상적으로 덧셈, 뺄셈이 같은 횟수만큼 수행되어서 의도한 대로 결과가 나왔습니다. 그렇다면 10만번은 어떨까요?
1
2
3
4
5
6
7
8
9
10
11
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
number++;
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
number--;
}
-510597 이라는 이상한 값이 나왔습니다. 왜 그럴까요? 정답은 코드의 원자성atomic이 충족이 되지 않았기 때문입니다. 우리는 분명 number++, 혹은 number–라는 단 한 줄만 수행하도록 했는데 사실은 이 명령을 수행하기 위해서 컴퓨터는 여러 줄의 코드를 순차적으로 진행합니다. ++연산의 의사코드는 다음과 같습니다.
1
2
3
int temp = number;
temp +=1;
number = temp;
쪼개어질 수 없는 가장 작은 원자적 단위의 코드인줄 알았는데 알고보니 아니었군요! 이 코드의 순서를 저번처럼 하드웨어가 마음대로 최적화를 해서 수행했던게 틀림없습니다. 그래서 우리는 이것을 원래의 의도대로 원자적으로 계산했으면 좋겠습니다. Interlocked.Increment(), Interlocked.Decrement() 메소드를 사용해보겠습니다.인터락을 걸어서 증산/감산 연산을 잠궈버림으로써 그 안의 로직에 순서적인 최적화를 하지 않도록 하는 기능입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using System;
using System.Data;
using System.Threading;
namespace ServerCore
{
internal class Program
{
static volatile int number = 0;
static void Thread_1()
{
// atomic = 원자성
// 집행검 User2 인벤에 넣어라
// 집행검 User1 인벤에서 없애라
for (int i = 0; i < 1000000; i++)
{
// All or Nothing. 이것 실행되는 동안에는 다른 것이 실행 안됨. 다른 것에는 접근 안함.
Interlocked.Increment(ref number); // 1
}
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Decrement(ref number); // 0
}
}
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);
}
}
}
정상적으로 실행이 되었습니다. 이러한 원자성을 확인하는 것은 멀티쓰레드 프로그래밍에서 의도치 않은 버그를 해결하기 위해서 매우 중요한 일 중에 하나일 것입니다. 그리고 특히 게임 프로그래밍에서도 예를 들면 매우 비싼 아이템을 유저들끼리 교환할 때 주고받는 이벤트가 원자적으로 절대 쪼개질 수 없도록 묶여있지 않다면, 교환 도중 서버가 다운Down되거나 하면 아이템복사가 일어날 수도 있는 상황이 되겠죠.
이 글은 인프런의 '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 강의를 참고하여 정리 목적으로 작성하였습니다. 감사합니다.





