[운영체제] Concurrency & Thread API

전윤혁·2024년 12월 12일
0

OS

목록 보기
13/18

이전 글까지 운영체제의 Memory Virtualization과 관련된 내용들을 알아봤다. 이번 글부터는 새로운 장인 Concurrency 파트로 넘어가보도록 하자!

이번 글에서는, 본격적으로 Concurrency와 관련된 내용을 알아보기 전, 스레드와 관련된 내용과, 주요 개념들을 짚고 넘어가보자.


1. Thread

운영체제 두 번째 글에서, 프로세스와 스레드를 간략하게 비교하며 스레드를 "하나의 프로세스 내에서 실행되는 여러 실행 흐름"이라고 표현했었다.

책에서 구체적으로 다루고 있지 않지만, 스레드의 등장 배경을 요약해보면,

  • 하나의 프로세스가 동시에 여러 작업을 수행하지는 못한다.
  • 프로세스의 컨텍스트 스위칭은 무거운 작업이다.
  • 프로세스끼리 데이터 공유가 까다롭다. (메모리 공간 독립적)
  • 듀얼 코어(하나의 CPU에 두 개의 코어)를 잘 쓰기 어렵다.

위와 같이 정리할 수 있을 것 같다.

또한, 이전에 프로세스와 달리 스레드끼리는 데이터를 공유하기 쉽다고 설명했다. 위의 그림에서 볼 수 있듯이, 한 프로세스의 여러 스레드는 하나의 힙을 공유하고, 각자의 고유한 공간인 스택 또한 갖고 있다.


2. Thread API

이번에는 스레드 API들을 살펴보며, 스레드의 동작 과정을 이해해보자. 먼저 스레드를 생성하는 pthread_create부터 살펴보자.

#include <pthread.h>
int pthread_create(pthread_t* thread,
                   const pthread_attr_t* attr,
                   void* (*start_routine)(void*),
                   void* arg);
  • thread
    생성된 스레드와 상호작용하기 위해 사용된다.
    (스레드 ID를 저장하거나 스레드의 상태를 추적하는 데 사용)

  • attr
    스레드의 속성을 지정할 때 사용된다.
    (스택 크기, 스케줄링 우선순위 등)

  • start_routine
    새로 생성된 스레드가 실행을 시작할 함수이다.

  • arg
    start_routine 함수에 전달할 인자이다.
    (void 포인터를 사용하므로 어떤 타입의 데이터도 전달할 수 있음)

만약 start_routine 함수가 다른 타입을 요구한다면, 인자나 리턴 값의 타입을 다르게 설정할 수 있다.

다음으로 pthread_join api를 살펴보자. pthread_join 함수는 POSIX 스레드(pthread) 라이브러리에서 제공하는 함수로, 특정 스레드의 종료를 기다리는 데 사용된다.

int pthread_join(pthread_t thread, void **value_ptr);
  • thread
    기다릴 대상이 되는 스레드의 식별자(pthread_t)

  • value_ptr
    종료된 스레드가 반환한 값을 저장할 포인터(void **). 스레드가 pthread_exit()로 반환하거나 함수 종료로 반환하는 값을 가져오기 위해 해당 포인터를 사용한다.

아래는 스레드 생명 주기의 전체적인 흐름이다.

#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); // this thread has been
                                  // waiting inside of the
                                  // pthread_join() routine.
    printf("returned %d %d\n", m->x, m->y);
    free(m); // Free the allocated memory to avoid memory leak
    return 0;
}
  • myarg_tmyret_t는 각각 스레드에 전달할 입력 데이터와, 스레드가 반환할 출력 데이터를 담고 있는 구조체이다.

  • mythread 함수는 스레드로 실행될 함수이다.

  • 메인 함수에서, args라는 myarg_t 구조체에 데이터를 설정하고, 이를 스레드에 전달한다.

  • 이후 pthread_create로 스레드를 생성하고 mythread를 실행한다.

  • pthread_join으로 스레드 종료를 기다리며, 스레드가 반환한 데이터를 m으로 받는다.

  • 받은 데이터를 출력하고, 동적으로 할당된 메모리를 해제(free)한다.

위의 과정에서 주목해볼 점은, mythread 내부에서 malloc을 사용하여 값을 넘겨주었다는 점이다. 만약 mythread가 아래와 같이 구현되었다고 가정해볼까?

void *mythread(void *arg) {
    myarg_t *m = (myarg_t *) arg;
    printf("%d %d\n", m->a, m->b);
    myret_t r; // ALLOCATED ON STACK: BAD!
    r.x = 1;
    r.y = 2;
    return (void *) &r;
}

코드를 위와 같이 작성하면, myret_t r은 스택 메모리에 생성된 지역 변수가 된다. 스택은 각 스레드의 고유한 공간이므로, 함수가 종료되면 이 메모리는 해제되게 되는데, 해당 주소를 반환하면 해제된 메모리를 참조하는 위험한 동작이 발생할 수 있는 것이다.

따라서, 스택에 리턴 값을 할당하는 대신, malloc을 통해 힙에 해당 값을 할당하여, 함수가 종료된 후에도 반환값을 안전하게 사용할 수 있도록 한 것이다.


3. Locks

위에서 코드를 통해 스레드의 동작 방식을 간단하게 살펴봤다. 지금부터는 본격적으로 Concurrency 문제를 다루기 전 주요 개념들을 살펴보자!

먼저 Lock에 대해 살펴보자. Lock은 말 그대로 "잠그는" 역할을 한다고 이해할 수 있는데, 구체적으로 Critical Section에 대한 Mutual Exclusion을 제공한다. 원활한 이해를 위해 각 용어를 단계적으로 정리해보자.

✅ Race condition

Race condition이란, 여러 프로세스/스레드가 동시에 같은 데이터를 조작할 때, 타이밍이나 접근 순서에 따라 결과가 달라질 수 있는 상황을 의미한다.

위의 예시를 살펴보자!

  • Thread1과 Thread2 모두 counter(50)에 1을 더하는 작업을 수행하려고 한다. (기대하는 연산 결과는 52)

  • Thread1이 counter의 주소 0x8094a1c의 값을 %eax 레지스터로 가져온 후, 레지스터의 값에 1을 더했다.

  • 하지만 Thread1이 해당 연산 결과를 반영하기 전 인터럽트가 발생하여, Thread2로 컨텍스트 스위칭이 발생하였다.

  • Thread2는 0x8094a1c의 값을 %eax 레지스터로 가져온 후 1을 더한 값을 0x8094a1c에 반영하였다. (현재 counter은 51)

  • 다시 Thread1으로 컨텍스트 스위칭되어, Thread1의 상태가 복구되었다. Thread1은 이전 작업에 이어서, 0x8094a1c%eax 레지스터의 값을 반영하였다. (현재 counter은 51)

  • 결과적으로, 의도와 달리 counter의 연산 결과가 51이 되었다.

✅ Critical Section

위와 같은 Race Condition 문제가 발생하는 영역, 즉 공유 자원(변수, 데이터 등)을 접근하거나 수정하는 코드 영역을 Critical Section(임계 영역)이라고 한다.

따라서, Critical Section을 다룰 때는 동시에 한 스레드만 코드에 접근하도록 보장해야 하는데, 이를 위해 Atomicity(원자성)이 보장되어야 한다.

이는 작업이 하나의 단위로 실행되어야 함을 의미하는데, 임계 영역에서의 작업은 중단되거나 다른 스레드에 의해 방해받지 않고 완전하게 수행되어야 한다. 어떻게?

✅ Mutual Exclusion

Mutual Exclusion이 바로 동시에 두 개 이상의 스레드가 Critical Section에 진입하지 못하도록, Atomicity를 보장하는 기법이다.

우리는 Mutual Exclusion의 여러 구현 방법 중, 가장 기본적인 Lock을 사용하는 구현에 대해 알아보려고 한다.

✅ Locks

사전 설명이 길었다! 그렇다면 Lock은 어떠한 방식으로 구현되어 있는지 알아보자.

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

pthread_mutex_lock은 Lock을 거는 함수, pthread_mutex_unlock은 Lock을 해제하는 함수이다. (mutex는 mutual exclusion의 줄임말)

pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1; // or whatever your critical section is
pthread_mutex_unlock(&lock);

위의 예시에서는 x = x + 1;을 Critical Section으로 가정하고 있다. 단순히 Lock을 걸고, Critical Section의 작업을 수행하고, Lock을 해제하는 단순한 예시이다.

하지만 이렇게 쉬웠다면, 앞으로 고생할 일도 없었을 것..! 지금은 아래의 문제를 기억하고 넘어가자.

  • 만약 다른 스레드가 Lock을 보유하고 있지 않다면, 현재 스레드가 Lock을 얻고 임계 영역에 진입한다.

  • 만약 다른 스레드가 이미 Lock을 보유하고 있다면, 현재 스레드는 Lock이 해제될 때까지 대기 상태에 들어간다.

즉, 늦게 도착한 스레드는 Lock이 해제될 때까지 계속 대기해야 한다는 점을 기억하고 우선 넘어가자!

다음으로, Lock을 초기화하는 부분을 살펴보자.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

첫 번째 방법은 정적 초기화 방법으로, 컴파일 시간에 Lock을 초기화한다. (PTHREAD_MUTEX_INITIALIZER는 뮤텍스 초기화를 위해 미리 정의된 키워드 정도로 이해할 수 있겠다.)

int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); // always check success!

런타임에 초기화가 필요할 때 사용된다. 첫 번째 인자로 초기화 할 Lock 객체의 주소를, 두 번째 인자로 Lock 속성을 지정하는 값을 기본값(예시에서는 NULL)을 전달한다.

성공적으로 Lock 이 초기화되면 0을 반환하기 때문에, 마지막에 assert(rc == 0);을 통해 성공 여부를 꼭 확인해줘야 한다.

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

마지막으로 두 가지 함수를 알아보자.

pthread_mutex_trylock()는 뮤텍스를 시도하여 Lock을 즉시 얻으려 한다. 만약 뮤텍스가 이미 다른 스레드에 의해 잠겨 있으면 기다리지 않고 실패(failure)를 반환한다.

pthread_mutex_timedlock() 또한 뮤텍스를 시도하여 Lock을 얻으려고 하지만, 특정 시간(timeout)동안 Lock을 기다린다.

해당 함수들은 당장은 와닿지 않지만, 이후 발생하는 Lock 관련 문제들을 해결하기 위해 다시 보게 될 것이다.


4. Condition Variables

마지막으로 Condition Variables에 대해 알아보자!

Condition Variables이라는 이름이 잘 와닿지는 않지만, 특정 조건이 만족될 때까지 기다리는 대기열이라고 표현할 수 있겠다.

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

pthread_cond_wait 함수는 호출한 스레드를 대기 상태로 만들고, 다른 스레드가 신호를 보낼 때까지 기다리게 한다. 대기 상태인 스레드는 현재의 Lock을 내려놓고 잠들게 되고, 대기 중 신호를 통해 깨어난 스레드는 다시 Lock을 얻은 후 이후 작업을 수행하게 된다.

pthread_cond_signal 함수는 대기 중인 스레드 중 하나를 깨워서 작업을 계속하도록 한다.

해당 함수들은 어떻게 사용되는 것인지 아래의 예시를 살펴보자.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t init = PTHREAD_COND_INITIALIZER;
pthread_mutex_lock(&lock);
while (ready == 0)
	pthread_cond_wait(&init, &lock);
pthread_mutex_unlock(&lock);

현재 스레드가 ready == 0인 동안, 즉 실행될 준비가 되지 않은 동안, 스레드는 pthread_cond_wait를 통해 대기 상태 빠지게 된다. 그 사이에 pthread_mutex_lock을 통해 Lock을 걸어주는 이유는 뭘까?

ready 값 또한 컨텍스트 스위칭을 통해 다른 스레드가 변경해버리면 안 되는 값이므로, Critical Section에 해당하기 때문이다.

pthread_mutex_lock(&lock);
ready = 1;
pthread_cond_signal(&init);
pthread_mutex_unlock(&lock);

스레드를 잠들게 하는 함수가 있다면 깨워주는 함수도 있어야겠지? 해당 부분에서는 ready 값을 1로 변경한 후, pthread_cond_signal을 통해 잠들어 있던 스레드를 깨우고 있다. 해당 부분 또한 pthread_mutex_lock을 통해 보호하고 있다.

스레드가 깨어나게 되면, 해당 스레드는 잠들었던 while문 내부에서 작업을 계속하게 된다. 현재 ready 값을 1로 변경되었기 때문에, while문을 빠져나온 후 pthread_mutex_unlock을 수행하게 된다.

해당 부분이 if문이 아니라 while문인 이유는, if문일 경우 스레드가 깨어난 후 ready == 0 조건을 재검사하지 않기 때문이다. 즉, 스레드가 ready 값이 1로 변경된 상태 외에 다른 이유로 깨워진 것일 수도 있는데, 그런 경우 ready == 0임에도 이후 작업을 수행해버리는 문제가 생긴다.

위의 과정을 아래와 같이 요약할 수 있다.

  • 현재 스레드에서 Lock을 얻었다.

  • 스레드의 ready 상태가 0이기 때문에 pthread_cond_wait로 인한 대기 상태에 빠지고, Lock을 내려놓는다.

  • ready 상태가 1로 변경된 후(스레드 준비가 완료된 상태), pthread_cond_signal을 통해 잠들었던 스레드를 깨운다.

  • 깨어난 스레드는 Lock을 얻고, while문을 통해 ready == 0 조건을 한 번 더 검사한 후 이후 작업을 수행한다.

  • 작업이 끝나면 pthread_mutex_unlock을 통해 Lock을 내려놓는다.

그렇다면 단순하게 아래와 같이 구현하면 어떨까?

while(ready == 0)
; // spin

현재 스레드가 ready == 0인 동안 계속 spinning하며 기다리게 하는 방식이다.

ready = 1;

다른 스레드가 ready = 1;로 바꿔줄 때, spinning을 멈추고 이후 작업을 수행한다.

딱 봐도 비효율적이라는 것이 느껴진다. 왜? ready == 0인 동안 아무 작업도 하지 않는 스레드가 CPU 스케줄링에 포함되어 자원을 좀먹기 때문이다. 즉, CPU 사이클이 낭비되는 문제가 발생한다.


마치며

Concurrency 파트로 들어오며 슬슬 복잡해지기 시작한다. 하지만 이번 글에서 알아본 API를 이해하고 있어야, 이후의 Concurrency 관련 문제들을 해결하는 과정을 보다 쉽게 이해할 수 있을 것이다.

다음 글에서는 Lock에 대해 더 자세히 파해쳐보자!

profile
전공/개발 지식 정리

0개의 댓글