언어/C#

스레드 동기화 - 기타 (이중 확인 락, 조건 변수, 컬렉션)

tsyang 2022. 6. 5. 14:56

2022.05.29 - [언어/C#] - 복합 스레드 동기화 요소

 

복합 스레드 동기화 요소

2022.05.09 - [언어/C#] - 단순동기화3 - 커널 모드 동기화 단순동기화3 - 커널 모드 동기화 2022.05.06 - [언어/C#] - 단순동기화2 (유저 모드 동기화 요소) 커널 모드 동기화 요소 커널 모드 동기화 요소는

tsyang.tistory.com

 

 

이중 확인 락 기법


늦은 초기화(Lazy initialization)를 이용해 싱글톤 객체를 생성할 때, 다수의 스레드가 동시에 싱글톤 객체를 요청한다면 싱글톤 객체가 여러 번 생성될 수 있다. 따라서 스레드 동기화를 통해 싱글톤 객체가 단 한 번만 생성되도록 해줘야 하는데 이때 자주 사용되는 기법이 이중 확인 락(double-check locking) 기법이다.

 

다음은 이중 확인 락 기법을 적용한 싱글톤 클래스이다.

internal sealed class Singleton
{
    private static readonly object _lockObj = new object();

    private static Singleton _instance = null;

    private Singleton()
    {
        //객체를 초기화하는 코드
    }

    public static Singleton Instance
    {
        get
        {
            if (_instance != null) return _instance;

            Monitor.Enter(_lockObj);

            if (_instance == null)
            {
                //왜 이런식으로 하는지는 이어서 설명
                Singleton temp = new Singleton();
                Volatile.Write(ref _instance, temp);
            }

            Monitor.Exit(_lockObj);

            return _instance;
        }
    }
}

설명이 필요 없는 간단한 코드이다.

 

그렇다면 싱글톤 객체를 생성할 때 왜

if (_instance == null)
{
	_instance = new Singleton();
}

 

대신에

 

if (_instance == null)
{
    Singleton temp = new Singleton();
    Volatile.Write(ref _instance, temp);
}

위와 같은 코드를 쓸까?

 

이유는 다음과 같다.

 

_instance = new Singleton();

몇몇 사람들은 위 코드를 보고 컴파일러가 다음과 같은 코드를 생성할거라 생각한다.

 

  1. Singleton 객체를 위한 메모리를 할당한다.
  2. 생성자를 호출하여 객체를 초기화한다.
  3. _instance에 참조 값을 할당한다.

 

그러나 실제로 컴파일러는 다음과 같은 코드를 생성한다.

 

  1. Singleton 객체를 위한 메모리를 할당한다.
  2. _instance에 참조 값을 할당한다.
  3. 생성자를 호출하여 객체를 초기화한다.

 따라서 스레드A가 2번을 수행하고 3번을 수행하기 이전에 스레드B가 싱글톤을 요청하여 아래의 코드를 만난다면?

if (_instance != null) return _instance;

_instance는 null이 아니므로 스레드 B는 아직 생성자가 호출되지 않은 Singleton 객체를 얻게 되고, 이런 문제는 잠재적으로 추적하기 상당히 어려운 버그가 될 수 있다.

 

그렇기 때문에 먼저 초기화된 Singleton 객체를 생성하고, 이를 Volatile.Write를 이용하여 참조값을 넘겨주도록 코드를 작성한 것이다.

 

참고로, Java에서는 위와 같은 코드가 제대로 동작하지 않을 수 있다. JVM은 첫 번째 if문(_instance가 null인지 체크하는 부분)에서 CPU 레지스터에 이 값을 저장하게 되고 두 번째 if문장이 수행되면 앞서 레지스터에 저장된 값을 사용하기 때문에 두 번째 if문의 조건은 항상 true가 된다. 결국 여러 스레드가 여러 번에 걸쳐 Singleton객체를 생성한다. 그러나 여러 스레드가 동시에 Singleton을 요청하고 초기화 하는 일은 매우 드물기 때문에 찾기 매우 힘든 버그가 될 수 있다.그러나 CLR에서는 모든 lock 메서드가 완벽히 메모리 펜스를 지원하기 때문에 이런 문제 소지가 없다.

 

 

그러나 이런 이중 확인 락 기법은 사실 효율이 좋지는 않다. 스레드가 블로킹 될 수 있기 때문인데, 스레드가 블로킹되면 스레드 풀이 CPU를 더 많이 사용하기 위해 추가적으로 스레드를 생성한다. 이렇게 되면 더 많은 메모리를 사용하고, 초기화에도 시간이 허비되며, 모든 DLL에 스레드 접속 통지(thread attach notification)를 전달해야 하기 때문에 상당한 시간이 허비된다.

 

대신에 이중 확인 락 기법 대신 사용할 수 있는 패턴 몇가지가 있다.

 

 

 

클래스 생성자(정적 생성자)를 이용한 방법

public sealed class Singleton2
{
    private static Singleton2 _instance = new Singleton2();

    private Singleton2()
    {
        //초기화
    }
    
    // 컴파일러에게 이 클래스가 beforefieldinit 타입이 아니란 걸 알려주는 용도의 정적 생성자
    static Singleton2()
    {
    }


    public static Singleton2 Instance => _instance;
}

사용자가 특정 타입의 멤버에 최초로 접근하려고 시도하면, CLR은 자동으로 해당 타입의 클래스 생성자 스레드-안정적으로 호출한다. 그러나 이런 방법은 Singleton2 객체가 아닌 다른 멤버에 접근하는 경우에도 Singleton2 객체가 생성된다는 단점이 있는데, 이는 중첩 클래스를 정의해서 해결할 수 있다.

 

 

 

 유저 동기화 요소를 이용한 방법

public sealed class Singleton3
{
    private static Singleton3 _instance = null;

    private Singleton3()
    {
        //초기화
    }

    public static Singleton3 Instance
    {
        get
        {
            if (_instance != null) return _instance;
            Singleton3 temp = new Singleton3();

            //_instance가 null이면 temp를 저장
            Interlocked.CompareExchange(ref _instance, temp, null);

            return _instance;
        }
    }
}

위 코드는 유저 동기화 요소를 사용하여 속도가 빠르지만, 여러 개의 Singleton3 객체가 생성될 수 있다. 그러나 이런 일은 흔히 발생하지 않으며, Interlocked를 이용해 단 하나의 객체만 _instance에 퍼블리싱 됨을 보장할 수 있다. 사용하지 않는 객체는 가비지로 수집된다. 생성자가 여러 번 호출되어도 문제 없는 경우에 이런 방식을 사용해야 한다. 

 

 

 

 

FCL에는 늦은 초기화를 수행해주는 기능을 제공한다. System.Lazy<T>와 LazyInitializer클래스의 정적 메서드이다.

 

 


 

조건 변수 패턴


여러 스레드가 동시에 접근할 수 있는 코드에서 특정 조건이 참일 경우에만 코드를 수행하도록 하고 싶을 수 있다. 이를 위한 대표적인 방법은 스레드를 계속 스피닝시키면서 조건을 지속적으로 확인하는 것이다. 그러나 이런 방법은 CPU를 낭비하고, 여러 변수로 구성되어 있는 복잡한 조건을 원자적으로 확인하지 못한다.

 

다행히 조건 변수 패턴(condition variable pattern)을 이용하여 이를 해결할 수 있다.

public sealed class Example
{
    private readonly object _lock = new object();

    //여기서는 단순한 bool이지만 상당히 복잡해 질 수 있다.
    private bool _condition = false;

    public void Thread1()
    {
        Monitor.Enter(_lock);

        while (!_condition)
        {
            //임시로 락을 해제하여 다른 스레드가 락을 획득할 수 있도록 한다.
            Monitor.Wait(_lock);
        }

        //원하는 작업 수행

        Monitor.Exit(_lock);
    }

    public void Thread2()
    {
        Monitor.Enter(_lock);

        //원하는 작업을 수행하고 condition을 바꿈
        _condition = true;

        //Monitor.Pulse(_lock);     //락을 해제하고 대기 중인 스레드 하나를 깨움.
        Monitor.PulseAll(_lock);    //락을 해제하고 모든 스레드를 깨움.

		//Pulse를 했어도 Exit을 해야 다른 스레드가 락을 소유할 수 있음
        Monitor.Exit(_lock);
    }
}

이 패턴을 이용하면 단순히 하나의 락만을 이용하여 여러 변수들을 조합한 복잡한 조건을 확인할 수 있다.

 

 


 

컨커런트 컬렉션 클래스


FCL은 ConcurrentQueue, ConcurrentStack, ConcurrentDictionary, ConcurrentBag (중복허용, 순서없는 집합) 클래스를 제공한다. 

 

모든 컬렉션들은 스레드를 블로킹하지 않는다. 따라서 컬렉션 내에 존재하지 않는 항목을 가져오려 하는 경우, 해당 항목이 삽입될 때 까지 기다리는 것이 아니라 즉각 반환한다. 그래서 TryXXX() 형태의 메서드들을 제공하는데 이 메서드들은 요청한 항목을 가져올 수 있을 경우 true를 아닌 경우 false를 반환한다.

 

ConcurrentDictionary는 내부적으로 Monitor를 사용하고 있지만 컬렉션 내의 항목을 조정하기 위해 아주 짧은 시간만 락을 사용한다.

ConcurrentQueue와 ConcurrentStack은 내부적으로 Interlocked를 이용하여 컬렉션 내의 항목을 조절한다.

ConcurrentBag은 스레드별로 독립된 컬렉션을 따로 가지고 있다. 스레드 전용의 컬렉션에 항목을 추가하는 경우 Interlocked를, 항목을 컬렉션으로 가져오려 할 때 해당 스레드의 컬렉션에 없다면 Monitor를 이용하여 락을 획득한 후 다른 스레드의 컬렉션으로부터 요청한 항목을 가져오려고 시도한다. 

 

암튼 뭐.. 일단 이런게 있다는 걸 알아만 두자.


참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 30장. 복합 스레드 동기화 요소  [921~942p]

'언어 > C#' 카테고리의 다른 글

(C# 7.2) Span<T>  (0) 2022.08.07
C# async/await  (0) 2022.06.12
복합 스레드 동기화 요소  (0) 2022.05.29
단순동기화3 - 커널 모드 동기화  (0) 2022.05.09
단순동기화2 - 유저 모드 동기화  (0) 2022.05.06