이 단원에서는 6 단원에서 언급한 도구를 여러 고전적인 동기화 문제에 적용.
단원 목표
- 유한 버퍼, 작가-독자, 식사하는 철학자 동기화 문제 이해
- 리눅스와 윈도우즈에서 프로세스 동기화 문제 해결하는 도구 사용법
- POSIX와 Java로 프로세스 동기화 문제 해결하는 방법
- POSIX와 Java API로 프로세스 동기화 문제 해결하는 법 설계
여러 문제 소개하고, 이에 대한 해법도 소개. 보통 세마포어로 해결하니까 세마포어를 쓰긴 할 건데, 상호배제 락으로도 할 수 있음.
유한 버퍼 문제 bounded-buffer problem은 6.1 절에서 언급했었음. 동기화 프리미티브의 힘을 보여주기에 제일 적합한 문제임.
생산자와 소비자는 다음 자료 구조를 공유함:
int n;
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0;
풀에 n
개의 버퍼로 차있다고 가정하고, 각 하나의 원소를 가질 수 있음. mutex
이진 세마포어가 버퍼 풀로의 상호 배제적 접근을 가능하게 해주며, 1로 초기화. empty
와 full
세마포어는 비어있는, 혹은 가득 찬 버퍼의 개수를 트래킹함. empty
는 n
으로 초기화하고, full
은 0
으로 초기화.
생산자 코드:
while (true) {
...
/* 새 원소를 생산하여 next_produced에 저장해 넣음 */
...
wait(empty);
wait(mutex);
...
/* next_produced를 버퍼에 추가 */
...
signal(mutex);
signal(full);
}
소비자 코드:
while (true) {
wait(full);
wait(mutex);
...
/* 버퍼에서 한 원소를 빼내어 next_consumed에 저장 */
...
signal(mutex);
signal(empty);
...
/* next_consumed에 저장한 원소를 소비 */
...
}
여러 동시 프로세스와 데이터베이스 공유한다는 가정. 데이터베이스를 읽기만 할 사람들(독자 reader)과 갱신하기만(쓰기만) 할 사람들(작가 writer)로 나뉠 것. 독자가 동시에 읽는 건 문제 없지만 독자랑 작가가 동시에 읽고 쓰면 문제가 발생할 것.
작가가 데이터베이스에 상호배제적인 접근권한이 있으면 해결임. 이걸 독자-작가 문제 readers-writers problem라 부름. 이걸로 거의 모든 새로운 동기화 프리미티브를 테스트함.
여러 변형이 존재함. 제일 간단한 변형은 첫번째 first 독자-작가 문제로, 작가가 접근 권한을 얻은 게 아니라면 그 어떠한 독자도 대기해서는 안된다는 것. 즉, 작가가 대기 중일 때 다른 독자가 읽고 있다고 해서 다른 독자가 대기해서는 안된다는 것임. 두번째 second 독자-작가 문제의 경우 작가가 쓸 준비가 된 순간 최대한 빠르게 집필을 해야한다는 것임. 즉, 작가가 공유 개체에 접근하려고 대기 중이면, 그 어떠한 독자도 읽기를 시작할 수 없음.
첫번째 독자-작가 문제의 해법의 경우 독자 프로세스는 다음 자료 구조를 가짐:
semaphore rw_mutex = 1;
semaphore mutex = 1;
int read_count = 0;
이진 세마포어 rw_mutex
는 독자와 작가 공용. 이진 세마포어 mutex
는 read_count
변수가 갱신될 때 상호배제 보장하려고 사용. read_count
변수는 현재 개체를 갖고 있는 프로세스가 몇 개인지를 의미. 세마포어 rw_mutex
로 작가의 상호배제 보장. 첫번째 혹은 마지막 독자가 임계 구역을 입장하거나 퇴장할 때 사용하기도 함. 그 중간에 입장/퇴장하는 독자들은 사용하지 않음.
작가 프로세스 코드:
while (true) {
wait(rw_mutex);
...
/* 집필 시작 */
...
signal(rw_mutex);
}
독자 프로세스 코드:
while (true) {
wait(mutex);
++read_count;
if (read_count == 1) {
wait(rw_mutex);
}
signal(mutex);
...
/* 읽기 시작 */
...
wait(mutex);
--read_count;
if (read_count == 0) {
signal(rw_mutex);
}
signal(mutex);
}
이때 작가가 임계 구역에 있고, n 명의 독자가 대기 중이면, 한 독자는 rw_mutex
에서 대기하고, 나머지 n - 1 명의 독자는 mutex
에 대해 대기 중. 작가가 signal(rw_mutex)
를 호출하면 대기 중인 독자나 대기 중인 한 작가의 실행을 재개해줄 수 있음. 이건 스케줄러가 두 경우 중 하나 고르는 것임.
독자-작가 문제와 이에 대한 해법은 아예 일반화가 되어서 몇몇 체제에서는 독자-작가 reader-writer 락을 제공함. 이 락 얻으려면 락에 읽기 read 혹은 쓰기 write 접근 모드를 설정해줘야함. 읽기만 할거면 읽기 모드의 독자-작가 락을 사용하면 되고, 쓰기만 할거면 쓰기 모드의 독자-작가 락 사용하면 됨. 여러 프로세스가 동시에 읽기 모드의 독자-작가 락을 얻을 순 있지만 작가의 상호 배제 때문에 오로지 한 프로세스만 쓰기 모드의 독자-작가 락을 얻을 수 있음.
독자-작가 락이 유용한 상황:
다섯 철학자, 원형 식탁, 다섯 의자, 식탁 중간에 밥, 다섯 젓가락 낱 개.
철학자가 사색에 잠기면 다른 철학자랑 대화 안 함. 중간에 배고파지면 양쪽에 있는 젓가락 가져가서 한 쌍을 만들어서 밥 먹음. 한 번에 한 젓가락 낱 개만 가져갈 수 있음. 먹을 땐 꼭 젓가락 한 쌍을 동시에 들고 먹음. 다 먹으면 젓가락 각각 돌려 놓고 다시 사색에 잠김.
식사하는 철학자 문제 dining-philosophers problem는 전형적인 동기화 문제. 이건 단순히 실무적으로도 중요한 내용이라거나 컴퓨터 과학자들이 철학자를 싫어해서가 아니라 동시성 제어 문제에서 가장 큰 부류의 대표적인 예시이기 때문. 여러 프로세스에게 여러 자원을 할당해주면서 데드락이나 기아 상태를 방지해야하는 경우를 매우 단순화한 예시임.
각 젓가락을 세마포어로 두면 됨. 각 철학자는 가져갈 젓가락의 세마포어에 대한 wait()
연산으로 젓가락을 가져감. 나중에 돌려 놓을 땐 signal()
연산으로 함. 즉, 공유 자료는 다음과 같음:
semaphore chopstick[5];
이때 전부 1로 초기화.
i
번째 철학자의 구조:
while (true) {
wait(chopstick[i]);
wait(chopstick[(i + 1) % 5]);
...
/* 잠깐 식사 */
...
signal(chopstick[i])
signal(chopstick[(i + 1) % 5]);
...
/* 잠깐 사색 */
...
}
이러면 동시에 두 이웃이 먹는 경우는 없음을 보장할 수 있지만, 데드락이 발생할 수 있다는 단점이 있음. 모든 철학자가 전부 동시에 배고파져서 자기 왼쪽에 있는 젓가락 집었다고 가정해보셈. 모든 chopstick
원소의 값은 이제 0이니까 오른쪽 젓가락 집을 수 있을 때까지, 즉 영원히 대기해야함.
데드락 해결하려면 다음과 같이 해줄 수 있음:
6.7 절에서 데드락 없도록 보장하는 식사하는 철학자 문제 해법을 제공. 하지만 이러한 대부분의 해법은 철학자 중 한 명이 굶다 죽을 수도 있다는 가능성이 있음. 데드락 해결했다고 해서 기아 상태를 방지해주지는 않으니까.
철학자는 오로지 양쪽 젓가락 둘 다 있을 때만 젓가락 들 수 있다는 제약 조건 추가. 우선 철학자의 상태를 정의해야함:
enum {THINKING, HUNGRY, EATING} state[5];
i
번째 철학자는 오로지 이웃들이 식사 중이 아닐 때만(state[(i+4)%5 != EATING && state[(i+1) % 5] != EATING
) 식사할 수 있음(state[i] = EATING]
).
추가적으로 i
번째 철학자가 배고프지만 젓가락을 못 얻었을 때 잠간 대기할 수 있게 해줄 변수 추가:
condition self[5];
젓가락 분배는 DiningPhilosophers
모니터가 관할함:
monitor DiningPhilosophers
{
enum {THINKING, HUNGRY, EATING} state[5];
condition self[5];
void pickup(int i) {
state[i] = HUNGRY;
test(i);
if (state[i] != EATING) {
self[i].wait();
}
}
void test(int i) {
if ((state[(i + 4) % 5] != EATING) &&
(state[i] == HUNGRY) &&
(state[(i + 1) % 5] != EATING)) {
state[i] = EATING;
self[i].signal();
}
}
initialization_code() {
for (int i = 0; i < 5; ++i) {
state[i] = THINKING;
}
}
}
각 철학자는 먹기 전에 먼저 pickup()
연산을 호출해야함. 이러면 철학자 프로세스가 잠깐 중단될 수도 있음. 연산이 다 끝나면 철학자는 먹어도 됨. 그 다음에 철학자는 putdown()
연산을 호출. 즉, 다음 순서로 진행됨:
DiningPhilosophers.pickup(i);
...
식사
...
DiningPhilosophers.putdown(i);
이러면 동시에 두 이웃이 먹지 않거니와 데드락도 발생하지 않는다. 물론 언급했듯이 철학자 중 한 명이 굶어 죽을 수 있긴 함. 이건 알아서 해결해보셈.
윈도우즈와 리눅스 운영체제에서 제공하는 동기화 메커니즘. 여러 다양한 접근법을 볼 수 있음. 둘이 좀 다름.
윈도우즈 운영체제는 실시간 어플리케이션과 다중 프로세서를 지원하는 멀티스레드 커널임. 단일 프로세스 시스템에서 전역 자원에 접근할 땐 임시적으로 전역 자원에 접근할 수도 있는 인터럽트를 잠깐 막아둠. 다중 프로세스 시스템에서는 스핀락으로 전역 자원으로의 접근을 보호함. 물론 커널은 스핀락 자체를 짧은 코드 구역만 보호할 때만 사용함. 효율성 때문에 스레드가 스핀락 들고 있으면 선점 불가하게 함.
커널 외의 스레드 동기화는 디스패처 개체 dispatcher object로. 디스패처 개체로 여러 메커니즘(상호배제 락, 세마포어, 이벤트, 타이머 등)을 바탕으로 스레드 간에 동기화 이뤄짐. 스레드가 상호배제에 소유권을 얻어야만 공유 자료에 접근할 수 있도록 해주는 방식으로 운영체제가 공유 자료 보호해줌. 세마포어는 6.6 절에서처럼 작동. 이벤트 event는 조건 변수랑 비슷함. 즉, 원하던 조건이 만족되면 대기하는 스레드에 노티를 줌. 타이머는 한(혹은 하나 이상의) 스레드에 정해진 시간만큼 시간이 지났다고 노티를 줌.
디스패처 개체는 신호 수신 상태, 신호 미수신 상태로 나뉨. 신호 수신 상태 signaled state인 개체의 경우 현재 사용 가능하며, 개체를 얻을 때 스레드가 막지 않을 것임을 의미. 신호 미수신 상태 nonsignaled state의 개체의 경우 사용이 불가하며 개체를 얻으려고 하면 스레드가 막을 것임. 상호배제 락 디스패처 개체의 상태 전이도:
디스패처 개체의 상태와 스레드의 상태 간에는 관계가 존재. 신호 미수신 상태의 디스패처 개체에서 스레드가 막고 있으면, 스레드의 상태는 준비에서 대기가 되며, 해당 개체에 대해 대기 큐에 들어가짐. 디스패처 개체의 상태가 신호 수신 상태로 전이되면 커널이 해당 개체에 대기 중인 스레드가 있는지 확인하고, 있다면 커널이 한(혹은 하나 이상) 스레드를 대기 상태에서 준비 상태로 옮겨주어 실행 재개될 수 있도록 해줌. 커널이 얼마나 옮기느냐는 스레드가 대기 중인 디스패처 개체의 유형에 따라 다름. 상호배제의 경우 하나만 옮김. 상호배제 개체는 오로지 한 스레드가 "소유"할 수 있기 때문. 이벤트 개체의 경우 모든 스레드를 옮김.
상호배제 락으로 디스패처 개체와 스레드 상태를 표현해줄 수 있음. 스레드가 신호 미수신 상태의 상호배제 디스패처 개체를 얻으려고 하면 해당 스레드는 중단되어 상호배제 개체의 대기 큐에 놓일 것임. 상호배제가 신호 수신 상태가 되면(어떤 스레드가 상호배제의 락을 반환했기 때문) 대기 중이던 첫번째 스레드가 대기 상태에서 준비 상태로 전이되어 상호배제 락을 얻을 것.
임계구역 개체 critical-section object는 사용자 모드 상호배제로, 커널의 개입이 거의 없이 얻고 반환할 수 있음. 다중 프로세서 시스템에서 우선 임계구역 개체는 스핀락으로 다른 스레드가 개체를 반환할 때까지 대기. 만약 너무 오래 스핀락이 돌고 있으면 지금 갖고 있는 스레드가 커널 상호배제를 할당받아 CPU에 반환함. 임계구역 개체는 커널 상호배제가 오로지 개체에 대한 경쟁이 있을 때만 할당되기 때문에 효율적임. 실제로는 경쟁이 적으니 상당히 절약이 많이 됨.
리눅스가 2.6 버전 이전까지는 비선점적 커널이었는데 이제는 완전히 선점적임.
대부분의 컴퓨터 구조가 간단한 수학 연산을 원자적으로 제공하니, 리눅스 커널에서의 가장 단순한 동기화 기법은 원자적 정수로, atomic_t
자료형으로 표현함. 이름에서부터 알겠지만, 모든 원자적 정수를 사용하는 모든 수학 연산은 인터럽트 없이 수행됨. 예시로:
atomic_t counter;
int value;
다음 코드는 여러 원자적 연산을 수행했을 때의 결과임:
원자적 연산 | 의미 |
---|---|
atomic_set(&counter, 5); | counter = 5 |
atomic_add(10, &counter); | counter = counter + 10 |
atomic_sub(4, &counter); | counter = counter - 4 |
atomic_inc(&counter); | counter = counter + 1 |
value = atomic_read(&counter); | value = 12 |
원자적 정수는 특히 카운터와 같은 정수 변수를 갱신해야 할 때 특히 효율적임. 왜냐면 원자적 연산은 락을 거는 것과 같은 부담이 없으니까. 물론 이런 경우에 밖에 못 쓰니 한정적이긴 함.
리눅스에선 상호배제 락으로 커널 내의 임계구역 보호함. 작업은 반드시 mutex_lock()
을 호출한 뒤에 임계구역에 입장하고, 퇴장하고 나선 mutex_unlock()
호출해야 함. 락 지금 누가 쓰고 있으면 mutex_lock()
호출한 작업은 잠깐 수면 상태에 있다가 락 임차인이 mutex_unlock()
호출해서 반환하면 깨워짐.
리눅스에선 커널에 락 걸 때 스핀락이랑 세마포어도 제공함(독자-작가 버전도 제공함). SMP 기계에서는 기본적인 락 메커니즘은 스핀락이고, 애초에 커널은 스핀락 짧은 기간 동안만 들고 있을 수 있도록 설계되어있음. 임베디드 시스템처럼 단일 프로세서의 경우 스핀락보다는 커널 선점을 활성화/비활성화시키는 방향으로 되어 있음:
단일 프로세서 | 다중 프로세서 |
---|---|
커널 선점 비활성화 | 스핀락 얻기 |
커널 선점 활성화 | 스핀락 반환 |
스핀락과 상호배제 락은 둘 다 비재귀적 nonrecursive이므로 스레드가 락 하나 얻으면, 이 락을 해제하기 전까지 똑같은 락을 얻을 수 없다는 것임. 그렇지 않으면 이 락을 한 번 더 얻으려 했다가 막힐테니까.
리눅스는 커널 선점을 활성화/비활성화한다는 방법을 사용. 두 개의 시스템 호출 제공: preempt_disable()
과 preempt_enable()
. 물론 커널에서 실행 중인 작업이 지금 락을 갖고 있으면 커널은 선점될 수 없음. 이 규칙을 강제하려면 시스템의 각 작업은 preempt_count
라는 이 작업이 가진 락의 개수를 의미하는 카운터를 갖는 thread_info
구조체를 가짐. 락을 얻으면 preempt_count
가 증가되고, 락을 반환하면 감소됨. 현재 실행 중인 작업의 preempt_count
의 값이 0보다 크면 지금 락을 갖고 있다는 소리니까 커널을 선점하기에 안전하지 않음. 0이라면 커널이 안전히 인터럽트될 수 있음(preempt_disable()
보다 더 상위의 호출이 없다는 가정).
스핀락은 커널 선점 활성화/비활성화처럼 커널이 짧은 기간 동안 락을 필요로 할 때(혹은 커널 선점을 비활성화 할 때)만 사용됨. 더 오랜 기간 락을 갖고 있어야 한다면 세마포어나 상호배제 락을 사용함.
지금까지는 커널 수준에서 다룸. POSIX API는 사용자단에서 제공.
상호배제 락은 Pthread의 기초적인 동기화 기법. 상호배제 락의 자료형은 pthread_mutex_t
. pthread_mutex_init()
함수로 상호배제 생성. 첫번째 매개변수는 상호배제에 대한 포인터. 두번째 매개변수에 NULL
전달하면 상호배제를 기본 속성으로 초기화:
#include <pthread.h>
pthread_mutex_6 mutex;
/* 상호배제 락 생성 및 초기화 */
pthread_mutex_init(&mutex, NULL);
/* 상호배제 락 얻기 */
pthread_mutex_lock(&mutex);
/* 임계구역 */
/* 상호배제 락 해제/반환 */
pthread_mutex_unlock(&mutex);
pthread_mutex_lock()
호출할 때 상호배제 락 사용 불가하면 이거 호출한 스레드는 상호배제 임차인이 pthread_mutex_unlock()
호출할 때까지 막힘.
모든 상호배제 함수는 제대로 실행 됐으면 0을 반환하고, 오류가 나면 0이 아닌 값을 반환함.
POSIX 표준은 아니고 POSIX SEM 확장에 속하긴 함. 지명 named과 익명 unnamed 두 가지 세마포어가 있음. 근본적으론 둘 다 같은데, 어떻게 생성되고 프로세스 간 공유되느냐에 차이점이 발생함. 리눅스 커널 2.6 버전부터 리눅스도 지명과 익명 세마포어 지원함.
#include <semaphore.h>
sem_t* sem;
/* 세마포어 생성하고 1로 초기화 */
sem = sem_open("SEM", O_CREAT, 0666, 1);
세마포어의 이름은 SEM
, O_CREAT
플래그로 세마포어가 존재하지 않으면 생성하라는 뜻. 이 세마포어는 다른 프로세스에 대해 읽기 쓰기 권한을 매개변수 0666
로 지정해주고, 값은 1로 초기화해줌.
지명 세마포어의 장점은 여러 무관한 프로세스가 단순히 세마포어의 이름을 통해 공통 세마포어를 사용하여 동기화를 해준다는 점. 위에서 생성을 해준 다음, 다른 프로세스가 (똑같은 매개변수로) sem_open()
호출하면 이미 존재하는 지정자가 반환될 것임.
6.6 절에서 전형적인 wait()
과 signal()
세마포어를 봤었음. POSIX의 경우 sem_wait()
과 sem_post()
연산으로 이를 수행함:
/* 세마포어 얻기 (입주) */
sem_wait(sem);
/* 임계구역 (실거주) */
/* 세마포어 해제/반환 (퇴거) */
sem_post(sem);
리눅스랑 macOS 둘 다 POSIX의 지명 세마포어 제공함.
sem_init()
함수로 생성 및 초기화. 세 개의 매개변수 전달 받음:
#include <semaphore.h>
sem_t sem;
/* 세마포어 생성 후 1로 초기화 */
sem_init(&sem, 0, 1);
플래그 0을 통해 세마포어가 오로지 이 세마포어를 생성한 프로세스에 속하는 스레드 간에만 공유 가능함을 의미. (0이 아닌 값을 넣어주면 세마포어가 서로 다른 프로세스의 공유 메모리 영역에 놓여져서 공유가 가능해질 수도 있음.)
익명 세마포어도 지명 세마포어처럼 sem_wait()
, sem_post()
연산 사용:
/* 세마포어 얻기 */
sem_wait(&sem);
/* 임계구역 */
/* 세마포어 해제/반환 */
sem_post(&sem);
상호배제 락과 동일하게 모든 세마포어 함수는 성공적이면 0을 반환하고 오류나면 0이 아닌 값을 반환함.
기존에 다룬 조건 변수는 모니터라는 맥락에서 다뤘음. Pthread는 C 프로그램 기반이고 C에는 모니터가 없으니 조건 변수를 상호배제 락으로 처리할 수 있음.
Pthread에서 조건 변수는 phread_cond_t
자료형을 사용하고, pthread_cond_init()
함수로 초기화:
pthread_mutex_t mutex;
pthread_cond_t cond_var;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond_var, NULL);
pthread_cond_wait()
함수로 조건 변수 대기:
pthread_mutex_lock(&mutex);
while (a != b) {
pthread_cond_wait(&cond_var, &mutex);
}
pthread_mutex_unlock(&mutex);
여기서 상호배제 락은 반드시 pthread_cond_wait()
함수 호출하기 전에 호출되어야 함. 왜냐면 이 락은 조건문의 자료를 경합 조건으로부터 지켜야 하니까. 락 얻었으면 조건을 확인해줌. 참이 아니면 pthread_cond_wait()
호출하여 상호배제 락과 조건 변수를 매개변수로 전달. 이러면 락을 해제해주어 다른 스레드가 공유 자료에 접근할 수 있게 해줌. 그럼 누군가가 이 자료에 접근해서 뭔가 하다가 조건문이 참이 되게 해줄 수도 있음. (프로그램 오류 방지를 위해 조건문을 루프 내에 넣어주어 신호 받은 뒤 조건을 다시 확인해줄 수 있게 해줘야하긴 함.)
공유 자료 수정한 스레드가 pthread_cond_signal()
호출하면 조건 변수에 대해 대기하던 스레드에게 신호를 보내줌:
pthread_mutex_lock(&mutex);
a = b;
pthread_cond_signal(&cond_var);
pthread_mutex_unlock(&mutex);
pthread_cond_signal()
호출한다고 해서 상호배제 락을 반환하는 건 아님. 그래서 pthread_mutex_unlock()
을 호출하고 나서 저걸 호출해야 함.