I never thought philosophy would be so deadly
42서울 본과정 입과 후 아홉번째로 수행한 과제로 배고픈 철학자 문제를 통해 프로세스 스레딩의 기본, 스레드를 만드는 방법, 뮤텍스와 세마포어 등을 공부하는 과제이다.
원탁이 하나 있고, 그 주변에 몇 명의 철학자들이 둘러앉아 있다. 원탁의 가운데에는 커다란 스파게티 보울이 놓여 있다. 철학자들은 배고플 때마다 양옆의 포크 두 개를 잡아야만 스파게티를 먹을 수 있으며, 포크를 내려놓고 다시 생각하기를 반복한다. 이를 통해 어떻게 하면 철학자들이 모두 굶지 않고 잘 먹을 수 있을지를 고민하는 과제이다.
‼️ 프로세스와 스레드에 대한 기본적인 이해가 있고, 바로 과제에 대한 정보를 얻고 싶다면? ‼️
철학자를살리러너만오면GO (« click!!!)
philo
number_of_philosophers
time_to_die
time_to_die
시간만큼 지나거나time_to_die
시간만큼이 지나도록 식사를 시작하지 않는다면time_to_eat
time_to_sleep
number_of_times_each_philosopher_must_eat
timestamp_in_ms X has taken a fork
timestamp_in_ms X is eating
timestamp_in_ms X is sleeping
timestamp_in_ms X is thinking
timestamp_in_ms X died
상황이 복잡하므로, 다시 한번 정리해보자
n
명의 철학자들이 테이블에 둘러앉아 있다.n
은 철학자 n-1
과 n+1
번 사이에 앉는다.먹기
, 생각하기
, 잠자기
를 반복하며, 한번에 한가지 동작만 취할 수 있다.philo
는 인자로 총 4-5개의 요소를 입력받는다.먹기
를 마치면 포크를 테이블에 내려놓고 잠자기
상태가 되고, 잠에서 꺠어나면 생각하기
시작한다.‼️ 프로세스와 스레드에 대한 기본적인 이해가 있고, 바로 과제에 대한 정보를 얻고 싶다면? ‼️
철학자를살리러너만오면GO (« click!!!)
프로세스는 현재 실행 중인 프로그램으로, OS 관리의 단위이자 메모리에 로드되어 CPU에 실행되는 프로그램의 독립적인 실행 단위이다 즉, 우리가 프로그램 A를 실행하면 A는 메인 메모리에 적재되어 하나의 프로세스로서 cpu 자원의 점유를 기다리게 된다. 각 프로세스는 자체의 주소 공간, 레지스터의 상태, file descriptor table, PCB 등 고유한 정보를 가진다.
프로세스의 상태
스케줄링 OS는 결국 이러한 프로세스의 순서를 결정하는 스케줄링을 해줘야한다. 짧은 것을 먼저 실행할 수도 있고, 오는 순서대로 실행할 수도 있다. 또는 모든 프로세스를 잘게 썰어 교체해가며 실행할 수도 있다. 또한 이러한 프로세스를 큐에 보관하며 관리할 수도 있다. 그만큼 스케줄링의 종류는 다양하다.
스레드는, 실행 흐름의 단위이다! 스레드를 설명할 때 가장 많이 듣는 말이지만 처음부터 마음 속으로 와닿지는 않는 표현이다… 프로세스와 달리 스레드는 같은 주소 공간을 공유하고, 또한 각 스레드만의 스택과 레지스터, TCB를 가지고 있다. 스레드를 통해 병렬처리, 자원공유가 가능하며 이를 통해 메모리 사용을 효율화할 수 있다! (스레드 또한 프로세스와 같이 cpu 자원 점유를 위해 노력한다.생성-준비-실행-대기-종료 등)
그렇다면 스레드 간의 자원 공유는 어떻게 일어날까? 철학자 과제에서 가장 중요한 것은 각각의 독립된 스레드로 이루어진 철학자들이 공유자원에 속한 포크를 적절하게 순서에 맞추어 나눠 사용하는 것이다. 즉 스레드가 자원공유 하는 과정에서 데이터 레이스가 일어나지 않도록 해야하고, 특정 스레드가 자원을 계속해서 점유하거나 무한 경쟁하는 등의 상황을 피해야 한다.
스레드 Local Storage를 의미한다. 즉, 각 스레드가 독립적으로 값을 저장하고 접근할 수 있는 메모리 영역이다. TLS는 스레드 간에 공유되지 않아야 할 데이터, 즉 스레드별로 독립적인 상태 정보를 유지해야 할 때 사용한다. 이 영역는 스레드가 시작될 때 생성되고, 스레드가 종료될 때 해제된다. 멀티스레드 환경에서 데이터의 독립성을 유지하면서도 스레드별 상태 관리를 용이하게 해주며, 이를 통해 스레드는 자신만의 데이터를 안전하게 관리 가능하다.
두 개 이상의 스레드가 동시에 동일한 메모리 위치를 읽거나 쓸 때 발생하는 문제이며, 우리는 이번 과제에서 이를 방지해야만 한다. 최소 하나의 스레드가 쓰기 작업을 수행하고 있으며, 스레드 간의 실행 순서가 정의되지 않은 경우 발생할 수 있고 Data race는 결국 프로그램의 예측 불가능한 동작을 초래할 수 있다. 따라서 뮤텍스, 세마포어 등의 동기화 메커니즘을 사용하여 스레드 간의 자원 접근을 방지해야한다.
두 개 이상의 스레드(또는 프로세스)가 서로 상대방이 점유하고 있는 자원을 기다리며 무한정 대기하는 상태로, 이로 인해 시스템이 멈추게 되고, 어떤 작업도 진행되지 않는 상황이 발생한다. Deadlock이 발생하기 위해서는 아래 네 가지 조건이 모두 만족되어야 하는데… 우리는 이 여러가지 조건 중 n개(아마 과제 조건 상 최대 2개가 가능하지 않을까?)을 파훼하여 Deadlock 발생을 피해야한다.
위의 문제들을 방지하기 위해서 우리는 한번에 한놈씩, 접근을 제한해야한다.
상호 배제를 위한 도구로, 오직 하나의 스레드만 특정 코드 섹션이나 자원에 접근할 수 있도록 보장한다. Mutex
는 잠금과 해제 동작을 통해 이를 구현한다.
화장실 문에는 자물쇠(뮤텍스)가 달려 있습니다. 어떤 사람이 화장실을 사용하려고 하면 자물쇠를 잠급니다. 다른 사람은 화장실이 잠겨있으므로 기다려야 합니다. 화장실을 사용한 사람이 나가면 자물쇠를 풀고 다음 사람이 들어갈 수 있습니다. 여기서 뮤텍스는 단 하나의 스레드(사람)만이 화장실(자원)에 접근하도록 보장합니다.
여러 스레드(또는 프로세스)가 공유 자원에 접근하는 것을 조절하기 위한 동기화 도구이다. 내부적으로 카운터를 사용하며, 이 카운터 값에 따라 스레드들의 자원 접근 여부가 결정된다.
당신이 카페에 있습니다. 이 카페에는 3개의 테이블이 있습니다. 카페의 문지기(세마포어)는 테이블이 다 차지 않는 한 사람들을 들여보냅니다. 만약 테이블이 다 차면 문지기는 더 이상 사람들을 들여보내지 않고 기다리게 합니다. 손님 중 한 명이 나가면 문지기는 대기 중인 손님 중 한 명을 들여보냅니다. 여기서 카운터 값은 현재 비어있는 테이블의 수입니다.
Mutex
는 결국 단일 스레드의 영역을 전개함으로, 크기가 1인 Semaphore
와 같지 않을까?
Mutex
: 오로지 하나의 스레드만이 자원에 접근해야 할 때 사용Semaphore
: 여러 스레드가 접근할 수 있지만 최대 한도를 정하고 싶을 때 사용하며, 제한된 수의 자원을 관리할 때 사용Mutex
: 잠금과 해제를 사용해 오직 하나의 스레드만 자원에 접근할 수 있도록 동작Semaphore
: 카운터를 사용하여 스레드의 접근을 조절하며, 카운터 값이 0이 되면 스레드들은 대기Mutex
: 단 하나의 잠금과 해제가 가능Semaphore
: 여러 개의 잠금과 해제가 가능스레드 프로그래밍에서 중요한 부분 중 하나는 자원에 언제 lock을 걸고, 언제 unlock할지를 결정하는 것이다. 여기서 lock이란 특정 스레드가 특정 자원을 독점적으로 점유하고 있음을 의미하며, 다른 스레드들은 해당 자원에 접근하지 못하고 lock이 풀릴 때까지 기다리게 된다.
자원
이라는 표현이 추상적이긴 하지만, 실제로는 단순한 메시지 출력(log 출력) 역시 동기화가 필요한 자원이 된다. 예를 들어, 여러 스레드(철학자)가 동시에 자신의 행동을 출력할 때, 만약 서로 동기화를 하지 않고 마음대로 메시지를 출력한다면, 출력 결과가 뒤섞이게 되어 시간 순서를 제대로 알 수 없게 된다. 따라서 메시지 출력에도 mutex lock을 통해 동기화를 하는 것이 중요하다.
뿐만 아니라, 철학자 문제에서 철학자들이 공유하는 포크와 같은 자원도 반드시 mutex lock을 걸어야 한다. 철학자 문제에서 포크는 철학자들 사이에 하나씩 놓여 있는 공유 자원이기 때문에, 인접한 두 철학자가 동시에 같은 포크를 잡으려 할 경우 data race가 발생하게 된다.
만약 각 포크에 락 없이 접근한다면 어떤 문제가 발생할까?
이러한 문제들을 해결하기 위한 일반적인 방법은 다음과 같다
그렇다면 정확히 철학자의 생사 조건은 어떻게 될까? 아무리 노력해도, 어떤 철학자는 스파게티를 먹지 못해 죽을 수 있다. 죽을 수 밖에 없는 상황도 있을 것이다. 사용자가 논리적으로 철학자 시뮬레이션이 불가능한 값을 프로그램에 입력하면 철학자 시뮬레이션은 특정 시점에 종료되는 것이 정상이다. 줄글로 작성했으니 다시한번 차근 차근 인자부터 확인해보자.
현재 우리는 이런 인자들을 받는다
현재 시뮬레이션 상황는 이렇다
이때, 철학자의 수가 짝수인 경우는…
최소한의 조건이 이렇다. -> lifetime >= (먹는 시간 * 2)
왜 lifetime이 (먹는 시간 * 2) 보다 커야 할까?
철학자들을 짝수/홀수로 나누어 번갈아 식사하게 하면, 동시에 포크 충돌이 줄어든다. 예를 들어 짝수 철학자들이 먼저, 그다음 홀수 철학자들이 식사한다고 하자. 이렇게 되면 처음 식사한 철학자는 다음 식사까지 최소 두 번의 식사 차례를 기다려야 한다. 즉, 먹는 시간 × 2
만큼 기다려야 다시 식사 기회가 온다.
그런데 철학자는 일정 시간 안에 식사하지 않으면 죽는다. 따라서 죽기까지의 시간(lifetime) 은 최소한 먹는 시간 × 2
보다 커야 한다. 또한, 어떤 철학자가 식사하려면 자신과 포크를 공유하는 이웃 철학자가 식사를 마쳐야 하므로, 실제로는 조금 더 긴 시간 여유가 필요할 수도 있다.
결론적으로, 철학자가 굶어죽지 않으려면 lifetime ≥ 먹는 시간 × 2 + 여유 시간
이어야 한다.
그렇다면 철학자 수가 홀수인 경우는?
최소한의 조건이 이렇다. -> lifetime >= (먹는 시간 * 3)
왜 lifetime이 (먹는 시간 * 3) 보다 커야 할까?
철학자 수가 홀수일 경우, 동시에 식사할 수 있는 철학자 수는 최대 절반(N/2)이므로, 항상 한 명 이상은 대기해야 하는 상황이 발생한다. 예를 들어 5명의 철학자가 있다면, 한 번에 최대 2명만 식사할 수 있고 1명은 반드시 대기해야 한다.
이 구조에서 어떤 철학자는 가장 늦게 식사 기회를 얻게 된다. 예를 들어, 한 철학자가 처음 식사 차례를 놓쳤다고 하면, 이웃 철학자가 식사하고, 다시 다른 철학자가 식사하고, 그제서야 자신의 차례가 돌아올 수 있다. 즉, 먹는 시간 × 3 정도의 시간을 기다려야 첫 식사를 할 수 있는 최악의 경우가 생길 수 있다.
그런데 철학자는 일정 시간 안에 식사를 하지 않으면 죽는다. 따라서 죽기까지의 시간(lifetime) 은 최소한 먹는 시간 × 3보다 커야 한다. 여기에 포크 충돌이나 시스템 지연 등의 요소까지 고려하면, 실제로는 조금 더 긴 여유 시간이 필요할 수 있다.
결론적으로, 철학자가 단 한 번이라도 식사할 기회를 얻고 생존하려면 lifetime ≥ 먹는 시간 × 3 + 여유 시간 조건이 반드시 충족되어야 한다.
/* 포크·로그 등 공용 뮤텍스를 한데 모아 관리 */
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
로 접근할 수 있게 했다.
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
을 기록해 기준 시간을 맞추고, 철학자 스레딩을 시작한다.
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
함수를 통해 자원 해제 후 종료된다.
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
을 사용해 스레드 내부 메모리 누수를 방지하고자 했다.
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);
}
usleep(time_to_sleep)
홀수 ID 철학자에게 time_to_eat / 2
만큼 선행 딜레이를 주어 모두 동시에 포크를 집으려다 교착 상태가 일어날 확률을 낮춘다. 여러가지 방법을 사용해보자
2025.05 코드 리뷰 추가 삽입
try1 - review 1
try1 - reivew 2
try1 - review 3
평가를 받으며 내가 구현한 동기화 방식의 성능이 기대에 못 미친다는 사실을 깨달았다. 두 번째 평가자께서는 매번 sleep 을 거는 방식 대신, 뮤텍스를 이용한 배리어(Barrier) 기반 동기화 기법을 소개해 주셨고, 관련 유튜브 영상도 공유해 주셨다.
세 번째 평가에서는 3인 철학자 시뮬레이션에서 작은 문제가 발생했다. ‘일단 고쳐서 다음 시도에 통과해야지’라고 생각하고 있었지만, 평가자께서 “지금 라이브로 함께 해결해 보자”고 제안하셨다. 덕분에 현장에서 바로 문제를 수정하며 많은 것을 배웠다.
이번 경험을 통해 ‘42 과정을 제대로 활용하려면 어떻게 해야 할까?’라는 고민이 깊어졌다. 마음만 먹으면 형식적인 코드로 빠르게 통과할 수도 있겠지만, 그렇게 얻는 것은 없고 실력 향상에도 도움이 되지 않는다. 평가자와 나눈 여러 이야기까지 포함해, 의미있는 시간이었다.