반응형

POSIX thread

 

메모리 공유에 필요한 간단하고 신속한 도구Daniel Robbins

CEO (Gentoo Technologies, Inc.)

2000년 7월


POSIX (Portable Operating System Interface) 쓰레드는 사용자의 코드 반응성 및 성능 향상에 필요한 방법이다. Daniel Robbins는 사용자의 코드에 쓰레드를 적용하는 방법을 제시한다. 감춰진 많은 세부사항을 다루기 때문에 이 글의 시리즈를 모두 읽은 후에는 스스로 멀티 쓰레드 프로그램(multithreaded programs)을 만들 수 있을 것이다.


재미있는 쓰레드

쓰레드의 올바른 사용법을 익히는 것은 훌륭한 프로그래머의 자격요건 중의 하나이다. 쓰레드는 프로세스와 비슷하다. 쓰레드는 프로세스와 같이 커널에 의하여 시간 분할(time-sliced)된다. 단일 프로세서 시스템에서 커널이 프로세스에 사용하는 것과 마찬가지로, 쓰레드의 동시 실행을 시뮬레이션 하는데 시간 분할을 사용한다. 그리고 멀티 프로세서 시스템(multithreaded programs)에서는 두 개 이상의 프로세스가 실행되는 것 처럼, 쓰레드는 실제로 동시에 실행될 수 있다.

대부분의 공동작업에서 멀티 개별 프로세스보다 멀티 쓰레딩을 선호하는 이유는 무엇인가? 쓰레드는 같은 메모리 공간을 공유한다. 개별 쓰레드는 메모리에서 같은 변수에 접근할 수 있다. 그래서 프로그램의 모든 쓰레드는 선언 글로벌 정수 읽기 또는 쓰기가 가능하다. fork()로 중대한 코드를 구성한 경험이 있으면, 이 도구의 중요성을 인정할 것이다. 그 이유는fork()로 멀티 프로세스를 생성할 수 있지만, 그것은 다음의 통신문제를 발생시키기 때문이다. 각각 별개의 메모리 공간을 차지하는 멀티 프로세스가 어떻게 통신할 것인가. 이것은 단순한 문제가 아니다. 많은 종류의 로컬 IPC(프로세스 간 통신)가 있지만, 이것들은 모두 두 가지의 중요한 단점이 있다.


로컬 IPC는 성능을 떨어뜨리는 커널 오버헤드(overhead)를 가중시킨다.

거의 모든 상황에서, IPC는 코드의 자연스러운 확장이 아니다. 그것은 종종 프로그램을 매우 복잡하게 만든다.



두 가지 단점(Double bummer): 오버헤드와 복잡성은 좋은 것이 아니다. IPC 지원을 위해 프로그램을 대폭 수정했던 경험이 있다면, 쓰레드가 제공하는 간단한 메모리 공유 접근의 가치를 현실적으로 인정할 것이다. POSIX 쓰레드가 동일 공간에 있으므로 비경제적이고 복잡한 장거리 호출이 필요 없다. 약간의 동기화로 전체 쓰레드는 기존 프로그램 데이터 구조를 읽고 수정할 수 있다. 파일 기술자(file descriptor)를 통해 데이터를 펌프하거나 타이트하게 공유된 메모리 공간으로 데이터를 스퀴즈하지 않아도 된다. 이런 이유 하나만으로도 멀티 프로세스/단일 쓰레드 모델보다 단일 프로세스/멀티 쓰레드 모델을 고려해야 한다.신속한 쓰레드

쓰레드는 또한 매우 신속하다. 표준 fork()와 비교할 때, 쓰레드는 훨씬 적은 오버헤드를 전달한다. 커널은 프로세스 메모리 공간 및 파일 기술자 등의 별개 복사본을 새로 생성할 필요가 없다. 쓰레드 생성이 프로세스 보다 10~100배 정도 빠르게 되므로 CPU 시간이 많이 단축된다. 이러한 이유로, 많은 수의 쓰레드를 사용하더라도, 이에 따른 CPU와 메모리 오버헤드에 대하여 걱정하지 않아도 된다. fork()를 사용하는 방식처럼 CPU를 크게 혹사시키지 않아도 된다. 즉, 사용자의 프로그램에서 쓰레드가 필요할 때에는 언제든지 쓰레드를 생성시켜도 된다는 말이다.

프로세스와 같이, 쓰레드는 멀티 CPU를 사용한다. 사용자의 소프트웨어가 멀티 프로세서 머신에서 사용되도록 설계된 것이라면 이 것은 매우 유익한 특징이다. (소프트웨어가 오픈 소스라면, 이러한 특징을 가진 소프트웨어가 매우 많을 것이다). 쓰레디드 프로그램의 종류, 특히 CPU 집중적 프로그램의 성능은 시스템의 프로세서의 수와 거의 비례할 것이다. 만약 여러분이CPU 집중적인 프로그램을 작성하려 한다면, 분명히 코드의 멀티 쓰레드 사용 방법을 검토할 것이다. 쓰레디드 코드 작성에 익숙하게 되면, IPC의 까다로움으로 인한 부작용(red tape) 및 기타 사소한 것(mumbo-jumbo)에 신경 쓸 필요 없이, 새롭고 창조적인 방식으로 코딩 문제에 접근할 수 있다. 이 모든 특징들이 합쳐져서, 멀티 쓰레디드(multithreaded) 프로그래밍은 재미있고, 빠르며, 유연하게 될 것이다.클론은 어떠한가?

Linux 프로그래밍 경험이 있으면, __clone() 시스템 콜에 대해 알 것이다. __clone()은 fork() 와 비슷하지만, 쓰레드가 할 수 있는 많은 것을 허용한다. 예를 들어, __clone()을 사용하면 부모(parent) 프로세스의 실행 문맥(메모리 공간, 파일 기술자 등) 부분을 새로운 자식 프로세스(child process)와 선택적으로 공유할 수 있다. 이것은 장단점을 가지고 있다. __clone() 매뉴얼 페이지(manpage)에는 다음과 같이 내용이 있다.

"__clone호출은 Linux 고유한 것이며 이식성을 고려한 프로그램에 사용해서는 안 된다. 쓰레디드 애플리케이션(동일 메모리 공간에서 멀티 쓰레드를 컨트롤함) 프로그래밍에서는, Linux 쓰레드 라이브러리 같은, POSIX 1003.1c thread API를 구현하는 라이브러리를 사용하는 것이 좋다. pthread_create(3thr)를 검토하라. "



__clone()은 쓰레드가 갖는 여러 특징을 제공하지만, 이식성은 없다. 이 말이 여러분의 코드에 __clone()을 사용하면 안된다는 의미는 아니다. 그러나 여러분의 소프트웨어에 __clone() 를 사용할 것이라면 신중히 고려해야 할 사항이다. 다행스럽게도, __clone() 매뉴얼 페이지에 나타난 것처럼 더 나은 대안이 존재한다. 그것이POSIX 쓰레드이다. Solaris, FreeBSD 및 Linux 등에서 작동하는 이식성이 높은 멀티 쓰레디드 코드를 작성을 원한다면 POSIX 쓰레드를 사용하는 것이 좋다. 쓰레드 시작하기

다음은 POSIX 쓰레드 사용의 간략한 예제 프로그램이다.

thread1.c

#include <pthread.h>

#include <stdlib.h>

#include <unistd.h>


void *thread_function(void *arg) {

  int i;

  for ( i=0; i<20; i++ ) {

    printf("Thread says hi!\n");

    sleep(1);

  }

  return NULL;

}


int main(void) {


  pthread_t mythread;

 

  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {

    printf("error creating thread.");

    abort();

  }


  if ( pthread_join ( mythread, NULL ) ) {

    printf("error joining thread.");

    abort();

  }


  exit(0);


}



이 프로그램을 컴파일 하려면, thread1.c로 단순하게 저장하고 다음을 입력한다.

$ gcc thread1.c -o thread1 -lpthread


다음을 입력하여 실행한다.

$ ./thread1


thread1.c 이해하기

thread1.c은 아주 간단한 쓰레디드 프로그램이다. 유용성은 없어도 쓰레드 운용방법을 이해하는데 좋을 것이다. 프로그램의 기능에 대해 단계적으로 살펴보자. main()에서 처음으로 mythread라는 변수를 선언하는데, 이 변수는 pthread_t 형(type)이다. pthread.h 파일에 정의된 pthread_t 형은 종종 "thread id"라고 한다(보통 "tid"로 생략). pthread_t 를 쓰레드의 핸들로 생각하면 된다.

mythread 가 선언된 후(mythread는 단지 "tid", 또는 생성하려는 쓰레드의 핸들임을 기억하라), 실제로 쓰레드 생성을 위해 pthread_create함수를 호출한다. pthread_create() 함수가 "if "문 내부에 있다고 의아해 하지 마라. pthread_create() 함수는 성공할 경우 0을, 실패할 경우 0이 아닌 값을 리턴하기 때문에, 함수 호출을 if()에 두는 것은 실패한 pthread_create() 호출을 탐지하는 매우 섬세한 방법이다. pthread_create의 인수를 살펴보자. 첫 번째는 mythread 포인터인 &mythread 이다. 현재 NULL로 설정된 두 번째 인수는 쓰레드의 속성 정의에 사용될 수 있다. 디폴트 쓰레드 속성은 잘 작동될 것이므로, 단순히 NULL로 설정하면 된다.

세 번째 인수는 새로운 쓰레드가 시작할 때 실행할 함수명이다. 이 경우에 함수명은 thread_function()이다. 이 thread_function()이 리턴값을 반환하는 시점에서, 새로운 쓰레드는 종료될 것이다. 이 예제에서는 쓰레드 함수가 특별히 수행하는 일은 없다. 단지 "Thread says hi!"를 20회 출력한 후 빠져나간다. thread_function()은 void * 형 데이터를 인수를 받아들이고 void * 형 데이터를 리턴한다는 것에 주목하라. void *으로 새로운 쓰레드에 임의의 데이터 조각을 건네주고, 새로운 쓰레드가 종료할 때 임의의 데이터 조각을 돌려줄 수 있다는 것을 보여준다. 이제, 쓰레드에 임의의 인수를 어떻게 넘겨줄 것인가? 간단하다. pthread_create() 함수의 네 번째 인수로 넘기면 된다. 이 예제에서는 NULL에 설정하는데, 하찮은 thread_function() 함수에 어떤 데이터도 건네줄 필요가 없기 때문이다.

추측대로 성공적으로 pthread_create() 함수가 리턴한 후, 프로그램은 두 개의 쓰레드로 구성될 것이다. 잠깐, 쓰레드가 두개라고? 방금 하나를 만들지 않았나? 맞다. 그러나 메인 프로그램도 또한 쓰레드로 간주된다. 이렇게 생각하자. 사용자가 하나의 프로그램을 생성하고 POSIX 쓰레드를 전혀 사용하지 않았다면, 그 프로그램은 단일 쓰레디드인 것이다(이 단일 쓰레드를 "main"쓰레드라 한다). 새로운 쓰레드 생성으로 프로그램에 지금 두 개의 쓰레드를 가지고 있다.

여기서 적어도 두 가지 의구심이 생길 것이다. 첫번째는 새로운 쓰레드가 생성된 다음 메인 쓰레드의 기능? 메인 쓰레드는 순차적으로 프로그램의 다음 행을 연속 실행한다("if ( pthread_join(…))" 행). 다음 두 번째의 의구심은 새로운 쓰레드가 종료될 때 어떻게 되는가 이다. main 쓰레드는 클린업 과정으로서 다른 쓰레드에 병합되거나 "joined"되기 위하여 멈추어서 기다릴 것이다.

좋다. 이제 pthread_join()다. pthread_create() 가 단일 쓰레드를 두 개로 분리시키듯이, pthread_join()는 두 개의 쓰레드를 단일 쓰레드로 병합한다. 첫 번째 인수는 tid인 mythread이다. 두 번째 인수는 void포인터의 포인터이다. void포인터가 NULL이 아니라면, pthread_join는 쓰레드의 void * 리턴값을 우리가 지정하는 위치에 둘 것이다. 우리는thread_function() 함수의 리턴값에 관심이 없기 때문에, 리턴값을 NULL에 둔다.

thread_function() 완료에 20초 정도 소용된다는 것을 깨달을 것이다. thread_function() 완료되기 오래 전, 메인 쓰레드는 이미 pthread_join()를 호출하였다. 이때 메인 쓰레드는 중단되고(슬리프(sleep)로 이동한다) thread_function() 완료를 기다릴 것이다. thread_function()이 완료되면, pthread_join()이 리턴할 것이다. 이제 프로그램은 다시 하나의 main 쓰레드를 가진다. 프로그램이 종료할 때는, 이미 모든 새로운 쓰레드가 pthread_join()된 상태이다. 이것이 프로그램에서 생성된 새로운 쓰레드들을 다루는 정확한 방법이다. 새로운 쓰레드가 결합되지 않는다면 시스템의 최대 쓰레드 한도에 불리하게 카운트되는 것이다. 이는 올바른 클린업이 되지 않는다면 결과적으로 새로운 pthread_create() 호출은 실패함을 의미한다.

부모가 없으면, 자식도 없다

fork() 시스템 콜을 사용해 본 적이 있다면, 아마도 부모와 자식 프로세스 개념에 익숙할 것이다. fork()으로 프로세스가 다른 새로운 프로세스를 생성할 때, 새로운 프로세스는 자식으로 원래의 프로세스는 부모로 간주된다. 이것은 특히 자식 프로세스 종료를 기다릴 때, 유용한 계층적 관계를 생성한다. 예를 들어, waitpid()는 현재의 프로세스가 어떤 자식 프로세스의 종료를 기다리도록 한다. waitpid()는 부모 프로세스에 간단한 클린업 루틴(cleanup routine) 구현에 사용된다.

POSIX 쓰레드와 함께하면 좀더 재미있다. 작가가 의도적으로 "parent thread"와 "child thread"라는 용어를 사용하지 않은 것을 알게 될 것이다. 이유는, POSIX 쓰레드는 이러한 계층적 관계가 존재하지 않기 때문이다. 메인 쓰레드가 새로운 쓰레드를 생성하고, 그 새로운 쓰레드가 새로운 추가 쓰레드를 생성해도, 표준 POSIX 쓰레드는 모든 쓰레드를 단일 풀(pool) 안의 동등한 것으로 간주한다. 따라서 자식 쓰레드의 종료를 대기한다는 개념은 의미가 없다. 표준 POSIX 쓰레드(POSIX thread standard)은 어떤 "family" 정보를 기록하지 않는다. 이러한 계보의 결핍은 중요한 하나의 함축성을 가진다. 하나의 쓰레드의 종료를 기다려야 하는 경우라면, 적합한 tid를 pthread_join()에 넘김으로써 대기해야 하는 쓰레드를 지정해야 되는 것이다. 쓰레드 라이브러리는 그것을 알아서 해결해주지 않는다.

다수의 사용자에게 이것은 좋은 소식은 아니다. 그 이유는 두개 이상의 쓰레드 구성의 프로그램을 더욱 복잡하게 말들 수 있기 때문이다. 표준 POSIX 쓰레드는 섬세한 멀티 쓰레드를 취급하는데 필요한 모든 도구를 제공한다. 실제로, 부모/자식 관계가 없다는 사실이 프로그램에서 쓰레드를 사용하는데 창조적인 방법을 제시한다. 예를 들면, 쓰레드1이라는 쓰레드를 가지고 쓰레드2라는 쓰레드를 생성하면, 쓰레드1 자체가 반드시 쓰레드2에 대하여 pthread_join()을 호출할 필요는 없다. 프로그램에서 어떤 쓰레드도 그렇게 할 수 있다. 이것은 무거운 멀티 쓰레디드 코드를 작성할 때, 어떤 가능성을 제시한다. 예를 들어, 모든 중단된 쓰레드를 담고 있는 범용의 "dead list"를 생성해서, 하나의 항목이 리스트에 추가되기를 기다리는 특별한 클린업 쓰레드를 포함할 수 있다. 클린업 쓰레드는 그 자신과 합병할 pthread_join()를 호출한다. 이제 전체적인 클린업이 하나의 쓰레드에서 깔끔하고 효율적으로 처리될 것이다. 싱크로나이즈드 스위밍

예상을 빗나간 thread2.c의 코드 실행을 검토한다.

thread2.c

#include <pthread.h>

#include <stdlib.h>

#include <unistd.h>

#include <stdio.h>


int myglobal;


void *thread_function(void *arg) {

  int i,j;

  for ( i=0; i<20; i++ ) {

    j=myglobal;

    j=j+1;

    printf(".");

    fflush(stdout);

   sleep(1);

    myglobal=j;

  }

  return NULL;

}


int main(void) {


  pthread_t mythread;

  int i;


  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {

    printf("error creating thread.");

    abort();

  }


  for ( i=0; i<20; i++) {

    myglobal=myglobal+1;

    printf("o");

    fflush(stdout);

    sleep(1);

  }


  if ( pthread_join ( mythread, NULL ) ) {

    printf("error joining thread.");

    abort();

  }


  printf("\nmyglobal equals


  exit(0);


}



Understanding thread2.c

이 프로그램은 위의 첫번째 프로그램과 마찬가지로 새로운 쓰레드를 생성한다. 메인 쓰레드와 새로운 쓰레드 둘 다 myglobal이라는 글로벌 변수를 20번 증분 한다. 그러나 프로그램은 어떤 기대하지 않은 결과를 산출한다. 다음을 입력하여 컴파일해 보자.

$ gcc thread2.c -o thread2 -lpthread


그리고 실행한다.

$ ./thread2


다음은 사용자 시스템의 출력이다.

$ ./thread2

..o.o.o.o.oo.o.o.o.o.o.o.o.o.o..o.o.o.o.o

myglobal equals 21


전혀 기대하지 않은 것이다! myglobal은 0에서 시작하고, 메인 쓰레드와 새로운 쓰레드 둘 다 각각 myglobal을 20까지 증분 하기 때문에, 프로그램의 끝에서 40인 myglobal을 보아야 한다. Myglobal이 21이기 때문에, 여기에서 수상한 어떤 것이 진행되고 있다는 것을 안다. 하지만 그것은 무엇일까?

어려운가? 좋다, 왜 이렇게 되었는지 보자. thread_function()을 살펴보자. "j"라는 지역변수(local variable)에 myglobal을 어떻게 복사했는지 주목하자. 어떻게 j를 증분 하고 나서 1초 동안 슬리프하고, 그리고 나서 새로운 j값이 myglobal에 복사 되었는가? 이것이 열쇠다. 새로운 쓰레드가 myglobal의 값을 j에 복사하고 그 직후에 메인 쓰레드가 myglobal을 증분한다면 어떤 일이 일어나는지 생각해 보라. thread_function()이 j의 값을 myglobal에 되돌려 적을 때, 이 함수는 메인 쓰레드가 생성한 것을 수정하여 덮어 쓸 것이다.

쓰레디드 프로그램을 작성할 때, 방금 보았던 것과 같은 부작용은 시간 낭비이므로 피하기를 원할 것이다. (여러분이 POSIX 쓰레드에 관한 글을 쓰고 있다면 물론 예외지만). 이제, 이 혼란을 제거하기 위하여 무엇을 해야 할까?

myglobal을 j에 복사하고, myglobal에 되돌려 적기 전 1초 동안 myglobal을 그곳에 잡아두어서 문제가 발생하는 것이기 때문에, 임시 국소변수의 사용과 myglobal의 직접적인 증분을 피하려고 시도할 수 있다. 이 해법은 아마도 이 개별적인 예에서는 작용할 것이지만, 그것은 정확하지 않다. 그리고 증분 대신 myglobal에 상대적으로 복잡한 수학적 연산을 수행한다면 분명히 실패할 것이다.

문제 이해를 위해 쓰레드는 동시 실행되는 것을 기억할 필요가 있다. 단일 프로세서 머신도(커널은 실제 멀티작업의 시뮬레이션을 위해 타임 슬라이싱한다) 우리는 프로그래머의 입장에서 동시에 실행하는 두개의 쓰레드를 상상할 수 있다. thread2.c 는 thread_function() 안의 코드가 myglobal은 증분 전 1초 동안 수정되지 않을 것이라는 사실에 의지하기 때문에 문제가 있는 것이다. 하나의 쓰레드가 myglobal을 변경시키는 동안, 다른 쓰레드에게 "hold off"라고 말할 수 있는 어떤 방법이 필요하다. 다음에는 구체적인 방법을 제시하겠다.

참고자료


Sean Walton, KB7rfa 의 Linux threads 참조

Arizona대학, Mark Hays의 POSIX threads tutorial

An Introduction to Pthreads-Tcl : Tcl 변경에 대한 상세 정보

Getting Started with POSIX Threads : Amherst, Massachusetts 대학, 컴퓨터공학과의 Tom Wagner와 Don Towsley 가 공동 집필한 튜토리얼

쓰레드 맨 페이지 ("man -k pthread") 참조

FSU PThreads : SunOS 4.1.x, Solaris 2.x, SCO UNIX, FreeBSD, Linux 및 DOS용 POSIX 쓰레드를 구현하는 C 라이브러리.

POSIX and DCE threads for Linux

The LinuxThreads Library

Proolix : i8086용 간단한 POSIX호환 운영 시스템.

Programming with POSIX Threads (David R. Butenhof)

W. Richard Stevens의 UNIX Network Programming: Network APIs: Sockets and XTI, Volume 1

필자소개

Daniel Robbins(drobbins@gentoo.org)는 Gentoo Technologies, Inc.의 회장/CEO이고, Gentoo Project의 핵심 설계자이며, Caldera OpenLinux Unleashed, SuSE Linux Unleashed, Samba Unleashed의 저자이다.



출처 : http://c.lug.or.kr/study/etc/posix_thread.html

+ Recent posts