출처 : http://kuaaan.tistory.com/114


예전에는 고사양이라 하면 힘쎈 CPU를 의미했지만 지금 시대의 고사양이란 CPU 여러 개를 의미합니다. 이른바 멀티코어의 시대죠. 예전에 3.4GHz 4CPU가 최고사양 서버였다고 하면 요즘에는 1.6GHz 16Core (4Core * 4ea)가 동급으로 받아들여집니다. 

 이러한 H/W적인 패러다임의 변화는 S/W 개발에도 영향을 주어 예전에는 (어셈블리) 코드 한줄이라도 줄이는 게 퍼포먼스 향상의 열쇠였다고 한다면 지금은 여러 스레드(Thread) 들이 한 머쉰에서 서로 엉키지 않고(!) 조화롭게 돌아가는 구조를 구현하는 것이 퍼포먼스 향상의 열쇠라고 볼 수 있습니다. 

 그렇다면 멀티스레드 프로그램의 성능을 결정하는 열쇠는 무엇일까요?

 먼저 "스레드의 수"를 생각해 볼 수 있습니다.
 멀티스레드 프로그래밍을 해보시지 않으신 분들은 일단 스레드를 많이 만들면 퍼포먼스도 올라갈 것이라고 생각하시는 경향이 있읍니다만 이것은 잘못된 생각입니다. 
 중요한 것은 한개의 CPU는 동시에 한 개 씩의 스레드만 실행시킬 수 있다는 사실입니다. 스레드가 여러개가 생성되면 CPU는 각각의 스레드를 시분할하여 각각의 스레드를 번갈아가며 실행하게 되는데, 이때 이전 스레드의 문맥 정보 (레지스터 값, 실행중인 스택 정보 등)을 백업받고 백업받아놓았던 다음 스레드의 문맥정보를 로딩하는 과정을 거치게 됩니다. 이 과정을 Context Switching(문맥 교환이라고 번역하더군요) 이라고 하는데, 이러한 스레드가 많아질 수록 Context Switching 에 많은 부하가 걸리기 때문에 오히려 퍼포먼스는 떨어지게 됩니다. 
 그렇다면 스레드가 적을수록 퍼포먼스가 좋아질까요? 물론 그렇지는 않습니다. ^^
 

 위 그림과 같이 스레드는 작업을 진행함에 따라 Running <-> Waiting , Sleeping, Blocked 등으로 상태변화를 하게 되는데, 멀티스레드 프로그램은 한 스레드가 Waiting, Sleeping, Blocked 중일 때 CPU가 다른 스레드를 실행시킬 수 있기 때문에 동시성(Concurrency)이 높아져서 성능이 좋아지는 것입니다. 바꾸어 말하면 I/O가 많이 발생하는 프로그램일 수록 멀티스레딩의 효과를 크게 볼 수 있다고 말할 수도 있습니다.

위의 두가지 면을 종합할 때 멀티스레드 프로그램의 성능은 스레드의 "동시성 향상 효과"와 "Context Switching 비용"의 Trade Off 에 의해 결정된다고 말할 수 있습니다.
 따라서 가장 적절한 스레드의 수는 CPU의 수와 스레드가 수행하는 작업의 성격을 함께 고려하여 결정되는데 일반적으로는 연산 위주의 작업의 경우 CPU당 2~3개, I/O 위주 작업의 경우 CPU당 5개 내외를 적절한 스레드의 수로 가이드하며, 보통 스레드 풀(Thread Pool)을 생성할 때 워커 스레드 (Worker Thread)의 수를 산정하는 방식으로 사용됩니다.

 요즘 네트워크 프로그램에서 많이 사용되는 IOCP (Input Output Completion Port)가 성능이 좋은 이유는 I/O가 진행되는 동안 스레드가 스위칭되거나 블러킹되지 않기 때문에 스레드의 Context Switching을 최소화할 수 있고, 따라서 필요한 스레드의 수를 최소화할 수 있기 때문입니다. 그런 면에서 어찌보면 최소한의 스레드를 써야 높은 성능을 낼 수 있다고 볼 수 있는 거죠.


   그렇다면 "동시성 향상 효과"를 높이는 요인은 무엇이 있을까요? 바로 "효율적인 스레드 동기화" 를 들 수 있습니다.
  멀티스레드 프로그래밍은 프로세스의 동시성을 향상시켜 성능을 향상시키는 효과가 있지만, 여러 스레드 들이 동시에 데이터를 접근하면서 생기는 문제들이 빛과 그림자처럼 쫓아다니게 됩니다. 예를 들어 스레드 A가 링크드 리스트에서 Read를 시도하려고 하는 순간에 스레드 B가 해당 링크드 리스트에 ClearAll() 을 수행한다면 어떤일이 벌어질까요? 스레드 A는 메모리 폴트를 발생시키고 해당 프로세스는 중지되고 말 것입니다. 이러한 일을 방지하기 위해서는 스레드들이 링크드 리스트에 동시에 접근하지 못하도록 직렬화(Serialize)시킬 필요가 있는데 이런 작업을 "스레드 동기화"라고 합니다. 
  스레드 동기화가 잘못되었을 때 생기는 문제들이나 동기화 개체 사용법 등에 대해서는 인터넷에 좋은 포스트가 많이 공개되어 있으니, 여기서는 어떻게 하면 보다 효율적인 스레드 동기화를 구현할 수 있을 것인가에 대해 생각해보겠습니다. 
 문제는 스레드 동기화의 정도가 높아질수록 데이터에 대한 단일접근이 보장되어 안정성과 데이터 무결성은 높아지지만, 반면에 데이터에 접근할 때의 동시성이 저하되어 성능은 (형편없이) 떨어지게 됩니다. 여기서도 Trade Off 문제가 발생하는 거죠. 원칙적으로 모든 전역 데이터(Global Data)에 접근할 때 CriticalSection등의 동기화 개체를 사용하여 동시접속을 차단하는 것이 원칙이겠지만 그렇게 하게 되면 너무 성능이 떨어지기 때문에, 대부분의 서버 개발자들은 "이정도는 괜찮더라"는 나름대로의 선을 정해놓고, 그 범위 안에서 "적당한 물타기"를 시도하게 됩니다. 
 멀티스레드 프로그래밍을 할 때 제 나름대로의 동기화 기준을 공개한다면 대략 다음과 같습니다.

1. 전역 데이터에 멀티스레드가 읽기를 동시에 시도하는 경우에는 동기화 할 필요가 없습니다. 

2. 전역 데이터에 멀티스레드가 읽기와 쓰기를 혹은 쓰기와 쓰기를 동시에 시도하는 경우에는 "원칙적으로" 직렬화를 시켜야 합니다. (직렬화를 한다 함은 동시접근을 차단한다는 뜻입니다.) 이때, 저는 읽기 및 쓰기가 행해지는 데이터의 성격에 따라 다음과 같이 "적당한 대처"를 합니다. ^^
   1) 멀티CPU 환경에서는 기본적으로는 4바이트 정수 연산 하나에 대해서도 원자성을 100% 보장할 수 없습니다. 따라서 동기화의 비용(성능 감소)와 데이터 무결성이 깨어졌을 때의 발생가능한 손해를 비교하여 동기화 수준을 결정해야 합니다.
   2) 포인터 데이터에 대한 무결성 문제는 바로 메모리 폴트로 이어집니다. 따라서 대상 데이터 중에 "포인터"가 포함된 경우에는 반드시 동기화를 시켜야 합니다. 
   3) 단순한 정수/실수형의 경우 "대부분의 경우"에는 동기화에 신경쓸 필요가 없습니다. 하지만 이 데이터가 무결성에 얼마나 민감한지를 검토해볼 필요는 있습니다. 예를 들어, 어떤 일을 해야 할지 말지를 나타내는 Boolean (True/False) 값 등은 굳이 동기화할 필요가 없습니다. 만약 어떤 값을 카운트하는 변수일 경우, 카운트가 1~2개정도 어긋나면 어떤 문제가 생길지를 체크해봅니다. 만약 ++연산을 100번했을 때 99만 증가해도 큰 문제가 되지 않는다면 동기화하지 않아도 되겠지만, 정확한 카운트가 보장되어야 하는 경우라면 해당 변수를 Volatile 로 선언한 후 InterlockedAdd 함수 등을 이용해 동기화해주어야 합니다.
   4) Linked List, Binary Tree 등의 자료구조는 각 Entry들이 포인터로 연결되어 있습니다. 따라서 반드시 동기화해주어야 합니다. STL도 예외는 아닙니다. 다만, Linked List에 Entry가 Insert, Delete되지 않는다면 (포인터 연산이 일어나지 않기 때문에) 동기화가 필요하지 않을 수도 있습니다.
   5) 구조체나 클래스, 배열 등의 경우에는 각 Entry 의 데이터 타입이나 성격에 따라 위의 1)~3) 기준에 따라 판단해주면 됩니다.
   6) Memory Mapped File 등 프로세스간 공유되는 데이터의 경우에는 가급적이면 동기화해주는 것이 좋습니다.

3. 요즘에는 그런 일은 거의 없겠지만, 멀티CPU에서 실행되지 않는다는 것이 보장된다면... 사실 왠만한 동기화는 신경 안써도 됩니다. 뒤집어서 말하면... 멀티CPU에서는 동기화에 대해 깊이 고민해야 합니다.


다음번에는 각 케이스 별로 데이터 무결성을 보장하면서 성능 저하를 최소화하는 동기화 방법(구조)에 대해 생각해 보려고 합니다.

※ 위 글에는 필자의 개발 경험에서 우러나온 "적당"한 통밥이 사용되고 있습니다. 위의 내용을 실제로 개발에 적용하였다가 낭패를 보더라도 당연히 저는 책임지지 않습니다. ^^

+ Recent posts