[Part 4 게임 서버] 멀티쓰레드 프로그래밍 3 (Interlocked)

전에 배운 메모리 베리어를 사용하지 않더라도 다양한 방법이 있다. 

<Interlocked>

1. 공유 변수 접근 문제점

 1) 하나의 공유 변수 number를 두고 Thread_1은 for 루프 1000번동안 number++하는 동작을 하고 Thread_2는 for 루프 1000번 동안 number--을 하는동작을 한다.

 2) 메인 함수에 Task 2개를 만들어서 각각 Thread_1과 Thread_2를 동작하는 스레드를 실행하도록 한다. 

 3) 해당 스레드가 끝날 때까지 Task.WaitAll로 기다린 후 콘솔에 number을 출력하면, 직관적으로 생각한 것과 같이 0이 나온다. 

* 하지만, for문의 횟수를 늘리면 이상한 값이 나온다. 

namespace SeverCore
{
    class Program
    {
        // 공유 변수 - 전역 변수
        static 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);
          
        }
    }
}

for문의 횟수가 1000일때
for문의 횟수가 10000

 4) volatile을 통해 가시성 문제인지 확인해보면 가시성 문제가 아니라는 것을 확인할 수 있다. 

 

2. 경합 조건 (Race Condition)

: race condition이란 두 개 이상의 프로세스공통 자원을 병행적으로 읽거나 쓰는 동작을 할 때, 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 같지 않고 달라지는 상황을 말함

 

 1) 위의 코드에서 number++의 동작을 확인하기 위해 메인 함수에 number++; 명령어를 적고 breakingpoint를 아래 아무곳이나 하여 F5 로 디버깅을 한다. 디버그 -> 창 -> 디스어셈블리 를 통해 기계어인 어셈블리어를 확인해보면

number++의 동작이 세가지로 분할된다. 

   a) number가 저장된 메모리에서 데이터를 가져와 레지스터에 저장한다. 

   b) 레지스터의 저장된 데이터를 +1한다

   c) 레지스터의 데이터를 다시 number가 저장도니 메모리에 저장한다. 

 

코드로 확인해보면 다음과 같이 동작한다고 할 수 있다. 

	static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                int temp = number;
                temp += 1;
                number = temp;
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                int temp = number;
                temp -= 1;
                number = temp;

            }

        }

 2) 1 번과 같은 결과가 나오는 이유가 2번의 Race Condition 때문이다. 

  a) 처음, Thread_1과 Thread_2가 동시에 실행이 될 때 number를 temp로 불러오면 각각 0을 불러온다. 

  b) 그 후에 temp += 1 과 temp -= 1연산을 각각 실행하면, 1, -1이 된다 

  c) number에 각각의 값을 저장할 때, 어떤 스레드가 먼저 실행되느냐에 따라서 number에 저장된 값이 달라진다. 

=> 이렇게 하나의 명령어가 쪼개져서 실행되기 때문에 Race Condition 발생 

=> 원자성 (Atomic) : 쪼개지지않고 한번에 원자적으로 실행되어야한다. 원자성이 보장되면 위와 같은 문제가 발생하지 않는다. 

 

3. Interlocked

: 위와 같은 Race Condition 문제를 해결하기 위해 Interlocked 사용 가능

-> 특정 변수를 Atomic하게 사용하겠다. CPU 내부에 Atomic하게 실행하도록 하는 명령어가 존재한다. 그것을 이용하여 실행하겠다

but, 성능적인 측면에서 굉장히 좋지않다.

아래의 코드는 C#에서 제공하는 Interlocked.Increment와 Interlocked.Decrement를 사용

이렇게 하면, 아무리 for 문의 횟수를 늘려도 0이 나온다. 

	static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                Interlocked.Increment(ref number);
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                Interlocked.Decrement(ref number);

            }

        }

a) 왜 Interlocked를 이용하면 Race Condition이 해결되는가? 

Interlocked는 All or Nothing으로 하거나 하지않거나 둘 중 하나로 동작한다. 즉, 한번 동작을 하면 해당 동작이 완료될 때까지 기다린다. 

스레드끼리 경쟁을 하여 어떤 한 스레드가 실행되면 해당 동작에 대한 결과를 보장하고 다음 스레드는 최신 결과를 업데이트받아서 동작을 할 수 있다. 

무조건, 원자적으로 실행되어 결과가 보장된다. 

rf> but, 이런식으로 원자적으로 실행하면 캐시의 개념이 필요없어진다. 

 

 b) Interlocked 함수에서 인자가 무조건 ref 형태로 가져옴 -> 즉, 해당 변수의 주소값을 가져온다. 

 그냥 int number 값을 가져오면 다른 스레드에 의해 수정된 값을 가져올 수 있으므로 무조건 Interlocked에서는 ref 값을 가져온다는 것은 메인 메모리에 저장되어있는 해당 변수를 가져온다는 뜻이다. 

 * number가 ++ 연산을 하기 전에 값과 ++ 연산을 한 이후의 값을 추출하기 위해 아래와 같이 한 다음 콘솔로 number의 값을 추출하면, 

내가 의도한 값이 출력되는 것이 아닌 다른 스레드에 의해 수정된 캐시 number의 값이 추출된다. 

실제로 number++ 연산에 의해 나온 결과 값을 출력하기 위해서는 Interlocked.Increment 함수의 reuturn값을 변수로 받아서 출력해야한다. 

		for (int i = 0; i < 1000000; i++)
            {
                int before = number;
                Interlocked.Increment(ref number);
                int after = number;
            }