[cpp] VS 2015의 magic statics 구현 세부 사항

@codemaru · November 24, 2016 · 11 min read

최근에 좀 골치 아픈 문제를 겪어서 여기에다 정리해 놓는다. 컴파일러를 업그레이드하면서 알게 된 문제인데 정적 지역 변수의 초기화와 관련이 있었다. 문제의 발단은 이랬다. 컴파일러 업그레이드 후 이상하게 싱글턴 등을 구현하기 위해서 사용된 static 지역 변수 코드가 정상적으로 동작하지 않는 문제가 있었다. 구글 형님들께서도 POD를 제외하고 전역 객치를 만들지 말라는 조언을 하셨기에 우리가 만든 코드는 대체로 포인터를 사용하도록 변경했다. 그러다 급기야 어제는 부스트 라이브러리 코드를 디버깅 하기에 이르렀다. 잘 알겠지만 부스트 같은 템플릿 라이브러리는 쓰기는 편리하지만 디버깅은 지옥이다. 문제는 property_tree에서 발생했다. property_tree가 내부적으로 사용하는 rapidxml을 쓰면 잘 동작했는데 property_tree 코드만 쓰면 xml을 정상적으로 파싱하지 못했다. 여기저기 printf를 추가해서 따라간 마지막 장소에는 static 객체가 있었다.

여기까지만 읽고 마치 새로운 컴파일러가 문제가 있다고 생각할 사람들이 있을까봐 노파심에 이야기를 하자면 컴파일러가 생성한 코드를 그냥 그대로 사용하면 문제가 되지 않는다. 우리가 그대로 사용하지 않고 별도의 처리를 해서 사용하면서 발생하는 문제였다.

이 문제의 원인은 지역 static 객체 생성과 관련이 있었다. C++11 부터는 지역 static 객체 생성이 스레드 안전하게 보장하도록 표준에 추가되었다. 이 기능을 magic statics라 부르는 듯하다. 그간에는 이 기능에 대한 지원이 되지 않다가 VS2015에서 이 기능을 구현하면서 문제가 된 것이다. 그러면 VS2015가 내부적으로 스레드 안전한 초기화를 보장하기 위해서 어떻게 구현을 했는지 살펴보도록 하자. 그래야 왜 문제가 발생했는지도 알 수 있고, 어떻게 고칠 수 있는지도 알 수 있을테니 말이다. VS2015가 static 지역 객체 초기화를 스레드 안전하게 하기위해서 사용하는 전략은 단순하다. 락과 TLS다.

int const Uninitialized    = 0;
int const BeingInitialized = -1;
int const EpochStart       = INT_MIN;

// Access to these variables is guarded in the below functions.  They may only
// be modified while the lock is held.  _Tss_epoch is readable from user
// code and is read without taking the lock.
extern "C"
{
    int _Init_global_epoch = EpochStart;
    __declspec(thread) int _Init_thread_epoch = EpochStart;
}

static CRITICAL_SECTION   _Tss_mutex;
static CONDITION_VARIABLE _Tss_cv;
static HANDLE             _Tss_event;

static decltype(SleepConditionVariableCS)* encoded_sleep_condition_variable_cs;
static decltype(WakeAllConditionVariable)* encoded_wake_all_condition_variable;

컴파일러가 동기화를 위해서 사용하는 자원이다. 크리티컬 섹션으로 동기화하고 조건 변수나 조건 변수를 지원하지 않는 시스템이면 이벤트를 통해서 초기화 완료를 알리겠다는 생각이다. 위에 적힌 전역 변수는 모두 DLL이나 EXE가 실행될 때 최초에 초기화 된다. 위와는 별개로 정적 지역 객체가 존재하면 컴파일러는 해당 객체의 초기화 상태를 트래킹하기 위한 전역 변수를 추가한다. 이제 간단한 싱글턴 객체를 만들어보자.

class Single
{
private:
	Single() { printf("ctor\n"); }

public:
	~Single() { printf("dtor\n"); }

	static Single& Instance() { static Single s; return s; }
};

핵심은 멀티스레드에서 Single::Instance를 미친듯이 호출한다고 할 때 그 내부에 있는 s 객체의 초기화를 단 한번만 하도록 만드는 것이다. VS2015는 다음과 같은 형태로 코드를 만든다. 여기서 우리에게 문제가 된 부분은 최초 락 없이 생성됐는지를 체크하는 조건문 if(g_single_once > _Init_thread_epoch)에 있었다. g_single_once는 단순히 0으로 초기화 되지만 _Init_thread_epoch은 0x80000000으로 초기화 된다. 문제는 이게 TLS 변수라는 점에 있다. VS2015는 이를 위해서 __declspec(thread)를 사용해서 암시적 TLS로 처리했는데 이를 정상적으로 지원하지 않아서 _Init_thread_epoch이 0으로 함께 초기화됐고 그러면서 생성도 되지 않은 객체가 생성됐다고 판단이 이루어진 것이었다.

int g_single_once = Uninitialized;

static SIngle&
Single::Instance()
{
	if(g_single_once > _Init_thread_epoch)
	{
		_Init_thread_header(&g_single_once);

		if(g_single_once == BeingInitialized)
		{
			// 생성자 호출
			Single::Single();

			// 종료 시에 소멸자 호출되도록 등록
			atexit(Single::~Single);
			_Init_thread_footer(&g_single_once);

		}
}

동기화 구현의 핵심은 _Init_thread_header와 _Init_thread_footer에 있다. 해당 함수의 구현은 다음과 같다.

// Control access to the initialization expression.  Only one thread may leave
// this function before the variable has completed initialization, this thread
// will perform initialization.  All other threads are blocked until the
// initialization completes or fails due to an exception.
extern "C" void __cdecl _Init_thread_header(int* const pOnce)
{
    _Init_thread_lock();

    if (*pOnce == Uninitialized)
    {
        *pOnce = BeingInitialized;
    }
    else
    {
        while (*pOnce == BeingInitialized)
        {
            // Timeout can be replaced with an infinite wait when XP support is
            // removed or the XP-based condition variable is sophisticated enough
            // to guarantee all waiting threads will be woken when the variable is
            // signalled.
            _Init_thread_wait(XpTimeout);

            if (*pOnce == Uninitialized)
            {
                *pOnce = BeingInitialized;
                _Init_thread_unlock();
                return;
            }
        }
        _Init_thread_epoch = _Init_global_epoch;
    }

    _Init_thread_unlock();
}
// Called by the thread that completes initialization of a variable.
// Increment the global and per thread counters, mark the variable as
// initialized, and release waiting threads.
extern "C" void __cdecl _Init_thread_footer(int* const pOnce)
{
    _Init_thread_lock();
    ++_Init_global_epoch;
    *pOnce = _Init_global_epoch;
    _Init_thread_epoch = _Init_global_epoch;
    _Init_thread_unlock();
    _Init_thread_notify();
}

이 모든 구현을 살펴보고 나면 의문이 든다. 왜냐하면 암시적 TLS를 사용하는 엄청난 결정을 한 반면 그 암시적 TLS가 가져다주는 이점은 Single::Instance 구현의 첫번째 조건문 if(g_single_once > _Init_thread_epoch) 앞에 메모리 장벽이 없어도 된다는 것 밖에는 없어 보이기 때문이다. Interlocked 함수 하나 안 쓰겠다고 암시적 TLS 지원이 정상적으로 안 되는 XP 시스템을 포기한 셈이다. 실제로 이 구현을 사용한 DLL을 XP 시스템에서 LoadLibrary 등으로 동적 로딩하면 정상적으로 동작하지 않는다.

그러니 더 고민이 되었다. 이 신박한 구현에는 내가 생각하지 않은 다른 이유가 있을거야. Interlocked 함수 하나 안 쓰겠다고 이렇게 만든 것은 아닐거야. 뭔가 다른 이유가 있을거야 라면서 엄청 고민을 해봤다. 심지어 실행 카운트를 추적하는 기법 같아 보여서 이게 혹시 ABA 문제 등과 연관된 것은 아닐까 싶어 이것 저것 예외 케이스를 많이 생각해 보았지만 그 또한 아닌 것 같았다. thread local storage, lock optimizing, double checked 등과 같은 키워드로 겁나 검색했지만 걸리는 건 아무것도 없었다.

그렇게 이틀을 고민하며 보낸 끝에 어이없는 곳에서 이 구현의 원본을 찾게 되었다. Mike Burrows라는 구글에 계신 형님께서 A Fast Implementation of Synchronized Initialization 이름으로 전파한 복음이었다. 그 상세한 알고리즘 분석 내용에는 내가 찾던 정답 또한 들어 있었다. "The technique is useful only if such an access is faster than a memory barrier." 이 테크닉은 TLS 접근이 메모리 배리어보다 빠를 때에만 의미가 있다는 말.

lock cmpxchg 보다는 mov 몇 개 더 있는게 더 빠르겠지? 아마도? 하지만 여전히 남는 의문은 그 차이가 XP를 포기할만큼 그리 컸을까 싶은 생각…​ 지원 중단된 과거 보다는 미래를 더 중요하게 생각해서 내린 결정인지도 모르겠다. 어쨌든 XP에서 돌릴 수 있도록 해당 기능을 끌 수 있는 플래그는 존재하니 말이다.

참고한 것들

There is a way to access thread-specific data or thread-local storage. The technique is useful only if such an access is faster than a memory barrier. For the disassembly above, the compiler used its ability to access thread-local storage using variables declared with __thread, so the thread-local access is a normal "mov" instruction with a different segment register.

-- http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2660.htm#Appendix

This is a known limitation with Windows XP and thread-safe local static initialization. The thread-safe local initialization code relies on thread-local storage for efficient lock-free checking in many cases. Unfortunately, XP contains bugs that prevent loading DLLs with thread-local storage variables from working correctly when the DLL is loaded through LoadLibrary. Since XP is out of its service lifetime these bugs are unlikely to be fixed.

As a workaround, you can compile the program with /Zc:threadSafeInit-, which disables the thread-safe implementation. The old implementation does not rely on thread-local storage and will work on XP even in the presence of LoadLibrary, but is, of course, not thread-safe. If the function containing the local to initialize may be called from multiple threads you will need to handle appropriate synchronization manually when using /Zc:threadSafeInit-.

-- https://connect.microsoft.com/VisualStudio/Feedback/Details/1941836

Starting in C++11, a static local variable initialization is guaranteed to be thread-safe. This feature is sometimes called magic statics. However, in a multithreaded application all subsequent assignments must be synchronized. The thread-safe statics feature can be disabled by using the /Zc:threadSafeInit- flag to avoid taking a dependency on the CRT.

-- https://msdn.microsoft.com/en-us/library/y5f6w579.aspx

@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중