[42 seoul] 철학자야 밥먹자 (philosopher)

yecn·2023년 3월 13일
0

42seoul

목록 보기
1/2

📖 글을 시작하며

42seoul의 프로젝트(과제) 중 하나인
philosopher를 정리한 글입니다.

과제를 진행하며 했던 생각, 배운 것 등을
주관적으로 정리한 글임을 참고 부탁드립니다.


📕 philosopher 소개

philosopher는 철학자들에게 밥을 먹이는 문제이다.

모든 철학자는 둥근 책상에 둘러 앉아있고
각 철학자 사이에는 포크가 1개 있다.
책상의 중앙에는 스파게티가 있는데
철학자가 스파게티를 먹으려면 2개의 포크를 집어야 한다.

예를들어 5명의 철학자가 있다면
포크도 5개밖에 없기 때문에
모든 철학자가 동시에 스파게티를 먹진 못한다.

적절히 스파게티를 먹도록 해
철학자들이 최대한 죽지 않도록 해야한다.

철학자들은 다음과 같은 순서로 행동한다.

  1. 포크를 든다
  2. (2개의 포크를 들었으면) 먹는다
  3. 잔다
  4. 생각한다.

만약 죽는 시간보다 마지막으로 먹기 시작했을 때부터의 시간이
길다면 해당 철학자는 죽게되고 프로그램은 종료된다.

프로그램의 인자로는
철학자 수, 죽는 시간, 먹는 시간, 자는 시간, (선택)먹어야하는 횟수
를 받는다.


📚 프로젝트에서 배운 것

1. 스레드(thread)

그 동안의 42 프로젝트에서는 하나의 프로세스에서 하나의 스레드로만
진행되는 프로그램(또는 함수)를 제작해왔다.

이번 'philosopher' 프로젝트에서는
각 철학자들이 먹고 자는 등의 행동을 각자 해야했기 때문에
철학자 한 명당 하나의 스레드를 할당해 줘야 했다.

스레드 생성은 pthread_create 함수로 다음과 같은 방법으로 한다.

pthread_create(&philo[i].thread, 0, thread_ing, (void *)&philo[i]

첫번째는 pthread_t 구조체,
세번째는 스레드를 만들고 처음 실행할 함수,
마지막은 그 함수에 넘겨줄 데이터이다.

pthread_create 함수는 스레드 생성을 성공하면 0을 반환한다.
여기서 고민이 하나 생겼었다.

스레드 생성 자원이 부족하거나 너무 많은 스레드를 생성하는 등
스레드 생성을 실패하는 경우가 존재한다.
그렇다면 이런 경우에는 어떻게 해 줘야할까?

주변 사람들에게도 물어보고 인터넷도 찾아보며 나름대로 생각해본 결과
내가 생각한 정답은 '그때 그때 다르다' 였다.

네이버 같은 웹에서 스레드 생성을 하나 실패했다고
웹 전체를 내려버리면 안되기 때문에
이런 경우에는 다시 스레드 생성을 시도하는 등 적절한 처리가 필요하다.

하지만 이 프로그램은 하나의 철학자라도 없으면
정상적으로 작동할 수 없기 때문에
하나라도 스레드 생성에 실패할 경우
프로그램이 종료되는 것이 맞다고 생각했다.

따라서 다음과 같이

while (i < philo->info.philo_num)
{
		if (pthread_create(&philo[i].thread, 0, thread_ing, (void *)&philo[i]) != 0)
		{
			while (j < i)
			{
				pthread_detach(&philo[j].thread);
				j++;
			}
			return ;
		}
		i++;
}

중간에 스레드 생성에 실패할 경우,
그 전에 생성된 스레드를 회수하고 종료하도록 설계했다.

사실 이 부분을 꽤 오래 고민했다.

거슬러 올라가 malloc의 null가드(에러 처리)를 해야할까?부터 시작해
에러 처리를 해야할까?
한다면 어느 정도 수준까지 해야할까? (printf같은 함수들도 해야하나?)
등에 대해 고민해보았다.

나름 스스로 내린 결론은 내가 할 수 있는 부분들은 관리하자 였다.

안했을 때 단점보다 했을 때의 장점이 훨씬 큰 것 같다.
요즘 컴퓨터 사양에 이정도 프로그램 돌리는데 오류가 날까 싶기도 하지만 이런 처리를 안해주고 오류가 생겼을 경우를 가정해보면 해주는 것이 낫다.
(위의 경우는 코드가 특별히 어렵거나 긴 것도 아니고 정상 실행시 성능에 영향을 주는 것도 아니기 때문에 단점은 없고 안정성만 높여준다)

지금이야 나 혼자 만드는 작은 프로그램이지만
나중에 큰 프로젝트를 진행한다면 이런 부분들이 큰 문제가 될 수도 있기에 지금부터 연습하는 것이 맞지 않을까

pthread_join, pthread_detach의 차이
-> 두 함수 모두 스레드의 자원을 회수하는데 사용된다. 차이점은 pthread_join은 스레드가 종료될까지 기다린다. ptread_detach를 사용하면 기다리지 않고 계속 실행된다. 따라서 메인 함수가 종료된다면 해당 스레드가 아직 실행중이더라도 바로 프로그램이 끝난다.

2. 뮤텍스(mutex)

뮤텍스는 공유 자원을 관리하기 위한 기법이다.
이 프로젝트에서는 하나의 포크두명의 철학자가 공유한다.
두 명이 동시에 한 개의 포크를 드는 것은 불가능 하므로
포크를 뮤텍스로 지정해 한 포크는 한명만 들 수 있도록 한다.

pthread_mutex_init(&data->fork[i], 0)
pthread_mutex_destroy(&data->fork[i])
pthread_mutex_lock(&data->fork[philo->right])
pthread_mutex_unlock(&data->fork[philo->right])

init은 뮤텍스를 할당하는 함수이다.
pthread_create 함수와 마찬가지로 성공시 0을 반환한다.
(실패할 시 그때의 index를 받아 그 전까지 free(destroy)해줬다)

destroy는 할당된 뮤텍스를 해제해준다.

lock은 뮤텍스를 획득한다. 만약 해당 뮤텍스를 이미 다른 스레드에서 획득했다면 unlock될 때까지 대기한다.
unlock은 획득했던 해당 뮤텍스를 해제한다.

끝! 인줄 알았으나,,,

프로젝트에 이런 글이 있다.

데이터 레이스가 일어나면 절대 안됩니다.

데이터 레이스는 컴파일 옵션에

-g -fsanitize=thread 

를 넣으면 확인 할 수 있다.

포크만 뮤텍스 처리를 하고 프로그램을 위 옵션을 넣고 돌려보니
굉장히 많은 data race가 일어났다.

이유는 '여러 스레드가 같은 공유자원에 동시에 접근 하려고 했기' 때문이다.

내 코드에서는 철학자의 죽음을 확인하기 위해 flag를 썼다.
모든 철학자 스레드는 flag != 1일 때만 돌아가도록 했기 때문에 모든 스레드에서 flag라는 공유자원을 사용한다.
이런 곳에서 데이터 레이스가 굉장히 많이 일어났던 것이다.

flag외에도 main thread와 공유하는 자원들이 있었기 때문에
모든 공유자원에 mutex를 생성해 데이터 레이스가 일어나는 것을 방지했다.

나는 인간의 관점에서
'철학자가 포크 동시에 안들면 끝 아닌가?'
라고 단순하게 생각했는데
flag 등도 컴퓨터에겐 포크와 동일한 '공유 자원' 이었던 것이다.

당연한건데 왜 생각을 못했을까...

3. gettimeofday

현재 시간을 구할 수 있는 함수이다.


long long	ft_gettime(void)
{
	struct timeval	time;
	long long		now;

	gettimeofday(&time, 0);
	now = time.tv_sec * 1000 + time.tv_usec * 0.001;
	return (now);
}

이번 프로젝트에서는 단위가 ms이기 때문에 단위를 맞춰줬다.

4. 시간 밀림

철학자들이 굉장히 많이 생성되면 스레드간 Context Switching으로 시간이 밀리게 된다.
어떻게 잘 하면 이를 더 잘 방지하는 방법이 있을 것 같긴 한데
결국 Context Switching으로 인한 시간밀림은 불가피하다고 생각해
usleep을 쪼개서 사용하는 것으로 만족했다.

usleep에게 주는 값이 클 수록 시간 차가 많이 날 수 있기 때문에

while(실제 지난 시간 <= 지나야하는 시간)
	usleep(100);

이런 식으로 작은 단위로 usleep을 시켜줬다.

그런데 이 때 너무 작은 값으로 쪼개면 스레드가 많을 경우
연산이 너무 많아져 Context Switching으로 인한 시간밀림이 심해질 수 있어
적절한 값을 줘야했다.

5. 철학자야 밥먹자

철학자들 모두가 동시에 밥을 먹을 수 없기 때문에 굶어 죽는 철학자가 최대한 생기지 않도록 적절하게 처리해 줘야한다.

굶음을 방지하기 위한 여러 방법이 있지만 나는 짝수와 홀수로 나누는 방법을 선택했다.

  1. 짝수 철학자들은 왼쪽, 홀수 철학자들은 오른쪽을 먼저 집도록 했다.

이렇게 하면 한쪽 포크를 들고 계속해서 놓지 않는 경우를 방지 할 수 있다.

예) 철학자가 2명인 경우 1번 철학자는 1번 포크, 2번 철학자는 2번 포크를 들고 놓지 않고 있는 경우

  1. 홀수 철학자들은 '잠깐' 대기하고 포크를 잡도록 했다.

이렇게 하지 않으면 짝수 철학자와 홀수 철학자가 포크를 잡기위해 경쟁을 하게된다. 최대한 굶음을 방지하기 위해서는 (철학자의 총 수가 짝수일 경우) 짝수만 먹고, 홀수만 먹고 를 반복해야하는데, 경쟁을 하게 되면 짝수와 홀수 철학자가 섞여 먹을 가능성이 생긴다.

예외로 1명일 때는 포크가 1개밖에 없기 때문에 절대 식사를 하지 못한다.
따라서 포크를 하나 잡고 대기하다가 죽는 시간이 되면 죽도록 처리했다.

📕 프로젝트를 마치며

생각보다 이 과제를 하는데 시간이 많이 걸렸다.

하지만 스레드나 뮤텍스, 데이터 레이스 등 새로운 지식을 익히고
그 동안 별 생각 없이 해오던 에러 처리를 어떻게 할 것인지 깊게 고민해보며 (42서울에서의 널가드 등)
여러 부분에서 발전한 것 같아 나름 유익한 시간이 되었던 것 같다.

✅ 평가

평가를 받으며 알게된 것들이 있다.

1. 뮤텍스 자체를 포크로 사용해도 될까?

평가표에 보면 포크는 value를 가지고 있고 이것을 확인하고 변경할 수 있어야 한다는 내용이 있다.
그렇기에 뮤텍스 자체를 포크로 사용하면 안되고 따로 변수로 포크의 상태를 지정해야한다는 주장이 있다.

나는 뮤텍스 구조체 내부에 뮤텍스의 상태를 나타내는 int형 변수가 있고, lock, unlock 함수로 이 변수를 변경할 수 있기 때문에 문제가 되지 않는다고 디펜스 했다.

프로젝트 파일이나 평가표에 '뮤텍스 자체를 포크로 지정하면 안된다' 는 내용이 없기 때문에 디펜스 영역이라고 생각한다.

2. usleep을 얼마나 작게 나눌까?

위에 서술한 내용이다. usleep이 정확하지 않기 때문에
보다 작은 단위로 나눠서 사용했다.

작게 나눌 수록 정확도가 올라가지만 그만큼 연산이 많아져 Context Switching으로 인한 시간밀림이 심해진다.

나는 100정도로 나눠줬었는데,
한 평가자 분께서는 철학자의 수만큼 usleep을 나누셨다고 한다.
스레드가 많을 수록 큰 값으로 나눠야하기 때문에 굉장히 좋은 방법이라는 생각이 들었다.

3. 정의되지 않은 행동

한 평가자께서 '포크를 잡을 수 있는데 잡지 않는 것은 정의되지 않은 행동이다.'라는 말씀을 하셨다.

예를들어, 포크를 2개 다 잡고 바로 식사를 할 수 있을 때 포크를 잡도록 처리한 분들이 있다. 이런 경우는 모두 정의되지 않은 행동 으로 잘못되었다는 주장이다.

나는 일단 그렇게 처리하지 않아 짧게 이야기한 뒤 넘어갔는데
만약 이런식으로 구현하신 분이라면 디펜스를 잘 준비하셔야 할 것 같다.

개인적으로 나는 괜찮다는 입장이다. (프로젝트 파일에 나와있지 않은 내용이기 때문에 디펜스 영역)

보너스는 생략했다.

드디어 Philosopher (진짜) 끝!! 💯

profile
CNUE & 42SEOUL 8th cadet

0개의 댓글