Deadlock(교착 상태)

1bio ㅣ 2025. 3. 29. 18:26

멀티 스레드

멀티스레드 환경에서 값을 단순히 변수에 할당하는 것은 별로 큰 문제가 되지 않는다. 

하지만, 할당된 값을 동시다발적으로 다른 스레드들과 같이 값을 쓰기 시작하면 문제가 된다.

그래서 싱글 스레드처럼 쓰도록 바꿔줘야 한다. 

using System.Threading;

 

해당 네임스페이스를 추가하면 Monitor 클래스에 접근할 수 있다.

Monitor 클래스는 멀티스레드 환경에서 리소스에 대한 접근을 제어하는 기능을 제공한다.

 

주로 여러 스레드가 동시에 공유 자원에 접근할 때 경쟁 조건(Race Condition)데이터 불일치 문제를 방지하기 위해 사용한다.

// Monitor 클래스를 활용한 예시
class ServerCore
{
    static int number;
    static object _obj = new object();

    static void MainMethod1()
    {
        for (int i = 0; i < 1000000; i++)
        {
            Monitor.Enter(_obj);
            number++;
            
			// 여기서 그냥 return 하면?
            Monitor.Exit(_obj);
        }
    }

    static void MainMethod2()
    {
        for (int i = 0; i < 1000000; i++)
        {
            Monitor.Enter(_obj);
            number--;
            Monitor.Exit(_obj);
        }
    }

    static void Main(string[] args)
    {
        Task t1 = new Task(MainMethod1);
        Task t2 = new Task(MainMethod2);

        t1.Start();
        t2.Start();

        Task.WaitAll(t1, t2);

        Console.WriteLine(number);
    }
}

// 출력 결과: 0

 

만약, 중간에 Monitor.Exit()를 하지 않고 return 하게 된다면 어떻게 될까?

결과는 출력값이 나오지 않는다. 왜 이런 현상이 생길까?


Deadlock 정의

이런 상황을 deadlock(교착상태)이라고 표현한다.

스레드들끼리 서로 자원을 기다리다가 영원히 멈춰버리는 현상을 뜻한다.

 

일반적으로  안전하게 사용하고 싶다면 try-catch문을 활용해서 마지막에 Monitor.Exit()로 빠져나온다.

이렇게 하면 예외가 발생해도 finally에서 한 번 실행된다.

// try-catch 문을 활용한 예시
class ServerCore
{
    static int number;
    static object _obj = new object();

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

    static void MainMethod2()
    {
        for (int i = 0; i < 1000000; i++)
        {
            Monitor.Enter(_obj);
            number--;
            Monitor.Exit(_obj);
        }
    }

    static void Main(string[] args)
    {
        Task t1 = new Task(MainMethod1);
        Task t2 = new Task(MainMethod2);

        t1.Start();
        t2.Start();

        Task.WaitAll(t1, t2);

        Console.WriteLine(number);
    }
}

// 출력 결과: 0

 

또한 lock 키워드를 사용해서 Monitor 클래스와 비슷하게 데드락을 방지할 수 있다.

lock가 보편적으로 더 많이 활용된다. 

// lock 키워드 예시
class ServerCore
{
    static int number;
    static object _obj = new object();

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

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

    static void Main(string[] args)
    {
        Task t1 = new Task(MainMethod1);
        Task t2 = new Task(MainMethod2);

        t1.Start();
        t2.Start();

        Task.WaitAll(t1, t2);

        Console.WriteLine(number);
    }
}

// 출력 결과: 0

 

lock 키워드는 내부에 Monitor.Enter()와  Monitor.Exit()가 구현되어 있어서 가독성이 좋고 훨씬 편리하다. 

아래 예시는 보편적으로 일어나는 데드락 현상이다.

// lock 키워드를 활용한 데드락 예시
class SessionManager
{
    static object _lock = new object();

    public static void Test()
    {
        lock (_lock)
        {
            UserManager.TestUser();
        }
    }

    public static void TestSession()
    {
        lock (_lock)
        {

        }
    }
}

class UserManager
{
    static object _lock = new object();

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

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

        }
    }
}

class ServerCore
{
    static int number;
    static object _obj = new object();

    static void MainMethod1()
    {
        for (int i = 0; i < 10000; i++)
        {
            SessionManager.Test();
        }
    }

    static void MainMethod2()
    {
        for (int i = 0; i < 10000; i++)
        {
            lock (_obj)
            {
                UserManager.Test();
            }
        }
    }

    static void Main(string[] args)
    {
        Task t1 = new Task(MainMethod1);
        Task t2 = new Task(MainMethod2);

        t1.Start();
        
        // 0.1초 후에 t2.start()
        // Thread.Sleep(100); 
        
        t2.Start();

        Task.WaitAll(t1, t2);

        Console.WriteLine(number);
    }
}

// 출력 X

 

정상적으로 출력한다면 number가 0이 찍힐 것이다. 하지만, 데드락 현상에 걸리게 되었고 호출 스택을 확인해보면 아래 사진과 같다. 

 

35320 스레드가  SessionManager.TestSession()을 통해서 lock을 흭득했는데 

35324 스레드가 UserManager.TestUser()를 통해서 lock을 흭득한 상태이다. 

아래 표는 데드락이 걸릴 수 있는 상황을 표로 나타냈다.

35324 (MainMethod1) 35320 (MainMethod2)
lock(SessionManager._lock) 획득  
  lock(UserManager._lock)  획득
call UserManager.TestUser() call SessionManager.TestSession()
lock(UserManager._lock) 데드락 발생 lock(SessionManager._lock) 데드락 발생

 

35324 스레드는 UserManager.lock을 기다리고

35320 스레드는 SessionManager.lock을 기다린다. 

결국 서로 가진 락을 기다리면서 프로그램이 멈추게 된다.

 

만약, 중간에 Thread.Sleep()을 넣어줘서 lock을 흭득하는 시간에 텀을 주게되면 정상적으로 출력할 수 있다.

이렇게 데드락을 방지하려면 lock 순서를 지정해줘야 데드락 현상을 피할 수 있다.

 

하지만 데드락을 방지하는 것은 굉장히 까다롭다. 그래서 라이브 서비스를 진행하기 전에 최대한 방지한 후에 데드락이 발생하면 그때 수정하는 것이 쉽고 편리하다.

 

이 글은 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버 강의를 참고하여 제작하였습니다.