References
- An Introduction to Parallel Programming
Contents
- 공유 메모리 시스템
- 프로세스, 스레드 그리고 pthreads
- Hello, Pthreads
개발자 입장에서 공유 메모리 시스템은 모든 코어가 모든 메모리 위치에 접근할 수 있습니다. 그러므로 공유 메모리 시스템에서의 접근 방법은 특정 메모리 위치를 "공유"하도록 설정하는 것입니다. 이는 병렬 프로그래밍에서 당연한 방법입니다. 그러나 앞으로 pthread에 대해서 알아보면서 공유 메모리 시스템에서 프로그래밍할 때 문제가 있다는 것을 알아보도록 할 것입니다. 이러한 문제점들은 분산 메모리 프로그램에서 발생한 문제와는 조금 다릅니다.
프로세스, 스레드, Pthreads
스레드(thread)는 MPI 프로그래밍에서의 프로세스와 비슷합니다. 그러나 스레드가 조금 더 경량화(lighter-weight)되어 있습니다. 프로세스(process)는 실행하는(또는 중지된) 프로그램의 인스턴스입니다. 그리고 실행파일은 다음과 같은 구조로 구성되어 있습니다.
- A block of memory for the stack (스택 메모리)
- A block of memory for the heap (힙 메모리)
- Descriptor of resources (for example, file descriptors)
- Security information (for example, 프로세스가 액세스할 수 있는 하드웨어와 소프트웨어 리소스)
- Information aboue the state of the process (프로세스가 실행할 준비가 되었는지, 리소스를 기다리는 지 등의 프로세스 상태 정보), the contents of the registers (program counter 등을 포함한)
대부분의 시스템에서 기본적으로 프로세스 메모리 블록은 private입니다. OS의 간섭이 없다면 다른 프로세스가 이 프로세스의 메모리에 직접적으로 액세스하는 것은 불가능합니다. 우리가 텍스트 에디터를 사용하여 프로그램을 작성한다면, 다른 프로세스가 덱스트 에디터가 사용 중인 메모리를 덮어쓰는 상황이 발생하면 안되기 때문입니다. 이는 다중 사용자 환경에서 더욱 중요합니다.
하지만 공유 메모리 프로그램을 사용할 때는 최소한 어떤 변수는 여러 프로세스에서 사용하기를 원합니다. 따라서 공유 메모리의 프로세스들은 각자의 메모리에 더욱 쉽게 접근할 수 있도록 해줍니다. 또한 stdout을 액세스하는 것처럼 공유할 수도 있습니다. 실제로, 스택이나 프로그램 카운터를 제외하고는 프로세스의 특정한 정보를 비롯한 거의 모든 것을 공유할 수 있습니다. 이는 하나의 프로세스에서 시작하고 경량화(lighter-weight) 프로세스들을 시작하는 프로세스를 가짐으로 비교적 쉽게 가능합니다. 이러한 프로그램을 경량화 프로세스(light-weight process)라고 합니다.
더 공통적으로 사용하는 용어는 스레드(thread)이며, 'thread of control'이라는 컨셉에서 나왔습니다. thread of control은 프로그램에서의 문장 순서(sequence of statements)입니다. 이는 싱글 프로세스에서 제어 스트림을 의미하며, 공유 메모리 프로그램에서 하나의 프로세스는 여러 개의 threads of control을 가질 수 있습니다.
이번 포스팅부터 POSIX thread라는 스레드의 구현에 대해서 알아볼 예정입니다. POSIX thread를 pthread라고 하며, POSIX는 UNIX 형태의 OS 표준입니다. 리눅스, Mac OS X 등이 이에 속합니다. POSIX는 이러한 시스템에서 사용 가능한 기능들을 정의해두었고, 특히 멀티스레드(multithreaded) 프로그래밍 인터페이스를 정의하고 있습니다.
Pthreads는 C와 같은 프로그래밍 언어가 아닙니다. 오히려 MPI처럼 Pthread는 C 프로그램에 링크될 수 있는 라이브러리입니다. MPI와는 달리 Pthread API는 오직 POSIX 시스템에서만 사용 가능합니다. 다른 환경에서 멀티스레드 프로그래밍을 위해 Java threads, Windows threads, Solaris threads와 같은 다양하게 사용되는 사양(specification)이 존재합니다만, 기본적으로 같은 아이디어로 동작됩니다.
Pthread가 C 라이브러리이기 때문에 C++프로그램에서도 사용할 수 있습니다. C++11부터는 표준으로 thread가 지정되었기 때문에 C++ 프로그래밍에서는 thread를 사용하면 될 것 같습니다 !
Hello, Pthreads
아래의 Pthread 프로그램을 살펴보도록 하겠습니다. 아래 프로그램은 여러 스레드를 시작하는 main 함수로 구성되어 있는데, 각 스레드는 메세지를 출력한 후에 종료됩니다.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
const int MAX_THREADS = 64;
/* global variables: accesible to all threads */
int thread_count;
void Usage(char* prog_name);
void* Hello(void* rank);
int main(int argc, char* argv[])
{
if (argc != 2) {
Usage(argv[0]);
}
/* Get number of threads from command line */
thread_count = strtol(argv[1], NULL, 10);
if (thread_count <= 0 || thread_count > MAX_THREADS) {
Usage(argv[0]);
}
pthread_t* thread_handles;
thread_handles = malloc(thread_count*sizeof(pthread_t));
for (long thread = 0; thread < thread_count; thread++) {
pthread_create(&thread_handles[thread], NULL, Hello, (void*)thread);
}
printf("Hello from the main thread\n");
for (long thread = 0; thread < thread_count; thread++) {
pthread_join(thread_handles[thread], NULL);
}
free(thread_handles);
return 0;
}
void* Hello(void* rank)
{
long my_rank = (long)rank;
printf("Hello from thread %ld of %d\n", my_rank, thread_count);
return NULL;
}
위 프로그램은 Pthreads 라이브러리를 링크하는 것만 빼면 일반적인 C프로그램처럼 컴파일하면 됩니다.
$ gcc -Wall -o 00_pth_hello 00_pth_hello.c -pthread
여기서 '-pthread'는 pthreads 라이브러리를 링크하라고 지시하는 명령어입니다. 어떤 시스템에서는 자동으로 라이브러리를 링크하기 때문에 필요없을 수도 있으며, -lpthread를 사용해도 됩니다. 차이점은 -pthread는 링크하면서 pre-defined 매크로를 정의해주고, -lpthread는 pre-defined 매크로없이 링크만 해줍니다. -pthread로 정의되는 매크로는 _REENTRANT 입니다.
프로그램을 실행하려면 다음과 같이 입력하면 됩니다.
$ ./00_pth_hello <number of threads>
실행하면 위와 같은 출력을 확인할 수 있습니다.
위의 프로그램 코드를 살펴보면, 일반적인 C 프로그램 코드입니다. 그러나 새로운 pthread.h 헤더파일을 include하고 있습니다. 이 파일은 Pthreads 헤더 파일이며 다양한 Pthreads 함수, 상수, 타입 등등이 선언되어 있습니다.
line 8에는 global 변수로 thread_count가 정의되어 있습니다. Pthreads 프로그램에서 Global 변수는 모든 스레드에서 공유됩니다. 반대로 Local 변수나 함수의 인수는 함수 내부에서 선언된 변수이기 때문에 실행되는 함수의 스레드에서 private합니다. 여러 스레드가 동일한 함수를 실행하면, 각 스레드는 자신만의 local 변수와 함수 인수들의 복사본을 가지게 됩니다. 이는 스레드들의 자신들만의 스택을 가지고 있다는 것을 기억해보면 이해가 빠를 것입니다.
Global 변수를 사용하게 되면 버그를 일으킬 수 있다는 점을 알아야 합니다. 예를 들어, global 변수 int x를 선언한 프로그램을 작성한다고 가정해봅시다. 그리고 선언하는 것을 까먹고 local 변수 x를 사용하는 함수 f를 작성합니다. 함수 f 내에서 global 변수 x에 접근하기 때문에 컴파일하면 에러없이 컴파일이 되지만, 프로그램을 실행하면 global 변수 x에 이상한 값이 저장된 것을 발견할 수 있습니다. 따라서 global 변수는 꼭 필요한 곳에서만 사용되도록 확실하게 제한해야 합니다.
line 20에서 프로그램은 커맨드 라인으로부터 실행할 스레드의 수를 입력받습니다. MPI 프로그램과는 달리 Pthreads 프로그램은 시리얼 프로그램처럼 컴파일되고 실행됩니다. 실행하는 스레드의 수를 설정하는 간단한 방법은 커맨드 라인으로 입력하는 것입니다. 여기서 입력받은 스레드 수를 stdlib.h에 선언된 strtol 함수를 사용하여 문자열을 long int로 변경하였습니다. string.h의 stol 함수를 사용해도 상관없습니다.
MPI가 스트립트(mpiexec, mpirun)에 실행되는 것과는 달리, Pthread에서 스레드는 프로그램 실행 파일에 의해서 실행됩니다. 약간 더 추가적인 작업이 필요한데, 작성하는 프로그램에 스레드를 명시적으로 시작하기 위한 코드를 추가해야하고, 스레드의 정보를 저장하기 위한 자료구조가 필요합니다. line 25-26에서 각 스레드에 대한 pthread_t 객체를 위한 공간을 할당하는데, pthread_t 자료 구조는 스레드 고유의 정보를 저장하는데 사용됩니다.
pthread_t 객체는 opaque type의 자료구조입니다. opaque는 불투명이라는 의미로, 이 타입에 저장하는 실제 데이터는 시스템 고유의 정보이며, 그 안의 데이터 멤버는 사용자 코드에서 직접 액세스할 수 없고 볼 수도 없습니다. 즉, 내부의 데이터 구조를 들여다 볼 수 없는 자료구조라고 생각하시면 됩니다. Pthreads 표준은 pthread_t 객체가 관련있는 thread를 식별할 수 있도록 충분한 정보를 저장한다는 것을 보장합니다.
line 28-30에는 스레드를 시작하기 위한 pthread_create 함수를 사용합니다. pthread_create의 문법은 다음과 같습니다.
int pthread_create(
pthread_t* thread_p /* out */,
const pthread_attr_t* attr_p /* in */,
void* (*start_routine)(void*) /* in */,
void* arg_p /* in */);
첫 번째 인수는 적절한 pthread_t 객체의 포인터입니다. 참고로 이 객체는 pthread_create의 호출로 할당되는 것이 아니라, 이 함수를 호출하기 전에 할당되어 있어야합니다. 두 번째 인수는 일단 여기서 사용되지 않으므로 NULL을 넘겨주면 됩니다. 이 인수는 thread를 detach하거나 할 때 사용됩니다. 세 번째 인수는 스레드가 실행하는 함수이며, 마지막 인수는 start_routine 함수에 전달해야하는 인수들의 포인터입니다.
대부분의 pthread 함수의 리턴값은 함수 호출에 에러가 있는지 확인해줍니다. 여기서는 일단 리턴값들은 모두 무시되었습니다.
마지막 두 개의 인수인 스레드가 실행하는 함수와 전달되는 인수들의 포인터에 대해 조금 더 살펴보도록 하겠습니다. 우선 pthread_create의 호출에 의해서 시작되는 함수는 다음과 같은 프로토 타입을 가져야합니다.
void* thread_function(void* args_p);
void* 타입은 C에서 어떠한 포인터 타입으로도 캐스팅될 수 있기 때문에 args_p는 스레드 함수에서 필요한 하나 이상의 값을 포함하는 리스트를 가리킬 수 있습니다. 동일하게 thread_function의 리턴값 또한 하나 이상의 값의 리스트를 가리킬 수 있습니다. 우리가 작성한 프로그램의 pthead_create에서 마지막 인수로 각 스레드에 유니크한 정수인 rank를 할당합니다. 이는 만약 스레드에서 에러가 발생했을 때 사용자 입장에서 어떤 스레드가 발생했는지 알기 위해서 사용될 수 있는 스레드의 고유 번호라고 할 수 있습니다. 여기서는 스레드 함수가 void* 인수를 갖기 때문에 각 스레드에 할당하기 위해서 main에서 하나의 유니크한 값을 가지는 long을 void*로 캐스팅하여 tprhead_create의 호출로 넘겨줍니다. 이렇게 전달된 rank는 line 45에서 사용됩니다.
main함수를 실행하는 스레드는 보통 메인 스레드(main thread)라고 합니다. 메인 스레드는 프로그램이 시작한 후에 'Hello from the main thread'라는 메세지가 출력됩니다. pthread_creat를 호출하여 시작한 스레드는 그 이후에 실행이 되는데, line 45에서 캐스팅된 rank를 얻고 메세지를 출력합니다. 스레드가 완료되면 리턴해야 하는데, 이 예제에서는 리턴할 필요가 없으므로 NULL을 리턴합니다.
Pthread에서는 개발자가 스레드가 어디에서 실행될 지 어떠한 순서로 실행되는 지를 직접 제어할 수 없습니다. 스레드의 배치는 운영체제에 의해서 제어됩니다.
line 34-36을 보면 각 스레드에서 pthread_join이 한번씩 호출됩니다. pthread_join을 한 번 호출하면 pthread_t 객체와 연결된 스레드가 완료될 때까지 대기합니다. pthread_join의 문법은 다음과 같습니다.
int pthread_join(
pthread_t thread /* in */,
void** ret_val_p /* out */);
여기서 두 번째 인수는 스레드에서 연산된 리턴값을 전달받는데 사용됩니다. 위 예제에서는 각 스레드가 return을 수행하고, main 스레드에서만 pthread_join을 호출하여 스레드가 완전히 종료될 수 있도록 합니다.
pthread_join이라는 이름은 멀티스레드 프로세스에서 스레드가 묘사되는 다이어그램 때문입니다. 만약 메인 스레드가 다이어그램에서 하나의 싱글 라인으로 생각할 때, pthread_create 호출을 통해서 우리는 메인스레드에서 나오는 brach나 fork를 만들 수 있습니다. 그리고 pthread_create로 부터 실행된 스레드가 종료될 때, 다이어그램에서 각 brach가 메인 스레드로 join되는 것을 볼 수 있습니다.
이렇게 간단한 프로그램으로 pthread_create, pthread_join을 사용하여 pthread를 사용하는 방법에 대해서 간단하게 알아보았습니다.
위의 예제 코드의 전체 내용은 아래 링크에서 확인하실 수 있습니다 !
https://github.com/junstar92/parallel_programming_study/blob/master/pthread/00_pth_hello.c
'프로그래밍 > 병렬프로그래밍' 카테고리의 다른 글
[pthread] Critical Section (0) | 2021.11.17 |
---|---|
[pthread] 행렬 - 벡터 곱 연산 (+ 캐시 일관성, 거짓 공유) (0) | 2021.11.16 |
[MPI] Odd-even transposition sort (MPI Safety) (0) | 2021.11.13 |
[MPI] MPI Derived Datatypes (파생 데이터타입) (0) | 2021.11.13 |
[MPI] 행렬 - 벡터 곱 연산 + 성능 평가 (0) | 2021.11.12 |
댓글