[Part 4 게임 서버] 멀티쓰레드 프로그래밍 4(Lock 기초, DeadLock)

<Lock 기초>

Interlocked의 단점: 정수를 다루는 작업만 할 수 있음. 다양하고 긴 코드를 작성하고 그때마다 모든 코드에 Interlocked를 해줄 수 없다.

 

1. 임계 구역(Critical Section)

: 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하는 코드의 일부를 말한다. 

공유 자원에 접근하여 수정하고 write을 하는 것에서 문제가 발생

 

2. Monitor

Interlocked보다는 구역을 정해놓고 해당 구역에 들어가면 다른 스레드는 접근 못하도록 하는 방식이 더 좋음 -> Monitor

아래의 코드를 보면 전역 변수로 object를 만들고 임계 구역 전에 Monitor.Enter를 통해 어떤 스레드가 공유 자원을 사용하고 있음을 알린다. 임계 구역에 빠져나오면 Monitor.Exit을 통해 공유 자원을 사용하지 않음을 알려준다. 

따라서, Thread_1에서 Monitor.Enter에 들어가면 다른 스레드는 접근하지 못함 => 이런 상황을 상호배제 Mutual Exclusive

Monitior 안에 블록에서는 싱글 스레드로 실행된다고 생각해도 됨.

아래의 코드를 사용하면 Race Condition이 발생하지 않고 결과 값이 0이 나온다. 

	class Program
    {
        // 공유 변수 - 전역 변수
        static int number = 0;
        static object _obj = new object();

        static void Thread_1()
        {
            for (int i = 0; i < 1000000; i++)
            {
                Monitor.Enter(_obj); // 문을 잠그고 있는 행위 
                number++;
                Monitor.Exit(_obj); // 잠금을 풀어주는 것 
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 1000000; i++)
            {
                Monitor.Enter(_obj);
                number--;
                Monitor.Exit(_obj);
            }
        }
        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);
          
        }
    }

 3. DeadLock 상황

아래와 같이 Monitor.Enter로 임계 구역에 들어간 후 Monitor.Exit으로 잠금을 풀지않고 return을 하면 다른 스레드는 Monitor.Enter에서 무한대로 기다리게 된다 -> DeadLock 상황이라고 함

아래의 코드를 실행하면 계속 실행하여 number가 출력되지않는다.

	static void Thread_1()
        {
            for (int i = 0; i < 1000000; i++)
            {
                Monitor.Enter(_obj); // 문을 잠그고 있는 행위 

                {
                    number++;
                    return; 
                }
                Monitor.Exit(_obj); // 잠금을 풀어주는 것 
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 1000000; i++)
            {
                Monitor.Enter(_obj);
                number--;
                Monitor.Exit(_obj);
            }
        }

 

 1) Monitor 사용으로 인한 DeadLock 해결 방안 

  a) 무조건 return 과 같은 코드 위에 Monitor.Exit(_obj)을 해준다. -> 하지만, 일반적이지않은 예상치못한 상황이 발생, exception이 발생하면 Monitor.Exit을 하지 못하고 빠져나간다. 

  b) try ~ finally 문을 사용한다. return한 후에도 무조건 finally로 Monitor.Exit을 하도록 한다. 

        static void Thread_1()
        {
            for (int i = 0; i < 1000000; i++)
            {
                try
                {
                    Monitor.Enter(_obj);
                    number++;
                    return;
                }
                finally
                {
                    Monitor.Exit(_obj);
                }
            }
        }

 c) 대부분 lock을 사용한다. (Monitor Enter과 Monitor Exit을 사용하기 보다는)

        static void Thread_1()
        {
            for (int i = 0; i < 1000000; i++)
            {
                lock(_obj)
                {
                    number++;
                }
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 1000000; i++)
            {
                lock (_obj)
                {
                    number--;
                }
            }
        }

 

<DeadLock>

1. 일반적인 상황에서 DeadLock이 발생하기 보다는 고차원적인 상황에서 DeadLock이 발생한다. 

스레드 1과 스레드 2가 공유 자원 A, B 둘다 얻어야 한다고 가정했을때, t1 시점에서 스레드 1이 공유자원 A를 얻고 스레드 2가 공유자원 B를 얻었다면 스레드 1은 공유자원 B를 기다리고 스레드 2는 공유자원 A를 기다리게 된다. 

하지만, 서로 원하는 공유 자원은 상대방에게 살당되어 있기 때문에 두 개의 스레드가 무한정 기다리게 되는데 이러한 상황을 DeadLock이라고 한다. 

 

2. 게임 개발의 상황에서는 lock이 클래스안에서 적용되는 일이 많다.  

 1) SessionManager 클래스와 UserManager 클래스 각자가 lock을 가지고 있음

 2) SessionManager와 UserManager 둘 다 서로의 자원에 접근해야할 일이 존재할 수 있음 

 3) 아래의 코드를 보면, SessionManager 클래스에서 Test 함수를 보면 본인은 lock이 걸려 있는 상태로 UserManager.TestUser을 호출한다. UserManager 클래스의 TestUser 함수는 본인을 lock 걸어놓는다. 또한, UserManager의 클래스에서 Test 함수에서도 마찬가지로 본인은 lock을 걸어놓고 SessionManager의 TestSession 함수를 호출한다. 

    class SessionManager
    {
        static object _lock = new object();
        public static void TestSession()
        {
            lock (_lock)
            {

            }
        }
        public static void Test()
        {
            lock (_lock)
            {
                UserManager.TestUser();
            }
        }
    }
    class UserManager
    {
        static object _lock = new object();

        public static void Test()
        {
            lock (_lock)
            {
                SessionManager.TestSession();
            }

        }
        public static void TestUser()
        {
            lock (_lock)
            {

            }
        }
    }

 

 4) 실제 테스트를 위해 아래의 코드를 돌려본다. 

Thread_1과 Thread_2를 10000번 실행하도록 하면, 서로 lock이 풀어질때까지 계속 기다리고 있는 DeadLock 상황이 발생한다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace SeverCore
{
    class SessionManager
    {
        static object _lock = new object();
        public static void TestSession()
        {
            lock (_lock)
            {

            }
        }
        public static void Test()
        {
            lock (_lock)
            {
                UserManager.TestUser();
            }
        }
    }
    class UserManager
    {
        static object _lock = new object();

        public static void Test()
        {
            lock (_lock)
            {
                SessionManager.TestSession();
            }

        }
        public static void TestUser()
        {
            lock (_lock)
            {

            }
        }
    }
    class Program
    {
        // 공유 변수 - 전역 변수
        static int number = 0;
        static object _obj = new object();

        static void Thread_1()
        {
            for (int i = 0; i < 10000; i++)
            {
                SessionManager.Test();
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 10000; i++)
            {
                UserManager.Test();
            }
        }
        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);
          
        }
    }
}

 

3. DeadLock 해결 방안

 1) 대부분은 상황을 보고 고치는 경우가 많다. 

 DeadLock이 발생할면 그 lock 구조가 잘못된 것을 의미하기 때문에 개발하다가 발생하면 그때 고친다. 

 2) DeadLock 발생을 예방하는 것보다 발생한 후 DeadLock 상황을 고치는 것이 쉽다

 위의 코드에서도 2개의 스레드를 동시에 실행시키는 것보다 살짝의 시간을 둬서 실행시키면 DeadLock 상황이 발생하지 않는다. 

    class Program
    {
        // 공유 변수 - 전역 변수
        static int number = 0;
        static object _obj = new object();

        static void Thread_1()
        {
            for (int i = 0; i < 100; i++)
            {
                SessionManager.Test();
            }
        }
        static void Thread_2()
        {
            for (int i = 0; i < 100; i++)
            {
                UserManager.Test();
            }
        }
        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();

            Thread.Sleep(1000);
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(number);
          
        }
    }