Devin.KR

스레드와 동기화 - pthread 로 경쟁 상태 재현하고 고치기

개발자KR 조회 12

이 장에서 배우는 것

앞 장에서 운영체제가 독립된 메모리 공간을 제공하는 프로세스를 다루었다면, 이 장에서는 같은 메모리 공간을 공유하면서 동시에 실행되는 스레드(thread)를 다룬다.

  • POSIX 스레드(pthread)를 생성하고 종료를 대기하는 방법
  • 여러 스레드가 같은 메모리를 수정할 때 발생하는 경쟁 상태(race condition) 원인
  • 컴파일러의 스레드 소독기(ThreadSanitizer)로 숨은 버그를 찾는 방법
  • 뮤텍스(mutex)를 이용한 공유 자원 보호와 교착 상태(deadlock) 회피 전략
  • 조건 변수(condition variable)를 활용해 대출 요청 큐(queue)를 구현하는 방법

문제 상황

동네 도서관이 인기를 끌면서 대출 요청을 처리하는 서버가 느려졌다. 성능을 높이려고 동시에 여러 요청을 처리하는 스레드를 도입했다. 웹 예약 요청을 처리하는 스레드와 무인 대출기 요청을 처리하는 스레드가 동시에 돌아간다.

그런데 이상한 일이 생겼다. 하루를 마감하며 전체 대출 건수를 세어보니, 실제 나간 책의 수보다 시스템에 기록된 대출 건수가 적은 것이다. 두 스레드가 "전체 대출 건수 = 전체 대출 건수 + 1"이라는 코드를 동시에 실행할 때, 가끔 숫자가 1만 오르는 문제가 발생했다. 시스템 로그에는 오류가 남지 않고, 프로그램도 죽지 않는다. 하지만 데이터는 조용히 오염되고 있다.

스레드 생성과 메모리 공유

스레드는 프로세스 안에서 실행되는 작업의 흐름이다. 프로세스를 새로 만드는 작업은 무겁고, 만들어진 프로세스끼리는 메모리가 격리되어 있어 데이터를 주고받기 까다롭다. 반면 스레드는 같은 프로세스 안에서 힙(heap) 공간과 데이터(data) 영역을 공유한다. 덕분에 전역 변수나 동적 할당된 메모리를 통해 쉽게 데이터를 나눌 수 있다. 함수 호출에 쓰이는 스택(stack)만 스레드마다 따로 가진다.

C 언어에서 POSIX 스레드를 다룰 때는 <pthread.h> 헤더를 쓴다. 스레드를 시작하려면 pthread_create 함수에 스레드가 실행할 함수의 포인터를 넘긴다. 시작된 스레드가 일을 마칠 때까지 기다릴 때는 pthread_join 함수를 쓴다.

경쟁 상태와 스레드 소독기

스레드들이 메모리를 공유하는 특성은 편리하지만 치명적인 약점을 만든다. C 코드에서 count++라는 한 줄은 컴퓨터 내부에서 세 단계로 나뉘어 실행된다.

  1. 메모리에서 현재 count 값을 중앙 처리 장치(CPU) 레지스터로 읽어온다.
  2. 레지스터의 값을 1 더한다.
  3. 계산된 값을 다시 메모리에 쓴다.

스레드 A가 1단계를 마치고 2단계를 진행하려는 찰나에, 스레드 B가 1단계를 실행해버리면 두 스레드는 같은 과거 값을 읽게 된다. 각자 1을 더한 다음 순서대로 메모리에 덮어쓰면 덧셈은 두 번 일어났지만 메모리 값은 1만 증가한다. 이렇게 여러 실행 흐름이 접근 순서에 따라 결과가 달라지는 상황을 경쟁 상태라고 부른다.

두 스레드가 공유 메모리의 값을 동시에 읽고 수정할 때 값이 덮어써져 데이터가 유실되는 과정

경쟁 상태는 언제 일어날지 예측할 수 없어 디버깅이 무척 어렵다. 이를 기계적으로 찾아내려면 컴파일할 때 -fsanitize=thread 옵션을 준다. 이 스레드 소독기를 켜면 프로그램이 실행되는 동안 여러 스레드가 같은 메모리에 접근하는 패턴을 감시하다가, 잠금장치 없이 동시에 접근하는 것을 발견하면 경고 문구와 함께 실행을 멈춘다.

뮤텍스와 교착 상태

경쟁 상태를 막으려면 누군가 공유 변수를 읽고 수정하여 다시 쓰는 동안, 다른 스레드가 끼어들지 못하게 막아야 한다. 이를 위해 상호 배제(mutual exclusion)를 의미하는 뮤텍스를 쓴다. pthread_mutex_t 타입으로 선언하며, 화장실의 문 열쇠와 같이 동작한다.

공유 자원을 쓰기 전에 pthread_mutex_lock을 호출해 잠근다. 다른 스레드가 이미 잠갔다면, 내 차례가 올 때까지 운영체제가 스레드를 재운다. 자원을 다 쓰고 나면 pthread_mutex_unlock으로 잠금을 푼다.

뮤텍스를 쓸 때 가장 조심해야 할 것은 교착 상태다. 스레드 A가 뮤텍스 1을 잠그고 뮤텍스 2를 기다리는데, 스레드 B가 뮤텍스 2를 잠그고 뮤텍스 1을 기다리면 두 스레드는 영구히 멈춘다. 이를 막는 가장 좋은 규칙은 모든 스레드가 항상 같은 순서로 뮤텍스를 잠그도록 설계하는 것이다.

조건 변수와 작업 큐

대출 요청이 언제 들어올지 모르는 상황에서 스레드가 계속 큐를 확인하며 반복문을 도는 것은 CPU 자원 낭비다. 이때 조건 변수 pthread_cond_t를 쓴다. 작업 스레드는 큐가 비어있으면 pthread_cond_wait를 호출해 잠든다. 이 함수는 잠들기 전에 쥐고 있던 뮤텍스를 자동으로 풀어주고, 깨어날 때 다시 뮤텍스를 쥔다.

새 요청이 들어오면 메인 스레드가 큐에 요청을 넣고 pthread_cond_signal을 호출해 잠든 스레드 중 하나를 깨운다. 이 신호를 받은 스레드가 깨어나 큐에서 작업을 꺼내 처리한다.

작업자 스레드가 조건 변수에서 대기하다가 메인 스레드의 신호를 받고 깨어나 작업을 처리하는 3단계 과정

완성 코드

도서관의 대출 요청을 처리하는 작업자 스레드 풀(thread pool) 프로그램이다. 메인 스레드는 요청을 큐에 넣고, 여러 작업자 스레드가 큐에서 요청을 꺼내 처리한다.

library_threads.c

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

#define MAX_QUEUE 100
#define NUM_WORKERS 3

typedef struct {
    int book_id;
    int is_reserved;
} LoanJob;

typedef struct {
    LoanJob jobs[MAX_QUEUE];
    int head;
    int tail;
    int count;
    int shutdown;
    pthread_mutex_t lock;
    pthread_cond_t not_empty;
} JobQueue;

JobQueue queue;
int total_loans = 0;
pthread_mutex_t stat_lock;

void init_queue(void) {
    queue.head = 0;
    queue.tail = 0;
    queue.count = 0;
    queue.shutdown = 0;
    pthread_mutex_init(&queue.lock, NULL);
    pthread_cond_init(&queue.not_empty, NULL);
}

void destroy_queue(void) {
    pthread_mutex_destroy(&queue.lock);
    pthread_cond_destroy(&queue.not_empty);
}

void submit_job(int book_id, int is_reserved) {
    pthread_mutex_lock(&queue.lock);
    
    if (queue.count < MAX_QUEUE) {
        queue.jobs[queue.tail].book_id = book_id;
        queue.jobs[queue.tail].is_reserved = is_reserved;
        queue.tail = (queue.tail + 1) % MAX_QUEUE;
        queue.count++;
        pthread_cond_signal(&queue.not_empty);
    }
    
    pthread_mutex_unlock(&queue.lock);
}

void* worker_thread(void* arg) {
    int worker_id = *(int*)arg;
    free(arg);
    
    while (1) {
        pthread_mutex_lock(&queue.lock);
        
        while (queue.count == 0 && queue.shutdown == 0) {
            pthread_cond_wait(&queue.not_empty, &queue.lock);
        }
        
        if (queue.count == 0 && queue.shutdown == 1) {
            pthread_mutex_unlock(&queue.lock);
            break;
        }
        
        LoanJob job = queue.jobs[queue.head];
        queue.head = (queue.head + 1) % MAX_QUEUE;
        queue.count--;
        
        pthread_mutex_unlock(&queue.lock);
        
        /* 대출 처리 시간 흉내 내기 */
        usleep(100000); 
        
        pthread_mutex_lock(&stat_lock);
        total_loans++;
        pthread_mutex_unlock(&stat_lock);
        
        printf("작업자 %d: 도서 %d 대출 완료\n", worker_id, job.book_id);
    }
    
    return NULL;
}

int main(void) {
    pthread_t workers[NUM_WORKERS];
    pthread_mutex_init(&stat_lock, NULL);
    init_queue();
    
    for (int i = 0; i < NUM_WORKERS; i++) {
        int* id = malloc(sizeof(int));
        *id = i + 1;
        pthread_create(&workers[i], NULL, worker_thread, id);
    }
    
    for (int i = 100; i < 105; i++) {
        submit_job(i, 0);
    }
    
    pthread_mutex_lock(&queue.lock);
    queue.shutdown = 1;
    pthread_cond_broadcast(&queue.not_empty);
    pthread_mutex_unlock(&queue.lock);
    
    for (int i = 0; i < NUM_WORKERS; i++) {
        pthread_join(workers[i], NULL);
    }
    
    printf("전체 대출 처리 건수: %d\n", total_loans);
    
    destroy_queue();
    pthread_mutex_destroy(&stat_lock);
    return 0;
}

줄별 해설

  • pthread_cond_wait(&queue.not_empty, &queue.lock);: 큐가 비어있으면 깨어날 때까지 기다린다. 이때 queue.lock을 알아서 풀어주어 메인 스레드가 작업을 넣을 수 있게 하고, 깨어날 때 다시 queue.lock을 잠근다.
  • while (queue.count == 0 && queue.shutdown == 0): 조건 변수에서 깨어났더라도 다시 조건을 검사한다. 운영체제 특성상 신호 없이도 깨어나는 가짜 깨어남 현상이 있을 수 있기 때문이다.
  • pthread_cond_signal: 자고 있는 스레드 중 하나를 깨운다.
  • pthread_cond_broadcast: 종료 신호를 보낼 때는 자고 있는 모든 스레드를 한 번에 깨워야 하므로 여러 스레드에게 동시에 신호를 전달하는 브로드캐스트를 쓴다.
  • pthread_mutex_lock(&stat_lock); total_loans++;: 전체 통계를 갱신하는 부분이다. 큐 뮤텍스와 별도의 뮤텍스를 써서 큐에 대한 접근과 통계 갱신이 서로 방해하지 않게 분리했다.

실행 결과

스레드 라이브러리를 연결하려면 컴파일할 때 -pthread 플래그를 추가해야 한다.

$ cc -std=c17 -Wall -Wextra -pthread library_threads.c -o lib_threads
$ ./lib_threads
작업자 1: 도서 100 대출 완료
작업자 2: 도서 101 대출 완료
작업자 3: 도서 102 대출 완료
작업자 1: 도서 103 대출 완료
작업자 2: 도서 104 대출 완료
전체 대출 처리 건수: 5

운영체제의 스레드 일정 관리(scheduling)에 따라 작업자가 작업을 맡는 순서는 실행할 때마다 달라질 수 있다.

실무에서 자주 틀리는 것

잠금을 풀지 않고 함수를 빠져나옴

오류 처리 코드나 분기문에서 return을 할 때 뮤텍스를 푸는 것을 잊기 쉽다. 이러면 이 뮤텍스를 기다리는 모든 스레드가 멈춘다.

/* 틀린 코드 */
pthread_mutex_lock(&lock);
if (error_occurred) {
    return -1; /* 잠금을 풀지 않고 반환 */
}
pthread_mutex_unlock(&lock);

/* 고친 코드 */
pthread_mutex_lock(&lock);
if (error_occurred) {
    pthread_mutex_unlock(&lock);
    return -1;
}
pthread_mutex_unlock(&lock);

조건 변수 대기에 if문을 사용함

조건 변수를 기다릴 때는 반드시 while문을 써야 한다. 깨어난 직후에 다른 스레드가 먼저 큐의 작업을 가져가서 큐가 다시 비었을 수도 있기 때문이다.

/* 틀린 코드 */
if (queue.count == 0) {
    pthread_cond_wait(&cond, &lock);
}

/* 고친 코드 */
while (queue.count == 0) {
    pthread_cond_wait(&cond, &lock);
}

스레드로 넘기는 인자의 수명

pthread_create로 넘기는 인자는 새 스레드가 읽기 전에 원래 스레드에서 메모리가 해제되거나 덮어써질 수 있다. 지역 변수의 주소를 넘기면 안 된다.

/* 틀린 코드 */
for (int i = 0; i < 3; i++) {
    /* i의 주소를 넘기면, 스레드가 읽기 전에 i 값이 바뀔 수 있음 */
    pthread_create(&t[i], NULL, worker, &i); 
}

/* 고친 코드 */
for (int i = 0; i < 3; i++) {
    int* id = malloc(sizeof(int)); /* 동적 할당하여 독립적인 메모리 제공 */
    *id = i;
    pthread_create(&t[i], NULL, worker, id); 
    /* worker 안에서 읽고 free() 호출하여 정리 */
}

한눈에 보기

주요 스레드 제어 함수 요약
기능 함수 원형 반환값
스레드 생성 int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg); 성공 시 0, 실패 시 오류 번호
스레드 대기 int pthread_join(pthread_t thread, void **retval); 성공 시 0, 실패 시 오류 번호
뮤텍스 잠금 int pthread_mutex_lock(pthread_mutex_t *mutex); 성공 시 0, 실패 시 오류 번호
뮤텍스 해제 int pthread_mutex_unlock(pthread_mutex_t *mutex); 성공 시 0, 실패 시 오류 번호
동기화 도구 비교
도구 주요 용도 특징
뮤텍스 공유 자원에 대한 동시 접근 차단 자물쇠와 같음. 한 번에 하나의 스레드만 획득 가능.
조건 변수 특정 조건이 만족될 때까지 대기 반드시 뮤텍스와 짝을 이루어 사용해야 함.

연습 문제

  1. 전역 변수 int counter = 0;가 있다. 스레드 2개가 각각 이 변수를 100만 번씩 1 증가시키는 코드를 짰는데, 최종 결과가 200만이 나오지 않는 이유를 설명해보자.
  2. 뮤텍스 잠금 순서를 무작위로 획득하도록 작성된 두 스레드가 있다. 스레드 A는 뮤텍스 1, 2 순서로, 스레드 B는 뮤텍스 2, 1 순서로 잠근다. 어떤 문제가 발생할 수 있는지 설명해보자.
  3. pthread_cond_wait 함수가 대기하기 전에 잠겨있는 뮤텍스를 내부적으로 해제하는 이유가 무엇인지 서술해보자.

정답과 해설

  1. 변수 값을 1 증가시키는 연산은 메모리 읽기, 값 더하기, 메모리 쓰기의 독립된 세 단계로 이루어진다. 두 스레드가 이 과정 중간에 섞여서 실행되면 다른 스레드가 증가시킨 결과를 덮어쓰게 되어 덧셈이 무시되는 경쟁 상태가 발생한다.
  2. 교착 상태가 발생할 수 있다. 스레드 A가 뮤텍스 1을 잠그고 스레드 B가 뮤텍스 2를 잠근 시점에서, A는 뮤텍스 2를 기다리고 B는 뮤텍스 1을 기다리게 되어 두 스레드 모두 더 이상 진행하지 못한다.
  3. 대기하는 스레드가 뮤텍스를 계속 잠근 채로 멈춰 있으면, 다른 스레드가 그 뮤텍스를 획득할 수 없어 큐에 새로운 작업을 넣을 수 없게 된다. 따라서 조건 변수는 대기 상태로 들어감과 동시에 뮤텍스를 풀어서 다른 스레드가 자원에 접근할 수 있게 열어준다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.