📚 /42seoul 시리즈

1. 소개


I never thought philosophy would be so deadly

42서울 본과정 입과 후 아홉번째로 수행한 과제로 배고픈 철학자 문제를 통해 프로세스 스레딩의 기본, 스레드를 만드는 방법, 뮤텍스와 세마포어 등을 공부하는 과제이다.

원탁이 하나 있고, 그 주변에 몇 명의 철학자들이 둘러앉아 있다. 원탁의 가운데에는 커다란 스파게티 보울이 놓여 있다. 철학자들은 배고플 때마다 양옆의 포크 두 개를 잡아야만 스파게티를 먹을 수 있으며, 포크를 내려놓고 다시 생각하기를 반복한다. 이를 통해 어떻게 하면 철학자들이 모두 굶지 않고 잘 먹을 수 있을지를 고민하는 과제이다.

‼️ 프로세스와 스레드에 대한 기본적인 이해가 있고, 바로 과제에 대한 정보를 얻고 싶다면? ‼️
철학자를살리러너만오면GO (« click!!!)



2. philosopher 명세서


philosopher 명세서

상황이 복잡하므로, 다시 한번 정리해보자

철학자, 원탁, 포크, 스파게티

프로그램 인자

스파게티를 먹는 규칙

철학자의 규칙

‼️ 프로세스와 스레드에 대한 기본적인 이해가 있고, 바로 과제에 대한 정보를 얻고 싶다면? ‼️
철학자를살리러너만오면GO (« click!!!)



3. 개념 정리


  1. 프로세스와 스레드의 개념을 공부하고,
  2. 그 이후 그것들이 작동하면서 일어날 수 있는 문제들을 살펴보고,
  3. 문제를 막기위해 사용하는 도구를 알아보자.

3-1. 프로세스와 스레드

프로세스란?

프로세스는 현재 실행 중인 프로그램으로, OS 관리의 단위이자 메모리에 로드되어 CPU에 실행되는 프로그램의 독립적인 실행 단위이다 즉, 우리가 프로그램 A를 실행하면 A는 메인 메모리에 적재되어 하나의 프로세스로서 cpu 자원의 점유를 기다리게 된다. 각 프로세스는 자체의 주소 공간, 레지스터의 상태, file descriptor table, PCB 등 고유한 정보를 가진다.

프로세스의 상태

  1. 프로세스 생성: 새로운 프로세스가 생성되면 우선순위가 설정됨
  2. 큐 배치: 프로세스는 준비 큐 또는 대기 큐에 배치됨
  3. 스케줄링: 스케줄링 알고리즘이 준비 큐에서 프로세스를 선택하여 CPU를 할당함
  4. 실행: 선택된 프로세스가 CPU를 사용하여 작업을 수행함
  5. 상태 전이: 프로세스가 완료되거나 대기 상태로 전환되면, 다른 프로세스가 CPU를 할당받음

스케줄링 OS는 결국 이러한 프로세스의 순서를 결정하는 스케줄링을 해줘야한다. 짧은 것을 먼저 실행할 수도 있고, 오는 순서대로 실행할 수도 있다. 또는 모든 프로세스를 잘게 썰어 교체해가며 실행할 수도 있다. 또한 이러한 프로세스를 큐에 보관하며 관리할 수도 있다. 그만큼 스케줄링의 종류는 다양하다.


스레드란?

스레드는, 실행 흐름의 단위이다! 스레드를 설명할 때 가장 많이 듣는 말이지만 처음부터 마음 속으로 와닿지는 않는 표현이다… 프로세스와 달리 스레드는 같은 주소 공간을 공유하고, 또한 각 스레드만의 스택과 레지스터, TCB를 가지고 있다. 스레드를 통해 병렬처리, 자원공유가 가능하며 이를 통해 메모리 사용을 효율화할 수 있다! (스레드 또한 프로세스와 같이 cpu 자원 점유를 위해 노력한다.생성-준비-실행-대기-종료 등)


3-2. 자원 공유

그렇다면 스레드 간의 자원 공유는 어떻게 일어날까? 철학자 과제에서 가장 중요한 것은 각각의 독립된 스레드로 이루어진 철학자들이 공유자원에 속한 포크를 적절하게 순서에 맞추어 나눠 사용하는 것이다. 즉 스레드가 자원공유 하는 과정에서 데이터 레이스가 일어나지 않도록 해야하고, 특정 스레드가 자원을 계속해서 점유하거나 무한 경쟁하는 등의 상황을 피해야 한다.

스레드의 공유 자원

  1. 주소 공간 (Address Space)
  2. 전역 변수 및 정적 변수 (Global and Static Variables)
  3. 파일 디스크립터 테이블 (File Descriptor Table)
  4. 프로세스 환경 (프로세스 Environment)

스레드간 비공유자원

  1. 스택 (Stack)
  2. 레지스터 (Registers)
  3. 스레드 로컬 저장소

TLS란?

스레드 Local Storage를 의미한다. 즉, 각 스레드가 독립적으로 값을 저장하고 접근할 수 있는 메모리 영역이다. TLS는 스레드 간에 공유되지 않아야 할 데이터, 즉 스레드별로 독립적인 상태 정보를 유지해야 할 때 사용한다. 이 영역는 스레드가 시작될 때 생성되고, 스레드가 종료될 때 해제된다. 멀티스레드 환경에서 데이터의 독립성을 유지하면서도 스레드별 상태 관리를 용이하게 해주며, 이를 통해 스레드는 자신만의 데이터를 안전하게 관리 가능하다.


3-3. 발생할 수 있는 문제들

Data race

두 개 이상의 스레드가 동시에 동일한 메모리 위치를 읽거나 쓸 때 발생하는 문제이며, 우리는 이번 과제에서 이를 방지해야만 한다. 최소 하나의 스레드가 쓰기 작업을 수행하고 있으며, 스레드 간의 실행 순서가 정의되지 않은 경우 발생할 수 있고 Data race는 결국 프로그램의 예측 불가능한 동작을 초래할 수 있다. 따라서 뮤텍스, 세마포어 등의 동기화 메커니즘을 사용하여 스레드 간의 자원 접근을 방지해야한다.

Deadlock

두 개 이상의 스레드(또는 프로세스)가 서로 상대방이 점유하고 있는 자원을 기다리며 무한정 대기하는 상태로, 이로 인해 시스템이 멈추게 되고, 어떤 작업도 진행되지 않는 상황이 발생한다. Deadlock이 발생하기 위해서는 아래 네 가지 조건이 모두 만족되어야 하는데… 우리는 이 여러가지 조건 중 n개(아마 과제 조건 상 최대 2개가 가능하지 않을까?)을 파훼하여 Deadlock 발생을 피해야한다.


3-4. Mutex와 Semaphore

위의 문제들을 방지하기 위해서 우리는 한번에 한놈씩, 접근을 제한해야한다.

Mutex

상호 배제를 위한 도구로, 오직 하나의 스레드만 특정 코드 섹션이나 자원에 접근할 수 있도록 보장한다. Mutex잠금해제 동작을 통해 이를 구현한다.

화장실 문에는 자물쇠(뮤텍스)가 달려 있습니다. 어떤 사람이 화장실을 사용하려고 하면 자물쇠를 잠급니다. 다른 사람은 화장실이 잠겨있으므로 기다려야 합니다. 화장실을 사용한 사람이 나가면 자물쇠를 풀고 다음 사람이 들어갈 수 있습니다. 여기서 뮤텍스는 단 하나의 스레드(사람)만이 화장실(자원)에 접근하도록 보장합니다.

Semaphore

여러 스레드(또는 프로세스)가 공유 자원에 접근하는 것을 조절하기 위한 동기화 도구이다. 내부적으로 카운터를 사용하며, 이 카운터 값에 따라 스레드들의 자원 접근 여부가 결정된다.

당신이 카페에 있습니다. 이 카페에는 3개의 테이블이 있습니다. 카페의 문지기(세마포어)는 테이블이 다 차지 않는 한 사람들을 들여보냅니다. 만약 테이블이 다 차면 문지기는 더 이상 사람들을 들여보내지 않고 기다리게 합니다. 손님 중 한 명이 나가면 문지기는 대기 중인 손님 중 한 명을 들여보냅니다. 여기서 카운터 값은 현재 비어있는 테이블의 수입니다.

Mutex vs Semaphore

Mutex는 결국 단일 스레드의 영역을 전개함으로, 크기가 1인 Semaphore와 같지 않을까?



4. Mandatory


4-1. pthread_mutex_lock과 철학자 문제의 동기화

스레드 프로그래밍에서 중요한 부분 중 하나는 자원에 언제 lock을 걸고, 언제 unlock할지를 결정하는 것이다. 여기서 lock이란 특정 스레드가 특정 자원을 독점적으로 점유하고 있음을 의미하며, 다른 스레드들은 해당 자원에 접근하지 못하고 lock이 풀릴 때까지 기다리게 된다.

자원 이라는 표현이 추상적이긴 하지만, 실제로는 단순한 메시지 출력(log 출력) 역시 동기화가 필요한 자원이 된다. 예를 들어, 여러 스레드(철학자)가 동시에 자신의 행동을 출력할 때, 만약 서로 동기화를 하지 않고 마음대로 메시지를 출력한다면, 출력 결과가 뒤섞이게 되어 시간 순서를 제대로 알 수 없게 된다. 따라서 메시지 출력에도 mutex lock을 통해 동기화를 하는 것이 중요하다.

뿐만 아니라, 철학자 문제에서 철학자들이 공유하는 포크와 같은 자원도 반드시 mutex lock을 걸어야 한다. 철학자 문제에서 포크는 철학자들 사이에 하나씩 놓여 있는 공유 자원이기 때문에, 인접한 두 철학자가 동시에 같은 포크를 잡으려 할 경우 data race가 발생하게 된다.

만약 각 포크에 락 없이 접근한다면 어떤 문제가 발생할까?

이러한 문제들을 해결하기 위한 일반적인 방법은 다음과 같다

  1. 포크를 mutex로 보호하기: 각 포크를 mutex로 보호하여 한 번에 한 명의 철학자만 포크를 점유할 수 있게 한다.
  2. 포크 잡는 순서 통일하기: 모든 철학자가 항상 같은 방향(예: 먼저 왼쪽 포크, 그다음 오른쪽 포크)으로 포크를 잡도록 강제하면 데드락의 위험을 줄일 수 있다.
  3. 홀짝 철학자 전략: 홀수 번째 철학자와 짝수 번째 철학자의 포크 잡는 순서를 다르게 하면 데드락이 발생하는 상황을 피할 수 있다.

4-2. 철학자의 생사 조건

그렇다면 정확히 철학자의 생사 조건은 어떻게 될까? 아무리 노력해도, 어떤 철학자는 스파게티를 먹지 못해 죽을 수 있다. 죽을 수 밖에 없는 상황도 있을 것이다. 사용자가 논리적으로 철학자 시뮬레이션이 불가능한 값을 프로그램에 입력하면 철학자 시뮬레이션은 특정 시점에 종료되는 것이 정상이다. 줄글로 작성했으니 다시한번 차근 차근 인자부터 확인해보자.

현재 우리는 이런 인자들을 받는다

현재 시뮬레이션 상황는 이렇다

이때, 철학자의 수가 짝수인 경우는…
최소한의 조건이 이렇다. -> lifetime >= (먹는 시간 * 2)

왜 lifetime이 (먹는 시간 * 2) 보다 커야 할까?
철학자들을 짝수/홀수로 나누어 번갈아 식사하게 하면, 동시에 포크 충돌이 줄어든다. 예를 들어 짝수 철학자들이 먼저, 그다음 홀수 철학자들이 식사한다고 하자. 이렇게 되면 처음 식사한 철학자는 다음 식사까지 최소 두 번의 식사 차례를 기다려야 한다. 즉, 먹는 시간 × 2만큼 기다려야 다시 식사 기회가 온다.

그런데 철학자는 일정 시간 안에 식사하지 않으면 죽는다. 따라서 죽기까지의 시간(lifetime) 은 최소한 먹는 시간 × 2 보다 커야 한다. 또한, 어떤 철학자가 식사하려면 자신과 포크를 공유하는 이웃 철학자가 식사를 마쳐야 하므로, 실제로는 조금 더 긴 시간 여유가 필요할 수도 있다.

결론적으로, 철학자가 굶어죽지 않으려면 lifetime ≥ 먹는 시간 × 2 + 여유 시간 이어야 한다.


그렇다면 철학자 수가 홀수인 경우는?
최소한의 조건이 이렇다. -> lifetime >= (먹는 시간 * 3)

왜 lifetime이 (먹는 시간 * 3) 보다 커야 할까?
철학자 수가 홀수일 경우, 동시에 식사할 수 있는 철학자 수는 최대 절반(N/2)이므로, 항상 한 명 이상은 대기해야 하는 상황이 발생한다. 예를 들어 5명의 철학자가 있다면, 한 번에 최대 2명만 식사할 수 있고 1명은 반드시 대기해야 한다.

이 구조에서 어떤 철학자는 가장 늦게 식사 기회를 얻게 된다. 예를 들어, 한 철학자가 처음 식사 차례를 놓쳤다고 하면, 이웃 철학자가 식사하고, 다시 다른 철학자가 식사하고, 그제서야 자신의 차례가 돌아올 수 있다. 즉, 먹는 시간 × 3 정도의 시간을 기다려야 첫 식사를 할 수 있는 최악의 경우가 생길 수 있다.

그런데 철학자는 일정 시간 안에 식사를 하지 않으면 죽는다. 따라서 죽기까지의 시간(lifetime) 은 최소한 먹는 시간 × 3보다 커야 한다. 여기에 포크 충돌이나 시스템 지연 등의 요소까지 고려하면, 실제로는 조금 더 긴 여유 시간이 필요할 수 있다.

결론적으로, 철학자가 단 한 번이라도 식사할 기회를 얻고 생존하려면 lifetime ≥ 먹는 시간 × 3 + 여유 시간 조건이 반드시 충족되어야 한다.



4-3. 코드

structs


/* 포크·로그 등 공용 뮤텍스를 한데 모아 관리 */
typedef struct s_mutex
{
    pthread_mutex_t msg;            // 표준 출력 동기화 (로그가 뒤섞이지 않도록)
    pthread_mutex_t ready;          // 스레드 동시 출발을 위한 대기
    pthread_mutex_t system_status;  // 시뮬레이션 ON/OFF 플래그 보호
    pthread_mutex_t *forks;         // 포크마다 하나씩 할당된 뮤텍스 배열
    pthread_mutex_t *last_meal;     // 각 철학자의 최근 식사시각 보호
    pthread_mutex_t *eaten_cnt;     // 각 철학자의 식사 횟수 보호
}   t_mutex;

/* 철학자 한 명에 대한 런타임 정보 */
typedef struct s_philo
{
    int         id;                 // 1 ~ N
    int         eaten_cnt;          // 현재까지 먹은 횟수
    int         num_philo;          // 총 철학자 수(전역 복붙 대신 캐싱)
    int         must_eat_cnt;       // 종료 조건(선택 인자)
    int         left_fork;          // 왼쪽 포크의 인덱스
    int         right_fork;         // 오른쪽 포크의 인덱스
    long long   time_of_last_meal;  // 마지막 식사 시작 시각(ms)
    long long   time_to_delay;      // 짝수/홀수 딜레이 조정용
    pthread_t   thread;             // 자신의 POSIX thread
    struct s_data *data;            // 전역 컨텍스트 포인터
}   t_philo;

포크·식사시간·횟수는 철학자 수만큼 존재하므로, 뮤텍스 또한 배열로 만들어 idx = id % n 로 접근할 수 있게 했다.

main

int main(int ac, char **av)
{
    t_data *data = malloc(sizeof(t_data));
    if (!data)
        return (1);
    memset(data, 0, sizeof(t_data));

    if (init_all(ac, av, data) != TRUE)
        return (ft_error_handler(data));

    if (simulation(data) != TRUE)
        return (ft_error_handler(data));

    ft_free_all(data);
    return (0);
}

우선 인자를 파싱하고 구조체를 채운뒤, 뮤텍스와 철학자 배열, 포크 배열을 동적 할당·초기화한다. 그 이후 start_time을 기록해 기준 시간을 맞추고, 철학자 스레딩을 시작한다.

simulation

int simulation(t_data *data)
{
    data->start_time = get_current_time();       // 기준 시각 고정

    if (data->arg.min_eat_cnt == 0)              // 0회 식사 조건이면 바로 성공 종료
        return (TRUE);

    if (data->arg.num_philo == 1)                // 포크 한 개 → 필연적 사망
        one_philo(data);
    else
    {
        if (run_philo(data) != TRUE)             // 철학자 스레드 생성
            return (FALSE);
        if (run_check(data) != TRUE)             // death 감시/식사횟수 감시 스레드
            return (FALSE);
        if (join_philo(data) != TRUE)            // 모든 스레드 종료 대기
            return (FALSE);
    }
    return (TRUE);
}

철학자가 1명일 경우 포크가 하나이므로 곧바로 죽어야 함으로 분기를 두었고, 그외에는 run_philo(철학자 스레드 생성) → run_check(감시 스레드) → join_philo(정리) 순으로 진행된다. 중간에 오류가 발생하면 ft_error_handler 함수를 통해 자원 해제 후 종료된다.

run_philo

static int run_philo(t_data *data)
{
    for (int i = 0; i < data->arg.num_philo; i++)
        if (pthread_create(&data->philo[i].thread, NULL,
                           philo_routine, &data->philo[i]) != 0)
            return (FALSE);
    return (TRUE);
}

반복문 안에서 실패 시 곧바로 FALSE를 반환 → 상위에서 모든 뮤텍스·메모리 해제한다. 또한 pthread_detach 대신 pthread_join을 사용해 스레드 내부 메모리 누수를 방지하고자 했다.

philo_routine

void *philo_routine(void *arg)
{
    t_philo *philo = (t_philo *)arg;

    update_last_meal_time(philo);   // 첫 식사 기준값 기록
    philo_ready(philo);             // start 라인까지 대기(barrier)
    philo_delay(philo);             // 짝수/홀수 오버랩 최소화

    while (TRUE)
    {
        if (philo_eat(philo)   != TRUE) return (NULL); // 포크 두 개 잠금 후 식사
        if (check_system_status(philo->data) != ON)    return (NULL);

        if (philo_sleep(philo) != TRUE) return (NULL); // 포크 해제 후 수면
        if (check_system_status(philo->data) != ON)    return (NULL);

        print_status(philo, "is thinking");
        usleep(50);                 // 짧은 think → 컨텍스트 스위치 유도
    }
    return (NULL);
}

홀수 ID 철학자에게 time_to_eat / 2만큼 선행 딜레이를 주어 모두 동시에 포크를 집으려다 교착 상태가 일어날 확률을 낮춘다. 여러가지 방법을 사용해보자



5. Evaluation


2025.05 코드 리뷰 추가 삽입

try1 - review 1

try1 - reivew 2

try1 - review 3

평가를 받으며 내가 구현한 동기화 방식의 성능이 기대에 못 미친다는 사실을 깨달았다. 두 번째 평가자께서는 매번 sleep 을 거는 방식 대신, 뮤텍스를 이용한 배리어(Barrier) 기반 동기화 기법을 소개해 주셨고, 관련 유튜브 영상도 공유해 주셨다.

세 번째 평가에서는 3인 철학자 시뮬레이션에서 작은 문제가 발생했다. ‘일단 고쳐서 다음 시도에 통과해야지’라고 생각하고 있었지만, 평가자께서 “지금 라이브로 함께 해결해 보자”고 제안하셨다. 덕분에 현장에서 바로 문제를 수정하며 많은 것을 배웠다.

이번 경험을 통해 ‘42 과정을 제대로 활용하려면 어떻게 해야 할까?’라는 고민이 깊어졌다. 마음만 먹으면 형식적인 코드로 빠르게 통과할 수도 있겠지만, 그렇게 얻는 것은 없고 실력 향상에도 도움이 되지 않는다. 평가자와 나눈 여러 이야기까지 포함해, 의미있는 시간이었다.



6. Reference