개발 일지/C#

[C#] Thread 쓰레드 - 데드락과 동기화 방법

뽕구 2025. 5. 3. 02:58
728x90
반응형

 

안녕하세요.

이번에는 C#으로 쓰레드의 데드락 개념과 쓰레드 간 동기화에 대해 알아보도록 하겠습니다.

데드락 개념과, lock, Monitor, Mutex 등 다룰 내용이 좀 있어서 글이 길어질 것 같습니다!

천천히 따라와주세요~

 

개발을 하다보면 2개 이상의 쓰레드를 돌리는 멀티쓰레딩을 하게 되는데요.

멀티 쓰레드들이 서로 점유한 자원들에 대해 점유를 대기하며, 영원히 대기 상태에 빠지는 현상을 데드락(Deadlock)이라고 합니다.

 

이를 해결하려면 2개 이상의 쓰레드 동기화를 통해 해결할 수 있습니다.

우선 데드락 발생부터 한번 알아봅시다.

 

데드락 Deadlock

데드락은 이론적으로 아래 조건이 충족될 때 발생합니다.

 

 

  • 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 스레드만 사용 가능.
  • 점유 대기(Hold and Wait): 자원을 보유한 채 다른 자원을 요청.
  • 비선점(No Preemption): 자원을 강제로 빼앗을 수 없음.
  • 순환 대기(Circular Wait): 스레드들이 순환적으로 서로의 자원을 기다림.

 

예제를 통해서 데드락 발생을 알아봅시다.

 

using System;
using System.Threading;

class Program
{
    static object lock1 = new object();
    static object lock2 = new object();

    public static void Main()
    {

        Thread t1 = new Thread(() =>
        {
            lock (lock1) //t1에서 lock1 점유
            {
                Thread.Sleep(1); // lock2를 확보하기 위해 잠시 대기

                lock (lock2)
                {
                    Console.WriteLine("t1 작업 완료");
                }
            }
        });


        Thread t2 = new Thread(() =>
        {
            lock (lock2) //t2에서 lock2 점유
            {
                Thread.Sleep(1); // lock1을 확보하기 위해 잠시 대기
                lock (lock1)
                {
                    Console.WriteLine("t2 작업 완료");
                }
            }
        });

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

 

 

코드 실행..아무것도 안찍힌다.

 

이 코드를 실행하면 콘솔에 찍히는 구문이 없습니다. 마치 멈춘것 같아요.

 

t1은 lock1을 점유하면서 lock2 대기상태, t2는 lock2를 점유하면서 lock1 대기상태

즉, 서로의 자원에 접근하려는 상태가 발생해 데드락이 발생한거죠.

쓰레드 점유 접근 상태
t1 lock1 lock2 대기에 빠짐
t2 lock2 lock1 대기에 빠짐

 

 

t1, t2 쓰레드 중 하나만 start 안해줘도 콘솔에 정상적으로 찍힙니다.

그럼 이를 어떻게 해결할까요.

 

데드락 해결 방법

쓰레드 동기화를 통해 쓰레드의 접근을 관리하여 데드락을 피하거나 최소화할 수 있습니다. 

해결 방법들은 아래 테이블로 정리했으며,  각 방법에 대한 예제를 접는 글로 만들어 볼게요.

 

도구 적용 대상 재진입성 사용 난이도 해제 방식 허용 접근 수 용도 예시 사용 예시
lock 스레드 지원 가장 쉬움 자동
(블록 종료 시)
1 간단한 임계 구역 보호 lock(obj) { }
Monitor 스레드 지원 lock보다 복잡 수동
(Exit 필요)
1 조건 대기
Pulse/Wait,
Timeout 활용
Monitor.TryEnter(obj)
Mutex 스레드,
프로세스
지원 lock보다 복잡 수동
(ReleaseMutex)
1 중복 실행 방지, IPC 등 mutex.WaitOne()
Semaphore 스레드,
프로세스
기본적으로 비지원 가장 복잡 수동 (Release) 필요 N개 리소스 풀, 연결 제한 등 sem.Wait()

 

lock 활용법


더보기

lock는 데드락을 회피할 순 없지만, 가장 간단하게 예방 코드 구현이 가능합니다.

 

점유 자원에 접근하는 쓰레드를 1개로 제한하는데, 쓰레드가 2개 이상인 경우에는 lock 순서를 일관화되게 작성해주면 해결됩니다.

 

위 데드락 발생 예시코드에서 t2는 lock2 점유 lock1 점유대기 상태에 빠졌었죠. 

이를  lock 점유 순서를 일관화되게 lock1 점유 후 lock2 점유대기로 바꿔주면 됩니다.

 

따라서, 두개 쓰레드가 순차적인 자원 접근이 가능하게 수정한 것이죠.

using System;
using System.Threading;

class Program
{
    static object lock1 = new object();
    static object lock2 = new object();

    public static void Main()
    {

        Thread t1 = new Thread(() =>
        {
            lock (lock1) //t1에서 lock1 점유
            {
                Thread.Sleep(1); // lock2를 확보하기 위해 잠시 대기

                lock (lock2)
                {
                    Console.WriteLine("t1 작업 완료");
                }
            }
        });


        Thread t2 = new Thread(() =>
        {
            lock (lock1) //t2에서 lock1 점유
            {
                Thread.Sleep(1); // lock2을 확보하기 위해 잠시 대기
                lock (lock2)
                {
                    Console.WriteLine("t2 작업 완료");
                }
            }
        });

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

 

 

아래는 위 코드의 실행 결과입니다. 

lock 점유 순서만 순차적으로 되게 바꾸어주었는데 정상 실행됨을 볼 수 있습니다.

lock 점유 순서 일관화 후 정상 실행

 

 

Monitor 활용법


더보기

lock보다 더 세밀하게 조작이 필요할 때 사용할 수 있습니다. 

Monitor클래스에서 제공하는 Enter, TryEnter, Exit 메서드를 사용해 일정 시간 점유 시도하다 실패 시 타임아웃으로 처리합니다.

또, lock의 경우 데드락 발생 시 그대로 빠져버리는데, Monitor는 실패 시 그에 따른 재시도 혹은 실패 처리가 가능합니다.

 

  • Monitor.Enter/Exit: 락 획득/해제(동일 객체로!)
  • Monitor.Wait/Pulse/PulseAll: 스레드 간 신호 전달
  • 반드시 try-finally로 Exit 보장

using System;
using System.Threading;

class Program
{
    private static readonly object lock1 = new object();

    static void DoWork()
    {
        while (true)
        {
            // 500ms 동안 락을 시도
            if (Monitor.TryEnter(lock1, TimeSpan.FromMilliseconds(500)))
            {
                try
                {
                    Console.WriteLine($"{Thread.CurrentThread.Name} : 락 획득 성공, 작업 시작");
                    Thread.Sleep(1000); // 처리할 작업 시작
                    break;//
                }
                finally
                {
                    // 작업이 끝나면 락 해제
                    Monitor.Exit(lock1);
                    Console.WriteLine($"{Thread.CurrentThread.Name} : 작업 완료, 락 해제");
                }
            }
            else
            {
                // 락을 획득하지 못한 경우
                Console.WriteLine($"{Thread.CurrentThread.Name} : 락 획득 실패");

                // 500ms 후 재시도
                Thread.Sleep(500);
                Console.WriteLine($"{Thread.CurrentThread.Name} : 락 획득 재시도");

            }
        }
    }

    public static void Main()
    {
        Thread t1 = new Thread(DoWork);
        Thread t2 = new Thread(DoWork);

        t1.Name = "t1";
        t2.Name = "t2";

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

        t1.Join();
        t2.Join();
    }
}

 

TryEnter를 활용하여 자원 점유에 Timeout을 주며, 점유 못하면 500ms 후 다시 시도하는 코드입니다.

 

t1과 t2가 lock을 점유하며 작업을 수행하고 lock을 해제합니다.

쓰레드 간 동기화되는 순서를 정리해봤어요. 

 

  1. t2가 먼저 lock 획득 성공, 작업시작
  2. t1 lock 획득 실패
  3. t1 lock 획득 재시도
  4. t2 작업 완료 및 lock 해제
  5. t1 lock 획득, 작업 시작
  6. t1 작업 완료 및 lock 해제

아래는 결과 화면입니다. 

 

Monitor활용 결과 화면

 

 

 

이처럼 Monitor를 활용해서 lock 점유를 통해 멀티 쓰레드 간 동기화할 수 있습니다.

 

Mutex 활용법


더보기

하나의 쓰레드 혹은 프로세스만  특정 영역(크리티컬 세션)에 접근을 허용하는 동기화 클래스입니다.

우선 Mutex 특징입니다.

 

  • 상호 배제 객체
  • 한번에 하나의 쓰레드만 임계영역에 접근 보장
  • 프로세스 간 동기화 가능 (lock과의 가장 큰 차이)

가장 큰 차이는 프로세스 간 동기화가 가능하는 점입니다.

 

활용법입니다.

  1. Mutex 클래스의 WaitOne(); 메서드로 점유가 가능할 때까지 기다렸다가 얻어옴
  2. 작업 처리
  3. 처리 완료 후 Mutex.ReleaseMutex(); 메서드로 점유 해제
using System;
using System.Threading;

class Program
{
    static Mutex mutex = new Mutex();

    static void DoWork()
    {
        while (true)
        {
            Console.WriteLine($"{Thread.CurrentThread.Name} 대기 중...");
            mutex.WaitOne(); //thread가 mutex를 얻을 때까지 대기
            Console.WriteLine();

            Console.WriteLine($"{Thread.CurrentThread.Name} 가 Mutex를 얻음");

            // 처리할 작업 시작
            Console.WriteLine($"{Thread.CurrentThread.Name} 작업 처리중");
            Thread.Sleep(500);
            Console.WriteLine($"{Thread.CurrentThread.Name} 작업 완료함");

            mutex.ReleaseMutex(); //thread가 mutex를 해제
            Console.WriteLine($"{Thread.CurrentThread.Name}가 Mutex를 해제함");
            Console.WriteLine();
            break;

        }
    }

    public static void Main()
    {
        Thread t1 = new Thread(DoWork);
        Thread t2 = new Thread(DoWork);

        t1.Name = "t1";
        t2.Name = "t2";

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

        t1.Join();
        t2.Join();
    }
}

 

t1, t2 쓰레드가 돌때 Mutex를 통해 동기화되는걸 확인할 수 있습니다.

예외가 발생할 수 있으니, try catch 문으로 싸고 finally에서 mutex.ReleaseMutex(); 처리하는게 더 안전해 보입니다.

 

 

t1과 t2가 Mutex을 활용해 자원을 점유하며 작업을 수행하고 ReleaseMutex하여 해제합니다.

쓰레드 간 동기화되는 순서를 정리해봤습니다.

 

  1. t1과 t2 모두 자원 점유 대기
  2. t2가 먼저 Mutex를 얻음
  3. t2 작업 처리
  4. t2 작업 완료
  5. t2 Mutex 해제
  6. ----------------구분------------------
  7. t1이 대기하다가 Mutex 얻음
  8. t1 작업 처리
  9. t1 작업 완료
  10. t1 Mutex 해제

아래는 결과 화면입니다.

mutex 활용법 처리 결과

 

 

Mutex 활용법 - 프로세스 간 동기화

같은 시스템 안에서 프로세스 간 같은 이름의 Mutex로 동기화합니다.

이를 네임드 Mutex라고 합니다.

 

먼저 네임드 Mutex를 활용을 위한 Mutex의 생성자와 파라미터를 먼저 볼게요.

 

public Mutex(bool initiallyOwned, string? name, out bool createdNew);

 

파라미터 타입 설명
initiallyOwned bool true이면 Mutex 생성 직후 해당 쓰레드가 소유함 (즉시 WaitOne() 없이 진입 가능)
따라서, 생성 직후부터 ReleaseMutex 호출할 때까지 다른 쓰레드는 진입 못함.
name string? 네임드 Mutex의 이름을 지정함. 프로세스 간 공유 가능. null이면 이름 없는 Mutex
createdNew out bool 반환값. true면 Mutex가 새로 만들어졌고, false면 이미 존재하는 Mutex를 참조한 것

 

생성 예시를 보겠습니다.

Mutex mutex = new Mutex(true, "Global\\NamedMutex", out isNew);

 

이렇게 생성할 수 있겠습니다.

 

추가로, 두번째 파라미터 name은 아래와 같이 활용 가능합니다.

Global\\NamedMutex  : 모든 세션(사용자)에서 접근이 가능

Global\\NamedMutex  : 현재 세션에서만 접근 가능

 

활용 예시를 한번 작성해볼게요.

 

2개의 콘솔 프로그램에서 1개의 쓰레드, 1개의 "NamedMutex" 이름을 값는 Name Mutex를 사용합니다.

 

첫번째 프로그램에서는 t1 쓰레드를 활성화,

두번째 프로그램에서는 t2 쓰레드를 활성화하여 프로세스를 실행합니다.

using System;
using System.Threading;

class Program
{
    static bool isNow = false; // Mutex가 이미 생성되었는지 여부
    static Mutex mutex = new Mutex(false, "Global\\NamedMutex", out isNow);
    static void DoWork()
    {
        while (true)
        {
            Console.WriteLine($"{Thread.CurrentThread.Name} 쓰레드 시작 {DateTime.Now.ToString("mm:ss.fff")}");
            Console.WriteLine();

            if (isNow)
            {
                Console.WriteLine($"{Thread.CurrentThread.Name}가 Mutex를 생성함 {DateTime.Now.ToString("mm:ss.fff")}");
            }
            else
            {
                Console.WriteLine($"이미 생성된 Mutex를 참조함 {DateTime.Now.ToString("mm:ss.fff")}");
            }


            Console.WriteLine($"{Thread.CurrentThread.Name} 대기 중...");
            try
            {
                mutex.WaitOne(); //thread가 mutex를 얻을 때까지 대기
                Console.WriteLine();

                Console.WriteLine($"{Thread.CurrentThread.Name} 가 Mutex를 얻음 {DateTime.Now.ToString("mm:ss.fff")}");

                // 처리할 작업 시작
                Console.WriteLine($"{Thread.CurrentThread.Name} 작업 처리중");
                Thread.Sleep(3000);
                Console.WriteLine($"{Thread.CurrentThread.Name} 작업 완료함");
                break;

            }
            finally
            {
                mutex.ReleaseMutex(); //thread가 mutex를 해제
                Console.WriteLine($"{Thread.CurrentThread.Name}가 Mutex를 해제함 {DateTime.Now.ToString("mm:ss.fff")}");
                Console.WriteLine();

                Console.WriteLine($"{Thread.CurrentThread.Name} 쓰레드 종료 {DateTime.Now.ToString("mm:ss.fff")}");
            }
        }
    }

    public static void Main()
    {
        Thread t1 = new Thread(DoWork);
        // Thread t2 = new Thread(DoWork); => 2번째 프로그램은 t2를 활성화

        t1.Name = "t1";
        // t2.Name = "t2";  => 2번째 프로그램은 t2를 활성화

        t1.Start();
        //  t2.Start(); => 2번째 프로그램은 t2를 활성화

        t1.Join();
        // t2.Join(); => 2번째 프로그램은 t2를 활성화

        Console.ReadLine();

    }
}

 

프로젝트 2개가 같은 코드이고, 단순히 Main 함수에서 쓰레드 t1, t2만 바꿔서 주석처리했습니다.

코드 똑같은거 두번 넣으면 오히려 헷갈려서 하나만 넣었어요~

 

프로세스 간 쓰레드 동기화를 비교하기 위해 Mutex 처리 과정마다 시간(분.초.밀리초)을 찍어봤습니다.

결과 이미지는 아래와 같아요.

 

프로세스 간 쓰레드 동기화 결과

 

프로세스 처리 순서를 테이블로 설명해드릴게요

프로세스 1 프로세스 2
  1. t1 쓰레드 시작함 (28:36.955)
  2. t1에서 Named Mutex 생성함 (28:36.973)
  3. t1 점유 대기중
  4. t1이 Mutex 점유를 얻음 (28:36.973)
  5. t1 작업 완료
  6. t1의 Mutex 점유 해제 (28:39.987)
  1. t2 쓰레드 시작함 (28:37.211)
  2. t1에서 생성한 Named Mutex를 참조함 (28:37.228)
  3. t2 점유 대기중
  4. t1의 Mutex 점유 해제 후 t2가 점유를 얻음 (28:39.987)
  5. t2 작업 완료
  6. t2 Mutex 점유 해제 (28:43.001)

 

t1에서 Named Mutex를 생성하고 t2에서는 그것을 참조합니다.

 

이어서 t1의 Mutex 점유 및 작업처리, 점유 해제가 되면 t2에서 점유가 가능해집니다.

t2 작업 후 최종적으로 Mutex 점유 해제가 됩니다.

 

이렇게 Named Mutex를 활용해서 프로세스 간 동기화를 수행할 수 있습니다.

 

Semaphore 활용법


더보기
마지막 세마포어 (Semaphore) 활용법입니다.

세마포어란 동시에 접근할 수 있는 쓰레드의 수를 제한하는 방식이에요.

다른 방식들은 1개만 허용하며, 점유권을 주고 받았었죠.

주로, DB 연결이나 API 호출 등 제한된 리소스에 활용됩니다.

 

방식을 쉽게 한번 설명해볼게요.

옷가게 피팅룸이라고 생각하면 될것 같아요.

 

3명까지 동시에 들어올 수 있어요. 번호표 드릴테니 나갈때 반납하세요! 

나머지는 기다리세요!

다음 분 들어오세요! 번호표 드릴테니 나갈때 반납하세요! 

다음 분은 기다리세요!

 

라고 하는것과 같아요.

 

그럼 기본 생성 방법을 알아볼게요.

// 초기 카운트 2, 최대 카운트 3
Semaphore semaphore = new Semaphore(initialCount: 2, maximumCount: 3);

 

세마포어도 마찬가지로 WaitOne()과 Realese()로 진입과 해제를 합니다.

 

메서드 설명
WaitOne() 세마포어 진입 시도 (허용 갯수 차감)
Release() 세마포어 해제 (허용 갯수 증가)

WaitOne()을 사용하면 0이 될 때 까지 허용 갯수를 차감하며 접근을 시도합니다.

허용 갯수가 0이 되면 이후의 접근 시도는 대기 상태가 됩니다.

 

처리 완료 후 Release()를 통해 사용 가능하게 허용 갯수를 증가시켜 줍니다.

 예제 코드를 같이 보시죠.

 

쓰레드 5개를 만들었고, 세마포어는 3개까지만 관리하면서 돌려줍니다.

using System;
using System.Threading;

class Program
{
    // 세마포어 객체 생성
    // 초기 허용 카운트 2, 최대 카운트 3
    static Semaphore semaphore = new Semaphore(2, 5);


    static void DoWork()
    {
        int count = 0;
        while (true)
        {
            // 쓰레드 당 5번 작업을 수행
            if (count > 5)
            {
                Console.WriteLine($"{Thread.CurrentThread.Name} 쓰레드 종료 {DateTime.Now.ToString("mm:ss.fff")}");
                Console.WriteLine();
                break;
            }
            
            Console.WriteLine($"{Thread.CurrentThread.Name} 세마포어 대기 {DateTime.Now.ToString("mm:ss.fff")}");
            semaphore.WaitOne(); // 세마포어를 얻을 때까지 대기
            Console.WriteLine($"{Thread.CurrentThread.Name} 세마포어 얻음 {DateTime.Now.ToString("mm:ss.fff")}");

            // 처리할 작업 시작
            Console.WriteLine($"{Thread.CurrentThread.Name} 작업 처리중");
            Thread.Sleep(3000);

            Console.WriteLine($"{Thread.CurrentThread.Name} 작업 완료함");

            semaphore.Release(); // 세마포어를 해제
            Console.WriteLine($"{Thread.CurrentThread.Name} 세마포어 해제 {DateTime.Now.ToString("mm:ss.fff")}");

            count++;
        }
    }


    public static void Main()
    {
        Thread t1 = new Thread(() => DoWork());
        Thread t2 = new Thread(() => DoWork());
        Thread t3 = new Thread(() => DoWork());
        Thread t4 = new Thread(() => DoWork());
        Thread t5 = new Thread(() => DoWork());

        t1.Name = "t1";
        t2.Name = "t2";
        t3.Name = "t3";
        t4.Name = "t4";
        t5.Name = "t5";

        t1.Start();
        t2.Start();
        t3.Start();
        t4.Start();
        t5.Start();


        t1.Join();
        t2.Join();
        t3.Join();
        t4.Join();
        t5.Join();
    }
}

 

확실한 구분을 위해서 콘솔 출력 색을 바꿔보았습니다.

아래는 결과입니다.

 

세마포어 활용한 쓰레드 동기화 결과

 

근데 돌아가는거 보니까 생각한거랑 달랐어요!

 

예상은 이랬습니다.

  1. 5개 쓰레드 대기
  2. 2개 쓰레드 최초 시작 (t1, t2)
  3. 1개 쓰레드 추가 시작 (t3)
  4. (MaxCount 3개만큼 동작)
  5. t1, t2 쓰레드 종료
  6. 대기상태 t4, t5 쓰레드 동작
  7. t3 쓰레드 종료

이렇게 될 줄 알았어요.

근데 initailCount 만큼만 돌더라고요.

 

제가 이해한 방식을 테이블로 정리합니다.

t1~t5 순서로 수행된다는 가정입니다.

 

순서 시점 수행 내용 세마포어 카운트
1 초기생성 세마포어 초기 상태. t1 ~ t5 대기상태 2
2 t1 진입 WaitOne(), 카운트 -1 1
3 t2 진입 WaitOne(), 카운트 -1 0
4 t3~t5 자원 없음 대기
5 t1 해제 Release(), 카운트 +1 1
6 t3 진입 WaitOne(), 카운트 -1 0
7 t2 해제 Release(), 카운트 +1 1
8 t4 진입 WaitOne(), 카운트 -1 0
9 t5 자원 없음 대기
10 t3 해제 Release(), 카운트 +1 1
11 t5 진입 WaitOne(), 카운트 -1 0
12 t4 해제 Release(), 카운트 +1 1
13 t5 해제 Release(), 카운트 +1 2

  

이렇게 되더라고요.

그래서 세마포어 생성할 때 설정한 initialCount 갯수만큼만 들어가게 됩니다.

 

제 코드가 예제처럼 짠거여서, 쓰레드마다 처리 코드가 동일하기 때문에 이렇게 처리되는것 같기도 합니다.

네트워크 트래픽이 변화하거나 쓰레드마다 처리 속도가 다르면, MaxCount까지 추가될 것 같습니다.

 

이상으로..세마포어를 활용한 쓰레드 동기화 방법을 알아보았습니다.

 

마치며..

쓰레드의 동기화하는 방법에 대해서 알아보았습니다.

 

글이 상당히 길어졌는데, 가독성을 위해서 방법마다 나누어 올려야하나 생각이 드네요ㅠㅠㅠ

그래도 한눈에 쭉 내리시면서 읽어보시면 많은 도움이 될거라고 생각됩니다.

 

저도 글 정리하면서 공부많이 되네요 ㅎㅎ

 

적절한 방식으로 쓰레드 동기화를 하여 데드락을 예방하시면 개발하시는데 큰 도움이 되겠습니다.

 

읽어주셔서 감사합니다!

 

728x90
반응형