SQL 버그라고 웃어 넘길려고 하다가 잠시, 내가 짠 코드에도 비슷한 문제는 없는지 다시 보게 되었습니다. 또, CPU 개수가 2의 배수라고 가정하고 개발된 프로그램들이 아마도 또 있을 것이라는 추측을 하게 됩니다. 같은맥락에서 32비트 unsigned int 값을 가지고 비트연산하여 각각의 비트를 하나의 CPU로 연관지어 프로그램하는 예는 흔히 있는 일 입니다. 이경우 32개 이상의 CPU지원은 불가능하게 됩니다.
최근에 구글은 Chrome 웹브라우저를 발표하였습니다. 그런데 흥미로운 점은 Chrome의 장점을 만화로 설명하는 온라인 책을 만들었는데, 그 내용은 전산학 OS강의를 방불케 합니다. 메모리 단편화(fragmentation)과 스레딩에 대해 설명하고 있으며 Chrome이 성능향상과 안정성을 위해 각각의 탭이 독립적인 프로세스에서 돌아간다고 합니다.
오랜동안 실행 중인 서버 프로그램이 갑자기 아무 이유없이 크래시를 일으키는 경우를 가끔 볼 수 있습니다. 그 이유 중 하나가 마찬가지로 메모리 단편화일 것입니다. 충분한 물리 메모리가 있음에도 불구하고 가상메모리가 단편화 되어 더 이상 메모리 할당이 불가능해질 수 있다는 것이지요.
그런데, 최근에 저는 또 한가지 이유를 발견하였는데 바로 스택 보호 페이지가 리셋되는 경우입니다. "단순히 정상인 메모리를 읽기만 함"으로써도 프로그램을 크래시 시킬 수 있다는 약간은 놀라운 사실입니다.
윈도우즈의 각각의 스레드 스택은 기본으로 1MB가 예약되어 있습니다. 하지만, 1MB전부가 commit (물리메모리로 매핑)된
것은 아니며, 스택 보호 페이지라는 것을 만들어 스택 크기가 커져 이 보호페이지를 액세스하게되면 예외(exception)가
발생하며, 이 때 OS 커널은 해당페이지를 Commit하고 새로운 보호페이지를 설정합니다. 아래 그림을 참고하시기 바랍니다.
이러한 방법으로 처음부터 모든 스레드에 1MB의 물리메모리를 스택으로 할당하지 않아도 되기 때문에 메모리 낭비를 상당히 줄일 수 있습니다.
문제는 어떤 특정 스레드의 스택 보호 페이지를 다른 스레드나 프로세스에서 읽기를 시도하는 것만으로도 보호 페이지가 리셋되어 사라지게 되며 이 경우 재설정이 되지 않고 스택크기도 늘어나지 않습니다.
보호 페이지가 사라진 상태에서 당장 문제가 나타나지는 않으나, 나중에 스택 보호 페이지가 없는 스레드가 스택을 많이 사용하게 되어 보호 페이지와 그 위의 commit되지 않은 페이지를 액세스하게 되면 Access Violation나며 어플리케이션이 종료될 가능성이 높습니다. 어플리케이션의 예외처리기 (unhandled exception handler)를 사용하여 오류 분석을 해봐도 원인을 찾기는 쉽지 않습니다.
이 문제를 실제로 보여주는 예제가 아래에 있습니다. 아래 코드를 복사하여 BrokenGuardPage.cpp라는 이름으로 저장한 다음, "cl BrokenGuardPage.cpp"와 같이 컴파일하면 예제 실행파일을 만들 수 있습니다.
AccessStackGuardFromOtherThread 함수는 새로운 스레드 하나를 생성하고 있으며 지역변수의 포인터를 새로운 스레드에 전달합니다. 새로 생성된 스레드는 전달된 지역변수 메모리값에 -0x2000을 하여 스택보호 페이지에 대해 읽기 만을 시도합니다.
이 프로그램을 실행하면 프로그램 STATUS_GUARD_PAGE_VIOLATION 예외가 먼저 발생합니다. 이 때 Unhandled exception handler가 설정되어 있어 프로그램은 종료하지 않고 계속실행이 됩니다. 다음으로 Crash 함수가 main 스레드로 부터 호출되는데, Crash는 단순히 큰 지역변수를 가지고 이를 초기화하려고 시도하고 있습니다. Crash 함수에 잘못된 코드는 전혀 없습니다만, Access Violation을 발생하며 프로그램이 종료되어 마지막의 "printf("End of Program.\n")"는 실행되지 않습니다.
//////////////////////////////////////////////////////////////// // // BrokenGuardPage.cpp // // Just use following commands for compile: // cl BrokenGuardPage.cpp // // www.devnote.net (9/26/2008) // ////////////////////////////////////////////////////////////////
같은 프로세스에서 뿐만 아니라, 읽기 메모리 권한(PROCESS_VM_READ)을 다른 프로세스에 부여하는 것만으로도 똑같이 프로그램 크래시를 유발할 수 있습니다. 또한 예외처리 핸들러에서 가끔 사용되는 IsBadxxxPtr API들도 똑같은 문제를 유발시킵니다. 아래 블로그를 참고하시기 바랍니다.
위 페이지의 첫번째 예제같이 보통 단순한 전역 bool 값을 사용하는 경우는 쉽게 문제를 알아낼 수 있지만, static object를 함수 안에 사용하는 경우 컴파일러가 내부 최적화하는 과정에서 전역변수 (두번째 예제의 "constructed")를 만들어 내기 때문에, 모르고 지나칠 가능성이 많습니다.
그러니까, 특히 C++를 사용한 멀티스레드 프로그램에서는 local static 변수를 사용을 자제하는 것이 좋겠습니다.
Visual C 2005에는 많은 Intrinsic 함수가 추가 되어 있습니다. 그 중 Lock-Free를 위해 많이 사용되는 Atomic Operation이 대부분 Intrinsic 함수로 지원되는데, 이것은 inline assembly를 사용하는 것과 동일한 효과를 발휘합니다.
아래 32비트 디스어셈블리 코드를 보면 Intrinsic 함수인 _InterlockedExchangeAdd 사용함으로써 함수 호출 없이 바로 에셈블리 코드가 삽입되는 것을 볼 수 있습니다.
Intrinsic 함수는 특히 inline assembly가 지원되지 않는 64비트 프로그램 제작에 매우 유용한데 아래 x64 디스어셈블리 코드를 보면, Intrinsic 함수가 아닌 윈도우즈 API함수도 Intrinsic과 동일하게 최적화 되고 있는 것을 알 수 있습니다.
전에 최대 생성 가능 스레드를 얘기하며, 스택오버플로우 예외처리에 관해 잠시 언급하였다. 오늘은 이에 관해 자세히 짚어 보자.
사실 이 주제는 멀티스레드 프로그래밍과 어느 정도 연관성을 가지고 있다. 예외처리(Exception handling)는 정상적인 프로그램의 흐름을 바꾸는 상황을 처리하기 위해 프로그래밍 언어에서 지원되는 것이다. 간단한 예로 "page fault" 혹은 "access violation" 이라고 불리우는 잘못된 메모리를 참조하는 경우이다. 이 문제는 가장 흔하게 발생하는 문제이며, 가장 골칫 거리이기도 하다. 초기화가 잘못된 메모리 변수를 사용하거나, 잘못된 메모리의 동적 할당 및 해제, 버퍼 오버플로우(buffer overflow)등등 여러가지 원인으로 나타난다. 또, "divde by zero"와 같은 것도 흔한 예외 상황 중의 하나이다.
이러한 예외가 발생하면 프로그램은 스스로 오류를 복구하여 계속 진행하는 것이 불가능해지며, 예외처리를 하지 않은 경우 프로그램은 종료되고, 윈도우즈 같은 경우윈도우즈 에러 리포트 다이얼로그가 나타난다.
그런데 멀티스레드 프로그램에서, 어떤 하나의 스레드가 실행하는 코드에서 예외상황이 발생했을 경우를 생각해 보자. 특히, 이 프로그램이 안정성이 매우 중요시되는 서버 프로그램일 경우, 그리고, 보통 이러한 서버 프로그램은 스레드풀(thread pool)을 가지고 병렬수행이나 비동기 작업을 수행하고 있다고 생각하면, 하나의 스레드에서 발생한 예외상황으로 전체 프로그램이 종료된다면 큰 문제가 될 수 있다. 그래서, 멀티스레드 프로그램에서는 예외상황에서도 안전한 코드(exception safety)를 작성하는 것이 매우 중요하다.
다른 예외와는 달리 스택오버플로우의 특징은, 스택을 더 이상 사용할 수 없기 때문에 __except 구문 안에서 스택을 많이 사용하는 함수 호출이나 지역변수의 사용을 금해야한다는 것이다. 그리고 가장 중요한 것은 스택의 메모리 보호페이지(guard page)가 사라진다는 것이다. 이와 관련된 좋은 MSDN의 예제가 아래 웹페이지에 있다.
위 MSDN에 나오는 예제를 보면 스택오버플로우를 두 번 이상 발생시키면 두번째부터는 access violation으로 바뀌는 것을 알 수 있다. 그 이유는 스택오버플로우 예외가 발생한 경우 스택이 손상되기 떄문이다. 스택의 맨 위 페이지는 PAGE_GUARD 페이지로 이 페이지를 액세스하려고 하면 윈도우즈는 이 guard page를 commit하여 사용가능하게 만들고 새로운 guard page를 그 바로 위에 만든다. 그런데 스택의 최대 크기는 프로그램 컴파일 당시 "/STACK:reserve[,commit]" 옵션으로 설정되거나 CreateThread API의 파라미터로 결정되며 이 크기 이상으로 더 커지려고할 경우 stack overflow가 발생되는 것이다. 이 경우 더이상 예약해놓은 메모리가 없으므로 guard page설정이 불가능해 진다.
프로그램에서 스택오버플로우가 생기는 주요인은 재귀함수의 잘못된 사용에 의한 것이다. 보통 디버깅을 위해서, __except 구문안에서 현재 콜스택을 로그 파일로 남기서나 덤프파일을 만드는데, 여기서 스택오버플로의 경우 특별히 처리해야할 사항은 다음과 같다.
1. 스택오버플로우가 발생했을 경우 최대한 스택 사용을 자제해야만 한다. 만약 로깅(logging)을 위해 스택사용을 할 수 밖에 없다면, 동적메모리를 어느정도 할당한 후, 현재 스택포인터 (ESP)를 이 메모리를 가르키게 하여 새로운 가상 스택을 만들고, 로깅이 끝나면 다시 ESP를 복구해주는 방법이 있다.
2. 버그 있는 재귀함수가 연속적으로 호출되면, 두번째 스택오버플로우 발생시에 guard 페이지가 이미 사라졌으므로 access violation을 일으킨다. 여기서 부터가 큰 문제이다. __except 구문 안에서 로깅을 위해 지역변수 값을 참조한다거나 하면, 또 다른 AV를 일으켜 프로그램이 갑자기 종료된다. 해결책은 예외코드가 EXCEPTION_STACK_OVERFLOW 일 경우__except 구문 안에서 _resetstkoflw()를 호출하여 guard page를 복구하면, 두번째 스택오버플로우가 발생해도, AV 예외코드로 처리되지 않는다.
이밖에도 멀티스레드 프로그램에서는 어떤 스레드에서예외상황이 발생했을 경우 이를 정상적으로 복구하는 것은 쉽지 않다. 특히 그 스레드가 Critical Section과 같은 lock을 가지고 있다면, dead lock을 발생시킬 확률이 매우 높다. 왜냐면 예외처리 루틴은 그 스레드가 어떤 lock을 가지고 있었는지 알기 힘들기 때문이다. 또, 동적메모리 할당을 했을 경우 그것을 해제 하기 어렵기 때문에, memory leak이 발생한다.
언젠가 어느 게임 커뮤티에서 하나의 프로그램이 만들 수 있는 최대 스레드 개수에 관해 논의 한 적이 있다. 잊어 버리기 전에 이에 관해 간단히 정리해 보겠다.
우선, 윈도우즈는 최대 스레드 개수에 대해 제한 사항을 두지 않는다는 것이다. 단지, 스레드 개수를 제한하는 가장 큰 요인은 다름 아닌 메모리이다. 각 스레드는 기본으로 1MB의 스택 메모리를 예약해 놓는다. 따라서 2000개의 스레드가 생성되면, 벌써 2GB의 메모리 주소 공간이 사용된다. 32비트 윈도우즈에서는 하나의 프로세스는 기본적으로 2GB의 사용자 메모리 주소를 가지므로 최대 2000개 스레드가 결국 한계이다.
여기서 한가지 주의하여야할 것은 가상 메모리와 실제 물리메모리를 구분해야 한다는 것이다. 위에서 2GB메모리 주소라고 한 것은 가상메모리 주소 공간이다. 스레드가 생성될 때 1MB중의 일부(약 4KB)만을 실제 물리메모리에서 가져와 사용(commit)하고, 나머지는 메모리 주소를 예약(reserve)해놓는 것이다. 그러니까, 가상메모리 공간 부족은 어쩔 수 없는 한계이다. 하지만, 물리메모리가 1GB라고 해도 가상메모리 공간이 줄어드는 것은 아니고 여전히 2GB이다. 즉, 1GB메모리를 가진 시스템에서도 최대 스레드 개수는 2000가 된다. 마찬가지로 물리 메모리가 4GB 있어도 하나의 어플리케이션은 가상 메모리 2GB의 제한이 걸린다. (Windows XP, Vista와 Windows Server 2003 Standard의 경우 4GB, Enterprise의 경우 32GB 의 최대 물리메모리를 가짐. 자세한 내용은 마이크로소프트사의 이 웹페이지를 참고 바람.)
* 실제 물리 메모리보다 큰 가상메모리를 사용하게 되면 OS는 당장 사용하지 않는 메모리 데이터의 일부분을 하드디스크의 페이지 파일로 옮겨(swap) 메모리를 사용 가능하게 만들어 줍니다. 이러한 경우 하드디스크 액세스로 인해 전체시스템 성능저하가 나타납니다.
하나의 어플리케이션이 최대 2000개의 스레드를 가지므로, 같은 어플리케이션 여러 개를 실행하면 2000개 이상의 스레드를 만드는 효과를 낼 수도 있다.
각, 스레드의 스택의 맨 위 페이지 (스택은 큰 메모리 주소에서 작은 주소로 자라난다)는 memory guard page로 설정되어, 만약 스택사용이 많아지게 되면 예외(exception)가 발생한다. 이 때 예약해 놓은 메모리 공간에 새 물리 메모리를 할당(commit)하여 스택의 크기는 최대 처음 확보해 놓은 가상 메모리(이것은 CreateThread API에서 파라미터로 변경할 수 있다)만큼 커질 수 있다. 이 이상 스택메모리를 사용하려고 하면 스택 오버플로우 예외가 발생한다.
여기서 중요한 점은 이처럼 한 번 할당된 스택메모리는 다시 줄어들지 않는다는 것이다. 스택을 많이 사용하는 것은 주로 재귀적(recursive) 함수 호출이나, 큰 지역변수(local variable)의 사용이다. _alloca를 쓰는 것도 마찬가지로 스택을 사용한다. 단 하나의 함수만이라도 큰 지역변수를 가지고 있다면, 그 함수가 호출된 이후 스택에 할당된 물리메모리는 계속 유지되게 된다.
할당할 수 있는 물리 메모리가 더 이상 없다면 Out Of Memory 에러가 발생하므로 예외처리를 제대로 하지 않으면 프로그램이 크래시될 수 있다. 물리적 메모리가 2GB이하인 경우, 스레드를 많이 만들면 이러한 Out Of Memory상황이 생길 가능성이 커진다.
스레드를 많이 만드는 것은 그리 권장할 만한 사항은 아니지만, 할 수 없이 많이 만들어야 한다면, 이러한 스택메모리에 의한 한계를 염두해 두어야할 것이다. 4GT라는 것을 사용하면 하나의 프로세스의 가상 메모리 주소를 3GB까지 늘릴 수 있다. 그렇다면 3000개의 스레드 생성도 가능해진다. 또, 모든 함수들이 스택을 많이 사용하지 않는다면 CreateThread API의 파라미터에 1MB보다 작은 스택메모리를 예약하도록 하면 10000개 이상의 스레드도 만들 수 있다. 만약 64비트 OS를 사용하면 더 이상 이러한 제약은 물론 없어질 것이다.
다음에 시간이 되면 스택오버플로우(stack overflow)가 발생하는 경우 어떻게 예외처리를 하는 지에 관해 써보도록 하겠다. 이것은 간단해 보이지만 스택오버플로우 상황에서는 더 이상의 스택을 사용하는 것이 허용되지 않고, 스택 보호페이지(guard page)가 사라져 프로그램이 아무런 에러 로그나 리포트를 하지 못하고 종료될 수 있다. 다시 말하면, 재귀적 함수호출이 무한히 반복되는 버그가 있는 경우 예외처리에 걸리지 않고 예기치 않게 프로세스가 종료되어 버리는 문제가 나타난다.