운영체제 아주 쉬운 세가지 이야기 9주차(쓰레드 API)

2025. 11. 3. 16:50개념 공부

POSIX Thread API 완전 해부

목차

  1. 왜 스레드인가?
  2. 스레드 생성: pthread_create() 시그니처를 해부하다
  3. 인자 전달 & 반환 패턴: void*의 진짜 의도
  4. 스레드 종료, pthread_join() / detach 라이프사이클
  5. 뮤텍스: 초기화, 사용 패턴, 에러 처리, 래퍼
  6. trylock / timedlock: 언제(거의 안) 쓰고, 왜 위험한가
  7. 조건 변수: 올바른 시그널링과 while 패턴
  8. 공유 메모리에서의 함정: 스택 포인터 반환, 경쟁 조건
  9. 실전 패턴: 워커 스레드, 병렬 태스크, 생산자–소비자
  10. 대학원생 점검표 & 실습 아이디어

1) 왜 스레드인가?

스레드는 같은 주소 공간을 공유하면서 여러 실행 지점(PC)을 둔 경량 실행 단위입니다. 프로세스 전환과 달리 주소 공간(page table)을 바꾸지 않아 컨텍스트 스위치가 상대적으로 가볍고, 각 스레드는 자신만의 스택을 갖습니다. 따라서 스택에 위치한 로컬 변수들은 thread-local 성질을 띱니다.

스택이 스레드별로 여러 개 생기므로, 재귀가 깊은 코드처럼 스택을 많이 쓰는 워크로드에서는 스택 크기/배치에 신경을 써야 합니다. 

2) 스레드 생성: pthread_create() 시그니처를 해부하다

#include <pthread.h>

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine)(void*),
                   void *arg);
  • thread: 생성된 스레드를 식별할 핸들(out-parameter). 이후 join 등에 사용.
  • attr: 스택 크기, 스케줄링 우선순위 등 속성. 대부분은 NULL로 기본값 사용.
  • start_routine: 스레드 시작 함수 포인터. 인자로 void* 하나를 받고 void*를 반환.
  • arg: 시작 함수에 넘겨줄 단 하나의 인자 (형은 void*).

void*인가? – 어떤 타입이든 포인터 캐스팅으로 넣고/빼도록 하기 위한 유연성을 위해서입니다. 반환도 마찬가지로 임의 타입을 포인터로 담아 되돌릴 수 있습니다.

// (예) 구조체로 여러 인자 포장해서 넘기기
typedef struct { int a; int b; } myarg_t;

void *mythread(void *arg) {
  myarg_t *args = (myarg_t*)arg;
  printf("%d %d\n", args->a, args->b);
  return NULL;
}

int main() {
  pthread_t p;
  myarg_t args = {10, 20};
  int rc = pthread_create(&p, NULL, mythread, &args);
  // ...
}
의도 / 학습 포인트
  • myarg_t로 여러 값을 패킹해 하나의 포인터로 전달 → 스레드 함수 안에서 캐스팅해 언패킹.
  • 주의: &args메인 스레드의 스택에 있는 지역 객체. pthread_create() 직후 함수가 끝나 args가 소멸하면 댕글링 포인터 위험. 안전하게 하려면 heap에 할당하거나, 생존 기간을 보장하세요.

3) 인자 전달 & 반환 패턴: void*의 진짜 의도

3.1 단일 정수만 주고받고 싶다

간단히 정수를 포인터 크기로 캐스팅해 넘기고, 반환도 같은 방식으로 처리할 수 있습니다.

void *mythread(void *arg) {
  long long v = (long long)arg;   // 받기
  printf("%lld\n", v);
  return (void*)(v + 1);          // 반환
}

int main() {
  pthread_t p;
  long long rvalue;
  Pthread_create(&p, NULL, mythread, (void*)100);
  Pthread_join(p, (void**)&rvalue);
  printf("returned %lld\n", rvalue);
}

Pthread_*는 오류를 assert로 잡아주는 래퍼 (아래 5절).

의도 / 학습 포인트
  • “정수 ↔ 포인터” 캐스팅은 구현 의존성이 있을 수 있으므로 폭이 충분한 정수형(예: long long)을 써서 저장/복원을 일관되게 합니다.
  • 이 패턴은 “값 하나만 주고받을 때” 짧고 명확합니다. 여러 값을 주고받으려면 구조체를 쓰세요. 

3.2 구조체/힙 반환

여러 값을 돌려주고 싶다면, 스레드 함수에서 malloc()한 구조체 포인터를 반환하고, 조인한 쪽에서 free()합니다(누수 주의).


4) 스레드 종료, pthread_join() / detach 라이프사이클

int pthread_join(pthread_t thread, void **value_ptr);

조인은 특정 스레드가 끝날 때까지 대기하고, 그 반환값(void*)을 value_ptr에 써줍니다. pthread_create() 때 받은 pthread_t 핸들을 보관해 두었다가 여기에 씁니다.

서버와 같은 장수(長壽) 프로그램은 항상 조인하지는 않습니다. 워커 스레드를 만들어 두고 메인 스레드가 요청을 계속 받아 분배하기도 합니다. 반대로, 병렬 계산 태스크는 다음 단계로 넘어가기 전에 join으로 완료를 보장합니다.

(참고) detach 상태의 스레드는 합류 대상이 아니므로 pthread_join()을 호출할 수 없습니다(리소스는 종료 시 자동 회수). joinable vs detached 설계는 수명 관리 정책의 문제입니다. (상세는 man pthreads 및 OS 텍스트를 참고)


5) 뮤텍스: 초기화, 사용 패턴, 에러 처리, 래퍼

5.1 API와 기본 사용

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

임계구역(critical section)을 보호하기 위한 상호배제입니다. 한 스레드가 락을 보유 중이면, 다른 스레드는 lock()에서 대기하다가 선점이 끝나면 들어갑니다.

pthread_mutex_t lock;  // 전역/공유

pthread_mutex_lock(&lock);
/* critical section */
x = x + 1;
pthread_mutex_unlock(&lock);

5.2 초기화: 정적 vs 동적

// 정적 초기화
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// 동적 초기화
pthread_mutex_t lock2;
int rc = pthread_mutex_init(&lock2, NULL);
assert(rc == 0);

// 해제
pthread_mutex_destroy(&lock2);

정적/동적 모두 기본 속성으로 사용 가능. 동적 방식은 종료 시 destroy()를 호출해야 합니다. 에러 코드는 반드시 확인하세요. 

5.3 안전한 래퍼: Pthread_*

// 실패 시 종료(assert)하는 간단 래퍼
void Pthread_mutex_lock(pthread_mutex_t *m) {
  int rc = pthread_mutex_lock(m);
  assert(rc == 0);
}

연구/교육 코드에서 깨끗한 본문을 유지하려면 이런 래퍼가 유용합니다(프로덕션은 적절한 에러 처리 권장).


6) trylock / timedlock: 언제(거의 안) 쓰고, 왜 위험한가

int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex, struct timespec *abs_timeout);

trylock은 즉시 실패/성공을 돌려주고, timedlock은 타임아웃까지 기다립니다. 대부분의 경우 지양됩니다. 다만 영원히 막히는 상황(예: 교착 가능성 탐지, 폴링 스타일의 제어 흐름)이 필요한 일부 케이스에서만 신중히 고려하세요.


7) 조건 변수: 올바른 시그널링과 while 패턴

조건 변수는 “일이 준비되었는가?” 같은 상태 변화를 기다릴 때 사용합니다. 핵심은 항상 관련 락과 함께 쓰고, while 루프로 조건을 재검사하는 패턴입니다.

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);

// 다른 스레드:
Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);
  • pthread_cond_wait()내부적으로 락을 원자적으로 해제하고 수면에 들어간 뒤, 시그널을 받으면 깨어나며 락을 다시 잡은 상태로 반환합니다. 그래서 호출 전후로 락을 보유해야 합니다.
  • while 재검사허위 기상(spurious wake-up)이나 조건 변경 경합을 안전하게 처리하기 위한 정석입니다. 
  • 시그널을 보낼 때도 락을 잡고 공유 상태(여기선 ready)를 업데이트한 뒤 signal을 호출합니다.

8) 공유 메모리에서의 함정: 스택 포인터 반환, 경쟁 조건

  • 스택 포인터 반환 금지: 스레드 함수에서 return &local 같은 패턴은 즉시 UB(정의되지 않은 동작)입니다. 수명 관리가 되는 힙/정적 메모리를 쓰거나, 조인 쪽에서 value_ptr에 결과를 써 넣으세요. (스레드 반환 값은 void*이며 조인으로 회수) 
  • 경쟁 조건(Race Condition): 임계 구역 없이 공유 데이터를 갱신하면 실행 간섭으로 결과가 비결정적이 됩니다. 임계 구역/상호배제/결정성의 핵심 용어를 재확인하세요.

9) 실전 패턴

9.1 워커 스레드 풀 (장수 서버)

초기 N개의 워커를 띄워두고, 메인 스레드는 요청을 받아 큐에 넣습니다. 워커는 cond로 잠들어 있다가 일을 꺼내 처리합니다. (이 경우 워커는 보통 join하지 않음)

9.2 병렬 태스크 배치 (배치/연산)

스레드를 파이어한 뒤, 다음 단계 전 join으로 완료를 보장합니다. 데이터 분할/스케줄링/결과 모음의 라이프사이클을 명확히 하세요. 

9.3 생산자–소비자 (조건 변수 필수)

버퍼가 비었는지/찼는지를 공유 상태 변수로 표현하고, 생산자/소비자는 각각 while 조건으로 대기–깨움 패턴을 구현합니다(7절 예제 응용).

 

부록: 코드 모음 (의도/학습 포인트 포함)

A. 구조체 인자 전달 코드

#include <stdio.h>
#include <pthread.h>

typedef struct { int a; int b; } myarg_t;

void *mythread(void *arg) {
  myarg_t *args = (myarg_t*)arg;
  printf("%d %d\n", args->a, args->b);
  return NULL;
}

int main() {
  pthread_t p;
  myarg_t args = {10, 20};
  int rc = pthread_create(&p, NULL, mythread, &args);
  // 에러 체크 생략
  pthread_join(p, NULL);
}
의도 / 학습 포인트
  • 패킹/언패킹 패턴: 여러 값을 하나로 묶어 void*로 전달. 
  • 지역 변수 주소 전달 시 수명 주의(생존 기간 보장 필요).

B. 정수만 주고받는 간단 패턴

void *f(void *arg) {
  long long v = (long long)arg;
  return (void*)(v + 1);
}

int main() {
  pthread_t p;
  long long r;
  pthread_create(&p, NULL, f, (void*)100);
  pthread_join(p, (void**)&r);
  printf("%lld\n", r);
}
의도 / 학습 포인트
  • 단일 값 교환에 유용. 폭이 충분한 정수형으로 캐스팅 일관성 유지.

C. 뮤텍스 초기화/사용/래퍼

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void Pthread_mutex_lock(pthread_mutex_t *m) {
  int rc = pthread_mutex_lock(m);
  assert(rc == 0);
}

void critical() {
  Pthread_mutex_lock(&lock);
  /* ... 임계구역 ... */
  pthread_mutex_unlock(&lock);
}
의도 / 학습 포인트
  • 정적 초기화 vs 동적 초기화(해제 필요) 구분. 
  • 래퍼로 에러 체크 습관화 (연구/교육 코드에서 특히 유용).

D. 조건 변수 기본 패턴

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  cond = PTHREAD_COND_INITIALIZER;
int ready = 0;

void wait_ready() {
  pthread_mutex_lock(&lock);
  while (ready == 0)
    pthread_cond_wait(&cond, &lock);
  pthread_mutex_unlock(&lock);
}

void set_ready() {
  pthread_mutex_lock(&lock);
  ready = 1;
  pthread_cond_signal(&cond);
  pthread_mutex_unlock(&lock);
}
의도 / 학습 포인트
  • while 재검사 패턴(허위 기상/경합 안전). 락과 항상 함께.

E. trylock/timedlock (데모용)

if (pthread_mutex_trylock(&lock) == 0) {
  /* lock 획득 성공 */
  pthread_mutex_unlock(&lock);
} else {
  /* 다른 일 하거나 나중에 재시도 */
}
의도 / 학습 포인트
  • 회피 제어 흐름이 필요할 때만 신중히 사용(일반적 패턴 아님).

핵심 요약

  • pthread_create()void*범용 인자/반환 통로다. 구조체 패킹/힙 반환/정수 캐스팅 등 패턴을 상황에 맞게 선택. 
  • 뮤텍스는 초기화/에러 체크/해제 루틴까지 포함해 습관화. 래퍼로 청결한 코드 유지.
  • 조건 변수는 while 재검사 + 락 보유가 정석. 시그널 쪽도 락 잡고 상태 갱신 → 시그널.
  • trylock/timedlock은 특수 상황에서만. 대부분은 블로킹 lock()이 더 단순하고 안전.