[CS] 멀티 쓰레드 환경에서의 상호 배제(Mutex)와 데드락 (+ C#에서의 적용)

2022. 8. 3. 16:16
반응형

 

https://ko.wikipedia.org/wiki/%EA%B5%90%EC%B0%A9_%EC%83%81%ED%83%9C

 

 

멀티 스레드 환경의 프로그램에서 공유 자원 접근에 대한 제어를 하는 것은 중요하다.

특히 순서가 중요한 일련의 과정에서의 임계구역에 대한 접근은 엄격하게 이루어져야 한다.

 

 

임계 영역과 상호 배제

임계 영역은 멀티 스레드(프로세스) 환경에서, 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원을 사용하는 코드 영역을 지칭한다. 

해당 임계 영역에 대한 접근을 해결하는 방법 중 하나는 상호 배제(Mutex)이다.

상호 배제는 공유자원을 어느 시점에서 무조건 한 개의 스레드만이 사용할 수 있으며 다른 스레드들을 접근하지 못하게 제어하는 방법이다. 다른 프로세스가 번갈아 가면서 공유 자원을 사용하는 것을 목표로 한다.

 

 

교착 상태(Deadlock)

상호 배제에서 나타날 수 있는 문제점은 교착 상태이다.

나는 교착 상태라는 말을 들으면 아래의 툰이 생각난다.

https://bbs.ruliweb.com/community/board/300143/read/55360827

 

소심한 사람들이 헬스장 이용하는 특징.manhwa | 유머 게시판 | 루리웹

소심한 사람들이 헬스장 이용하는 특징.manhwa

bbs.ruliweb.com

위처럼 헬스장에서 서로 A와 B라는 운동기구를 점유하고 있으면서, 다른 운동기구를 점유하고 있는 사람이 일어나기를 각자의 자리에서 대기하고 있는 상태를 교착 상태에 빗댈 수 있다.

 

 

교착 상태 예방

 

교착 상태를 유발하는 조건들 중 한가지를 무력화 하여 사전에 교착 상태를 예방하는 것.

그러나 현실성이 떨어져 보통 교착 상태 회복을 사용한다.

  1. 자원 상호 배제 조건 X : 여러 개의 스레드가 자원에 접근이 가능 (하나의 운동기구를 여러명이 사용할 수 있도록 함)
  2. 비선점 조건 X : 모든 자원을 도중에 빼앗을 수 있도록 허용 (다른 사람이 쓰고있는 운동기구를 강제로(?) 빼앗아 사용한다.)
  3. 점유와 대기 조건  X : 대기를 없애기 위해서, 실행 전에 필요한 모든 자원을 할당받거나 자원을 점유하지 않을 때만 자원을 요청할 수 있도록 함 (헬스장에 들어올 때부터 모든 운동 기구를 독차지하거나, 다른 운동기구를 사용하려면 현재 사용하는 운동기구를 포기한 채 일어서서 대기해야 함)
  4. 환형 대기조건  X : 자원의 사용 순서를 부여하여, 순서대로 사용하도록 한다. (운동기구 A, B, C는 반드시 A->B->C 순서로 이용하여야 한다.)

 

 

교착 상태 검출과 회복

교착 상태가 검출되었을 시 회복시키는 방법이다.

보통 Timeout이나 자원 할당 그래프 따위로 시스템에 교착 상태가 발생하였는지 점검하여, 교착 상태가 발생했다고 판단되는 프로세스(스레드)를 종료시켜 할당된 자원을 회수하는 방법이다.

 

 

 

 


C# 에서의 임계 영역에 대한 접근

대표적으로 C#에서의 공유 자원에 대한 접근 제어 키워드는 lock이 있다.

lock문은 자원에 대한 상호 배제 잠금을 획득 후 -> 코드 블록 실행 -> 잠금 해제의 과정을 거치며, 다른 스레드는 잠금을 획득할 수 없으며, 해제될 때 까지 대기하여야 한다.

 

 

Thread-unsafe 코드 예제

class Program
    {
        private static int money = 30000;

        public static void Run()
        {
            //20번의 출금
            for (int i = 0; i < 20; i++)
            {
                new Thread(Withdraw).Start();
            }
        }

        private static void Withdraw()
        {     
            Thread.Sleep(1000);	//일련의 작업 가정
            if (money < 1000)
            {
                Console.WriteLine("잔액이 부족합니다." + money);
                return;
            }
            money -= 1000;
            Console.WriteLine("1000원 출금, 남은 잔액 : " + money);
          
        }

        static void Main(string[] args)
        {
            Run();
        }
    }

다음 코드는 임계영역에 대한 상호 배제를 하지 않고 코드를 짠 경우이다.

해당 코드는 은행에서 10000원의 잔액에서 1000원씩 20번 반복하여 출금하는 코드이다.

다음은 실행 결과이다. 출금의 과정은 일련의 순서가 중요한데, 임계구역에 동시에 접근하여 Money라는 변수의 공유자원을 사용하다 보니 예상한 모습의 코드가 아니다.

엑세스 순서에 따라서 다른 결과를 나타낼 수 있는 멀티 쓰레드를 Thread-unsafe하다고 하는데 이럴 때 임계구역 자원에 접근하는 코드 블록인 Critical Section에 대한 상호 배제에 대한 필요성이 생긴다.

 

 

 

 

Lock 키워드를 이용한 상호 배제(Mutex) 코드

    class Program
    {
        private static int money = 10000;

        private static object _lock = new object();

        public static void Run()
        {
            //20번의 출금
            for (int i = 0; i < 20; i++)
            {
                new Thread(Withdraw).Start();
            }
        }

        private static void Withdraw()
        {
            lock (_lock)
            {
                Thread.Sleep(1000);
                if (money < 1000)
                {
                    Console.WriteLine("잔액이 부족합니다." + money);
                    return;
                }
                money -= 1000;
                Console.WriteLine("1000원 출금, 남은 잔액 : " + money);
            }

          
        }

        static void Main(string[] args)
        {
            Run();
        }
    }

다음과 같이 lock 키워드를 이용하여 임계구역에 대한 상호 배제를 걸어 두었다.

Withdraw라는 메서드는 lock에 의해 lock이 걸려있다면 Critical Section의 작업을 실행하지 않고 대기하게 되며 lock이 걸려있지 않을 경우 lock을 점유하여 작업을 진행하게 된다. 즉, 한 개의 스레드씩 차례로 실행되게 된다.

다음과 같이 Critical section은 동시에 실행되지 않아 순서대로 작업이 정상적으로 이루어 진 것을 볼 수 있다.

그러나 lock을 남발할 경우 의도치 않게 데드락이 걸릴 확률이 있으므로, Critical Section 블록의 범위는 작게 하는 것이 좋다.

 

 

 

 

데드락에 걸린 경우

    class Program
    {
        private static object _lock = new object();
        private static object _lock2 = new object();

        public static void Run()
        {
            for (int i = 0; i < 5; i++)
            {
                new Thread(LockingTest).Start();
                new Thread(LockingTest2).Start();
            }
        }

        private static void LockingTest()
        {
            lock (_lock)
            {
                Thread.Sleep(1000);
                Console.WriteLine("Lock!!");
                LockingTest2();
            }
        }

        private static void LockingTest2()
        {
            lock (_lock2)
            {
                Thread.Sleep(1000);
                Console.WriteLine("Lock2!!");
                LockingTest();
            }
        }

        static void Main(string[] args)
        {
            Run();
        }
    }

이상태로 멈추어버렸다.

다음과 같이 하나의 Critical Section에서 다른 Section을 사용할 때 데드락이 걸리는 경우가 있다.

이것은 위에서 말했던 점유하고 있는 자원을 포기하지 않은 채 서로 다른 자원을 얻으려고 하기 때문에 생기는 데드락이다. 그래서 사용하려면 아래와 같이 Critical Section 외부에서 Lock을 해제하고 나서 다른 Critical Section에 접근해야 한다.

class Program
    {

        private static object _lock = new object();
        private static object _lock2 = new object();

        public static void Run()
        {
            for (int i = 0; i < 5; i++)
            {
                new Thread(LockingTest).Start();
                new Thread(LockingTest2).Start();
            }
        }

        private static void LockingTest()
        {
            lock (_lock)
            {
                Thread.Sleep(1000);
                Console.WriteLine("Lock!!");
            }
            LockingTest2();
        }

        private static void LockingTest2()
        {
            lock (_lock2)
            {
                Thread.Sleep(1000);
                Console.WriteLine("Lock2!!");
            }
            LockingTest();
        }

        static void Main(string[] args)
        {
            Run();
        }
    }

데드락 예방 조건에서의 점유와 대기 조건을 없앴다. lock을 빠져나온 후에 Critical Section에 접근하여 Lock이 중첩되지 않고 작동하게 된다. 위에서 Critical Section 블록의 범위는 작게 하는 것이 좋다고 한 이유중 하나이다.

서로 번갈아가며 섹션을 점유한다.

이중 검사를 활용한 Mutex

프로그래밍을 하다 보면 여러개의 스레드가 스레드의 개수만큼 코드 블록을 실행하지만 여러개의 스레드들이 접근하더라도 단 한번만 실행되게 해야 하는 코드 블록이 있을 수 있다. 이 때 이중으로 Critical Section에 대한 검사를 행하여, 동시에 여러개의 스레드가 접근할 경우에는 단 한번만 실행되게 할 수 있다.

    class Program
    {
        private static int money = 10000;

        private static bool mut_ = false;
        private static object _lock = new object();
        private static string date = "Monday";

        public static void Run()
        {
            //20번의 출금 시도
            for (int i = 0; i < 20; i++)
            {
                new Thread(Withdraw).Start();
            }
            Thread.Sleep(5000);

            //날짜가 바뀜
            date = "Tuesday";

            //20번의 출금 시도
            for (int i = 0; i < 20; i++)
            {
                new Thread(Withdraw).Start();
            }
        }

        private static void Withdraw()
        {
            if (!mut_)
            {
                mut_ = true;
                lock (_lock)
                {
                    Thread.Sleep(1000);
                    money -= 1000;
                    Console.WriteLine(date + " 1000원 출금, 남은 잔액 : " + money);
                }
                mut_ = false;
            }
            
        }

        static void Main(string[] args)
        {
            Run();
        }
    }

다음 코드는 Monday와 Thuesday에 20번의 출금 시도를 해도 단 한번만 출금을 할 수 있도록 짠 코드이다.

다음과 같이 lock 이전에 mut_의 사용 여부에 따라 외부에서 잠금 처리를 하였다.

이렇게 하게 된다면 lock을 점유하고 있는 동안, 다른 스레드가 해당 코드 블럭에 접근하게 될 경우 대기하지 않고 무시하고 지나가게 된다. 

 

단 위의 코드는 lock에 Thread.Sleep(1000)으로 시간을 두었지만, Lock이 잠겼다가 곧바로 해제된다면, 아래와 같이 다른 스레드가 접근이 가능해진다. 그래서 만약 다음과 같은 기능을 두려면 일정 유예 시간을 두는 것이 필요하다.

 

 class Program
    {
        private static int money = 10000;

        private static bool mut_ = false;
        private static object _lock = new object();
        private static string date = "Monday";

        public static void Run()
        {
            //20번의 출금 시도
            for (int i = 0; i < 20; i++)
            {
                new Thread(Withdraw).Start();
            }
            Thread.Sleep(5000);

            //날짜가 바뀜
            date = "Tuesday";

            //20번의 출금 시도
            for (int i = 0; i < 20; i++)
            {
                new Thread(Withdraw).Start();
            }
        }

        private static void Withdraw()
        {
            if (!mut_)
            {
                mut_ = true;
                lock (_lock)
                {
                    //대기 시간을 두지 않음.
                    //Thread.Sleep(1000);
                    money -= 1000;
                    Console.WriteLine(date + " 1000원 출금, 남은 잔액 : " + money);
                }
                mut_ = false;
            }
            
        }

        static void Main(string[] args)
        {
            Run();
        }
    }

Thread.Sleep을 없애니 모든 스레드가 코드를 실행한다.

 

멀티 스레드 프로그래밍에서 이런 Mutex 키워드를 사용하는 것은 Thread-unsafe를 방지하고, 개발자의 의도대로 로직 흐름이 실행될 수 있도록 하는 것에 도움이 된다. 그러나 잘못 사용할 시 데드락 등을 야기할 수 있으므로, 적재적소에 잘 활용하는 것이 중요하다.

 

반응형

BELATED ARTICLES

more