2008-09-08

멀티쓰레딩 어플리케이션에 관해 모든 개발자가 알아야만 하는 것들 - (1)

[주의]
    초벌 번역한 것으로 세세하게 단어와 문맥을 다듬은 글이 아닙니다.;;;;
    대략적인 내용 파악을 위한 자료로만 활용해 주세요.^^;;

[원문]
    What Every Dev Must Know About Multithreaded Apps

이 글에서 다룰 주제들
  • 멀티쓰레딩과 공유 메모리 쓰레딩 모델
  • 경함 그리고 동시 접근이 불변식을 어떻게 깨뜨리는지
  • 경함에 대한 표준적인 해결법인 잠금
  • 잠금이 필요할 때
  • 잠금의 사용법과 이에 대한 비용을 이해하기
목차
  • 쓰레드와 메모리
  • 경합(Races)
  • 락(Lock)
  • 락 프로퍼티 사용하기
  • 락은 몇 개나?
  • 읽기에서 락 사용하기
  • 락을 사용한 보호를 필요로하는 메모리
  • methodical lock
  • 데드락
  • 락의 비용
  • 동기화에 관해 훑어보기
  • 결론
10년 전에는 하드 코어 시스템 프로그래머들 만이 하나 이상의 쓰레드를 실행하는 상황에서 올바르게 코드를 작성하는 복잡한 방법을 고민했었다.  대부분의 프로그래머들은 이런 문제를 피하기 위해서 순차적인 프로그램을 작성한다. 그러나 이제는 멀티코어 프로세서 머신이 보편화 되었다. 조만간 멀티쓰레드로 구현되지 않은 프로그램은 가능한 컴퓨팅 파워의 상당 부분을 사용하지 않게 될 것이라 불리한 입장에 처할 것이다. 불행히도 멀티쓰레드 프로그램을 제대로 작성하는 것은 쉽지 않은 일이다. 프로그래머는 다른 쓰레드들이 해당 쓰레드하의 메모리를 변경할 수 있다는 개념에 익숙하지 않다. 더우기 실수라도 하게 되면 프로그램을 제대로 작동하게 하는데 대부분의 시간을 보내야 한다. 특정 조건 하에서만 겨우 버그가 명백해 질것이고, 극히 드물게 오류시점에서의 충분한 정보를 디버그용 어플리케이션을 통해 효과적으로 얻을 수 있다. 그림 1은 순차적인 방식과 멀티쓰레드 방식의 프로그램의 차이점을 요약해서 보여주고 있다. 보는바와 같이, 멀티쓰레드 프로그램을 처음에 제대로 작성하기 위해서는 정말 지불해야할 비용이 많다. 그림 1 순차적인 프로그램과 멀티쓰레드 프로그램의 특징들
사용자 삽입 이미지
이번 글은 세 가지를 보여주려고 한다. 첫 번째로 멀티쓰레드 프로그램은 그렇게 난해한 것은 아니라는 것이다. 올바른 프로그램을 작성하기 위해 기본적으로 필요한 것은 순차적 방식이나 멀티쓰레드 방식이나 동일하다: 프로그램 내의 모든 코드는 프로그램내의 다른 부분에도 필요로하는 불변식은 무엇이든 보호해야 한다. 두 번째는 이런 지침이 단순하기는 하지만, 멀티쓰레드 환경에서 지키기에는 훨씬 더 어렵다는 점이다. 순차적 환경에서는 명백한 방식이 멀티쓰레드 환경에서는 놀라울 정도로 난해하다. 마지막으로, 이런 난해한 문제를 다루는 방법을 보여주고자 한다. 이번 가이드는 프로그램 불변식을 보호하기 위한 체계적인 전략을 정리하는 것으로, 그림 2에서 볼수 있듯이 멀티쓰레드의 경우에는 좀 더 복잡하다. 멀티쓰레드를 사용하면 복잡해지는 여러가지 이유가 있는데, 이 후의 섹션에서 설명할 것이다. 그림 2 순차적인 프로그램과 멀티쓰레드 프로그램에서의 프로그래밍
사용자 삽입 이미지
쓰레드와 메모리 사실 멀티쓰레드 프로그래밍도 꽤 단순해 보인다. 순서대로 하나의 프로세싱 유닛을 실행하는 대신에 두 개 이상을 동시에 실행하면 된다. 프로세서는 실제 멀티프로세서 하드웨어이거나 타임-멀티플렉싱 싱글 프로세서이기 때문에 쓰레드라는 용어는 프로세서 대용으로 사용된다. 멀티쓰레드 프로그래밍에서의 은근 골치아픈 부분은 쓰레드간에 통신을 하는 방법 부분이다. 그림 3 공유 메모리 쓰레딩 모델 대부분에서 보여지는 멀티쓰레드 통신 모델은 '공유 메모리 모델(shared memory model)'이라고 부른다. 이 모델에서의 모든 쓰레드는 그림 3에서 볼수 있듯이 같은 공유 메모리 풀에 접근한다.이 모델은 멀티쓰레드 프로그래밍을 순차적인 프로그래밍 방식과 같은 방법으로 할 수 있다는 이점이 있다. 그러나 이 이점이 또한 가장 큰 문제이기도 하다. 이 메모리는 쓰레드안에서 사용하는 로컬 메모리(로컬로 선언된 변수 같은 것들)와 다른 쓰레드와 통신하기 위해 사용하는 메모리(전역 변수나 힙 메모리 같은것)을 구별하지 않는다. 잠재적으로 공유된 메모리는 쓰레드 로컬 메모리보다 훨씬 더 신중하게 다뤄야 하기 때문에 실수가 발생하기 쉽상이다. 경합(races) 프로세스들이 요청을 하고, 이 요청이 완료될 때마다 값이 증가하는 전역 카운터 totalRequests를 가지고 있는 프로그램이 있다고 치자. 보다시피, 이를 수행하는 코드는 순차적인 프로그램에서는 명백하다.
totalRequests = totalRequests + 1
하지만, 요청과 카운터의 갱신을 처리하는 여러 개의 쓰레드를 가진 프로그램에서는 문제가 생긴다. 컴파일러는 다음과 같은 기계어 코드로 (카운터의)증가 코드를 생성할 것이다.
MOV EAX, [totalRequests]  // load memory for totalRequests into register INC EAX                            // update register MOV [totalRequests], EAX  // store updated value back to memory
두 개의 쓰레드가 이 코드를 동시에 실행하는 상황을 가정해 보자. 그림 4에서 볼 수 있듯이, totalRequests의 같은 값을 로딩하여, 증가시킨 후, 다시 저장시킬 것이다. 이 일련의 동작이 끝나는 시점에서 두 쓰레드는 요청을 처리했지만, totalRequests의 값은 이 전과 비교했을 때 1만 증가했을 뿐이다(역주: 두 개 처리 했으니가 2가 증가하는 것이 맞다). 확실히 이것은 우리가 원한 결과가 아니다. 이런 버그를 경합이라고 부르는데, 두 쓰레드 사이의 나쁜 타이밍 문제로 발생하는 것이다. 그림 4 경합의 경우 들여다보기
이 예는 자명해 보이지만, 일반적인 문제는 실생활에서 벌어지는 경합만큼이나 복잡한 것이다. 여기에 경함이 발생할 수 있는 네 가지 조건을 나열해 보겠다.

첫 번째 조건은 하나 이상의 쓰레드가 접근할 수 있는 메모리가 존재할 때 이다. 보통 이런 메모리는 전역/스태틱 변수이거나 이런 변수로부터 접근할 수 있는 힙 메모리이다.

두 번째 조건은 이런 공유 메모리가 프로그램이 올바르게 작동하기 위해 사용되는 속성일 경우이다. totalRequests가 이런 속성으로 어떤 쓰레드가 실행되고, 값이 증가하는 어떤 경우이던 전체 횟수를 정확히 나타내야 한다. 정확한 갱신을 위해서는 갱신전에 유효성을 확인(totalRequests가 정확한 값을 가져야 하기 때문에)할 칠요가 있다.

세 번째 조건은 그 속성이 실제 갱신되는 중에 점유되지 않는 경우이다. 이 경우에는 totalRequests를 불러와서 저장하는 동안에 불변성을 만족하지 않는다.

네 번째이자 마지막인 경합의 조건은 다른 쓰레드가 불변성이 깨졌을 때 접근한 후, 이로 인해 오동작이 발생하는 경우이다.

-- 다음에 계속 --

댓글 없음:

댓글 쓰기