개발 공부
[운영체제][OSTEP] 쓰레드 API
쓰레드 API를 이용하여 쓰레드를 생성하고 제어하는 방법에 대해 공부해봅니다

![[운영체제][OSTEP] 쓰레드 API](https://www.datocms-assets.com/66479/1686988115-ostep.jpg?auto=format&w=860)
1️⃣ 쓰레드 생성
멀티 쓰레드 프로그램 작성 시, 가정 먼저할 일은 새로운 쓰레드의 생성이다.
쓰레드 생성을 위해서는 해당 인터페이스가 존재해야 한다. POSIX에서는 다음과 같다.
#include <pthread.h>
int pthread_create( pthread_t * thread,
const pthread_attr_t * attr,
void * (*start_routine)(void*),
void * arg);
thread
, attr
, start_routine
, arg
라는 4개의 인자가 있다. 이를 하나씩 살펴보자.
첫 번째 인자: thread
pthread_t
타입 구조체를 가리키는 포인터로, 이 구조가 쓰레드와 상호작용하는 데 사용되므로 쓰레드 초기화 시 pthread_create()
에 이 구조체를 전달한다.
두 번째 인자: attr
쓰레드의 속성을 지정하는 데 사용하며, 스택의 크기와 쓰레드의 스케줄링 우선순위 같은 정보를 지정하기 위해서 사용될 수 있다.
개별 속성은 pthread_attr_init()
함수를 호출하여 초기화한다.
대부분의 경우에 디폴트 값(간단히 NULL
)을 지정하면 충분하다.
세 번째 인자: (*start_routine)(void*)
이 쓰레드가 실행할 함수(함수 포인터)를 나타낸다.
이때 전달하는 함수는 void *
타입의 인자 한개를 전달받고, void *
타입의 값을 반환한다.
void 포인터 타입 대신 integer
를 인자로 사용하는 루틴인 경우
int pthread_create(.... // 처음 두 인자는 동일함
void * (*start_routine)(int),
void * arg);
void 포인터 타입을 인자로 받지만, interger
타입을 반환하는 경우
int pthread_create(.... // 처음 두 인자는 동일함
int (*start_routine)(void*),
void * arg);
네 번째 인자: arg
실행할 함수에게 전달할 인자를 나타낸다.
void 포인터 타입이 필요한 이유
➡️ 어떤 데이터 타입도 인자로 전달할 수 있음.
➡️ 반환 값의 타입으로 사용하면 쓰레드는 어떤 타입의 결과도 반환 가능
2️⃣ 쓰레드 종료
다른 쓰레드의 작업 완료를 기다려야 하는 경우
➡️ POSIX 쓰레드에서는 pthread_join()
을 호출
int pthread_join(pthread_t thread, void **value_ptr);
이 루틴은 thread
, **value_ptr
두 개의 인자를 받는다. 이를 하나씩 살펴보자.
첫 번째 인자: thread
pthread_t
타입의 인자로, 어떤 쓰레드를 기다려야 하는지 명시한다.
➡️ 해당 변수는 쓰레드 생성 루틴(pthread_create()
)에 의해 초기화된다.
두 번째 인자: **value_ptr
반환 값에 대한 포인터로, 루틴이 임의의 데이터 타입을 반환할 수 있으므로 void에 대한 포인터 타입으로 정의한다.
pthread_join()
루틴은 전달된 인자의 값을 변경하기 때문에 값에 대한 포인터를 전달해야 한다.
예시
#include <stdio.h>
#include <pthread.h>
#include <assert.h>
#include <stdlib.h>
typedef struct __myarg_t {
int a;
int b;
} myarg_t;
typedef struct __myret_t {
int x;
int y;
} myret_t;
void *mythread(void *arg) {
myarg_t *m = (myarg_t *) arg;
printf("%d %d\n", m−>a, m−>b);
myret_t *r = malloc(sizeof(myret_t));
r−>x = 1;
r−>y = 2;
return (void *) r;
}
int main(int argc, char *argv[])
{
int rc;
pthread_t p;
myret_t *m;
myarg_t args;
args.a = 10;
args.b = 20;
Pthread_create(&p, NULL, mythread, &args);
Pthread_join(p, (void **) &m);
printf("returned %d %d\n", m−>x, m−>y);
return 0;
}
⚠️ 위의 예시에서는 기존 루틴들의 예상치 못한 값의 반환을 방지하기 위해 래퍼 함수(Pthread_create()
, Pthread_join()
)를 사용하고 있음에 유의하자.
mythread()
는 반환 값으로 myret_t
타입을 사용하고 있는데, 쓰레드가 실행을 마치면 반환한 값을 pthread_join()
함수에서 받아 접근할 수 있다.
만약 인자가 없는 쓰레드를 생성할 때에서는 NULL
을 전달하여 쓰레드를 생성할 수도 있고, 반환 값이 필요 없다면 pthread_join()
에 NULL
을 전달할 수도 있다.
그 외 기타
pthread_create()
를 사용하여 쓰레드 생성 직후 pthread_join()
을 호출?
➡️ 굳이 쓰레드를 사용할 필요가 없다..
➡️ 프로시저 호출(procedure call)을 하면 된다.
💡 모든 멀티 쓰레드 코드가 조인 루틴을 사용하지는 않는다.
➡️ 예를 들어, 웹서버에서 여러 개의 작업자 쓰레드 생성 후 사용자 요청을 받아 수행하는 경우
💡 특정 작업을 병렬적으로 실행하기 위해 쓰레드를 생성하는 병렬 프로그램의 경우
➡️ 종료/계산의 다음 단계 전 병렬 수행 작업이 모두 완료되었는지 확인하기 위해 join
을 사용
3️⃣ 락
POSIX 쓰레드 라이브러리는 락(lock)을 통해 임계 영역에 대한 상호 배제 기법을 가능하게 해주는 함수를 제공한다.
이러한 목적을 위하여 사용되는 가장 기본적인 루틴은 다음과 같이 쌍으로 이루어져 있다.
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
다음의 예시와 함께 이를 살펴보자.
// 락의 초기화
// 방법1: 디폴트 값으로 설정
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// 방법2: 동적으로 초기화
pthread_mutex_t lock;
int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); // 성공했는지 꼭 확인해야 한다!
// 락 획득
pthread_mutex_lock(&lock);
x = x + 1; // 또는 다른 임계 영역의 코드를 사용할 수 있음.
pthread_mutex_unlock(&lock);
락의 초기화
먼저 락을 초기화하는 방법을 살펴보자.
세 번째 줄처럼 정적으로 설정하는 방법이 있고, 5-6줄 처럼 동적으로 실행 중에 초기화(주로 사용)하는 방법이 있다.
동적으로 초기화하는 경우 pthread_mutex_init()
에는 두 개의 인자가 필요하다.
첫 번째 인자: 락 자체의 주소
두 번째 인자: 선택 가능한 속성(NULL
을 전달하면 디폴트 값 사용)
동적으로 초기화하는 경우 락 사용이 끝난 후 pthread_mutex_destroy()
를 호출해주어야 한다.
락 획득
다음으로 pthread_mutex_lock()
가 호출되었을 때의 동작을 살펴보자.
[✅ 다른 어떤 쓰레드도 락을 가지고 있지 않은 경우]
호출한 쓰레드가 락을 얻어 임계 영역에 진입
[ 🚧 다른 쓰레드가 락을 가지고 있는 경우]
락을 얻을 때까지 호출에서 리턴하지 않음.
➡️ 리턴은 락을 가지고 있던 쓰레드가 언락(unlock)을 호출하여 락을 양도했음을 의미
➡️ 많은 쓰레드들이 락 획득 함수에서 대기 중일 수 있음.
➡️ 락을 획득한 쓰레드만이 언락을 호출해야 함.
⚠️ 바로 pthread_mutex_lock()
를 호출하는 경우 에러 발생 시 여러 쓰레드가 동시에 임계 영역에 들어갈 수 있으므로 아래와 같은 래퍼 함수를 활용하여 루틴이 성공적으로 처리되었는지 확인하자.
// 이 방법을 써서 코드를 깔끔하게 유지하되 오류가 없는지 확인해야 한다.
// 프로그램이 오류에도 문제가 없었을 때에만 사용하자.
void Pthread_mutex_lock(pthread_mutex_t *mutex) {
int rc = pthread_mutex_lock(mutex);
assert(rc == 0);
}
기타 락 관련 루틴
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex,
struct timespec *abs_timeout);
위의 두 함수는 락을 획득하는 데 사용된다.
trylock
버전은 락이 이미 사용 중이라면 실패 코드를 반환한다.
timedlock
은 타임아웃이 끝나거나 락을 획득하거나 두 조건 중 하나가 발생하면 리턴한다.
➡️ 타임아웃을 0으로 설정하면 trylock
과 동일하게 동작한다.
이 두 함수는 사용하지 않는 것이 좋지만, 락 획득 루틴에서 무한정 대기하는 상황을 피하기 위해서 사용되기도 함.
4️⃣ 컨디션 변수
POSIX 쓰레드 라이브러리의 경우 분명하게 제공하는 주요 구성 요소로 컨디션 변수(condition variable)가 있다.
컨디션 변수는 한 쓰레드가 계속 진행하기 전 다른 쓰레드의 동작을 대기하는 상황에서 쓰레드 사이에서 시그널 교환 메커니즘이 필요할 때 유용하게 사용될 수 있다.
두 개의 기본 루틴은 다음과 같다.
int pthread_cond_wait(pthread_cond_t *cond,
pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
💡 컨디션 변수의 사용을 위해서는 해당 컨디션 변수와 연결된 락이 ⭐반드시 존재해야 한다.
pthread_cond_wait()
는 호출 쓰레드를 sleep 상태로 만들고 다른 쓰레드로부터의 시그널을 대기한다.
➡️ 📡 현재 sleep 중인 쓰레드가 관심 있는 무언가가 변경되면 시그널을 보낸다.
다음에서 전형적인 용례를 살펴보자.
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Pthread_mutex_lock(&lock);
while (ready == 0)
Pthread_cond_wait(&cond, &lock);
Pthread_mutex_unlock(&lock);
위의 코드에서는 연관된 락과 컨디션 변수를 초기화한 후 ready
변수가 0인지 검사한다.
➡️ ready
변수 값이 0이라면, 대기 루틴을 호출하고 sleep 상태가 된다.(다른 쓰레드를 기다림.)
이때, 다른 쓰레드에서 sleep 상태인 쓰레드를 깨우는 코드는 다음과 같다.
Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);
⚠️ 위 코드에서 유의할 점을 살펴보자.
시그널을 보내고 전역 변수
ready
를 수정할 때 반드시 락을 가지고 있어야 한다.➡️ 경쟁 조건이 발생하지 않는다는 것을 보장
시그널 대기 함수에서는 락을 두 번째 인자로 받고 있지만, 시그널 보내기 함수에서는 조건만을 인자로 받는다.
➡️ 시그널 대기 함수는 호출 쓰레드를 sleep 시키는 것 외에 락도 반납(release)해야 하기 때문
➡️ 락을 반납하지 않으면 깨울 쓰레드가 깨우기 위해 락을 얻을 수 없음..
pthread_cond_wait()
는 깨어나서 리턴하기 직전에 락을 다시 획득한다.➡️ 이 함수를 실행한 쓰레드들은 처음 락 획득 시부터 마지막에 반납 시까지 항상 락을 획득한 상태로 실행된다는 것을 보장
대기하는 쓰레드가 조건을 검사할 때
if
문을 사용하는 대신while
문을 사용한다.➡️ 만약
pthread
라이브러리에서 변수를 제대로 갱신하지 않고 쓰레드를 깨우는 경우 재검사가 필요➡️ 시그널의 도착을 변경 사실을 알리는 것이 아니라, 변경된 것 같으니 검사해보라는 정도의 힌트로 간주하는 것이 더 안전
컨디션 변수 대신 간단한 플래그를 사용하면 안되나? ➡️ 🚫
// 대기 코드
while (ready == 0); // 회전
---------------------------
// 시그널 보내기 코드
ready = 1;
🚫 위와 같이 바꾸어 구현해도 좋을 것 같지만 다음과 같은 이유로 절대로 하면 안된다.
조건 검사를 위한 오랜 시간 반복문 실행 = CPU 사이클의 낭비 초래
오류가 발생하기 쉬움.
관련된 연구에 따르면 임시방편적인 동기화 방법을 사용한 경우, 대략 절반 정도가 버그를 유발함.
따라서 꼭 컨디션 변수를 사용하도록 하자.
5️⃣ 컴파일과 실행
앞선 예시 코드들을 컴파일하기 위해서는 pthread.h
헤더를 포함시켜야 한다.
다음과 같이 -pthread 플래그를 명령어 링크 옵션 부분에 추가하여 사용함으로써 pthread
라이브러리와 링크할 수 있도록 명시해야 한다.
prompt> gcc −o main main.c −Wall −pthread
main.c
가 pthread
헤더를 포함하고 있다면 병행 프로그램이 성공적으로 컴파일된다.
✅ 쓰레드 API의 지침
마지막으로 POSIX 또는 다른 쓰레드 라이브러리를 사용하여 멀티 쓰레드 프로그램을 작성하는 데 있어 기억해야할 중요한 사항들을 짚어보자.
[✒️ 간단하게 작성하라.]
락을 획득하거나 쓰레드끼리 시그널을 주고 받는 코드는 가능한 간단해야 한다.
➡️ 쓰레드 간의 복잡한 상호 동작은 버그를 만든다.
[🏸쓰레드 간의 상호 동작을 최소로 하라.]
쓰레드끼리 상호 작용하는 방법의 개수를 최소로 해야 한다.
[🧹 락과 컨디션 변수를 초기화하라.]
초기화하지 않고 사용하면 어떤 때는 동작하지만 때로는 매우 이상한 방식으로 실패할 수 있다.
[🔄 반환 코드를 확인하라.]
반환 코드를 확인하지 않을 경우 기이하고 이해하기 어려운 동작을 초래할 수 있다.
[🧨 쓰레드 간에 인자를 전달하고 반환받을 때는 조심해야 한다.]
특히, 스택에 할당된 변수에 대한 참조를 전달할 경우 뭔가 잘못하고 있을 확률이 높다.
[🗄️ 각 쓰레드는 개별적인 스택을 가진다.]
실행 중인 어떤 함수 내에 지역적으로 할당된 변수가 있다면 그 변수는 본질적으로 쓰레드 전용으로 사용되어야 하고 다른 쓰레드가 (쉽게) 접근할 수 없어야 한다.
➡️ 쓰레드 간에 데이터를 공유하려면 힙에 할당하거나, 전역적으로 접근이 가능한 위치에 있어야 한다.
[☄️ 쓰레드 간에 시그널을 보내기 위해 항상 컨디션 변수를 사용하라.]
간단한 플래그 변수를 사용하지 않도록 한다.
[📖 메뉴얼을 사용하라.]
특히 Linux에 pthread
에 대한 설명에는 많은 정보가 있다.
📚 참고 문헌
Operating Systems: Three Easy Pieces ― 27: Interlude: Thread API