[Deep Dive] 쓰레드와 병렬 프로그래밍 - 1탄 개념 이해

정글 8주차에 웹서버를 구현하다 보니 쓰레드를 사용해서 병렬 처리를 해야 했다. 그래서 당장 구현을 할 수준으로만 이해를 한 뒤 구현을 끝낸 지금 다시 쓰레드에 대해 깊게 이해를 하려고 한다. 그래서 이번 Deep Dive에서는 프로세스가 아닌 쓰레드를 주로 다룰 생각이다.


1. 프로세스 vs 쓰레드 차이

프로세스(Process)

  • 정의: 실행 중인 프로그램. 운영체제로부터 독립된 주소 공간, 자원(File descriptor, Stack, Heap 등)을 부여받음.
  • 특징:
    • 각각의 프로세스는 커널에서 독립적으로 관리됨 (PID, 메모리 맵 등).
    • 프로세스 간 데이터 공유가 어렵다 (보통 IPC, Shared Memory, Pipe 등 필요).
    • 생성/문맥전환 비용이 큼 (Context switch: PCB 저장/복원 포함).

쓰레드(Thread)

  • 정의: 프로세스 내에서 실행되는 작업 단위. 하나의 프로세스 내에서 여러 실행 흐름을 가능하게 함.
  • 특징:
    • 같은 프로세스 내 쓰레드끼리는 코드, 데이터(힙), 열린 파일 디스크립터 등 공유.
    • 하지만 각 쓰레드는 별도의 스택 공간레지스터 상태를 가짐.
    • 생성/문맥전환 비용이 프로세스보다 작음.
항목 프로세스 쓰레드
주소 공간 각각 독립 동일 주소 공간 공유
힙/데이터 각각 소유 공유
스택 각자 소유 각 쓰레드마다 개별 스택
생성 비용 높음 (fork) 낮음 (pthread_create 등)
통신 방식 IPC 등 별도 기법 필요 전역 변수로 간단히 가능

2. 쓰레드 생성과 종료

pthread_create

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);
  • thread: 생성된 쓰레드의 ID를 반환.
    • 원형 : pthread_t *thread; 
    • 생성된 쓰레드의 ID가 이 변수에 저장된다. 추후 pthread_join() 또는 pthread_detach() 시 사용된다.
    • 예: pthread_t tid;
  • attr: 쓰레드 속성 (NULL이면 기본값 사용).
    • 원형 : const pthread_attr_t *attr
    • 쓰레드의 속성(스택 크기, 스케줄링 정책 등)을 설정한다.
    • 대부분의 경우 NULL을 넘겨서 기본 속성을 사용한다.
  • start_routine: 쓰레드가 시작할 함수.
    • 원형 : void *(*start_routine)(void *)
    • 쓰레드가 실행할 함수의 포인터다. 함수는 반드시 void*를 인자로 받고 void*를 반환해야 한다.
    • 쓰레드는 이 함수의 실행이 끝나면 종료된다.
  • arg: 함수에 전달할 인자.
    • 원형 : void *arg 
    • start_routine 에 넘길 인자다. 포인터형이므로 여러 값을 구조체로 묶어서 넘길 수 도 있다.
    • 함수 내부에서 int arg = *((int *)vargp);와 같이 사용한다.

반환값

  • 성공 시 0
  • 실패 시 에러 코드 (예: EINVAL, EAGAIN 등)

예시:

void* thread_func(void* arg) {
    printf("Hello from thread!\n");
    return NULL;
}

pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);

pthread_exit

pthread_exit(NULL); // 종료, 반환값 없음
  • 쓰레드의 종료 함수.
  • main 함수가 끝나도 프로세스는 다른 쓰레드가 살아있으면 종료되지 않음.

pthread_join

void* result;
pthread_join(tid, &result);
  • 특정 쓰레드가 끝날 때까지 기다림.
  • 일반적으로 detach되지 않은 쓰레드에 대해 사용
  • 반환값을 받아올 수 있음.

pthread_detach

pthread_detach(tid);
  • 쓰레드를 독립적으로 실행되게 함.
  • 자원 반환은 쓰레드가 끝날 때 자동으로 처리됨 (join 불가).
  • 역할: 쓰레드를 detach 상태로 만듭니다. 이렇게 하면 쓰레드가 종료될 때 자원을 자동 회수하므로, pthread_join이 필요 없다.
❗ 일반적으로 join 또는 detach는 반드시 하나만 사용해야 함. 둘 다 안하면 리소스 누수 발생 (Zombie thread 현상).

3. 쓰레드 실행 흐름과 메모리 공유 구조

쓰레드 메모리 구조

같은 프로세스 내의 쓰레드들은 다음을 공유함:

  • 코드 영역 (text segment): 실행 코드
  • 힙 영역 (heap): 동적 할당 메모리
  • 전역 변수 (data segment): static / global
  • 파일 디스크립터: open된 파일 목록
  • 환경변수 및 현재 디렉토리

반면, 아래 항목은 쓰레드마다 다름:

  • 스택 (stack): 함수 호출, 지역 변수 저장
  • 레지스터 상태: PC, SP, 등 CPU context

4. 스택 공간은 쓰레드마다 다르다 (→ 지역 변수 주의!)

지역 변수는 스택에 저장된다

  • 함수 내 지역 변수는 각 쓰레드의 스택에 위치하므로 서로 영향을 주지 않음.
  • 따라서 각 쓰레드가 같은 함수를 실행해도, 지역 변수는 서로 독립.
void* thread_func(void* arg) {
    int local_var = *(int*)arg; // 각 쓰레드의 스택에 저장됨
    ...
}

→ *(int *)arg : 이 표현은 "포인터 형변환 + 역참조"를 한 것이다.

더보기

1. void* arg

  • pthread_create로 쓰레드를 만들 때, 매개변수 하나를 전달할 수 있게 되어 있음.
  • 그 매개변수는 void* 타입이어야 함. 왜냐하면 모든 포인터 타입을 담을 수 있는 범용 포인터이기 때문.
  • 그래서 우리가 넘기는 포인터는 void*로 받게 됨.

2. (int *)arg

  • arg는 void*니까 직접 접근할 수 없음. 형을 명확히 알려줘야 함.
  • 예를 들어 int*를 넘겼다고 가정하면, 우리는 이렇게 캐스팅해야 함:
(int *)arg

이건 "arg를 int 포인터로 해석해라"는 뜻.

3. *(int *)arg

  • 여기서 *는 역참조 연산자.
  • 즉, 포인터가 가리키는 메모리 주소에 접근하겠다는 뜻.
  • (int *)arg는 int*이고,
  • 그걸 *로 역참조하면 → 실제 int 값에 접근하게 됨.

❗ 위험한 예: 지역 변수 주소를 다른 쓰레드에 넘김

void* start(void* arg) {
    int x = 42;
    return &x; // 위험: x는 쓰레드 종료 시 사라짐
}
  • 쓰레드가 종료되면 스택도 해제되므로 &x는 Dangling Pointer가 됨.
  • 안전하게 하려면 malloc 등으로 힙에 저장하거나 전역 변수 사용.

이번 포스트에서는 C에서 쓰레드의 개념과 쓰레드를 사용하는 방법의 기초를 다뤘다. 다음 포스팅에서 동기화 기법에 대한 이해를 다루겠다. 물론 정해진 것은 아니다 중간에 모르는 부분이 있다면 해당 부분을 딥 다이브 할 것이다.