본문 바로가기
42Seoul

42Philosophers

by 멍진 2022. 5. 19.

1. 프로세스와 스레드의 차이 Process & Thread


1. 프로세스와 스레드의 정의

  • 프로세스: 운영체제로부터 자원을 할당받은 작업의 단위.
  • 스레드: 프로세스가 할당받은 자원을 이용하는 실행흐름의 단위.

2. 프로그램 → 프로세스 → 스레드

2.1. 프로그램 → 프로세스

프로세스와 스레드에 대한 설명에 앞서 프로그램에 대해 알아보자. 프로그램의 정의는 파일이 저장장치에 저장되어 있지만 메모리에는 올라가 있지 않은 정적인 상태이다. 메모리에 올라가 있지 않다는 말은 아직 운영체제가 프로그램에게 독립적인 메모리 공간을 할당해주지 않았다는 뜻이다. 모든 프로그램은 운영체제가 실행되기 위한 메모리 공간을 할당해주어야 실행될 수 있다. 정적인 상태라는 말은 단어 그대로 움직이지 않는 상태라는 뜻이다. 다시말해 아직 실행되지 않고 가만히 있다는 의미이다. 즉, 프로그램이라는 단어는 아직 실행되지 않은 파일 그 자체를 가리키는 말이다. 윈도우의 .exe 파일이나 MacOS의 .dmg 파일 등 사용자가 실행하기 전의 파일을 말한다. 쉽게말해 코드 덩어리이다.

프로그램을 실행하는 순간 해당파일은 컴퓨터 메모리에 올라가게 되고, 이 상태를 동적인 상태라고 하며 이 상태의 프로그램을 프로세스라고 한다. 따라서 프로세스를 실행되고 있는 프로그램이라고 할 수 있다.

💡 실행할 수 있는 파일(코드 덩어리)을 프로그램, 그것이 메모리(RAM)에 적재되고 CPU 자원을 할당받아 실행되고 있는 상태를 프로세스라고 한다.

2.2. 프로세스 → 스레드

과거에는 프로그램을 실행할 때 실행 시작부터 끝까지 프로세스를 하나만 사용해서 진행했다고 한다. 하지만 점차 프로그램이 복잡해지고 프로세스 하나만을 사용해서 프로그램을 실행하기는 벅차게 되었다. 

한 프로그램을 처리하기 위한 프로세스를 여러개 만드는 것은 단순하지 않은 일이었다. 그 이유는 운영체제는 안전성을 위해 프로세스마다 자신에게 할당된 메모리 내의 정보에만 접근할 수 있도록 제약을 두고있고, 이를 벗어나는 정보에 접근하려면 오류가 발생하기 때문이다. 따라서 프로세스와는 다른 더 작은 실행단위의 개념이 필요하게 되었다. 그것이 스레드이다.

(프로세스가 진행되는 동안 스레드의 실행과정)

스레드는 앞서 언급한 프로세스의 특성적인 한계를 해결하기 위해 만들어진 개념이기 때문에 스레드의 특성은 쉽게 유추할 수 있다. 스레드는 프로세스와 다르게 스레드간 메모리를 공유하며 작동한다. 스레드끼리 프로세스의 자원을 공유하면서, 프로세스 실행흐름의 일부가 되는 것이다. 앞서 프로그램을 코드 덩어리라 칭하였는데, 스레드도 코드에 비유하자면, 코드 내에 선언된 함수들이 되고, 따라서 main 함수 또한 일종의 스레드라고 볼 수 있게 된다.

💡 스레드는 프로세스의 코드에 정의된 절차에 따라 실행되는 특정한 **수행경로**이다.

3. 프로세스와 스레드의 작동방식

앞서 프로세스가 메모리에 올라갈 때 운영체제로부터 시스템 자원을 할당받는다고 하였다. 이때 운영체제는 독립된 프로세스마다 각각 독립된 영역을, Code/Data/Stack/Heap의 형식으로 할당한다. 각각 독립된 메모리 영역을 할당해주기 때문에 프로세스는 다른 프로세스의 변수나 자료에 접근할 수 없다.

(프로세스들이 운영체제로부터 별도의 메모리 영역을 할당받은 모습)

이와 다르게 스레드는 메모리를 서로 공유할 수 있다. 더 자세히는 프로세스가 할당받은 메모리 영역내에서 Stack 형식으로 할당된 메모리 영역은 따로 할당받고, 나머지 Code/Data/Heap 형식으로 할당된 메모리 역역은 공유한다. 따라서 각각의 스레드는 별도의 스택을 가지고 있지만 힙 메모리는 서로 읽고 쓸 수 있게 된다.

(스레드들이 프로세스의 Code/Data/Heap 메모리 영역을 공유하는 모습)

만약 한 프로세스를 실행하다가 오류가 발생하여 프로세스가 강제로 종료된다면, 다른 프로세스에게 어떤 영향이 있을까? 공유하고 있는 파일을 손상시키는 경우가 아니라면 어떠한 영향을 미치지도 않는다. 하지만 스레드는 Code/Data/Heap 메모리 영역의 내용을 공유하기 때문에 어떤 스레드 하나에서 오류가 발생한다면 같은 프로세스 내의 다른 스레드 모두가 강제로 종료된다.

앞서 언급하였듯 스레드를 코드(프로세스) 내에서의 함수에 빗대어 보면 이해가 쉽다. 코드 내의 어떤 함수에서 Segmentation Fault 등의 오류가 발생한다면 이 오류가 어떤 함수에서 발생했든 간에 해당 코드(프로세스)는 다른 함수 모두에 대한 작업을 중단하고 실행을 중단해버린다.

4. 스레드의 메모리 공유

앞서 스레드는 흐름의 단위라고 하였다. 이는 CPU 입장에서는 최소 작업 단위가 된다. CPU는 작업을 처리할 때 스레드를 최소단위로 삼고 작업을 한다. 반면 운영체제는 이렇게 작은 단위까지 직접 작업하지 않기 때문에 운영체제 관점에서는 프로세스가 최소작업단위가 된다. 다시말해 같은 프로세스(하나 이상의 스레드를 가짐) 소속의 스레드들은 메모리를 공유한다.

💡 스레드는 CPU의 최소작업단위, 프로세스는 OS의 최소작업단위이다.

5. 멀티테스킹(프로세스)과 멀티스레드

멀티테스킹이란 하나의 운영체제 안에서 여러 프로세스가 실행되는 것을 의미한다. 이때 운영체제를 통해 CPU가 작업하는데 필요한 자원을 프로세스는 나누어 갖는다. 결과적으로 여러 응용 프로그램이 동시에 열리고 작업을 진행할 수 있다. 멀티테스킹은 자칫 여러 프로세스가 동시에 실행되는것처럼 보이지만 원리를 알면 그렇지 않다. 위의 그림에서는 여러 프로세스가 동시에 실행되고 관리되는 것처럼 보이지만, 사실 CPU는 한번에 한가지 명령어밖에 처리하지 못한다. 즉, 동시가 아닌 재빠르게 프로세스들을 매우 빠르게 번갈아가며 실행하고 관리하는 것이다. 이때 프로세스를 번갈아가며 실행하고 관리하는 것을 Context-Switching이라고 한다.

💡 **Context-Switching?**

하나의 프로세스가 CPU를 사용중인 상태에서 다른 프로세스가 CPU를 사용하도록 하기 위해, 이전의 프로세스의 상태를 보관하고 새로운 프로세스의 상태를 적재하는 작업을 의미한다. 이는 운영체제의 CPU 자원을 할당하는 스케쥴러(scheduler)에 의해 발생한다. CPU를 적절하고 효율적으로 사용할 수 있도록 하는 작업을 스케쥴링 이라고 한다.

멀티테스킹이 하나의 운영체제 안에서 여러 프로세스가 실행되는 것이라면, 멀티스레드는 하나의 프로세스가 여러 작업을 여러 스레드를 활용하여 동시에 처리하는 것을 의미한다. 멀티스레드의 장단점은 다음과 같다.

💡 **멀티스레드의 장점**

1) Context-Switching 할 때 공유하고 있는 메모리만큼의 메모리 자원을 아낄 수 있다.
2) 스레드는 프로세스 내의 Stack 영역을 제외한 모든 메모리를 공유하기 때문에 통신의 부담이 적어서 응답시간이 빠르다.

멀티스레드의 단점
1) 스레드 하나가 프로세스 내의 자원을 오염시키면 모든 프로세스가 종료될 수 있다.
2) 자원을 공유하기 때문에 필연적으로 동기화 문제가 발생한다.

동기화 문제(Synchronization Issue)

멀티스레드를 사용하면 각각의 스레드중 어떤것이 어떤 순서로 실행될지 그 순서를 알 수 없다. 만약 A 스레드가 어떤 자원을 사용하다가 B 스레드로 제어권이 넘어간 후 B 스레드가 해당 자원을 수정했을 때, 다시 제어권을 받은 A 스레드가 해당 자원에 접근하지 못하거나 의도치 않게 바뀐 자원에 접근하게 되는 오류가 발생할 수 있다. 이때 문제가 되는 공유하는 데이터공간을 임계구역(Critical Section)이라고 한다. 이처럼 다수의 스레드가 함께 전역자원(변수)를 사용하는 경우 발생할 수 있는 충돌을 동기화 문제라고 한다. 이는 운영체제가 해결해주지 못하기 때문에 프로그래머가 적절한 기법을 통해 해결(구현) 해주어야 한다.

 

2. 동기화 Synchronization


1. Data Race

동기화란 프로세스 내에서 사용되는 데이터의 일관성(Data Consistency)를 보장하는 행위이다. 데이터의 일관성이 필요한 이유는 해당 데이터가 여러 프로세스 혹은 스레드에 의해 공유되고 이를 조작하려는 행위가 있기 때문이다. 이러한 상황처럼 데이터가 경쟁 상태로 존재하는 것을 Data Race라고 한다.

// Shared Data Balance

Balance = 100;

// This is A Process (Balance is Shared)

Balance += 200;

// This is B Process (Balance is Shared)

Balance += 300;

// Where to Branch?

if (isBalanceEqual(600))
	return (CONSISTENT);
else
	return (ISCONSISTENT);

Balance를 조정하는 프로세스 A와 B가 존재한다. 만일 Balance를 조정하는 구문이 위의 코드처럼 프로세스 별로 나뉘어 있는 것이 아니라 한 프로세스 내에서 순차적으로 주어져 있다면 문제는 발생하지 않는다. Balance가 일관성이 없다는 것을 보이기 위해서는 Context Switch를 고려해야 한다.

단순히 위의 코드에서는 Context Switch를 고려한다고 하더라도 각 프로세스가 가지고 있는 한 줄의 코드는 별 문제가 없어 보인다. 프로세스 A에서 먼저 Balance를 조정한 후 Context Switch가 일어나서 프로세스 B를 처리하든, 반대로 프로세스 B에서 먼저 Balance를 조정한 후 Context Switch가 일어나서 프로세스 A가 처리되든 그 결과는 600으로 동일한것 처럼 보이기 때문이다. 이는 Context Switch의 발생을 코드 한 줄 단위로 보았기 때문이다. 우리가 알고 있는 코드 한 줄은 실제로 여러 Instruction으로 구성되어 있기 때문에, 코드 한 줄은 결코 Atomic하지 않다.

// This is A Process

Register1 = Balance;
Register1 = Register1 + 200;
Balance = Register1;

// This is B Process

Register2 = Balance;
Register2 = Register2 + 300;
Balance = Register2;

Context Switch는 운영체제의 Dispatcher호출에 따른 Interrupt에 의해 발생하며, Instruction 단위로 발생한다. 따라서 기존의 Balance에 대한 코드가 위 코드와 같이 치환되어 처리된다고 했을 때, 프로세스 A혹은 B의 어떤 Instruction 사이에도 Context Switch가 발생할 수 있다는 것이다. 위의 Instruction들을 기반으로 Context Switch가 발생 했을 때를 가정해보자.

/*
** Ti : Time Quantum i
**
** T0 : Process A / Register1 = Balance          / [Register1 = 100]
**
** T1 : Process A / Register1 = Register1 + 200  / [Register1 = 300]
**
** T2 : Process B / Register2 = Balance          / [Register2 = 100]
**
** T3 : Process B / Register2 = Register2 + 300  / [Register2 = 400]
**
** T4 : Process A / Balance = Register1          / [Balance = 300]
**
** T5 : Process B / Balance = Register2          / [Balance = 400]
*/

위의 코드와 같이 스케쥴링 되었을 때, 프로세스 A의 Balance는 300이고 프로세스 B의 Balance는 400이 되어 데이터의 일관성에 문제가 생긴것을 확인할 수 있다. 이와 같은 데이터를 동기화하기 위해 고안된 것이 Critical Section이다.

2. Critical Section

/*
** Entry Section
** 	Critical Section
**
** Exit Section
** 	Remainder Section
*/

Critical Section이라 함은 여러 프로세스 혹은 스레드들이 공유하는 데이터들이 있을 때, 이를 접근하는 코드 영역을 의미한다. 이전 항목에서 보았듯, Critical Section에 대해서는 하나의 프로세스 혹은 스레드가 진입했을 때는 다른 프로세스 혹은 스레드가 진입해서는 안된다. Critical Section에 대한 의사코드는 위와 같다. Critical Section을 이용하여 예시에서 살펴보았던 Balance와 문제에 대해서 동기화를 성공적으로 제공하기 위해서는 아래 제시된 3가지 조전을 모두 만족할 수 있어야 한다. 이들 중에서도 특히 Mutual Exclusion과 Progress는 필수적이다.

  • Mutual Exclusion:
  • 하나의 프로세스 혹은 스레드가 Critical Section에 진입해있다면, 다른 프로세스들은 Critical Section에 진입할 수 없어야 한다.
  • Progress:
  • Critical Section이 어떤 프로세스에게도 점유되어 있지 않고 Critical Section에 진입하려는 프로세스가 존재한다면, 그 중 한 프로세스는 Critical Section에 진입할 수 있어야 한다.
  • Bounded Waiting:
  • 어떤 프로세스가 Critical Section에 진입하고자 할 때, 해당 프로세스가 무한히 기다리지 않도록 대기 시간의 적절한 제한이 필요하다.

 

3. 뮤텍스와 세마포어 Mutex & Semaphore


뮤텍스와 세마포어는 모두 공유자원의 상호배제(Mutual Exclusion)를 달성하기 위해 고안된 기법이다. 즉, 동시성 프로그래밍의 가장 큰 과제인 다수의 프로세스나 스레드가 공유자원에 접근하는것을 제어/관리 하기 위한 방법이다. 다음과 같이 서로 다른 방식을 사용한다.

  • 뮤텍스:
    • mutex는 Mutual Exclusion의 약어이다. mutex의 범위 자체는 프로세스 내로 한정되어 스레드를 대상으로 사용된다. 이때 스레드들은 동일 프로세스 내의 스레드일 수도 있고, Cooperative Process와 같이 다른 프로세스의 스레드일 수 있다.
    • Critical Section을 가진 쓰레드들의 Runnig Time이 서로 겹치지 않게 각각 단독으로 실행되게 하는 기술이다. 다중 프로세스들의 공유 리소스에 대한 접근을 조율하기 위해 locking과 unlocking을 사용한다.
    • mutex에서 LOCK을 취득하는 행위를 lock, LOCK을 해제하는 행위를 unlock이라고 한다. mutex는 스레드가 LOCK을 취득했을 때 스레드에 귀속되는 Locking 매커니즘에 근거하여 동작한다. 이에 따라 오직 LOCK을 가지고 있는 스레드만이 LOCK을 해제할 수 있다.
  • 세마포어: 현재 공유자원에 접근할 수 있는 프로세스(스레드)의 개수를 나타내는 카운팅 변수를 두는 방식의 상호배제 기법이다. 세마포어는 이진수 (0 또는 1)를 사용하거나, 또는 추가적인 값을 가질 수도 있다.

1. 뮤텍스의 예시

뮤텍스는 화장실이 하나밖에 없는 식당과 유사하다. 화장실을 가기 위해서는 카운터에서 키를 받아가야 한다. 카운터에 키가 있다면 화장실에 사람이 없다는 뜻이고 그 키를 이용해 화장실에 들어갈 수 있다. 다른 손님들은 화장실을 이용중인 손님이 용무를 마치고 키를 카운터에 반납할 때까지 화장실에 들어갈 수 없으며 대기해야 한다. 이것이 뮤텍스가 동작하는 방식이다. 여기서 화장실은 공유자원, 화장실을 이용하는 손님들은 프로세스(스레드), 대기줄은 큐(Queue) 그리고 화장실 키는 공유자원에 접근하기 위해 필요한 어떤 오브젝트(뮤텍스)이다.

2. 세마포어의 예시

세마포어는 화장실이 여러개있고, 화장실 입구에는 현재 빈 화장실의 개수를 알려주는 전광판(?)이 있는 식당과 같다. 모든 칸의 화장실이 사용중일때는 전광판의 숫자가 0이 되며, 손님들은 전광판의 숫자가 1로 바뀔 때까지 대기해야 한다. 용무를 마친 손님은 화장실에서 나오면서 전광판의 숫자를 +1해주고, 대기하던 손님은 이 숫자에서 -1를 해주며 화장실을 이용한다. 즉, 세마포어는 공통으로 관리하는 하나의 값을 이용하여 상호배제를 달성한다. 여기서 전광판의 숫자는 공유자원에 접근가능한 프로세스의 개수를 나타내는 어떤 변수(세마포어)이다.

3. 뮤텍스의 예제

뮤텍스는 값을 1 또는 0으로 갖는 변수이다. 1이면 카운터에 화장실 키가 있다는 뜻이다. 임계영역(Critical Section)에 들어갈 때 락(lock)을 걸어 다른 프로세스(스레드)가 접근하지 못하도록 하고, 임계영역에서 나오며 해당 락을 해제(unlock)한다. 뮤텍스를 활용한 상호배제를 코드로 구현하면 다음과 같다.

💡 **임계영역(Critical Section)?**

다중 프로그래밍 운영체제에서 여러 프로세스가 데이터를 공유하면서 수행될 때 각 프로세스에서 공유 데이터를 접근(Access)하는 프로그램 코드 부분을 가리키는 말이다. 공유 데이터를 여러 프로세스가 동시에 액세스하면 시간적인 차이 등으로 인하여 잘못된 결과를 만들어 낼 수 있기 때문에 한 프로세스가 위험 부분을 수행하고 있을 때 즉, 공유 데이터에 액세스하고 있을 때는 다른 프로세스들은 절대로 그 데이터에 액세스하지 못하도록 하여야 한다.

int mutex = 1;

void lock(){
	while (mutex != 1)
	{
		// mutex 값이 1이 될 때까지 대기
	}
	// 이 구역에 도달했다는 것은 화장실 키를 획득했다는 것이고 mutex 값이 1이라는 뜻이다.
	// 이제 뮤택스 값을 0으로 만들어 다른 프로세스(혹은 스레드)의 접근을 제한.
	mutex = 0;
}

void unlock() {
	// 임계구역(화장실)에서 나온 프로세스는 다른 프로세스가 접근할 수 있도록 뮤텍스 값을 1로 만들며 락을 해제.
	mutex = 1;
}

4. 세마포어 예제

세마포어는 정수 값을 갖는 변수이다. 그 수는 공유자원에 접근가능한 프로세스(스레드)의 갯수를 의미힌다. 세마포어는 다음과 같은 구조를 활용한다.

struct semaphore {
	int cnt;
	queueType queue;
};

void semWait (semaphore s) {
	--s.cnt;
	if (s.cnt < 0)
	{
		// 이 구역에 도달했다는것은 현재 프로세스(스레드)가 공유자원에 접근할 수 없다는 것을 의미.
		// 요청한 프로세스를 s.queue에 연결
		// 요청한 프로세스를 블록상태로 lock
	}
}

void semSignal (semaphore s) {
	++s.cnt;
	if (s.cnt <= 0)
	{
		// 대기하고 있는 프로세스(스레드)를 위해 s.queue에 연결되어 있는 프로세스를 큐에서 제거
		// 프로세스의 상태를 실행가능으로 변경하고 ready list에 연결
	}
}
const int n = // 프로세스 개수
semaphore s = 1;

void Process (int i) {
	while (1)
	{
		semWait(s); // 세마포어 값을 감소시킨다.
		// 임계영역(Critical Section)
		semSignal(s); // 세마포어 값을 증가시킨다.
		// 임계영역 이후 코드
	}
}

void main() {
	parbegin (Process(1), Process(2), ..., Process(n));
}

semWait() 연산: 세마포어 값을 감소시킨다. 만일 값이 음수가 되면 semWait를 호출한 프로세스는 블록(락)된다. 즉, 음수가 아니면 프로세스는 계속 수행될 수 있다.

semSignal() 연산: 세마포어 값을 증가시킨다. 만약 값이 음수면, semWait 연산에 의해 블록된 프로세스들을 해제한다.

5. 정리

  • 가장 큰 차이점은 관리하는 동기화 대상의 개수이다. 뮤텍스는 동기화 대상이 오직 하나뿐일 때, 세마포어는 동기화 대상이 하나 이상일 때 사용한다.
  • 뮤텍스는 상태가 0과 1 뿐인 바이너리 세마포어라고 볼 수 있다. (완전히 같은것은 아니다.)
  • 세마포어는 프로세스(스레드)가 소유할 수 없는 반면, 뮤텍스는 프로세스(스레드)가 소유가 가능하며 프로세스(스레드)가 이에 대한 책임을 진다. 쉽게말해 뮤텍스의 경우 상태가 두개뿐인 lock이다.
  • 두 기법 모두 완벽한 기법은 아니다. 이 기법들을 쓰더라도 데이터 무결성을 보장할 수 없으며 데드락이 발생할 수도 있다. 하지만 상호배제를 위한 기본적인 기법이며 여기에 좀 더 복잡한 매커니즘을 적용해 꽤나 우아하게 동작하는 프로그램을 작성할 수 있다.

 

4. 식사하는 철학자 문제 Dinning Philosophers Problem


1. 문제인식

do
{
	...
	THINKING
	...
	wait(chopstick[i])
	wait(chopstick[(i + 1) % 5])
	...
	EATING
	...
	signal(chopstick[i]);
	signal(chopstick[(i + 1) % 5]);
	...
	SLEEPING
	...
} while(1);

i번 철학자가 i, i + 1번 젓가락을 사용해야 식사가 가능하고 가정하자, 위의 코드에서 i번 철학자의 식사 진행을 위해 하나씩 젓가락을 사용한다고 생각해보자. 이와 같이 i번 젓가락을 잡은 뒤 i + 1번 젓가락을 잡는 방법은 i번 철학자가 2개의 젓가락을 얻지 못해 교착상태(Deadlock)에 빠지는 결론에 이른다.

2. 개선방법

위의 문제를 개선할 수 있는 방법은 다음과 같다.

  • 젓가락의 수보다 적은 수의 철학자를 유지한다.
  • 젓가락을 하나씩 잡는 것이 아니라 양쪽 젓가락을 모두 사용할 수 있는 경우 동시에 두 젓가락을 잡는다.
  • i번째 철학자가 홀수 index라면 i번 젓가락을, 짝수 index라면 i + 1번째 젓가락을 잡도록 한다.

위에서 제시한 방법중 두번째 방법을 코드로 표현하면 다음과 같다.

// i번째 철학자가 식사를 할 준비가 되었는지 확인한다!
// 양 옆의 철학자가 식사를 하고있지 않다 젓가락을 모두 이용할 수 있다면, 철학자가 take_chopsticks에서 wait하지 않도록 signal을 보낸다.
// 주어진 조건을 만족하는 경우 philo[i]의 값이 1이므로 take_chopsticks에서 block되지 않고 EATING할 수 있다.

check(int i)
{
	if (state[i] == THINKING && state[LEFT] != EATING && state[RIGHT] != EATING)
	{
		state[i] = EATING;
		signal(philo[i]);
	}
}

// mutex를 통해 i번째 철학자의 상태를 변경한다.
// check를 통해 양쪽 철학자의 상태를 확인한다.
// i번째 철학자는 자신이 식사를 할 수 있을때까지 기다린다.

take_chopsticks(int i)
{
	wait(mutex);
	state[i] = THINKING;
	check(i)
	signal(mutex);
	wait(philo[i]);
}

// mutex를 통해 i번째 철학자의 상태를 변경한다.
// check를 통해 왼쪽 철학자와 오른쪽 철학자의 양 옆을 확인한다.
// 둘중 식사가 가능한 철학자에게 check 내부에서 signal을 보낸다.

put_chopsticks(int i)
{
	wait(mutex);
	state[i] = SLEEPING;
	check(LEFT);
	check(RIGHT);
	signal(mutex);
}

// solution
do
{
	...
	THINKING
	...
	take_chopsticks(i);
	...
	EATING
	...
	put_chopsticks(i);
	...
	SLEEPING
	...
} while(1);

3. 고려할 점

식사하는 철학자 문제, Consumer-Producer Problem, Readers-Writers Problem 등 동기화의 고전적인 문제들이 시사하는 점들은 결국 다음의 사항들과 같다. 멀티 프로세싱 혹은 멀티 스레딩은 많은 장점을 가져다 주지만, 결국 자원을 공유해야 하는 관점에서 동기화가 필수적으로 요구되고 그 과정에서 여러가지 이슈를 고려해 주어야 한다.

  • Data Consistency가 확보되는가?
  • Deadlock이 발생하는가?
  • Starvation이 발생하는가?
  • Concurrency의 제공이 원활한가?

4. Features

  • 상기 문제를 mutex, semaphore를 활용하여 두 가지 방법으로 해결한다.
  • pthread_create 혹은 fork에 의해 다수의 branch로 분기하며, main stream은 mutex 혹은 semaphore에 의해 대기 상태를 유지한다. 분기된 branch의 일련의 작업이 완료되면 main stream의 작업이 재개된다.
  • branch가 분기되면 각 branch(철학자)는 자신의 죽음을 감시하는 monitoring thread를 전개한다.
  • 이후 각 branch(철학자)는 Taking Fork, Eating, Putting Fork Down, Sleeping, Thinking을 반복(출력) 한다.
  • 철학자의 상태변화를 출력하고자 할 때, 각 branch는 pthread_mutex_lock 혹은 sem_wait를 활용하여 printing resource에 대한 lock을 획득하고 반환하는 절차를 거친다.
  • 각 branch의 시간을 동기화하기 위해, main stream에서 branch의 분기 직전 시간을 기록한다.
  • 특정 철학자가 일정시간 동안 식사를 하지 못하면(starvation), 시뮬레이션은 실패로 종료된다.
  • 오류 발생 시 pthread_mutex_unlock 혹은 sem_post가 호출되어 main stream의 작업이 재개되어 오류를 처리한다.

5. Flow chart

6. Scheduling

작성한 초기 프로그램은 실행 환경에 따라 이론상 시뮬레이션이 성공적으로 수행되어야 하는 테스트케이스에 대해서도 시뮬레이션 수행이 실패하는 것을 발견했다. 그 원인은 context switch에 의한 딜레이 누적으로, 철학자들에게 식사를 할 수 있도록 하는 과정이 동작 환경, 특히 머신의 논리 코어의 수에 의존적이기 때문임을 알게 되었다. 근본적으로 이는 프로그램 내의 시간 측정의 정확도, 프로세스 혹은 스레드의 스케줄링과 usleep 함수의 특성에서 기인한다.

usleep 함수는 인자로 받은 시간만큼 프로세스 혹은 스레드를 sleep 상태로 만들고, 이를 최소한 인자 만큼의 시간이 지난 뒤에 깨운다, 정확히 인자 만큼의 시간이 지난 후에 프로세스 혹은 스레드를 깨우는 것이 아니다. 또한 sleep 상태에서 깨어난 프로세스 혹은 스레드는 곧바로 running 상태가 되는 것이 아니며, ready queue에 enqueue되어 ready 상태로 kernel의 dispatcher에 의해 처리되도록 대기한다. 이후 dispatcher가 ready queue를 수거해야 비로소 running 상태로 전환되기 때문에, 특정 시간만큼 프로세스 혹은 스레드를 sleep 시킬 목적으로 usleep 함수에 해당 시간을 직접 인자로 넣는 과정은 결과적으로 큰 딜레이를 누적시키며, 이는 시뮬레이션 실패로 이어진다.

이를 극복하기 위해 ulseep 함수를 활용할 때는, 매우 짧은 시간(EPSILON)에 걸쳐 해당 함수를 적절한 횟수만큼(interval) 반복하여 실행하도록 하였다. 예를 들어 2000ms만큼 프로세스 혹은 스레드를 sleep 시켜야 한다면, 1ms(EPSILON)만큼 sleep 시키는 과정을 2000번 반복하여, 의도한 시간만큼을 sleep 시켰는지 확인하는 방식으로 진행할 수 있다. 개선된 방법으로 정확한 시간 측정을 통해 성공적으로 시뮬레이션 프로그램을 완성할 수 있었다.

 

코드 작성에 이해가 필요한 외부 함수들

'42Seoul' 카테고리의 다른 글

Born2beroot 평가 준비  (1) 2022.03.30
MiniLibX를 공부해보자 - 42Seoul [so_long]  (5) 2022.03.22

댓글