SimpleIsBest.NET

유경상의 닷넷 블로그

퀴즈 모범 답안: 동기화 버그

by 블로그쥔장 | 작성일자: 2006-09-27 오전 9:31:00
이 글은 오래된 전에 작성된 글입니다. 따라서 최신 버전의 기술에 알맞지 않거나 오류를 유발할 수 있습니다. 저자는 이 글에 대한 질문을 받지 않을 것입니다. 하지만 이 글이 리뉴얼 되면 이 글에 대한 질문을 하거나 토론을 할 수도 있습니다.

지난 주에 나간 퀴즈에 여러 분들의 열화와 같은 성원에 힘입어 서둘러 글을 쓰게 되었다. (열화와 같은 성원은 무슨... -_-; 항상 오는 독자들만 와서 글 남겼더만... -_-) 쓸데 없는 소리는 집어 치우고 곧장 본론으로 들어가자.

Answer: Synchronization Tip

지난 글의 문제는 다음 리스트1 에서 버그를 찾는 것이었다.

    1     // 키/값을 검색해주는 간단한 예제 클래스

    2     public static class LookupTableBug

    3     {

    4         // 내부 테이블(해시테이블 이용)

    5         private static readonly Hashtable _Table = new Hashtable();

    6 

    7         // 주어진 키에 대응되는 값을 찾아 반환한다.

    8         public static object Lookup(object key)

    9         {

   10             object val = _Table[key];

   11             if (val == null) {

   12                 lock (_Table.SyncRoot) {

   13                     val = PublishValue(key);

   14                     _Table[key] = val;

   15                 }

   16             }

   17             return val;

   18         }

   19 

   20         // 주어진 키에 대한 값을 DB, 파일, 웹 서비스 호출 등에서

   21         // 읽어 테이블에 추가한다.

   22         private static object PublishValue(object key)

   23         {

   24             return "Data";

   25         }

   26     }

리스트1. 키-값 검색을 하는 룩업(lookup) 테이블 클래스

사실 다중 쓰레드의 동기화에 관련된 문제는 그 원인을 파악하기 매우 힘들다. 필자가 다중 쓰레드의 문제가 발생할 때 주로 사용하는 방법은 로깅(logging)을 사용하여 코드를 추적하면서 강제적인 지연을 통해 여러 쓰레드가 동시에 동일 코드 블록을 수행하도록 하여 고의적으로 문제 현상을 만드는 것이다. 디버거(debugger)를 이용한 디버깅은 이와 같은 동기화 문제를 해결하는데 도움을 주지 못하는 경우가 많았다. 당연히 그럴 것이 대부분의 코드가 단일 쓰레드 상에서는 아주 잘 작동하기 때문이다.

리스트1 에서 동기화 문제의 가능성은 같은 key 값을 가지고 PublishValue 라는 메쏘드가 2개 이상의 쓰레드에 의해 진입될 수 있다는 점이다. 아니 C8 그게 말이 되는가? PublishValue 메쏘드는 private 인데다가 PublishValue 메쏘드를 호출하는 코드(13 라인)를 lock 키워드로 감싸놓아 동기화를 해 놓지 않았는가?

Reasoning

필자가 언급한 가능성을 확인하기 위해 PublishValue 메쏘드를 약간 수정해 보자. 먼저 PublishValue가 호출되었음을 알리기 위해 Console.WriteLine을 이용하여 로그를 남기고, PublishValue를 호출한 쓰레드가 잠시 sleep 되도록 Thread.Sleep을 호출한다. Thread.Sleep이 호출되면 호출한 쓰레드는 블로킹(blocking)되며 이는 데이터베이스 액세스나 웹 서비스 호출에 대개 동반되는 블로킹과 동등한 현상이라 간주하면 되겠다.

    1         // 주어진 키에 대한 값을 DB, 파일, 웹 서비스 호출 등에서

    2         // 읽어 테이블에 추가한다.

    3         private static object PublishValue(object key)

    4         {

    5             Console.WriteLine("Publishing item for key, {0}....", key);  // 디버깅을 위해 추가

    6             Thread.Sleep(1);                                             // 디버깅을 위해 추가

    7             return "Data";

    8         }

리스트2. 변경된 PublishValue 메쏘드

PublishValue 코드를 리스트2와 같이 수정하고 LookupTable 테이블을 사용하는 호출자 코드를 리스트3과 같이 작성해 보자.

    1         static void Main(string[] args)

    2         {

    3             Thread t1 = new Thread(new ThreadStart(ThreadMain));

    4             Thread t2 = new Thread(new ThreadStart(ThreadMain));

    5             Thread t3 = new Thread(new ThreadStart(ThreadMain));

    6             t1.Start();

    7             t2.Start();

    8             t3.Start();

    9 

   10             t1.Join();

   11             t2.Join();

   12             t3.Join();

   13         }

   14 

   15         static void ThreadMain()

   16         {

   17             int id = Thread.CurrentThread.ManagedThreadId;

   18             int key = 2;

   19             Console.WriteLine("Thread #{0} : Lookup {1} = {2}", id, key, LookupTable.Lookup(key));

   20         }

리스트3. LookupTable 클래스를 사용하는 호출자 코드

리스트3은 3개의 쓰레드를 만들고 이 쓰레드가 LookupTable.Lookup 메쏘드에 대해 2라는 키 값을 통해 호출하는 것이다. 세 쓰레드가 모두 키 값으로 2를 사용하고 있으므로, 수행 결과로서 예상할 수 있는 것은 PublishValue 메쏘드가 1회 호출되는 것으로 Console.WriteLine에 의해 출력되는 메시지가 1회 일 것이다. 리스트4는 수행 결과를 보여주고 있다.

Publishing item for key, 2....
Publishing item for key, 2....
Thread #3 : Lookup 2 = Data
Thread #4 : Lookup 2 = Data
Publishing item for key, 2....
Thread #5 : Lookup 2 = Data

리스트4. 버그를 포함한 코드의 수행 결과

What's the Problem ?

이런... 이게 무슨 조화일까? PublishValue 메쏘드가 3회나 호출된 것으로 결과가 나왔다. 분명 리스트1은 하나의 키 값에 대해 한 번의 PublishValue 호출이 일어나도록 의도 되었음은 의심할 바가 없다. 분명 무언가 잘못되어 있는 것이다. (당연히 잘못됐겠지... 그러니깐 문제랍시고 내 놓은 거 아닌가?)

원인은 이렇다. 세 개의 쓰레드 t1, t2, t3 가 Lookup 메쏘드를 호출하게 되면, 세 쓰레드는 모두 키 값 2에 대해 null 을 발견하게 될 것이다(리스트1의 라인 10). 그리고 세 쓰레드 중 한 쓰레드는 lock 키워드에 의해 잠금을 획득하고 lock 코드 블럭 안으로 진입할 것이고(이 쓰레드를 t1 이라 가정하자), 나머지 두 쓰레드는 lock 에 의해 블로킹 되어 리스트1의 라인 12에서 수행이 멈춘 채 잠들게(sleep) 될 것이다. t1 쓰레드는 PublishValue를 호출하여 키에 대한 값을 조회하고 이 값을 캐시 하기 위해 다시 해시 테이블에 삽입한다(리스트1의 라인 13-14).

여기까지는 매우 좋다. 그런데... t1 쓰레드가 lock 블럭을 빠져나가게 되면(잠금이 풀리게 됨) t2, t3 쓰레드 중 한 쓰레드(t2 라고 해 보자)가 다시 lock 블럭 안으로 진입하여 PublishValue 메쏘드를 호출하는 현상(?)이 발생한다. 그리고 최종적으로 마지막 쓰레드 하나(t3가 될 것이다) 역시 t2 쓰레드가 lock 블럭을 빠져 나간 후 다시 lock 블럭으로 진입하여 아무렇지도 않게 PublishValue를 호출하게 될 것이다.

리스트1의 경우 해시 테이블을 사용하고 인덱서(indexer) 속성을 사용했기 때문에 동일한 키에 대해 PublishValue가 여러 번 호출되더라도 성능 상의 저하 만 올 뿐 예외가 발생하지 않았다. 만약 인덱서가 아닌 Add 메쏘드를 호출했었다면 얄짤없이 ArgumentException이 발생했을 것이다.

비록 여러 쓰레드가 lock 문장에 의해 한꺼번에 블로킹 될 가능성은 높은 편은 아니다. 이렇게 동기화 문제의 대부분이 평소에는 아무런 문제가 없어 보인다는 것이다. 개발 기간에는 대개의 경우 부하가 크게 걸리지 않기 때문에 이러한 동기화 문제를 인지하지 못하곤 한다. 그러다가 실제 개발이 완료되고 운영되는 시점에서, 그것도 부하가 몰리는 상황이라면 충분히 이런 현상을 겪을 수 있게 되는 것이다. 이것이 다중 쓰레드의 동기화 문제가 우리를 아프게 만드는 점이기도 하다.

Solution

리스트1과 같이 테이블 자체를 퍼블리싱 하거나(한방에 해시 테이블을 채우는 걸 생각 하문 되긋다) 테이블의 각 아이템을 퍼블리싱 할 때는 항상 여러 쓰레드에 의해 테이블이 업데이트 되는 상황을 고려해야 한다. 그리고 이 때 간단히 lock 혹은 Monitor.Enter ~ Monitoer.Exit 메쏘드 호출을 사용하곤 한다. 이 때 항상 주의해야 할 사항은 앞서 언급한 대로 동시에 여러 쓰레드가 lock 혹은 Monitor.Enter에 걸려서 잠 퍼 자는 시나리오에 주의를 기해야 한다. 비록 lock 에 의해 보호되는 코드는 오직 하나의 쓰레드 만이 수행될 것임은 분명하다. 하지만 동시에 여러 쓰레드가 lock 에 걸리는 상황이라면 이들 쓰레드 중 하나의 쓰레드 만이 lock이 보호하는 코드 블록을 수행하고 나머지 쓰레드는 lock 에서 블로킹 되며 언젠가는 이들 블로킹 된 쓰레드는 블로킹이 풀려 lock 이 보호하는 코드를 수행하게 된다.

따라서, lock이 보호하는 코드 일지라도 특정 작업을 수행해야 할지 말아야 할지 다시 한번 검사하는 과정이 필요하다. 말로만 하면 졸라 햇갈리고 이해도 안 가므로 코드를 살펴 보자.

    1     public static class LookupTable

    2     {

    3         // 내부 테이블(해시테이블 이용)

    4         private static readonly Hashtable _Table = new Hashtable();

    5 

    6         // 주어진 키에 대응되는 값을 찾아 반환한다.

    7         public static object Lookup(object key)

    8         {

    9             object val = _Table[key];

   10             if (val == null) {

   11                 lock (_Table.SyncRoot) {

   12                     val = _Table[key];

   13                     if (val == null) {

   14                         val = PublishValue(key);

   15                         _Table[key] = val;

   16                     }

   17                 }

   18             }

   19             return val;

   20         }

   21 

   22         // 주어진 키에 대한 값을 DB, 파일, 웹 서비스 호출 등에서

   23         // 읽어 테이블에 추가한다.

   24         private static object PublishValue(object key)

   25         {

   26             Console.WriteLine("Publishing item for key, {0}....", key);

   27             Thread.Sleep(1);

   28             return "Unknown";

   29         }

   30     }

리스트5. 버그를 수정한 LookupTable 클래스

리스트5는 버그를 수정한 LookupTable 클래스를 보여주고 있다. 리스트1에 비해 달라진 것이라곤 라인 12 부터 라인 15까지 이다. lock 이후 다시 한번 해시 테이블로부터 해당 키 값이 있는지 검사하고 여전히 키에 대한 값이 존재하지 않을 경우에만 PublishValue를 호출하게 된다. 키 값을 두 번씩이나 검사하는 좀 이상하게 보이는 코드이지만 이러한 패턴의 코드는 상당히 자주 등장한다. lock 과 같이 상호 배제(mutual exclusion)를 제공하는 동기화 메커니즘에서 추가적으로 특정 보호되는 코드를 수행해도 되는지 검사를 추가적으로 해야만 경우가 많이 발생하곤 한다. (항상 그렇지는 않다)

독자들 중에 리스트5 보다는 다음과 같은 코드를 이용하여 Lookup 메쏘드 전체를 lock로 보호하면 어떨까 생각하는 독자가 있을지 모르겠다.

        public static object Lookup(object key)

        {

            object val = null;

            lock (_Table.SyncRoot) {

                val = _Table[key];

                if (val == null) {

                    val = PublishValue(key);

                    _Table[key] = val;

                }

            }

            return val;

        }

위 코드는 동기화 문제를 해결하긴 하지만 동시성(concurrency)에 있어서는 빵점 짜리 코드 이다. 테이블에서 키 값을 읽을 때에도 잠금이 발생하기 때문에 테이블에 접근할 수 있는 쓰레드는 오직 하나가 되어 버린다. ASP.NET 웹 어플리케이션과 같이 다중 쓰레드 환경이라면 LookupTable 클래스를 동시에 액세스 하는 수십, 수 백 개의 쓰레드는 쭈욱 일렬로 늘어서 손가락 빨며 자기 차례가 오길 기다려야 할 것이다.

닷넷 깨나 만져 봤고, MSDN 좀 뒤져본 독자라면 Hashtable.Syncronized 메쏘드에 대해 들어봤을 것이다. 요 메쏘드를 호출하면 동기화된 해시 테이블 객체가 반환된다. '동기화'가 된다고 그랬으니 이걸 이용하여 다음과 같이 코딩 하면 되지 않을까?

        public static object Lookup(object key)

        {

            Hashtable syncTable = Hashtable.Synchronized(_Table);

            object val = syncTable[key];

            if (val == null) {

                lock (syncTable.SyncRoot) {

                    val = PublishValue(key);

                    syncTable[key] = val;

                }

            }

            return val;

        }

위 코드 역시 동일한 키 값을 사용하는 2개 이상의 쓰레드가 PublishValue 메쏘드를 여러 번 호출하는 문제를 막을 수는 없다. Hashtable.Synchronized 메쏘드가 반환하는 해시 테이블은 동기화를 제공해 주기는 한다. 이 동기화는 해시 테이블에 동시에 2개 이상의 쓰레드가 접근하지 못하도록 막아 줄 뿐이다. 이해를 돕기 위해 다음 두 코드 조각은 정확히 동일한 코드가 되겠다. 즉, Hashtable.Synchronized 메쏘드가 반환해 주는 동기화 버전의 해시 테이블은 해시 테이블에 대한 액세스(인덱서, Add 등)를 lock으로 감싸주는 역할만을 담당한다.

            // Synchronized 메쏘드를 사용할 때

            Hashtable syncTable = Hashtable.Synchronized(_Table);

            object val = syncTable[key];

            val = SomeUpdate(val);

            syncTable[key] = val;

 

            // 맨땅에 헤딩할 때

            object val = null;

            lock (_Table.SyncRoot) {        // 요 lock 블록이 val = syncTable[key]에 해당 하것다.

                val = _Table[key];

            }

            val = SomeUpdate(val);

            lock (_Table.SyncRoot) {        // 요 lock 블록이 syncTable[key] = val 에 해당 하것다.

                _Table[key] = val;

            }

왜 Hashtable.Synchorized 를 사용하더라도 문제해결이 되지 않는 이유는 독자들이 자~알 생각해 보기 바란다.

Epilog

뭐 별로 쓰잘 것도 없는 내용을 가지고 주저리 주저리 떠든 느낌이 귀 언저리 살을 거쳐 후두부 그리고 대퇴부를 팍 걷어 찬다. 쯔읍... 졸라 복잡하고 어렵게 느껴진다면... 걍 조용히 까먹어도 될만한 토픽이지만... 하나 이 글에서 건져 갈만한 내용은 Hashtable.Synchronized 메쏘드가 반환하는 해시 테이블이 수행하는 작업 정도라고 할까? 그런 거 몰라도 먹고 사는데 지장 없다고 말해도 필자는 할말이 읍다... -_-;

자신이 정답을 말했다고 생각하는 독자는 언제고 필자를 찾아오길 바란다. 필자가 음주가무에 상당히 약한 관계로... 쓰디 쓴 커피 한 사발 정도는 대접할 수 있으니 말이다. 설마 찾아 오는 사람 있겠어 ? 흐흐흐...



Comments (read-only)
#re: 퀴즈 모범 답안: 동기화 버그 / 김명신 / 2006-09-27 오전 10:57:00
글을 잘 읽어 보았습니다. val 값이 null인지의 여부를 반복적으로 확인하지 않고, ReaderWriteLock을 사용하는 편이 좀 더 코드를 단순화 할 수 있을 것으로 보입니다.
#re: 퀴즈 모범 답안: 동기화 버그 / 위시 / 2006-09-27 오전 11:33:00
아..그렇군요...역시..^^;;;; 대단하십니다.
#re: 퀴즈 모범 답안: 동기화 버그 / 어흥이 / 2006-09-27 오후 1:33:00
답을 확신하지 못하고 여러날 기다렸답니다.^^.
기대를 저버리지 않으시고 시원스런 답안을 올려주시는군요.
커피 얻어마시러 멋진 여자분이 찾아가면 좋아하실까요? ㅋㅋ
아...제가 여자라는 말은 아니고...^^

좋은하루 되세요!~
#re: 퀴즈 모범 답안: 동기화 버그 / 블로그쥔장 / 2006-09-27 오후 2:10:00
김명신님께서 지적하신 대로 ReaderWriterLock을 쓰면 문제가 해결될까요?
저의 ReaderWriterLock 버전은 다음과 같습니다.

 1         public static object Lookup4(object key)
 2         {
 3             object val = null;
 4             _Lock.AcquireReaderLock(-1);
 5             try {
 6                 val = _Table[key];
 7             }
 8             finally {
 9                 _Lock.ReleaseReaderLock();
10             }
11             if (val == null) {
12                 _Lock.AcquireWriterLock(-1);
13                 try {
14                     val = PublishValue(key);
15                     _Table[key] = val;
16                 }
17                 finally {
18                     _Lock.ReleaseWriterLock();
19                 }
20             }
21             return val;
22         }

위 코드에서 동일한 키를 사용하는 2개의 쓰레드가 동시에 읽기 잠금을 획득한 경우,
(예를 들어 t1 쓰레드가 11번 라인에서 중단되고 t2 쓰레드가 12 라인까지 수행해버린 겨우)
본문에서 언급한 동일한 문제가 유발될 수 있을 것으로 보입니다.

제가 잘못 생각하고 있다거나 ReaderWriterLock 코드에 문제가 있다고 생각되시면 답글 주십시요. ^^
#re: 퀴즈 모범 답안: 동기화 버그 / 김명신 / 2006-09-28 오후 2:48:00
제가 착각한 부분이 있는것 같습니다. 위 코드를 전형적인 publisher/subscriber pattern을 적용할 수 있으리라 생각했는데, 단일의 Thread가 critical section 내의 variable에 대하여 i/o를 각기 수행할 수 있는 구조이므로 적절하지 않은 것 같습니다. 몇가지 방법을 고민해 보았는데 위에 작성하신 코드만큼 깔끔하게 구조가 나오질 않는군요.
감사합니다.
#re: 퀴즈 모범 답안: 동기화 버그 (질문) / 토이 / 2006-09-29 오전 9:29:00
올만에 찾아왔습니다..

쥔장님 위의 코드는 2.0이죠?

1.1에서는 static을 class에서는 쓸수가 없더군요.. ^^;; 저의 무지함이 들어난다는 오늘 집에 가서 2.0으로 테스트 해봐야겠네요

1.1에서는 public class LookupTableBug 코드를 쓰고 해봤더니 잘되는것 같던데요 ㅡ.ㅡ;;

2.0에서는 저런 결과가 나오다니 신기할 따름입니다. 역시 pulic static class 차이로 저런 원인과 결과가 생긴거겠죠?
^^
암튼 재미있게 봤습니다..
#re: 퀴즈 모범 답안: 동기화 버그 / 블로그쥔장 / 2006-09-29 오전 9:39:00
안녕하세요...

2.0에는 클래스에 static 키워드를 붙일 수 있습니다. static 키워드가 붙으면
instance 메쏘드나 필드를 사용할 수 없습니다. 1.x에서 동등한 효과를 내시려면
private 디폴트 생성자(매개변수 없는 생성자)를 만드시면 동등한 효과가 납니다.
사실 클래스의 static 키워드는 C# 컴파일러가 제공해 주는 기능이며 닷넷 고유의 기능은 아닙니다.

그리고... 1.1이건 2.0이건 문제는 동일하게 발생합니다.
다만 대부분의 경우 문제가 유발되지 않는 것 처럼 보이지요. 아주 드물게 문제가 발생되곤 합니다.
이점이 다중 쓰레드가 어렵다고 말하는 이유중에 하나구요.
그래서 강제로 문제가 발생되도록 리스트2 처럼 코드를 수정한 겁니다. ^^
#re: 퀴즈 모범 답안: 동기화 버그 / 토이 / 2006-09-29 오후 5:02:00
감사합니다.. 많이 배우고 갑니다.. ^^

이번 데브데이때 뵙겠습니다.
#re: 퀴즈 모범 답안: 동기화 버그 / spponge / 2006-10-03 오전 10:13:00
비스무리하게 맞춘거 같은데 ㅋㅋ 데브데이때 커피라도 한잔 얻어 마시고 싶지만 일땜에 못가는 관계로...
택배로 보내주세요~ ㅋㅋ
여튼 좋은 글 잘 읽고 갑니다.
#re: 퀴즈 모범 답안: 동기화 버그 / 블로그쥔장 / 2006-10-03 오후 2:18:00
네!! spponge 님께서 가장 근접하게 답변하셨지요... ^^
제가 음주가무에 약하기 때문에... 언제 뵙게 되면 쓰디쓴 차 한잔 대접하겠습니다.
죄송하지만 택배는 착불입니다... 주소 알려주시면 보내드리겠습니다...
흐흐흐 (난 내가 생각해도 넘 사악해... ^____^; )