[C#] 38. lock 키워드와 deadlock(교착 상태)


Study/C#  2021. 9. 28. 17:45

안녕하세요. 명월입니다.


이 글은 C#의 lock 키워드와 deadlock(교착 상태)에 대한 글입니다.


이전 글에서 쓰레드에 대해 설명했습니다.

link - [C#] 36. 쓰레드(Thread)를 사용하는 방법, Thread.Sleep 함수 사용법


쓰레드란 프로세스 안에서 동시에 여러 처리를 실행하기 위한 병렬 처리라고 설명했습니다. 그러면 이 병렬 처리를 처리할 때 하나의 인스턴스나 변수에 값을 처리할 때 어떻게 될까?

using System;
using System.Threading;
using System.Collections.Generic;

namespace Example
{
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // 총 합에 대한 변수
      int sum = 0;
      // ThreadPool에서 Join을 위한 리스트
      var list = new List<EventWaitHandle>();
      // 람다 함수
      var action = new Action(() =>
      {
        // EventWaitHandle 인스턴스 생성
        var wait = new EventWaitHandle(false, EventResetMode.ManualReset);
        // 리스트에 인스턴스 추가
        list.Add(wait);
        // 쓰레드 풀에 쓰레드 추가
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 반복문 0부터 10까지
          for (int i = 0; i <= 10; i++)
          {
            // 더해서 sum 변수에 넣는다.
            sum += i;
            // 쓰레드 대기 시간 1 밀리 초
            Thread.Sleep(1);
          }
          // WaitHandle 해제
          wait.Set();
        });
      });
      // 반복문 0부터 9까지
      for (int i = 0; i < 10; i++)
      {
        // 람다 함수 실행 (쓰레드 실행)
        action();
      }
      // list에 있는 EventWaitHandle 함수가 전부 Set이 호출되면 Join 해제
      WaitHandle.WaitAll(list.ToArray());
      // 쓰레드에서 더한 모든 값 출력
      Console.WriteLine(sum);

      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

위 결과를 보시면 0부터 10까지 더하는 쓰레드를 10번 실행했습니다.

그러니깐 0부터 10까지 더하면 55이고 10번 실행했으니 예상되는 결과 값은 550이 나와야 정상입니다.


그러나 결과는 550이 아닌 481이네요.. 원하는 결과가 나오지 않았습니다.

이유는 sum에 값을 더할 때, sum의 값이 0일 경우 첫번째 쓰레드에서 1를 더하면 1이 되고 두번째 쓰레드에서 더할때 1를 더하면 2가 될꺼라고 예상합니다.

그런데 Thread라는 건 병렬 치이다 보니 첫번째 쓰레드에서 0에서 1를 더할 때, 두번째 쓰레드에서 1에서 2를 더한다는게 보장이 안됩니다. 즉, 첫번째에서 0에서 1를 더할때, 두번째 쓰레드에서 1를 더할때, 아직 sum이 첫번째 쓰레드에서 1이 되기 전이여서 sum의 값이 0일 수도 있습니다.

다시 말해, 첫번째, 두번째에서 0에서 1를 더할 수 있습니다. 그렇게 경합되는 값이 몇 번 겹치다보니 sum의 값이 550까지 도달하지 못하는 것입니다.


쓰레드는 조금 빠른 처리를 위해 병렬 처리를 하지만, 예상되는 값이 나오지 못하면 의미가 없습니다.

그러나 쓰레드이지만 이 모든 병렬처리에서 sum에 더하는 처리만 쓰레드를 모두 동기화를 하면 이 문제가 해결됩니다.

using System;
using System.Threading;
using System.Collections.Generic;

namespace Example
{
  // 예제 클래스
  class Node
  {
    // Sum 변수 프로퍼티
    public int Sum { get; set; }
  }
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // 총 합에 대한 인스턴스
      Node node = new Node();
      // node 인스턴스의 Sum 값 초기화
      node.Sum = 0;
      // ThreadPool에서 Join을 위한 리스트
      var list = new List<EventWaitHandle>();
      // 람다 함수
      var action = new Action(() =>
      {
        // EventWaitHandle 인스턴스 생성
        var wait = new EventWaitHandle(false, EventResetMode.ManualReset);
        // 리스트에 인스턴스 추가
        list.Add(wait);
        // 쓰레드 풀에 쓰레드 추가
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 반복문 0부터 10까지
          for (int i = 0; i <= 10; i++)
          {
            // node 인스턴스에 lock
            lock (node)
            {
              // 더해서 sum 변수에 넣는다.
              node.Sum += i;
            }
            // 쓰레드 대기 시간 1 밀리 초
            Thread.Sleep(1);
          }
          // WaitHandle 해제
          wait.Set();
        });
      });
      // 반복문 0부터 9까지
      for (int i = 0; i < 10; i++)
      {
        // 람다 함수 실행 (쓰레드 실행)
        action();
      }
      // list에 있는 EventWaitHandle 함수가 전부 Set이 호출되면 Join 해제
      WaitHandle.WaitAll(list.ToArray());
      // 쓰레드에서 더한 모든 값 출력
      Console.WriteLine(node.Sum);

      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

클래스 인스턴스에 lock를 걸면 한 곳에서 lock의 영역을 사용하면 다른 곳에서 lock을 통과하는 것을 기다리게 되는 것입니다.

즉, 병렬 처리에서 첫번째 쓰레드에서 lock(node)을 통과하게 되면 두번째 쓰레드에서는 첫번째 쓰레드의 lock(node)의 스택 영역이 종료할 때까지 대기를 하기 됩니다.

그러면 첫번째 쓰레드의 lock(node)에서 스택이 종료되면 두번째 쓰레드의 lock(node)이 통과되고 세번째 쓰레드의 lock(node)이 대기를 하기 됩니다. 그런 순서대로 lock(node)영역을 통과하게 되므로 결론은 동시에 값이 더해지는 일은 일어나지 않습니다.

모든 쓰레드에서 Node 인스턴스에 대해서 동기화기 되었습니다.


lock의 키워드는 원시 데이터, 즉 int char byte 같은 자료형에는 되지 않고 무조건 클래스타입의 인스턴스에만 lock를 걸 수 있습니다.

using System;
using System.Threading;

namespace Example
{
  // 예제 클래스
  class Node
  {
  }
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // 총 합에 대한 인스턴스
      Node node1 = new Node();
      Node node2 = new Node();
      // 람다 함수
      var action = new Action<Node, Node, string>((a, b, str) =>
      {
        // 쓰레드 풀에 쓰레드 추가
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 반복문 0부터 10까지
          for (int i = 0; i <= 10; i++)
          {
            // node 인스턴스에 lock
            lock (a)
            {
              // 더해서 sum 변수에 넣는다.
              lock (b)
              {
                // 콘솔 출력
                Console.WriteLine("lock and lock " + str + " = " + i);
                // 쓰레드 대기 시간 1 밀리 초
                Thread.Sleep(1);
              }
            }
          }
        });
      });
      // 람다 함수 실행
      action(node1, node2, "Action 1");
      action(node2, node1, "Action 2");

      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

위 예제의 결과를 보면 쓰레드가 lock의 의해 멈췄습니다.

여기서 문제는 lock이 중첩으로 있어서 문제가 아닙니다. 상황에 따라서는 lock을 중첩을 넣을 수는 있습니다만, 위 형식처럼 만들게 되면 교착 상태 deadlock에 걸리게 됩니다.

이유는 action의 첫번째 호출에서 파라미터로 node1과 node2를 넣고 두번째 호출에서는 node2와 node1로 순서를 바꾸어서 넣었습니다.


그렇게 되면 action 함수의 첫번째 쓰레드에서 node1 인스턴스의 lock을 걸고 그와 동시에 두번째 쓰레드에서 node2에 lock을 걸게 됩니다.

다시 첫번째 쓰레드에서 node2의 lock을 들어가려고 하니 두번째 쓰레드에서 node2의 락이 걸린 상태에서 락이 풀릴 때까지 대기를 하게 됩니다.

두번째 스레드에서는 node1의 lock을 들어가려고 하니 첫번째 쓰레드에서 node1의 락이 걸릴 상태에서 락이 풀릴 때까지 대기를 하게 됩니다. 즉, 양쪽의 lock이 서로의 lock이 풀릴 때까지 기다리는 교착 상태 (deadlock)에 빠지는 것입니다.


교착 상태이라는 것은 서로의 lock의 영역에서 lock이 걸린 상태에서 서로의 lock이 풀릴 때까지 영원히 기다리는 것을 말합니다. 교착 상태에 빠지게 되면 따로 에러가 발생하는 것이 아니고 그냥 프로그램이 멈춰버리는 현상인데, 문제는 이게 에러가 발생하는 것이 아니기 때문에 프로그램 상에서 교착상태가 빠지는 것을 찾기가 쉽지가 않습니다.

그래서 프로그램을 작성할 때, 교착 상태가 빠지지 않게 설계를 조심해야 하는데 가장 중요한게 lock을 중첩으로 만들지 않는 게 중요합니다.

당연히 위처럼 작성하지는 않습니다.

using System;
using System.Threading;

namespace Example
{
  // 예제 클래스
  class Node
  {
  }
  class Program
  {
    // Lock 함수
    static void SetTestLock(Node node1, Node node2)
    {
      lock (node1)
      {
        // Lock 함수 호출
        SetTestLock(node2);
      }
    }
    // Lock 함수
    static void SetTestLock(Node node)
    {
      lock (node)
      {
        // 콘솔 출력
        Console.WriteLine("lock");
        // 쓰레드 대기 시간 1 밀리 초
        Thread.Sleep(1);
      }
    }
    // 실행 함수
    static void Main(string[] args)
    {
      // 총 합에 대한 인스턴스
      Node node1 = new Node();
      Node node2 = new Node();
      // 람다 함수
      var action = new Action<Node, Node>((a, b) =>
      {
        // 쓰레드 풀에 쓰레드 추가
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 반복문 0부터 10까지
          for (int i = 0; i <= 10; i++)
          {
            // Lock 함수 호출
            SetTestLock(a, b);
          }
        });
      });
      // 람다 함수 실행
      action(node1, node2);
      action(node2, node1);

      // 아무 키나 누르면 종료
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}

lock이 중첩처럼 보이지 않게 lock을 함수에 넣어서 사용했습니다. 그런데 결국 처리 흐름은 lock이 중첩이 되기 때문에 교착 상태(deadlock)에 빠집니다.

즉, 실무에서도 이런 식으로 작성해서 교착 상태(deadlock)에 빠지는 경우가 굉장히 많습니다. 서로가 관계 없는 함수에서 각자의 인스턴스에 lock을 걸었지만 교착 상태에 빠지는 경우입니다.


교착 상태를 피하기 위해서는 몇가지 규칙이 있습니다.

먼저, lock을 거는 인스턴스는 가능한 하나로 통일하는 것이 좋습니다. 사실 설계상 편하게 데이터의 변환이나 사용되는 인스턴스에 lock을 거는 것이 좋지만, lock 전용 Objec 타입의 인스턴스를 생성하여 lock을 관리하는 것이 좋습니다.

그리고 lock의 영역을 가능하면 적은 스택으로 적성하는 것이 좋습니다. lock 자체로는 성능 상에 문제가 없지만, lock 안의 처리가 느려지만 다른 동기화되는 lock에서 lock이 종료될 때까지 대기를 하게되니 결론적으로 성능이 느려질 수 있습니다.


여기까지 C#의 lock 키워드와 deadlock(교착 상태)에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.