IBM®
메인 컨텐츠로 가기
    Korea [국가변경]    이용약관
 
 
   
        제품    서비스 & 솔루션    고객지원 & 다운로드    회원 서비스    
메인 컨텐츠로 가기

한국 developerWorks  >  리눅스  >

pthreads의 기초

POSIX 쓰레드 소개

developerWorks
문서 옵션

JavaScript가 필요한 문서 옵션은 디스플레이되지 않습니다.


난이도 : 중급

Peter Seebach, Freelance writer

2004 년 1 월 21 일

쓰레드는 많은 프로그래머들에게 두려움의 대상이다. 유닉스의 프로세스 모델은 간단하고 이해하기도 쉽지만 가끔은 비효율적이다. 쓰레딩은 퍼포먼스를 향상시키지만 약간 혼돈스럽다는 단점이 있다.

쓰레드는 프로그래머들이 가장 두려워하는 대상이다. 가끔은 경량의 프로세스라고 불리는 쓰레드는 거대하고 복잡한 프로젝트와 관련되어 있다. 라이브러리 함수들은 "쓰레드 보안"이 되어있지 않을지도 모른다는 끔찍한 경고성을 갖고 있다. "쓰레드"는 무엇인가? 왜 사용하는가? 위험한 부분은 무엇인가?

이 글에서는 간단한 쓰레디드 애플리케이션을 사용하여 쓰레딩을 소개한다. 사용되는 쓰레딩 모델은 POSIX 쓰레딩 인터페이스이고 종종 pthreads라고도 한다. SuSE Linux 8.2 기반으로 설명하겠다. 이 코드는 SuSE Linux 8.2와 NetBSD-current의 최신 구현에서 테스트되었다.

쓰레드란 무엇인가?

쓰레드는 프로세스와 비슷하다. 단지 좀더 작을 뿐이다. 쓰레드의 개념은 다중 실행 쓰레드가 많은 리소스들을 공유하는 것이다; 예를 들어, 일반적으로 같은 주소공간에서 실행한다. 하나의 쓰레드에서 다른 쓰레드로 바꾸는 것은 한 프로세스에서 다른 프로세스로 전환하는 것 보다 싸다. 더욱이, 많은 메모리를 사용하고 있는 프로세스의 경우, 쓰레드로 메모리를 훨씬 더 효율적으로 관리할 수 있다.

쓰레드는 가끔 서로서로 인터랙팅 할 필요가 있고, 그렇게 할 때, 일종의 프로세스간 통신(IPC)를 사용하는 다중 프로세스 통신에서 직면하는 것과 같은 문제에 빠지게 된다. 이 중 몇 가지를 이 글에서 간단히 다루겠다. POSIX 쓰레드 API는 교착상태와 경쟁 조건 같은 문제들을 다루는 툴을 제공한다. 이 글에서는 멀티쓰레디드 프로그래밍 관련 문제와 솔루션을 다루겠다. 멀티프로그래밍에 대한 질문들은 다음을 기약하겠다.

쓰레디드 프로그램은 다중 프로세스와 IPC에서는 일반적으로 보이지 않는 몇 가지 문제들에 빠지게 된다. 예를 들어, 두 쓰레드가 asctime()같은 함수(정적 데이터 영역을 사용함)를 호출한다면 놀라운 결과가 나올 수 있다. 이것이 "쓰레드 안전"이라는 개념이 나오게 된 이유이다.




위로


간단한 프로그램

이 글에서 사용되는 샘플 프로그램은 주사위 굴리기 프로그램이다. 네트워크에 액세스가 가능한 die roller는 데모 프로그램에 맞는 많은 기능을 갖고 있다. 가장 중요한 것은 이 프로그램의 실제 코드는 매우 간단해서 쓰레딩을 쉽게 이해할 수 있다.

가장 큰 산란함은 사실 네트워킹 코드이다. 간단하게 하기위해 두 개의 서브루틴에 모든 것을 숨겼다. 우선, socket_setup() 은 연결을 받아들일 준비가 된 소켓을 리턴한다. 두 번째인 get_sockets()는 소켓을 가져다가 연결을 수락하고 struct socket 객체를 만든다.

struct socket 객체는 인풋/아웃풋 포트의 빠른 추상화일 뿐이다. 들어갈 때의 FILE *과 나올 때 하나와 플래그는 우리에게 소켓을 적절할 때 닫으라는 것을 상기시킨다.

다른 버전의 프로그램을 tarball로 다운로드 할 수 있고(참고자료), 개별 쉘에서 보기 또는 실행할 수 있다. 또는 개별 브라우저 윈도우에서 볼 수 있다. 우선 dthread1.c이다.

dthread1으로 실험해보자. 여러 가지 옵션이 있다. 첫 번째 옵션은(현재는 사용되지 않지만 알아두면 편리하다.) 디버깅 플래그로서 -d옵션으로 지정된다. 두 번째 -s는 콘솔에서 실행할지의 여부를 결정한다. -s 가 제공되지 않으면 프로그램은 연결을 기다리고 이들에게 요청한다. 세 번째인 -t는 멀티쓰레디드를 실행하는지의 여부를 결정한다. -t옵션이 지정되지 않는다면 프로그램은 정확히 하나의 커넥션을 핸들한 다음 종료한다. 마지막으로 -S옵션이 있는데 이는 각 주사위가 구르는 사이 일초 동안 프로그램을 휴면상태로 둔다. 다중 연결들 간 인터리빙(interleaving)을 보는 것이 더 쉽다는 것이 이것의 효용이다.

커널 또는 사용자 쓰레드?

어떤 시스템의 경우 dthread1 은 기대하는 대로 작동하지 않는다. 소켓으로부터 읽기를 시도하면 하나의 쓰레드만이 아니라 전체 프로그램을 막는다. 이것이 커널과 사용자 쓰레드의 차이점이다.

사용자 쓰레드는 정교한 소프트웨어 핵(hack)으로서 멀티쓰레디드 프로그램들이 특별한 커널 지원 없이 실행될 수 있도록 한다. 부작용으로는 어떤 쓰레드라도 막는 시스템 호출을 하면 전체 프로세스는 차단될 수 있다. 커널 쓰레드는 하나의 쓰레드가 차단되게 하여 다른 것들이 계속 실행되도록 한다.

POSIX API는 쓰레딩이 어떻게 작동하는지를 지정하지 않는다. 다행스러운 일이다. 대부분의 시스템들은 커널 쓰레드를 갖고 있다. 이 상자 안에서 설명한 것은 극도로 함축된 것이다. 자세한 내용을 알고 싶다면 시스템의 pthreads 라이브러리 소스를 참조하기 바란다.

이 프로그램을 실행해보자. 일단 실행되면 이에 연결하기 위해서는 telnet localhost 6173을 시도한다. 사람들이 주사위를 굴리는 이런 종류의 게임에 익숙하지 않다면 2d6으로 시작하라. 일반적으로 지원되는 문법은 "NdX"이다. 이것은 N 주사위를 굴린다. 각각 1에서 X 까지 나올 수 있다. (일반적으로 X 면 주사위를 N번 굴리게 된다.)

이제 쓰레디드 애플리케이션의 가장 간단한 형식을 보게 된다. 알아야 할 중요한 주제는; 각 쓰레드는 그 쓰레드만의 데이터를 조작하는 것이다. 이렇게 되면 쓰레드들이 서로 방해하지 않는다. 이를 위해 pthread_create()void *유형의 인자를 취한다. 이는 쓰레드가 실행을 시작하는 곳의 해당 함수로 전달된다. 이로서 복잡한 데이터 구조를 만들어 쓰레드가 작동하도록 하고 이를 싱글 포인터로서 전달한다. 주소가 pthread_create()로 전달된 함수가 종료되면 쓰레드가 완료되지만 다른 쓰레드는 계속 실행된다.

멀티쓰레디드 버전이 깔끔하게 종료되도록 하는 방식이 이것의 부재로 극명해진다.(Ctrl-C를 사용하여 종료할 수 있다.) 하나의 쓰레드로 무엇을 할지 알기 쉽다. 사용자가 중지를 명령할 때 종료한다. 네 명의 연결된 사용자가 있다면 언제 종료해야 하는가? 분명한 대답은 "마지막 사용자가 종료된 후" 이다. 하지만 이것을 어떻게 확인하는가? 한 방법은 쓰레드가 만들어질 때마다 변수를 늘리는 것이고 쓰레드가 종료할 때 마다 이를 줄이는 것이다. 0에 다다르면 전체 프로세스를 닫는다.

놀랍다. 하지만 물론 함정도 있다.




위로


경쟁 조건과 mutex

다른 쓰레드가 만들어지듯 하나의 쓰레드가 종료한다면? 쓰레드 스케줄러가 실행에 개입한다면 그 결과는 당황스럽다. Thread 1이 i = i + 1;에 해당하는 코드를 실행하고 있다. Thread 2는 i = i - 1;에 해당하는 코드를 실행한다. 변수 i가 2 값으로 시작한다고 상상해 보자.

Thread 1: fetch value of i (2)
Thread 1: add 1 to value (yielding 3)
Thread 2: fetch value of i (2)
Thread 2: subtract one from value (yielding 1)
Thread 2: store value in i (i = 1)
Thread 1: store value in i (i = 3)

이럴수가.

"경쟁 조건"이다. 문제가 있다는 뜻이다. 흔한 일이 아니기 때문에 디버깅은 악몽이다.

thread 1과 thread 2가 이를 못하도록 하는 몇 가지 방법이 필요하다: thread 1이 "어떤 누구도 내가 이것으로 실행하기 전까지는 i 를 건드릴 수 없다" 라고 명령하도록 하는 것이다. 또는 이런 방식을 고안하는 것도 좋겠다; 두 개의 쓰레드가 충돌하지 않도록 하는 방식이다. 기존 메커니즘을 사용하고 자신의 코드에서 실행시키면 더 좋겠다.

다음으로 이해해야 할 것은 mutex라는 개념이다. mutex(MUTual EXclusion)는 쓰레드가 서로서로 영향주지 않도록 하는 방식이다. 어떤 누구도 이를 갖고 있지 않으면 실행시킬 수 있다. 독자적인 객체를 소생시키는 과정을 mutex를 잠금 또는 획득이라고 한다. 많은 사람들이 이에 대해 다양한 용어를 사용하는 만큼 단어 사용에 대해 너무 당황하지 말라. POSIX 쓰레드 인터페이스는 매우 일관성 있는 용어인 잠금(locking)을 사용한다.

mutex를 만들고 사용하는 과정은 쓰레드를 시작하는 과정보다 복잡하다. mutex 객체는 선언이 되어야 한다; 일단 선언되면 초기화되어야 한다. 이 후에 잠금과 잠금 해제 될 수 있다.




위로


Mutex 코드: 예제

브라우저에서 dthread2.c를 보라. (또는 확장된 pth 디렉토리에서 볼 수 있다.)

편의를 위해 pthread_create()호출은 spawn()이라고 하는 새로운 루틴으로 분리된다. 이러한 방식으로 mutex 코드에만 추가하는 변경은 한 장소에서 발생해야 한다. 이 루틴은 새로운 것을 수행한다. count_mutex라는 mutex를 잠근다. 잠근 후에 새로운 쓰레드를 만들고 threadcount를 늘린다. 이렇게 한 후에 mutex를 잠금 해제 한다. 그런 다음 쓰레드가 종료할 준비가 되면 mutex를 잠그고 threadcount를 줄이고 mutex를 잠금을 해제한다. threadcount가 0에 다다르면 실행되는 쓰레드가 없음을 알게 되고 종료할 때라는 것을 알게 된다. 여전히 실행되는 하나의 쓰레드가 있는데 이것은 프로세스와 함께 시작한 쓰레드이다.

pthread_mutex_init()이라는 함수로의 호출을 주목하자. 이 함수는 mutex에 필요한 모든 런타임 프리젠테이션을 핸들한다. mutex로 수행했을 때 pthread_mutex_destroy()에 대한 호출로 초기화하는 동안 할당된 모든 리소스를 릴리스 할 수 있다. 어떤 구현은 리소스를 할당 또는 릴리스 할 수 없다. 어쨌든 API를 따라가라. 새로운 구현을 사용하게 되면 곧 새벽 세시가 된다.

mutex 잠금 코드를 spawn()threadcount에 대한 변경 주위에 두는 것이 합리적인 것 처럼 보인다. 놀랍다. 하지만 불행히도 이는 끔찍한 버그를 만들어낸다. 이 프로그램은 자주 프롬프트를 프린팅하여 괴롭힐 것이다. 놀랍지 않은가? pthread_create()가 호출되자 마자 새로운 쓰레드는 실행을 시작한다. 이벤트 순서는 다음과 같다:

Main thread: Call pthread_create()
New subthread: Print prompt
Old subthread: exit, decrementing threadcount to 0
Main thread: Lock mutex and increment threadcount

물론, 이것은 마지막 단계에 결코 다다르지 않는 경우를 제외한 것이다.

threadcount호출에 앞서 pthread_create()에 변경으로 이동한다면 mutex 코드는 실제로 감소한다. 샘플 프로그램은 이러한 방식으로 작성되지 않는다.




위로


교착상태 풀기

이 글 앞부분에 프로그램의 미묘하고 잠재적인 경쟁 조건을 언급했다. 이제 그 미스터리가 풀린다. 숨겨진 경쟁 조건은 rand()가 내부 상태를 갖고 있다는 것이다. 두 개의 쓰레드로부터 rand()로의 호출이 겹치면 잘못된 숫자가 리턴 될 수 있다. 이 프로그램의 경우 그다지 큰 손실은 아니지만 랜덤 숫자의 재생산성에 의존하는 중요한 시뮬레이션의 경우 진짜로 큰 문제이다.

이제 랜덤 숫자 생성기 주변에 mutex를 추가하기로 한다. 문제 없다. 쉽게 경쟁 조건을 해결했다.

dthread3.c를 보거나 pth 디렉토리의 같은 파일을 열어라.

불행히도 또 다른 잠재적인 문제가 드러나고 있다. 바로 교착상태(deadlock) 이다. 교착상태는 두 개(또는 그 이상)의 쓰레드들이 또 다른 것을 기다리며 붙어있는 것이다. 한 쌍의 mutex를 상상해 보라. 각각 count_mutexrand_mutex라고 한다. 이제 두 개의 쓰레드가 이들을 사용한다고 상상해보자. 하나의 쓰레드는 다음을 수행한다:

mutex_lock(&count_mutex);
mutex_lock(&rand_mutex);
mutex_unlock(&rand_mutex);
mutex_unlock(&count_mutex);

두 번째 쓰레드는 다른 순서로 수행한다.:

mutex_lock(&rand_mutex);
mutex_lock(&count_mutex);
mutex_unlock(&count_mutex);
mutex_unlock(&rand_mutex);

이것은 교착상태가 발생하기를 기다리는 것이다. 이 두 쓰레드들이 동시에 이러한 코드 경로로 시작한다면 잠금을 시작할 것이다:

Thread 1: mutex_lock(&count_mutex);
Thread 2: mutex_lock(&rand_mutex);

다음에는 어떤 일이 일어날까? 하나의 쓰레드가 실행하면 나머지 쓰레드에 의해 rand_mutex가 잠겨야 할 것이다. 두 번째 쓰레드가 실행하면 첫 번째 쓰레드에 의해 count_mutex가 잠겨야 할 것이다. "두 대의 기차가 기차길 교차로에서 만나면 두 개 모두 멈추거나 아니면 다른 것이 지나 갈 때 까지 진행을 멈춘다."

이 같은 문제에 대한 한 가지 간단한 솔루션은 잠금이 언제나 같은 순서로 이루어지도록 하는 것이다. 이와 비슷하게 안전하게 실행하는 방법은 장치의 제어를 늦추지 않는 것이다. 실제 프로그램에서 교착상태가 되는 호출은 잘 구성된 코드라고 볼 수 없다. 우리의 간단한 예제에서도 mutex 호출은 모두 인접하지 않는다.

이제 다음 예제인 dthread4를 보자.

이 버전의 프로그램은 교착상태의 일반적인 원인이 되는 프로그래머의 실수를 언급한다.

이 버전은 다양한 스팩을 한 줄에 허용한다. role-playing 게임에서 종종 볼 수 있는 polyhedral 유형으로 주사위를 제한한다. 이러한 극악무도한 행태의 무기력한 개발자는 실제로 사용되어야 할 때 잠기도록 아이디어를 짜낸다. 하지만 실행이 끝날 때 까지 잠금 해제를 미룬다. 따라서 사용자가 "2d6 2d8"을 입력하면 프로그램은 6면의 주사위를 잠그고 이를 두번 굴린 다음 8면 주사위를 잠그고 그것을 두 번 굴린다. 모든 주사위 굴리기가 끝날 때만 이들 중 하나가 잠금 해제 된다.

이는 이전 버전과는 다르게 교착상태에 취약하다. 두 명의 사용자가 동시에 주사위 돌리기를 요청한다고 상상해 보라. 하나는 "2d6 2d8"을 요청하고 다른 한 명은 "2d8 2d6"을 요청한다. 어떻게 될까?

Thread 1: lock the six-sided die; roll it
Thread 2: lock the eight-sided die; roll it
Thread 1: try to lock the eight-sided die
Thread 2: try to lock the six-sided die

"똑똑한" 솔루션은 없다. 주사위 굴리는 사람이 주어진 굴리기를 수행하자마자 각 주사위를 잠금 해제한다면 아무런 문제가 없을 것이다.

여기에서 얻은 첫 번째 교훈은 너무 먼 곳 까지 갈 수 있다는 것이다. 개별 주사위로 잠금 액세스는 솔직히 바보 같다. 하지만 두 번째 교훈은 코드 내부의 교착상태를 반드시 볼 수 있는 것은 아니라는 것이다. 어떤 mutex가 잠겨질 것인가를 결정하는 것은 런타임 시 사용할 수 있는 데이터에 의존한다. 이것이 문제의 본질이다.

버그를 보고 싶다면 -S옵션을 사용하라. 하나의 터미널에서 또 다른 터미널까지 교환할 충분한 시간이 있다. 이제 이것을 어떻게 픽스할까? 인자의 관점에서 보면 개별 주사위를 잠글 필요가 있다. 어떻게 하겠는가? 한 가지 간단한 방법은 dthread5.c에서 제안하고 있다. 이를 수행한다면 각 주사위에 랜덤 숫자 생성기를 줄 수 있다. 똑똑한 게이머들은 주사위 롤 안에 좋고 나쁨이 있음을 안다. 작은 주사위 때문에 정말로 좋은 롤을 낭비하고 싶은가?

교착상태는 하나의 쓰레드 내에서도 발생할 수 잇다. 사용되는 디폴트 mutex 유형이 "빠른" mutex 유형으로서 놀라운 기능을 갖고 있는데 이것이 이미 잠겨있는 동안 잠금을 시도한다면 교착상태가 된다. 가능하다면 프로그램이 mutex를 재잠금되지 않도록 설계하라. 그렇지 않으면 "재귀적인" mutex 유형을 사용할 수 있는데 이는 잠금의 홀더가 같은 mutex를 여러 번 잠글 수 있도록 한다. 또 다른 유형의 mutex는 일반적인 에러를 검사한다. 이미 잠금이 해제된 mutext를 잠금 해제하는 것이다. 재귀적인 mutex는 잠금 버그를 실제로 갖고 있을 때 도움이 되지 않는다. dthread3.c의 초기 버전에는 다음 코드가 포함된다:


Listing 1. 버그를 포함하고 있는 dthread3.c


   int
   roll_die(int n) {
      pthread_mutex_lock(&rand_mutex);
      return rand() % n + 1;
      pthread_mutex_unlock(&rand_mutex);
   }

버그를 잡아냈는가? (내 경우는 5분 걸렸다) 이 글 말미에 있는 sidebar에서 해답을 확인하라.

교착상태에 대한 충분한 논의는 이 글의 범위를 벗어난다. 참고자료섹션에서 mutex와 교착상태에 대한 자세한 내용을 참조하기 바란다.




위로


조건 변수

조건 변수는 또 다른 재미있는 기능이다. 조건 변수는 조건이 true가 될 때까지 쓰레드가 휴면상태가 되도록 하는데 사용된다. 이에 기본적으로 사용되는 함수는 pthread_cond_wait()이다. 두 개의 인자가 들어간다. 첫 번째는 조건 변수에 대한 포인터이고 두 번째는 잠겨진 mutex이다. mutex 같은 조건 변수는 API 호출을 사용하여 초기화되어야 한다. 이 경우, pthread_cond_init()이다. 조건 변수로 수행했을 때 pthread_cond_destroy()로의 호출을 이용한 초기화 동안 할당된 모든 리소스들을 배포할 수 있다. mutex를 사용할 경우 이러한 호출들이 어떤 구현에서는 잘 실행하지 않을 수 있지만 어쨌든 사용할 수는 있다.

호출되면 pthread_cond_wait()는 mutex를 잠금 해제하고 이것의 쓰레드 실행을 중지시킨다. 다른 쓰레드가 이를 실행시킬 때 까지 중지 상태로 되어있다. 이러한 작동은 "원자식"이고 언제나 함께 발생한다. 이들 사이에 실행되는 쓰레드는 없다. 이후에 다른 쓰레드가 실행된다. 또 다른 쓰레드가 pthread_cond_signal()을 호출하면 조건 변수를 기다리고 있었던 쓰레드는 깨어난다. 또 다른 쓰레드가 어떤 pthread_cond_broadcast()를 호출하면 모든 쓰레드는 깨어난다.

마지막으로, pthread_cond_wait()에서 깨어날 때 모든 쓰레드가 시도하는 첫 번째 일은 처음 호출될 때 잠금을 해제했던 mutex를 다시 잠그는 것이다. 시간이 조금 걸린다. 사실 여러분이 기다리는 그 조건까지 시간이 걸린다. 예를 들어, 링크 된 리스트에 아이템이 추가될 때까지 기다리는 쓰레드는 깨어나서 비어있는 리스트를 찾는다. 어떤 구현의 경우 쓰레드는 조건 변수로 보내지는 신호 없이 깨어난다. 쓰레드 프로그래밍은 정확한 과학은 아니다. 방어적으로 프로그래밍을 짤 필요가 있다.




위로


요약

POSIX 쓰레딩 API의 빙산의 일각만을 살펴봤다. 어떤 사용자들은 pthread_join() 호출을 사용해야 할 필요도 있을 것이다. 이는 하나의 쓰레드가 다른 것이 완료될 때까지 기다릴 수 있도록 한다. 이들은 설정될 수 있는 속성이고 스케줄링도 제어될 수 있다. 웹 상에 POSIX 쓰레드에 대한 리소스가 많이 있다. man 페이지를 반드시 읽어라. apropos pthreadman -k pthread같은 pthread 관련 man 페이지도 참조하라.

해답

Listing 1코드에는 한 가지 분명하지만 쉽게 간과될 수 있는 버그가 있다. 찾았는가?

문제는 mutex가 잠금 해제 되기 전에 리턴 문장이 함수의 실행을 끝낸다는 것이다.




위로


참고자료




위로


필자소개

Peter Seebach는 10년 이상 C 프로그래밍 분야에서 일해왔다.





위로


기사에 대한 평가

매우 불만족 (1)
불만족 (2)
보통 (3)
만족 (4)
매우 만족 (5)




위로



    IBM 소개개인정보 보호정책문의