22.5K
sealed class Singleton
{
private static Singleton s = new Singleton();
private Singleton() { Console.WriteLine("A constructor is called"); }
public static Singleton Create()
{
Console.WriteLine("Create() is called");
return s;
}
}
먼저 클래스 정의는 sealed로 되어 있다. C#에서 sealed의 의미는 봉인된(sealed) 클래스라는 의미이며, 이는 클래스 상속을 금지시킨다. 이와 같이 선언하는 이유는 클래스를 상속하여 상속된 클래스의 인스턴스 생성을 금지시키기 위한 것이다. 즉, 하나의 클래스가 하나의 인스턴스를 갖도록 보장하기 위한 것이다. C#에서 모든 클래스는 System.Object 클래스, 즉 Object 클래스를 상속하기 때문에 sealed로 선언된 Singleton 클래스도 Object 클래스의 메소드를 물려받는다. 이중에 클래스의 복사본을 반환할 수 있는 MemberwiseClone() 메소드는 protected이므로 상속받은 클래스에서 오버라이드(override)할 수 있으며 또 다른 문제는 ICloneable 인터페이스를 상속하여 Clone() 메소드를 상속받은 클래스에서 구현할 수 있다는 것이다. 그러나 sealed로 선언한 클래스는 상속 자체가 금지되기 때문에 이러한 문제에서 자유로울 수 있다. 물론, Singleton.MemberwiseClone()을 사용하기를 원할 수도 있지만 컴파일러에서 컴파일조차 되지 않기 때문에 이런 문제에서도 자유롭다.
using System;
sealed class Singleton
{
private static Singleton s = new Singleton();
private Singleton() { Console.WriteLine("A constructor is called"); }
public static Singleton Create()
{
Console.WriteLine("Create() is called");
return s;
}
}
class SingletonApp
{
public static void Main()
{
Singleton s1 = Singleton.Create();
Singleton s2 = Singleton.Create();
// can’t use because of sealed class
// Singleton s3 = (Singleton) s2.MemberwiseClone();
Console.WriteLine(Object.ReferenceEquals(s1, s2));
}
}
결과는 다음과 같다.
A constructor is called Create() is called Create() is called True몇 개의 Create()를 호출하더라도 항상 같은 인스턴스를 갖게 되며, 결과적으로 하나의 인스턴스만 생성할 수 있다. 그러나 위와 같은 구현은 멀티 스레드 환경에서는 불안하다. 멀티 스레드 환경에서도 안전하도록 Singleton 클래스를 바꿔보자. 그리고 C# 언어에 맞도록 적절하게 리팩토링을 해보자.
sealed class Singleton
{
private Singleton() { Console.WriteLine("a instance is created"); }
public static readonly Singleton Instance = new Singleton();
}
새롭게 바꾼 Singleton 클래스는 위와 같다. Singleton() 생성자에는 어떤 코드도 작성하지 않지만 여기서는 어떤식으로 동작하는지 알 수 있게 하기 위해 Console.WriteLine을 사용했다. 어쩌면 위 코드는 조금 당황스러울 수도 있다. 그러나 앞서 작성한 것과 동일하게 동작하는 완전한 Singleton 클래스이며 마찬가지로 멀티 스레드 환경에서도 안전하다.(thread-safety)
using System;
using System.Diagnostics;
sealed class Singleton
{
private Singleton() { }
public static readonly Singleton Instance = new Singleton();
}
class SingletonApp
{
public static void Main()
{
Singleton s1 = Singleton.Instance;
Singleton s2 = Singleton.Instance;
Console.WriteLine(Object.ReferenceEquals(s1, s2));
}
}
s1과 s2의 비교결과는 True라는 것을 알 수 있을 것이다. 즉, 하나의 인스턴스만 생성된다.
sealed class SingletonCounter
{
private SingletonCounter() {}
public static readonly SingletonCounter Instance = new SingletonCounter();
private static int count = 0;
public int NextValue()
{
return ++count;
}
}
클래스 코드는 위와 같다. 처음의 Singleton에서 카운터를 유지하기 위해 count 멤버 변수를 추가하고, 값을 증가시키는 NextValue() 메소드를 추가했다. 또한 count를 int 형으로 사용하고 있다. Int 형은 32비트를 사용하기 때문에 32 비트 아키텍처에서 최소 단위 오퍼레이션(atomic operation)을 허용한다. 즉, 인스턴스 수준이거나 클래스 수준(static)이냐에 상관없이 항상 원자성(atomic)을 보장한다. 32 비트 아키텍처에서 64 비트 데이터 형식인 long등을 사용할 수 있지만 static에 대해서만 스레드 안전성을 보장할 뿐이고 인스턴스 수준에서는 스레드 안정성을 보장하지 않는다. Long과 같은 64비트 데이터 형식을 사용할 경우, 하나의 스레드가 하위 32 비트를 조작한 다음에 블록 당할 수 있고, 이러한 불안정한 값을 다른 스레드가 읽어들이는 경우도 생길 수 있다. 따라서 두 스레드가 64 비트 데이터 형식을 업데이트하는 경우에는 결과값을 예측할 수 없게 된다.(하위 32 비트가 먼저 채워지는 것은 Intel, DEC, Alpha 프로세서와 같이 리틀 엔디안(little-endian)을 사용하는 아키텍처의 경우이며, IBM 370, RISC, Motorola 프로세서와 같은 빅엔디안(big-endian)을 사용하는 아키텍처의 경우에는 상위 32비트만 채워진체 반환될 수 있다.) 또한, 32 비트 아키텍처에서 64 비트 데이터 값을 대입하는 것은 원자 연산(atomic operation)이 아니다. 이와 같은 이유로 SingletonCounter 클래스의 내부 변수는 모두 int 형식을 사용하였다.(즉, native int 형의 크기까지만 atomic operation이 보장되며, native int보다 큰 형들은 atomic operation이 보장되지 않는다.)
using System;
sealed class SingletonCounter
{
private SingletonCounter() {}
public static readonly SingletonCounter Instance = new SingletonCounter();
private static int Count = 0;
public int NextValue()
{
return ++Count;
}
}
class SingletonApp
{
public static void Main()
{
for (int loopctr = 0; loopctr < 5; loopctr++)
{
Console.WriteLine(SingletonCounter.Instance.NextValue());
}
}
}
실행 결과에서 알 수 있는 것처럼 매우 잘 실행된다는 것을 알 수 있다. 여기서 유일한 단점은 클래스에서 카운터의 값을 감소시킬 수 없다는 것 뿐이다. 전체 응용 프로그램에서 유일한 카운터를 유지하는 경우에도 사용할 수 있으며 일정한 개수의 인스턴스를 생성할 수 있게 구현하여 객체 풀을 생성할 수도 있다. 이러한 객체 풀링에는 ADO.NET등에서 사용하는 연결 풀링(connection pooling)도 있으며 프린터 스풀러와 같은 프린터 스풀(printer spool)도 있다.
using System;
class Singleton
{
private static Singleton instance = null;
private Singleton()
{
Console.WriteLine("a constructor is called");
}
public static Singleton Instance()
{
if ( instance == null )
{
instance = new Singleton();
}
return instance;
}
}
class WrongSingletonApp
{
public static void Main()
{
Singleton s1 = Singleton.Instance();
Singleton s2 = Singleton.Instance();
Console.WriteLine(Object.ReferenceEquals(s1, s2));
}
}
위 예제는 C++, Java 등에서 소개되는 전형적인 Singleton 구조를 C#으로 옮긴 것이다. 실행해보면 이 예제는 잘 동작하며, 앞에서 소개한 것과 같아 보일 것이다. 그러나 멀티 스레드 환경에서는 제대로 동작하지 않는다. (앞서 소개한 Singleton과 SingletonCounter 클래스는 멀티 스레드 환경에서 잘 동작한다.) 왜 제대로 동작하지 않는지 살펴보자.
public static Singleton Instance()
{
if ( instance == null ) // 1
{
instance = new Singleton(); // 2
}
return instance;
}
두 스레드가 있을 때 두 가지 시나리오가 가능하다. 첫번째는 A 스레드가 1번 위치까지 실행된 다음에 블록되고 B 스레드가 1번과 2번까지 모두 실행하여 인스턴스를 얻은 다음에 A 스레드에게 제어권이 돌아가면 instance 변수가 null 이라고 알고 있으므로 새로운 인스턴스를 추가로 생성하게 된다. 두 번째는 두 스레드가 각각 사이 좋게 1번까지 번갈아 실행한 경우를 말하는 것으로 두 스레드 모두 instance 변수가 null이라고 알고 있으므로 2번까지 실행하게 되고, 결과적으로 두 개의 인스턴스가 생성된다. 즉, instance 변수의 값을 읽는 스레드와 instance 변수의 값을 쓰는 스레드간의 경쟁이 원인이 되는 것이다.
[MethodImpl(MethodImplOptions.Synchronized)]
public static Singleton Instance()
{
if ( instance == null )
{
instance = new Singleton();
}
return instance;
}
메소드 전체에 대해서 동기화를 하고 있기 때문에, 멀티 스레드 환경에서도 잘 동작할 것이다. 그러나 메소드 전체를 동기화하기 때문에 부하가 높다. 이를 줄이기 위한 다양한 변형이 있지만 여기서는 생략할 것이다. (이러한 변형들은 모두 C++, Java에서만 의미가 있을 뿐이며 완전한 C# 버전은 본 기사의 처음에 소개했다.)
public static Singleton Instance()
{
if ( instance == null )
{
lock(typeof(Singleton) )
{
if ( instance == null )
{
instance = new Singleton();
}
}
}
return instance;
}
이 코드는 상당히 잘 동작하는 것 같지만, 멀티 스레드 환경에서 이미 동작하지 않는다는 것을 알 수 있다.(Java를 사용하고 있다면 lock(typeof(Singleton)) 대신에 synchronized(Class.forName(“Singleton”))으로 바꾸면 된다.) 이러한 동작은 메모리 모델, 최적화, 리오더링(reordering)과 같은 복잡한 문제로 동작하지 않는다. 이에 대한 자세한 내용은 관련 자료 링크를 참고하기 바란다. 또한, 자바와 관련하여 많은 내용들이 있지만 그러한 내용들이 C#과 닷넷 플랫폼에 그대로 적용된다고 생각하지 않기 바란다.
JLS(Java Language Specification)을 참고할 때, 변수가 volatile로 선언되면 실행 순서가 일관적인 것으로 여겨지며, 재배치(reordering)이 일어나지 않는다. Peter Haggar은 두 가지 문제를 지적하고 있다. 첫번째는 순서 일관성의 문제가 아니라 최적화를 통해 코드가 옮겨지는 문제, 두번째는 많은 JVM이 volatile에 대한 순서 일관성조차 제대로 구현하고 있지 않다.라는 것이다. 자세한 것은 그의 글을 참고하기 바란다.이러한 차이점 때문에 Java에서는 volatile을 사용한 방법이 DCL에 대한 해법이 될 수 없지만, C#에서는 volatile을 사용한 방법이 해법이 될 수 있다. 다음은 volatile를 사용해서 C#에서 DCL을 사용한 방법을 보여준다.
#define TRACE
using System;
using System.Threading;
using System.Diagnostics;
public sealed class MTSingleton
{
private static volatile MTSingleton instance = null;
private static object syncRoot = new Object();
private MTSingleton() { Console.WriteLine("Hello"); }
public static MTSingleton Instance
{
get
{
if ( instance == null )
{
lock(syncRoot)
{
if ( instance == null )
instance = new MTSingleton();
}
}
return instance;
}
} // Instance
}
class MTSingletonApp
{
public static void Main()
{
Trace.Listeners.Add(new TextWriterTraceListener(Console.Out));
Trace.AutoFlush = true;
Trace.Indent();
MTSingleton s1 = MTSingleton.Instance;
MTSingleton s2 = MTSingleton.Instance;
MTSingletonApp ap = new MTSingletonApp();
ap.DoTest();
Thread.Sleep(2000);
Console.WriteLine(Object.ReferenceEquals(s1, s2));
Trace.Unindent();
}
public void DoTest()
{
Thread t1 = new Thread(new ThreadStart(CreateMTSingleton));
Thread t2 = new Thread(new ThreadStart(CreateMTSingleton));b
t1.Start();
t2.Start();
Trace.WriteLine(t1.ToString());
Trace.WriteLine(t2.ToString());
}
public void CreateMTSingleton()
{
MTSingleton s1 = MTSingleton.Instance;
MTSingleton s2 = MTSingleton.Instance;
Trace.WriteLine(s2.ToString());
}
}
여기에서 다음을 눈여겨 봐야 한다.
private static volatile MTSingleton instance = null; private static object syncRoot = new Object();MTSingleton 클래스에 대한 인스턴스를 instance에 저장하고 있으며, 키워드는 volatile로 정의되어 있다. 스펙에 있는 대로 읽기와 쓰기가 동작하는 경우에 volatile read와 volatile write라고 한다. (The ECMA Common Language Infrastructure(CLI) spec, in Partition I, section 11.6.5와 11.6.7을 보면 volatile read는 ‘acquire semantics’를 의미하고, volatile write는 ‘release semantics’를 의미한다는 것을 알 수 있다.) 자바 역시 스펙에는 volatile의 동작이 정해져 있지만, 왜 동작하지 않는가는 Peter Haggar의 글을 참고하기 바란다. 또한 JLS Chapter 17에 정의된 volatile이 왜 제대로 동작하지 않으며, 새로운 volatile에 대한 정의가 어떻게 진행되는지에 대해서는 JSR 133 - Java Memory Model and Thread Specification Revision를 참고하기 바란다. 그러나 닷넷에서는 잘 동작한다. 두 번째로 선언된 object 변수 syncRoot는 동기화 블록을 얻기 위해 사용한 것이다.
public static MTSingleton Instance
{
get
{
if ( instance == null )
{
lock(syncRoot)
{
if ( instance == null )
instance = new MTSingleton();
}
}
return instance;
}
} // Instance
Instance는 메소드로 하지 않고, 간단히 읽기 전용 속성으로 구현하였다. 이 코드는 자바에서는 잘못된 DCL이지만 C#에서는 문제없이 동작한다(이 문제 역시 Peter Haggar와 다른 자바 커뮤니티의 글을 참고하기 바란다). 물론 이외에도 다양하게 동기화를 할 수 있다. Lock(syncRoot)와 같은 다른 객체를 두어 동기화를 시도하는 것은 자바에서 사용되던 방법이며, 마찬가지로 lock(typeof(MTSingleton))과 같이 사용하는 방법도 있고, MethodImpl을 사용하여 동기화하는 방법도 있다.
private static volatile MTSingleton instance = null; // 인스턴스화된 객체를 메모리에 확실히 쓰기 위한 동기화 블럭용 객체 private static object syncRoot = new Object();그리고 다시 volatile을 사용했다. volatile의 의미는 앞에도 적었지만 다시 옮기자면, volatile로 선언된 변수는 메모리를 캐시하는 WorkingSet등을 통해서 값을 액세스하지 않고, 항상 메모리에 있는 값을 직접 액세스한다는 것이다. 즉, volatile로 선언된 변수에 대한 연산은 최소 단위 오퍼레이션(atomic operation)을 보장하지 않는다. 따라서 instance 변수에 대한 액세스는 모두 캐시되지 않으며, 메모리에 직접 액세스한다. 이 동작은 C# Language Specification에 명시되어 있으며, 닷넷 플랫폼에서 일관되게 수행된다. 반면에 자바에서는 JLS에 volatile이 정의되어 있으나, 각 벤더들이 구현한 JVM에서 이들은 일관되게 구현되지 않고 있다. 즉, 여러 스레드가 공유 자원에 대해 액세스할 때 액세스되는 순서에 대한 일관성을 보장하지 않고 있다. 따라서 자바에서는 DCL을 위해 volatile 키워드를 사용하는 것은 특정 JVM에서는 수행되지만 다른 JVM에서는 수행되지 않는 문제점이 있다. 즉, volatile을 사용한 DCL 구현은 사용할 수 없다.(이것으로 그의 질문에 대한 답이 되었으면 좋겠다.)
unlock() | mb | some code(instruction 1| instruction 2 | instruction 3…) | mb | lock()우리가 사용하는 lock()은 메모리상에 위와 같이 쓰여질 수 있을 것이며, mb 사이에 있는 코드에 대한 재배치를 막을 것이다. 그러나 mb 내부에 있는 some code들의 순서가 바뀌는 것은 막을 수 없다. 이를 막기 위해 메소드와 속성들의 get 블록과 set 블록 내부도 모두 동기화를 구현해야 한다. 즉, 단 하나의 문장으로 이뤄진 코드일지라도 동기화 블록 안에 배치하여 mb를 사용하게 해야 한다는 것이다. C++에서는 인라인 어셈블리를 사용하여 직접 메모리 장벽에 해당하는 코드를 작성할 수 있으나, 그것은 플랫폼 독립이 아니다. 메모리 모델, 메모리 장벽, 메모리 재배치(reordering 또는 out-of-order wirtes)에 대한 자세한 내용은 각 언어의 명세서를 살펴보기 바란다. 여기서는 자세히 설명하지는 않을 것이다.
댓글