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


들어가기에 앞서 : 이 글은 크리티컬 섹션등의 동기화 개체나 관련 API 사용법 혹은 동기화가 이루어지지 않았을 때 발생하는 문제점 등을 설명하지 않습니다. 이 글은 성능저하를 보다 줄이면서 안전한 멀티스레드 프로그램을 구성하는 방법(스레드 모델)에 대해 얘기합니다.


1. 동기화 개체에 대한 잘못된 생각들

  스레드를 동기화한다 함은 스레드 간의 실행순서를 정하거나, 스레드 간 특정작업이 동시에 일어나지 않도록 구현하는 것을 말합니다. 

1) Manager Thread가 Worker Thread를 생성한 후, Worker Thread의 초기화가 끝날 때까지 기다려야 하는 경우. 이런 경우에는 보통 Event 개체나 메시지를 사용합니다.

2) 전역 데이터에 접근할 때에 한번에 스레드 한개씩만 해당 데이터를 사용하도록 구현하는 경우 : 이 경우에는 보통 CriticalSection 을 사용합니다. 일반적으로 스레드 동기화라 함은 이러한 경우를 말합니다.

3) 일정 숫자 이상의 스레드(혹은 프로세스)가 동시에 특정 자원을 사용하거나 동작하지 못하도록 하는 경우 : 이 경우에는 보통 Semaphore를 사용합니다.


CriticalSection을 사용하는 데에 있어서 초보자분들은 다음과 같은 착각을 하기 쉽습니다.

착각1) EnterCriticalSection()을 호출하면 Data에 Lock이 걸리고 다른 Thread가 접근하지 못하게 된다. (X)

==> CriticalSection 등의 동기화 개체에 Lock을 건다 함은 화장실이라는 Resource에 문을 걸어 잠그는 개념이 아니라, 화장실의 문에 "사용중"이라고 써붙이는 개념입니다. 이게 무슨 얘기냐 하면, "g_szData 라는 Data에 접근할 때는 crit_szData에 Lock을 걸어야 한다"라고 개발자가 스스로 규칙을 세우고, 코드의 모든 부분에서 g_szData를 사용할 때 CriticalSection에 Lock을 걸도록 구현해야 한다는 뜻입니다. 화장실의 문에 "사용중"이라고 써붙이고 들어갔는데, 어떤 사람이 문에 붙어있는 표시를 확인하지 않고 문을 열고 들어간다면? 운이 좋으면 문제가 안생기겠지만 재수가 없으면 민망한 상황이 벌어질 것입니다. 

엄밀히 말하면 데이터에 동기화개체는 스레드 들이 특정 데이터에 동시 Read/Write하지 못하도록 하는 기능을 제공하지 않습니다. 단지 동기화개체에 동시에 Lock을 걸지 못하는 기능을 제공할 뿐이죠. :)


착각2) 데이터에 Read할 때는 동기화가 필요 없다. 따라서 EnterCriticalSection은 Data에 Write를 수행할 때만 수행하면 된다. (X)

==> Data를 Read하는 스레드에서는 그냥 Read를 하면서 Data에 Write를 하는 스레드에서만 Lock을 건다고 해서 다른 스레드가 그 Data에 접근을 못하게 되는 것이 아닙니다. 어떤 CriticalSection에 Lock을 건다고 해서 스레드간 ContextSwitching이 중지되는 건 더더욱 아닙니다. 보호하고자 하는 데이터를 참조하는 모든 부분에 동기화 처리를 해주지 않으면 동기화 개체를 사용하는 것은 아무런 의미도 없습니다. 
간단하게 생각하면 CriticalSection이란 보호할 데이터를 사용하는 중에 BOOL형 변수에 "사용중"이라는 의미로 "TRUE" 값을 Setting하는 이상의 의미는 없습니다. 단지 두 Thread에서 동시에 TRUE를 설정하지 못하도록 OS에서 보장해준다는 점과 TRUE를 Setting하려고 할 때 이미 TRUE라는 값이 Setting되어 있다면 이 변수가 FALSE로 바뀔 때까지 스레드가 BLOCK되는 등의 처리가 자동으로 구현되어 있다는 정도가 차이가 있을 뿐입니다.
단지 프로그램 전체에 걸쳐서 어떠한 "Write"도 수행되지 않고 "Read"만 수행되는 데이터라면 동기화가 필요 없습니다. 

대부분의 개발자 분들은 이런 얘길 보면서 "이렇게 당연한  얘기를 왜 하나?"라고 생각하시겠지만... 바로 얼마 전에 십 수년간 개발을 하신 서버 개발자께서 "CriticalSection에 Lock을 걸면 말이쥐... 다른 스레드들은 이 데이터에 접근을 못하게 되는 거란다"라고 친절하게 설명해주시는 걸 듣고... 까무라치는 줄 알았습니다. ㅡ.ㅡ

※ 선언 함수나 초기화 함수를 보면 알수 있듯이, CriticalSection은 그 자체로서 어떠한 데이터와도 연관되지 않습니다. 어떠한 데이터를 보호하는데 어떤 CriticalSection 개체를 사용하겠다 하는 것은 순전히 개발자의 로직상으로 구현되는 부분입니다. 위에서 얘기했듯이 OS에서는 동시에 한개의 스레드만 EnterCriticalSection에 성공할 수 있다는 것 외에는 아무것도 보장하지 않습니다. 나머지는 개발자의 몫인 거죠.


2. Case I : Multiple-Reader + Multiple-Writer 환경 (CriticalSection Per Data Model)

  멀티스레드 환경에서 발생할 수 있는 가장 일반적인 경우는 다수의 스레드가 동시에 데이터를 읽고 쓰는 경우입니다. 이 경우에는 어떠한 "읽기"와 "읽기", "읽기"와 "쓰기" 혹은 "쓰기"와 "쓰기" 도 동시에 발생해서는 안되며, 완전하게 동시접속이 차단되어야 합니다. 이렇게 처리하는 것을 직렬화(Serialize)한다고 합니다.

  이런 환경을 구현하기 위해서는 CriticalSection을 접속하고자 하는 Data의 수만큼 만들고, (읽기/쓰기에 상관없이) Data에 접근할 때마다 해당 Data에 Matching 되는 CriticalSection에 대해 EnterCriticalSection을 수행해주어야 합니다. 말하자면 "CriticalSection Per Data" 정도 되겠네요.

가장 안전한 환경이 구현되지만, 병렬수행이 불가능해지므로 성능은 최악이 됩니다. 

그림으로 표현하면 다음과 같이 되겠네요.



3. Case II : Multiple-Reader + Single-Writer 환경 (CriticalSection Per Thread Model)

  모든 스레드가 읽기와 쓰기를 행한다면 Case I  과 같이 직렬화를 시켜야겠지만, 대부분의 Server용 Application에서는 다수의 Worker Thread는 전역 데이터에 대해 Read만을 수행하고 한개의 Manager Thread가 Write를 수행하는 구조인 경우가 많습니다. 대표적인 예가 Manager Thread가 기록한 정책을 참조하여 Worker Thread가 클라이언트의 접속을 처리하는 경우를 예로 들 수 있겠죠. (Thread Pool 이 보통 이런 동작을 합니다.)

이러한 경우는 다음과 같은 특징이 있습니다.


1) Read의 빈도가 매우 높고, Write의 빈도는 낮다. 

2) Read와 Read는 동시에 이루어지는 것이 바람직하지만 Write와 Read는 동시에 이루어져선 안된다.

3) WokerThread간의 병렬처리가 이루어져야 하므로 Read의 속도가 매우 중요하며 Write의 성능은 상대로 덜 중요하다.


위의 조건을 만족시키기 위해서는 스레드와 동기화 개체들을 어떤 식으로 구성해야 할까요? 제가 내린 결론은 다음과 같습니다. (더 좋은 방법을 아시는 분은 가르쳐주세요. ^^)

1) WorkerThread 마다 CriticalSection을 하나씩 생성한다. WorkerThread는 각자 자기의 CriticalSection을 가지고 있다. 말하자면 워커스레드가 6개라면 CriticalSection도 6개가 생성되며 이것이 Array처럼 접근되도록 구현된다.

2) WorkerThread 는 작업을 시작할 때마다 자기의 CriticalSection에 대해 EnterCriticalSection()을 호출하고 작업을 끝낸 후에는 LeaveCriticalSection을 호출합니다. 이렇게 되면 각 WorkerThread 간에는 작업이 병렬수행되며, 전역 Data에 대해서도 병렬 Read가 이루어집니다. (이때 ManagerThread 는 쉬고 있겠죠)

3) ManagerThread 가 정책을 업데이트해야 할 때는 For Loop 를 돌면서 모든 CriticalSection에 대해 EnterCriticalSection()을 호출합니다. 일단 ManagerThread가 CriticalSection에 Lock을 걸고 나면 해당 WorkerThread는 새로운 작업을 시작하지 못하기 때문에, For Loop가 끝나게 되면 결과적으로 ManagerThread는 모든 WorkerThread를 정지시키게 됩니다. (이때는 물론 시간이 좀 걸리겠죠? ^^)

4) ManagerThread는 정책을 수정합니다.

5) ManagerThread는 For Loop를 돌면서 모든 CriticalSection에 대해 LeaveCriticalSection()을 호출합니다. 이제 WorkerThread들은 다시 자신의 CriticalSection을 획득하고 밀린 일을 처리할 수 있습니다.


그림으로 표현하면 대략 다음과 같이 되겠네요.


WorkerThread간의 읽기작업을 표현하면 다음과 같습니다.


ManagerThread가 쓰기 작업을 진행할 때는 다음과 같이 됩니다.


말하자면... Write속도를 희생시켜서 Read속도를 증가시킨다고나 할까요? ^^


4. Case III : 동기화를 하지 않는 방법(?)

  "멀티스레드 프로그래밍에 관한 고찰 (1)" 에서 논했듯이 멀티스레드가 Data에 동시접근한다고 해도 경우에 따라서는 동기화 하지 않고 사용할 수 있습니다. 만약 이게 가능하다면 멀티스레드 프로그램은 최고의 효율을 얻을 수 있습니다. 이렇게 구성하기에 앞서 해당 Case가 이런 방식으로 구성 가능한지 검토해야 하며, 가능하다면 자료구조가 안전한 동시접근이 가능하도록 설계되어야 합니다.

정수형 Data가 Linked List에 저장되는 경우의 예를 들어 보겠습니다.

1) Data가 변경될 때 Linked List에 Entry가 추가/삭제되는 경우에는 반드시 스레드 동기화가 필요합니다. 반대로 시종일관 LinkedList 자체는 변화가 없이 각 Entry의 정수형 데이터값만 변경시키는 경우에는 동기화 없이도 Concurrent Read/Write가 안전하게 수행될 수 있습니다. 
   이러한 경우의 예를 들면 오목게임의 바둑판을 들 수 있습니다. 모든 좌표에 대해 게임을 시작하기 전에 19 X 19 개의 좌표 Data를 생성한 후, 게임이 진행되는 동안에는 해당 좌표에 대한 Data(흑/백/無)만 Update될 뿐 새로운 좌표가 추가되지는 않습니다. 이런 경우라면 동기화가 필요하지 않습니다.

2) 만약 Data가 Linked List에 추가/삭제되어야 하는 경우라면, 추가되는 데이터의 "경우의 수"에 대해 생각해보아야 합니다. 경우에 따라서는 Insert될 수 있는 Data의 경우의 수가 한정되어 있어 초기화시에 모든 경우의 수에 대한 메모리를 미리 생성해놓고 시작할 수 있는 경우가 있습니다. 이러한 경우라면 동기화 없이 멀티스레드가 동작할 수 있습니다.
   예를 들어, 사내 IP체계를 B Class를 사용하는 회사에서 IP를 사용하는 Mac주소와 사용자 정보를 저장하는 자료구조가 있다고 가정해보겠습니다. 

. 새로운 IP가 생성될 때마다 Linked List에 Entry가 생성되는 방식으로 설계한다면 스레드 동기화가 필요하며, 동기화하지 않을 경우 Entry를 LinkedList에 Insert하는 순간 Access Violation이 발생할 수 있습니다.

. 모든 경우의 수 (255 * 255 = 65525개)만큼의 Entry를 처음에 일괄 생성해놓고, 사용하지 않는 IP 에 대해서는 FALSE, 사용하는 IP에 대해선 TRUE를 기록하여 미사용 IP를 관리하고, 사용중인 IP에 대해서는 Mac주소와 사용자정보를 미리 할당된 메모리에 memcpy하는 방식으로 자료구조를 설계할 경우 동기화 처리 없이도 안전하게 Concurrent Read/Write가 가능합니다. 한마디로 "메모리를 희생하여 속도를 향상"시키는 방법이죠 :)



이 글에 있는 내용들은 저의 경험에 비추어 적은 것입니다. 물론 원칙적으로 얘기하면 정수형 데이터 하나에 대해서도 동기화를 해 주어야 합니다. 알고 있으니까요... 이런 태클은 사양합니다. ^^;;

+ Recent posts