[Philosophers]병렬 처리의 동시성 문제

JH Bang·2022년 7월 16일
0

42 Seoul

목록 보기
6/9

스레드

하나의 프로그램을 여러 프로세스로 실행할 경우 효율적이지만 자원이 낭비된다. 하지만 코드, 데이터 등 공통 자원을 공유하고 프로세스는 한 개만 띄운다면 더 효율적으로 자원을 사용할 수 있게된다. 각각의 스레드에는 CPU 수행에 대한 각각의 레지스터를 할당하고 각각의 프로그램 카운터를 할당받으며 관리하는 스택도 구분된다.

리눅스에서는 pthread.h(POSIX thread)를 통해 스레드 생성하는데, 자식 프로세스와 달리 모든 스레드는 동등하다.

pthread를 컴파일 할 때는 cc -pthread옵션을 사용한다.

참고로 fork()는 해당 프로세스의 모든 스레드를 복제하는 것이 아니라 fork()를 호출한 스레드 만 복제한다.

뮤텍스

뮤텍스는 critical section이라고 불리는 공유 자원 접근 코드 영역을 '아토믹'하게 실행하기 위한 것이다.
다른 스레드가 접근 가능하도록 냅두면 예측이 어려운 CPU 스케쥴링 때문에 예상치 못한 식으로 프로그램이 동작하게 된다.
따라서 critical section에 한 스레드가 접근 중일 경우, 다른 스레드의 접근을 막기 위해 뮤텍스를 사용한다.
뮤텍스를 잠근 스레드를 뮤텍스의 소유자라고 한다. 뮤텍스 소유자만 뮤텍스를 풀 수 있다.
다만 뮤텍스는 권고일 뿐으로 다른 스레드가 뮤텍스 규칙을 따르지 않고 공유자원에 접근할 수도 있다.

뮤텍스에는 정적 할당 뮤텍스와 동적 할당 뮤텍스가 있다. 동적할당 뮤텍스의 경우 옵션을 줄 수 있다.

정적으로 할당된 뮤텍스 변수의 초기화

pthread_mutext_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mtx);

/*. critical section */

pthread_mutex_unlock(&mtx);

동적으로 할당된 뮤텍스 변수의 초기화

int pthread_mutex_init(pthread_mutex_t *mtx, const pthread_mutexattr_t *attr);

..
pthread_mutex_destroy(mtx);
#include <pthread.h>
int pthread_join(pthread_t pthread, void **value_ptr);

return : ( 성공시 0, 실패시 error_code )

어떤 pthread가 종료될 때까지 기다린 후 자원 반환
pthread : 스레드 식별자.
value_ptr : pthread의 return값

교착상태

공유하는 자원을 서로 요청하는 상태다. 교착상태가 발생할 수 있는 영역을 critical section이라고 한다.
상호배제/비선점/점유대기/순환대기 4개를 모두 만족하면 교착상태에 빠지게 된다.

usleep()

usleep함수는 최소 시간을 의미하는 것으로, 해당 시간동안 실행큐에서 빼버렸다가, 대기큐로 다시 집어넣게 된다. 만약 대기큐가 비어있다면 바로 실행되지만, 차있다면 딜레이가 더 발생하게 된다.

실제 적용

🔐Thread && Mutex

pseudo code


int main(int ac, char **av)
{
	t_info 	info;  //for input params and some info
    t_philo philo; //each philo's state

	if (ac != proper args)
    	return (error);

	get_parsed_params_using_atoi(av, &info);  
  	make_mutex(&info);
    
    int i = 0;
    while (i < philo_num)
    {
    	create_thread_philo(info, i);
        philo_routine(pickup_fork, eat, putdown_fork, sleep, think)
        {
        	if (philo == starved_to_death)
            {
            	printf("philo dead");
                finish_the_philo_routine();
            }
        }
    	i++;
    }
  
}

pthread 컴파일

cc -pthread 옵션으로 컴파일 해야 함.
libpthread 라이브러리와 링크되고, pthread관련 _REENTRANT 매크로를 사용하여 thread safe한 코드 작성이 가능해 진다. reentrant(재진입 가능)하다는 것은 병렬 처리가 가능하다는 것이다.
thread safe하다는 것은 여러 스레드로가 공유자원에 동시에 접근해도 되는 코드이다.

사용 함수

필수적으로 알아야 하는 pthread 데이터 타입

pthread_t //스레드 ID
pthread_mutex_t //뮤텍스

pthread_t는 리눅스에서는 unsinged long으로 정의돼 있지만, 다른 운영체제에서는 구조체나 포인터 등으로 정의돼 있다.

pthread_create()

새 스레드를 만들고, 스타트 함수를 인자와 함께 실행한다. 성공하면 0, 실패하면 errno에 해당하는 양수를 리턴

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(* start)(void *), void *arg);

pthread_join()

특정 thread의 종료를 기다리게 한다. 성공하면 0, 실패하면 errno에 해당하는 양수를 리턴.
retval = NULL이 아니면 특정 스레드가 종료후 반환하는 리턴값을 받아볼 수 있다. 리턴값을 몰라도 되면 pthread_detach()를 사용한다.

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval)

pthread_detach()

특정 thread의 종료를 기다리지 않고 thread를 병렬 실행 시키는 함수이다.

#include <pthread.h>

int	pthread_detach(pthread_t thread);

pthread_mutex_init()

mutex를 사용할 수 있게 한다. 뮤텍스 초기화에는 정적 초기화와 동적초기화(pthread_join())가 있다.
정적 초기화는 뮤텍스의 수를 가변적이게 할 때, 힙에 동적으로 할당할 때는 적용이 되지 않는다. 스택에 할당된 자동변수 일 때도 마찬가지다. 또한 pthread_join()을 사용하면 해당 mutex의 기본 특성을 정해줄 수 있다. 기본적으로 NULL을 대입하면 되는데, 특성 종류로는 fast, recurisev, error checking 등이 있다. 디폴트 값은 fast이다.

phtread_mutex_init 함수를 사용한다.

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);

pthread_mutex_destroy()

뮤텍스가 풀려있는 상태에서 뮤텍스는 함수 리턴 전에 폐기돼야 한다.

#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t *mutex);

gettimeofday()

int gettimeofday(struct timeval *tv, struct timezone *tz);

time(2)와 매우 비슷하지만 마이크로초 단위의 시간 까지 되돌려준다. 현재는 time(2)를 대신해서 쓰이고 있으며, 가능한 time(2)대신 이 함수를 사용하는 걸 권장한다.

첫번째 인자인 tv는 현재 시스템 시간을 저장하기 위한 구조체로 다음과 같이 정의되어 있다.

struct timeval
{
    long tv_sec;       // 초
    long tv_usec;      // 마이크로초
}

tz는 사용하지 않으므로 NULL

참고

  1. 스레드와 시그널은 서로 충돌하는 부분이 많음. 이는 시그널이 스레드 탄생 전부터 있었기 때문. 따라서 스레드들은 pthread_kill이나 pthread_sigqueue() 등을 통해 시그널을 다루게 된다.

  2. 스레드가 exec()계열 함수를 호출하면, 호출한 스레드를 제외한 모든 스레드가 즉시 없어진다.

  3. 스레드가 fork()를 호출하면 호출 스레드만 자식 프로세스에 복제된다. 나머지 스레드는 자식 프로세스에서 사라진다. 만약 다른 스레드가 뮤텍스를 잠근 상태에서 fork()가 호출되면 문제가 될 수 있다. 따라서 fork()를 호출한 뒤에는 exec()을 호출하는 것이 바람직하다.

홀수명의 철학자

철학자가 짝수명일 때는 짝수와 홀수 id를 가진 철학자 반반씩 교대로 식사를 하면 된다. 문제는 홀수명일 때로, 한명이 남는다.

1명일 때

굶어 죽으면 된다.

3명일 때

(1)-(2)-(3) 순으로 반복해서 식사하면 된다.

5명일 때

(1,3)-(2,4)-(3,5)-(4,1)-(5,2) 순으로 식사를 반복하면 된다.

7명일 때

(1,3,5)-(2,4,6)-(3,5,7)-(4,6,1)-(5,7,2)-(6,1,3)-(7,2,4) 순으로 식사를 반복하면 된다.

2n + 1 명일 때 (n >= 1)

2n + 1명의 홀수명의 철학자 경우는 n명씩 식사를 하고,
1, 3, 5, ..., 2n-2 번째 철학자가 첫 식사를 시작,
2, 4, 6, ..., 2n-1 번째 철학자가 식사,
3, 5, 7, ..., 2n
4, 6, 8, ..., 2n+1
5, 7, 9, ..., 1
이런 식으로 반복됨.

특정 홀수 N이 입력됐을 때

실제 코드에서는 철학자들의 id를 0번부터 할당했다.
n = (N - 1) / 2 이므로, 이를 바탕으로 수도 코드를 짰다.

n = (philo_num - 1) / 2;

if (id % 2 == 1 || id == philo_num - 1)
{
	sleep(time);
}
while (LOOP)
{
	if (id == sleep_target())
    	sleep(time);
	routines();        
}

타깃에 대한 함수는 다음과 같이 될 것이다.

int sleep_target()
{
	int	tmp;
    static int	increment = 0;
    
    tmp = (philo_num - 1) + increment;
    target = tmp % philo_num;
    if (id == target)
    	increment++;
	return (target);
}

🔐Process && Semaphore

세마포어

세마포어(semaphore)는 네덜란드 컴퓨터 과학자인 다익스트라가 공유자원 문제를 해결하기 위해 깃발 신호에 착안해 만들었다. 세마포어는 커널이 관리하는 정수값으로 데이터를 동기화한다. 이 값들은 권한을 갖는 모든 프로세스가 볼 수 있으며, 프로세스는 세마포어의 값을 수정하며 공유자원의 상태를 다른 프로세스에게 알려줄 수 있다.

세마포어는 임계영역에 진입할 때 (V), 나올 때 (P) 신호를 기준으로 임계영역에 진입할지를 결정하게 된다. V연산과 P연산이 있다. POSIX에서 세마포어 함수는 semaphore.h에 정의돼 있다. V연산은 sem_post(), P연산은 sem_wait()에 대응된다.

네덜란드어로 각각 P = Probeer ('Try'), V = Verhoog ('Increment', 'Increase by one')이다.

세마포어에는 named와 unnamed가 존재한다. named는 프로세스 간의 공유자원 문제를 해결하기 위한 세마포어이고, unnamed는 한 프로세스 내 스레드들 간의 공유자원 문제, 혹은 자식 프로세스 와의 공유자원 문제를 해결하기 위한 것이다.
named 세마포어는 파일로 만들어지며, 스레드 간 공유자원 문제 및 자식 프로세스와의 공유자원 문제 등의 해결도 가능하다. 다만 unnamde 세마포어를 사용하는 것과 비교해 파일의 path와 실행권한 등을 설정해야 하며, 이미 자원을 공유하는 스레드 사이에 named 세마포어를 사용하면 자원을 낭비하게 된다는 점 등이 있다.

sem_open()

#include <semaphore.h>

sem_t *sem_open(const char *name, int oflag, ...);

//The parameters "mode_t mode" and "unsigned int value" are optional.

sem_close()

     #include <semaphore.h>

     int
     sem_close(sem_t *sem);
     #include <semaphore.h>

     int
     sem_unlink(const char *name);

named 세마포어를 삭제한다. 다만 특정 프로세스가 세마포어를 참조하는 동안에는 해당 프로세스가 sem_close(sem_t *sem)를 호출할 때까지 제거되지 않는다.

sem_init()

int sem_init(sem_t *sem, int pshared, unsigned int value)

sem은 초기화할 세마포어의 포인터, 그리고 pshared 값이 0인 경우에는 세마포어는 프로세스 안에서의 쓰레드들끼리 공유가 되나, 그 외 경우에는 프로세스 간 공유가 된다. value는 세마포어가 가지는 초기 값이다.

sem_destroy()

익명 세마포어는 sem_destroy(sem_t *sem)

sem_post()

     #include <semaphore.h>

     int
     sem_post(sem_t *sem);

sem_t *sem으로 받은 세마포어의 값을 증가시킨다. 세마포어 값이 1 이상이 되면 sem_wait()에 의해 대기 중인 프로세스 혹은 쓰레드가 작업을 진행하도록 한다.

sem_wait()

#include <semaphore.h>
     int
     sem_wait(sem_t *sem);

sem_t *sem에서 받은 세마포어 값이 1 이상이면 그 값을 감소시키고 즉시 함수를 종료한다. 값이 0 이라면 작업을 대기한다.

전체적인 방향성

mandatory

철학자들이 스레드로 생성되므로, 스레드가 종료되는 조건을 감시하는 코드가 필요하다. 크게 세가지 방법이 있는데 main 스레드를 모니터링 스레드로 하거나, 모니터링 스레드를 하나 만들거나, 각 철학자를 감시하는 같은 수의 스레드를 생성하는 방법이 있다. 나는 메인 스레드에서 직접 모니터링 하는 것이 효율적이라고 생각했고, 철학자의 죽음이 감지되면 메인 스레드를 종료시키도록 했다.
주의할 점은 모니터링이 연속적으로 이뤄져야 하는 것이다. 즉, if문 형식으로 철학자의 루틴 전후로 체크하면 안 되고, 메인스레드가 지속적으로 무한루프를 돌면서 감시를 해야한다.

수도 코드

int main(ac, av)
{
	get_parsed_av;
    while (philo_num)
    {
		thread_create;
        execute_philo_routines;
    }
    while (1)
    {
    	monitoring;
        if (death_found)
        	break ;
	}
    return ;
}

bonus

철학자들이 프로세스로 생성되는 데다가, IPC를 활용해 서로 통신할 수 없다는 핸디캡이 있다. 이를 해결하기 위해선 각 자식 프로세스는 routine thread, monitoring thread의 이중 스레드 구조로 만든다. 그 뒤 부모 프로세스가 자식 프로세스에 대해 하나라도 종료가 되는지 체크하고 있다가, 죽어서 종료되는 자식프로세스가 발생하면 kill 시그널로 자식 프로세스 모두를 종료시키도록 한다.

수도 코드

int main(ac, av)
{
	get_parsed_av;
    while (philo_num)
    {
		pid = fork();
        if (pid == 0)
        	execute_philo_routines;
    }
    while (1)
    {
    	monitoring;
        waitpid(&status);
        if (status == child_proc_exit)
        {
        	send_kill_signals;
        	break ;
        }
	}
    return ;
}

세마포어와 뮤텍스의 차이점

뮤텍스는 특정 프로세스에서 동작하는 반면 세마포어는 프로세스 간 동기화 처리도 가능하다. 또한 세마포어로 뮤텍스 구현이 가능하지만, 뮤텍스로는 세마포어 구현이 불가능하다.

Data race

공유 변수에 대해 data race가 발생하지 않도록 뮤텍스 또는 세마포어로 보호해 주도록 한다. 다만 mutex_unlock등을 통해 강제로 문을 따고(?) 변수에 동시 접근이 가능해질 수 있으므로 중복해서 unlock하지 않았는지 잘 살펴보도록 한다.

profile
의지와 행동

0개의 댓글