포스트

경합 조건(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 &#x3D; 0; i < 1000; i++)
                number++;
        }

        static void Thread_2()
        {
            for (int i &#x3D; 0; i < 1000; i++)
                number--;
        }

        static void Main(string[] args)
        {
            Task t1 &#x3D; new Task(Thread_1);
            Task t2 &#x3D; 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 &#x3D; 0; i < 1000000; i++)
                number++;
        }

        static void Thread_2()
        {
            for (int i &#x3D; 0; i < 1000000; i++)
                number--;
        }

-510597 이라는 이상한 값이 나왔습니다. 왜 그럴까요? 정답은 코드의 원자성atomic이 충족이 되지 않았기 때문입니다. 우리는 분명 number++, 혹은 number–라는 단 한 줄만 수행하도록 했는데 사실은 이 명령을 수행하기 위해서 컴퓨터는 여러 줄의 코드를 순차적으로 진행합니다. ++연산의 의사코드는 다음과 같습니다.

1
2
3
int temp &#x3D; number; 
temp +&#x3D;1;
number &#x3D; 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 &#x3D; 0;

        static void Thread_1()
        {
            // atomic &#x3D; 원자성

            // 집행검 User2 인벤에 넣어라
            // 집행검 User1 인벤에서 없애라

            for (int i &#x3D; 0; i < 1000000; i++)
            {
                // All or Nothing. 이것 실행되는 동안에는 다른 것이 실행 안됨. 다른 것에는 접근 안함.
                Interlocked.Increment(ref number); // 1
            }



        }

        static void Thread_2()
        {
            for (int i &#x3D; 0; i < 1000000; i++)
            {
                Interlocked.Decrement(ref number); // 0
            }
        }

        static void Main(string[] args)
        {
            Task t1 &#x3D; new Task(Thread_1);
            Task t2 &#x3D; new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(number);
        }
    }
}

정상적으로 실행이 되었습니다. 이러한 원자성을 확인하는 것은 멀티쓰레드 프로그래밍에서 의도치 않은 버그를 해결하기 위해서 매우 중요한 일 중에 하나일 것입니다. 그리고 특히 게임 프로그래밍에서도 예를 들면 매우 비싼 아이템을 유저들끼리 교환할 때 주고받는 이벤트가 원자적으로 절대 쪼개질 수 없도록 묶여있지 않다면, 교환 도중 서버가 다운Down되거나 하면 아이템복사가 일어날 수도 있는 상황이 되겠죠.


이 글은 인프런의 '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 강의를 참고하여 정리 목적으로 작성하였습니다. 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.