Coding Memo

[C#] lock (Monitor) 본문

Language/C#

[C#] lock (Monitor)

minttea25 2023. 8. 8. 13:29

멀티 쓰레드 환경에서는 여러 쓰레드가 한번에 어떤 공유 자원(전역 변수, 맴버 변수 등등)에 접근하고 수정하려고 하면 매우 큰 문제가 발생할 수도 있다. 

 

아래 코드의 실행 결과를 예상해보자.

class Program
{
    static int n = 0;

    static void Thread1()
    {
        for (int i=0; i<100000; i++)
            n++;
    }

    static void Thread2()
    {
        for (int i = 0; i < 100000; i++)
            n--;
    }

    static void Main()
    {
        Thread t1 = new(Thread1);
        Thread t2 = new(Thread2);

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

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

        Console.WriteLine(n);
    }
}

실제로 위 프로그램을 실행시켜보면 n값은 0이 아니라 엉뚱한 값이 출력될 것이다.
(자세한 내용은 이 글을 통해 확인하자: https://minttea25.tistory.com/99)

 

두 쓰레드가 동시에 n 값을 읽고 쓰려고 해서 발생한 문제이다.

여러 쓰레드가 공유 자원에 동시에 접근하고 수정하려고 할 때 발생하는 문제를 경합 조건 (Race Condition)이라고 한다.

또한 여러 쓰레드가 동시에 접근하면 문제가 발생할 수 있는 코드 구역을 임계구역 (Critical Section)이라고 한다.

 

임계구역 (Critical Sesction): 멀티쓰레드 환경에서 여러 쓰레드가 동시에 접근하게 되면 문제가 발생 할 수 있는 코드 영역

경합 조건 (Race Condition): 멀티쓰레드 환경에서 여러 쓰레드가 공유 자원에 접근하고 수정하려고 발 때 발생하는 문제

 

그렇다면 한번에 하나의 쓰레드만 n에 접근하도록 하려면 어떻게 해야 할까?

 

C#에서 제공하는 lock 기능을 사용하면된다.


lock

 

lock은 멀티쓰레드 환경에서 공유 자원에 대한 동기화를 컨트롤하기 위한 메커니즘의 키워드이다. 여러 쓰레드가 어떤 공유 자원을 접근하려고 할 때, lock을 이용하여 한 번에 하나의 쓰레드가 접근할 수 있도록 할 수 있다.

 

lock을 이용하여 코드 블록을 {}(중괄호)로 묶을 수 있다. 이 블록 내에는 한 번에 하나의 쓰레드만 접근할 수 있고, 한 쓰레드가 이 lock을 사용하고 있는 중이라면 다른 쓰레드는 이 블록의 코드를 실행하지 못하고 대기 상태가 되며, 순번을 기다린다.

 

lock으로 둘러싸인 코드 블록을 빠져나오면 lock은 자동으로 해제된다.

 

C#에서의 lock은 lock을 위한 키로 object를 사용한다.

readonly object _lock = new(); // lock 선언, lock에 사용할 객체

// ...

lock (_lock) 
{
    // 공유 자원에 대한 작업 수행
    // 이 부분은 하나의 쓰레드만 접근 가능
}
// 괄호를 빠져나오면 자동으로 lock 해제

위를 이용해 처음 n을 ++하고 --하는 코드를 정상적으로 작동하도록 고칠 수 있다.

class Program
{
    static int n = 0;
    static readonly object _lock = new();

    static void Thread1()
    {
        for (int i=0; i<100000; i++)
        {
            lock(_lock)
            {
                n++;
            }
        }
    }

    static void Thread2()
    {
        for (int i=0; i<100000; i++)
        {
            lock(_lock)
            {
                n--;
            }
        }
    }
	// main은 동일
}

위와 같이 수정하면 n 값은 0이 나온다!

 

n에 접근하는 코드를 lock으로 구역을 설정하여 n++과 n--는 동시에 일어나지 못한다.

 

Note: lock은 함부로 막 쓰지 말자. 남발하면 성능상 문제가 발생할 수 있으니 필요한 부분에만 쓰자.

 

 

상황에 따라 lock을 직접 해제해야 하는 경우도 있을 것이다.

이 경우에는 Monitor클래스를 이용해 직접 컨트롤할 수 있다.


Monitor

 

Monitor(Monitor.Enter, Monitor.Exit)는 기본적으로 lock과 동일한 목적을 가지고 사용한다.

(실제로 lock은 내부적으로 Monitor로 구현되어 있다고 한다.)

lock은 무조건 코드 블록을 감싸고 블록을 벗어나면 자동으로 lock이 해제되지만, Monitor는 그렇지 않다.

 

직접 Monitor.Enter()로 lock을 획득할 수 있고, Monitor.Exit()으로 lock을 해제해야한다.

중요한 것은 명시적으로 Monitor.Exit()을 호출하여 lock을 해제해야 한다는 것이다.

만약 해제하지 않는 다면 실행도중 교착상태(deadlock)이 일어날 수 있다.

 

교착 상태 (Deadlock): 멀티쓰레드 환경에서 두 개 이상의 쓰레드가 서로가 가진 리소스(lock)을 얻으려고 무한히 기다리는 상태 (다른 쓰레드가 이미 lock을 가지고 있고 이 lock을 해제하지 않는 이상 다른 쓰레드는 lock을 얻으려고 계-속 기다리게 된다.)

 

lock 획득: Monitor.Enter()

lock 해제: Monitor.Exit()

 

class Program
{
    static int n = 0;
    static readonly object _lock = new();

    static void Thread1()
    {
        for (int i = 0; i < 100000; i++)
        {
            Monitor.Enter(_lock);
            n++;
            Monitor.Exit(_lock);
        }
    }

    static void Thread2()
    {
        for (int i = 0; i < 100000; i++)
        {
            Monitor.Enter(_lock);
            n--;
            Monitor.Exit(_lock);
        }
    }
	// main 함수는 동일
}

 

Monitor로 lock을 사용할 때, 사용자가 직접 원하는 시점에 lock을 해제할 수 있다. 물론 Monitor를 사용하여 lock을 구현 할 때는 같은 메서드 내에서 lock을 해제할 수 없을 때 사용할 것이라고 생각된다. 이 때 실행 흐름을 잘 확인하여 어느 흐름에서든지 lock을 직접 해제하는 코드가 나타나는지 확인해야만 한다.

즉, 제어권을 더 유연하게 다룰 수 있는 대신 책임이 따른다는 것이다.

 

웬만하면 Monitor 대신 lock을 사용해야 하는 이유는 한가지 더 있다. lock도 내부적으로는 Monitor로 구현되어 있지만 몇몇 최적화가 더 적용되어 성능이 더 낫다고 한다.