1. Classic Problems of Synchronization
- The bounded-buffer problem
- The readers-writers problem
- The dining-philosophers problem
1) The Bounded-Buffer Problem
두 종류의 프로세스가 있다. 하나는 Producer, 또 다른 하나는 Consumer이다. 이는 생산자-소비자 문제(Producer-Consumer Problem)이라고도 불린다. 여기서 발생할 수 있는 문제점은 어느 producer가 버퍼 중 빈 곳에 데이터를 쓰려고 할 때, interrupt가 발생하여 다른 프로세스한테 공유자원이 넘어가서 다른 프로세스가 해당하는 빈 곳에 데이터를 쓸 때 나타날 수 있다. 그렇게 되면 둘 중 한 프로세스의 데이터가 유실될 가능성이 있다는 것이다.
- 만약 buffer가 꽉 찼다면 producer는 consumer가 item을 삭제할 때까지 기다려야 한다.
- producer는 buffer 안에 empty space가 필요하다.
- # of empty slot 은 semaphore empty에 의해 나타나진다.
- 만약 buffer가 비었다면 consumer는 producer가 item을 넣을 때까지 기다려야 한다.
- consumer는 buffer 안에 item이 필요하다.
- # of item은 semaphore full에 의해서 나타나진다.
- Producer-consumer problem with bounded buffer
- Semaphores: full = 0, empty = n, mutex = 1;
- 버퍼의 개수 n, binary semaphore : mutex, counting semaphore: empty, full
//Producer
while(true){
...
/* produce an item in next_produced(새로운 item 하나 생성) */
...
wait(empty) // empty 는 empty-1이 된다. empty가 1~n 값중에서 시작했다면 다음줄로 넘어감.
//만약 empty가 0이었다면 -1이 되어 block됌
wait(mutex)
...
/* add next produced to the buffer(버퍼에 새로 생성된 item을 추가) */
...
signal(mutex); //버퍼에 item을 추가했으면,
//binary semaphore의 signal 함수를 호출하여 critical section의 탈출을 알린다.
signal(full); //signal(full)을 호출해서 0의 초기값을 count++를 통해 1로 만들어준다.
}
//Consumer
while(true){
wait(full); // 데이터를 꺼내기 위해 full 자원을 얻는다.
//버퍼에 item이 한 번도 안들어갔다면 0은 -1이 되어 block에 걸린다.
wait(mutex); //full 자원이 있다면 lock을 걸기 위해 mutex를 실행한다.
...
/* remove an item from buffer to next_consumed(해당 버퍼에서 item을 꺼냄) */
...
signal(mutex); //item을 빼온 이후 signal 함수를 호출하여 critical section 탈출을 알린다.
signal(empty); //signal(empty)를 호출해 데이터가 채워져 있는 버퍼의 공간 개수 하나를 뺀다.
...
/* consume the item in next consumed(item을 소비함) */
...
}
2) The Readers-Writers Problem
Readers와 Writers는 하나의 공유하는 데이터베이스를 가지고 있다. Readers는 동시에 데이터베이스에 접근할 수 있지만, 한 writer가 shared data에 접근할 때, 어떤 thread도 그것에 접근할 수 없다.
- Behavior of a writer
- 만약 한 thread가 critical section에 있을때, 모든 writers은 무조건 기다려야 한다.
- writer은 오직 어떤 thread도 critical section에 있지 않을 때에만 진입할 수 있다.
- 모든 thread들이 critical section에 진입하는 것으로부터 이를 예방해야 한다.
- Behavior of a reader
- 만약 어떤 writer도 critical section에 있지 않으면, 그 reader는 critical section에 들어갈 수 있다.
- 만약 어떤 writer가 critical section에 있다면, reader는 그 writer가 critical section에 빠져 나올 때까지 기다려야 한다.
- reader가 critical section에 있을 때, 아무 reader나 critical section에 진입할 수 있다. 하지만 writer은 불가능하다.
- 첫번째 reader의 condition은 뒤따라오는 reader들과 다르다.
//Shared data
semaphore mutex = 1;
semaphore rw_mutex = 1;
int read_count = 0;
//Writer
while(true){
//writer은 공유 자원에 접근하면 다른 모든 프로세스들의 접근을 차단함.
//binary semaphore로 다른 프로세스들의 critical section 진입을 일체 차단함.
wait(rw_mutex);
...
/* writing is performed(writing 수행) */
...
signal(rw_mutex);
}
//Reader
while(true){
//Reader에서는 Writer의 접근만 막을 뿜, Reader가 몇 명이 접근하는지는 신경쓰지 않음.
wait(mutex); // read_count에 대한 mutual exclusion을 보장하기 위한 설정
read_count++; // read_count가 0이라면 1로 증가시킨다.
if(read_count == 1)// read_count가 1이라면 wait(rw_mutex)를 실행, 이를 통해 writer은 임계구역에 진입 못함.
wait(rw_mutex);//reader는 read_count를 증가시킬 뿐, wait(rw_mutex)를 호출하지 않으므로 임계구역에 그냥 진입.
signal(mutex);
...
/* reading is performed */
...
wait(mutex);
read_count--;
if(read_count == 0)//임계구역에 머물러 있는 Reader가 하나도 없을 경우 signal(rw_mutex)를 호출
signal(rw_mutex);//그럼으로써 writer가 공유 자원에 접근 가능하다.
signal(mutex);
}
3) The dining-philosophers problem(식사하는 철학자 문제)
'식사하는 철학자 문제'는 5명의 철학자가 식사를 하고 있는 상황을 가정한 문제이다. 5명의 철학자들이 원형의 테이블에 앉아 있는데 철학자들 사이에는 젓가락 한 짝만 놓여있고 철학자 앞에는 음식이 놓여있다. 철학자는 배가고프면 양 옆에 있는 젓가락을 사용해서 식사를 하고 다시 젓가락을 원위치에 두고 생각을 해야한다. 이때 생기는 문제는 한명이 음식을 먹고 있다면 양 옆에 앉은 철학자 두명은 배가 고파도 음식을 먹을 수 없는 상황이 온다. 따라서 젓가락에 대한 접근을 하는 것에 대한 문제 해결이 필요하다.
해결책은 deadlock-free와 starcation-free가 되어야 한다. 즉, 교착과 기아가 생기지 않게 하는 방법을 생각해야 한다.
- Semaphore 해결방법
- Shared data: Bowl of rice(dataset) / Semaphore chopstick[5] initialized to 1
- 아래 코드는 해결가능성이 있는 방법을 나타낸다. 하지만 deadlock이 발생할 수 있다.
while(true){
wait(chopstick[i]); //pick up left chopstick
wait(chopstick[(i+1) % 5]); //pick up right chopstick
/* eat for a while */
signal(chopstick[i]); //release left chopstick
signal(chopstick[(i+1) % 5]); //release right chopstick
/* think for a while */
}
Deadlock이 발생하는 경우는 어떤 경우일까?
- 5명의 철학자가 다같이 배고파서 다같이 동시에 자신 왼쪽의 젓가락을 잡는다면 chopstick의 모든 원소는 0이 된다. 결국 철학자는 오른쪽 젓가락을 영원히 잡을 수 없다. -> 이렇게 Deadlock이 발생
Starvation의 경우는?
- 가운데 사람이 껴서 양쪽의 사람만 밥을 먹고 가운데 사람은 밥을 먹지 못하는 starvation이 생길 수 있다.
- Deadlock을 해결할 수 있는 방법(아래 3가지 조건 중 하나만 추가하면 해결 가능하다. 하지만 이 방법은 starvation의 가능성을 업애는 것은 아니다.)
- 최대 4명의 철학자만이 테이블에 동시에 앉을 수 있게 한다.
- 한 철학자가 두 개를 모두 집을 수 있을 때만 젓가락을 집도록 한다.
- 비대칭 해결안을 사용한다. ex)홀수번째 철학자는 왼쪽 젓가락부터 집어야 하고 짝수번째 철학자는 오른쪽 젓가락부터 집어야 한다.
- Monitor solution
monitor DiningPhilosophers{
enum {THINKING; HUNGRY; EATING} state[5];
condition self[5]; // shared data의 영역에서 프로세스의 대기상태를 설정하기 위한 변수
void pickup(int i){
state[i] = HUNGRY; // 젓가락을 들면 해당 철학자의 상태는 배고픈 상태로 설정
test(i);
if(state[i] != EATING)
self[i].wait; // 다른 철학자가 eating하고 있어서 해당 state의 상태를 wait로 설정
}
void putdown(int i){
state[i] = THINKING; // 해당 철학자는 다시 상태를 생각하는 것으로 설정
//test left and right neightbors
test((i+4) % 5); // 왼쪽 철학자에게 시그널을 보낸다.
test((i+1) % 5); // 오른쪽 철학자에게 시그널을 보낸다.
}
void test(int i){
if ((state[(i+4) % 5] != EATING) &&
(state[i] ==HUNGRY) &&
(state[(i+1) % 5] != EATING)) { // 왼쪽 철학자와 오른쪽 철학자가 생각하는 상태이고 나의 상태가 배고픈 상태일때
state[i] = EATING; // 나의 상태를 먹는 상태로 전환
self[i].signal(); // 해당 코드는 putdown에서 사용 가능
}
}
void initialization_code(){
for(int i = 0; i<5; i++)
state[i] = THINKING;
}
}
monitor를 이용한 dining philosophers problem
- 각각의 철학자 i는 아래와 같이 pickup()과 putdown() operation을 실행한다.
DiningPhilosopher.pickup(i)
...
/** EAT **/
...
DiningPhilosopher.putdown(i);
이 해결법은 Deadlock은 발생하지 않지만 starvation은 일어날 수 있다.
2. Synchronization within the Kernel
이 부분에서는 linux에 의해 제공되는 동기화 메커니즘에 대해서 설명한다.
- Synchronization in Linux
- Linux : 2.6 버전 이전에는 비선점형 커널이었다. 이는 kernel mode에서 작동하는 한 프로세스가 선점될 수 없다는 것을 의미한다. 하지만 지금은 linux kernel은 완전히 선점형이다.
- Linux는 Semaphores(kernel에서 locking을 위해), atomic integers(수학연산을 위한), spinlocks(kernel에서 locking을 위해), 그리고 reader-writer version of both을 제공한다.
- Atomic variables
- 컴퓨터 아키텍쳐가 간단한 수학 연산을 위해 atomin versions에 대해 명령어를 제공하기 때문에, Linux kernel 내에서 가장 간단한 동기화 기법
- atomic_t는 atomic integer의 타입이다.
- 모든 math operation은 interruption없이 수행된다.
- ex) atomic_t counter; int value; // 아래의 코드는 atomic integer counter를 선언하고 다양한 atomic연산을 수행하는 것을 보여준다.
- Mutex locks
- kernel 내의 critical section을 보호하기 위해서 mutex lock을 사용하는 것이 linux에서 가능하다.
- int mutex_init(struct mutex *lock); // initialize the mutex
- int mutex_lock(struct mutex *lock); // acquire the mutex, critical section에 들어가기 전에 꼭 호출해야만 한다. 만약 mutex lock이 이용가능하지 않다면 mutex_lock()을 호출한 task는 sleep state에 들어가고, lock의 주인이 mutex_unlock()을 부를 때 깨어난다.
- int mutex_unlock(struct mutex *lock); // Release the mutex, must be invoked after exiting the critical section, If the mutex lock is unavailable, a task calling mutex_lock() is put into sleep state and is awakened when the lock's owner invokes mutex_unlock()
- Spinlocks(spinlock은 다른 스레드가 lock을 소유하고 있다면 그 lock이 반환될 때까지 계속 확인하며 기다리는 것이다)
- spin_lock(), spin_unlock(), etc.
- SMP machines에서, 기본 locking mechanism은 spinlock이고, 그 kernel은 spinlock이 짧은 시간동안만 보유되도록 설계된다.
- 오직 단일 프로세싱 코어를 가진 임베디드 시스템 같은 단일 프로세서 머신에선 spinlock을 사용하기엔 부적절하고, kernel 선점을 활성화/ 비활성화 하여 대체된다.
- Enabling/disabling kernel preemption
- Linux는 kernel 선점을 비활성화하고, 활성화 하는데 흥미로운 접근법을 사용한다. 그것은 커널 선점을 비활성화 하고 활성화 하는데 간단한 system calls을 사용한다.
- preempt_disable(), preempt_enable()
Single Processor | Multiple Processors |
Disable kernel preemption | Acquire spin lock |
Enable kernel preemption | Release spin lock |
3. POSIX Synchronization
POSIX API는 1) Mutex locks 2) Semaphores 3) Condition variable을 제공한다.
UNIX, Linux, macOS에서 널리 사용된다.
1) POSIX Mutex Locks
- Creating and initializing the lock
#include <pthread.h>
pthread_mutex_t mutex; // global declaration
//create and initialize the mutex lock
pthread_mutex_init(&mutex, NULL); //call before first lock
or
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
- Acquiring and releasing the lock
pthread_mutex_lock(&mutex); // acquire the mutex lock
/* critical section */
pthread_mutex_unlock(&mutex); // release the mutex lock
2) POSIX Semaphores
- Named semaphores
- Multiple unrelated processes는 common semaphore를 쉽게 사용할 수 있다.
- Creating and initializing the lock
#include <semaphore.h>
sem_t *sem; // global declaration
//create the semaphore and initialize it to 1
sem = sem_open("SEM", O_CREAT, 0666, 1);
- Acquiring and releasing the lock
sem_wait(sem); // acquire the semaphore
/* critical section */
sem_post(sem); // release the semaphore
- Unnamed semaphores
- Creating and initializing the lock
#include <semaphore.h>
sem_t sem; // global declaration
//Create the semaphore and initialize it to 1
sem_init(&sem, 0, 1);
- Acquiring and releasing the lock
sem_wait(&sem); // acquire the semaphore
/* critical section */
sem_post(&sem); // release the semaphore
3) POSIX Condition Variables
POSIX는 일반적으로 C/C++에서 사용되고 이러한 언어는 모니터를 제공하지 않는다.
POSIX condition variables은 POSIX mutex lock과 연관되어 mutual exclution을 보장한다.
- Creating and initializing
pthread_mutex_t mutex;
pthread_cond_t cond_var;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond_var, NULL);
- Example
- 조건 a==b가 true가 되는 것을 기다리는 스레드
pthread_mutex_lock(&mutex);
while(a!=b)
pthread_cond_wait(&cond_var, &mutex);
pthread_mutex_unlock(&mutex);
- condition variable에 대기중인 다른 스레드에게 시그널을 보내는 스레드
pthread_mutex_lock(&mutex);
a = b;
pthread_cond_signal(&cond_var);
pthread_mutex_unlock(&mutex);
4. Synchronization in Java
Java는 가장 많은 synchronization feature을 제공한다.
- Java monitors
- Reentrant locks
- Semaphores
- Condition variables
1) Java Monitors
Java의 모든 객체는 lock을 가지고 있다.
먼저 아래 코드를 보자
public class BoundedBuffer<E>
{
private static final in BUFFER_SIZE = 5;
private int count, in, out;
private E[] buffer;
public BoundedBuffer(){
count = 0;
in = 0;
out = 0;
buffer = (E[]) new Object[BUFFER_SIZE];
}
//Producers call this method
public synchronized void insert(E item){
while(count == BUFFER_SIZE){
try{
wait();
}catch{
InterruptedExceprion ie
}
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++;
notify();
}
}
//Consumers call this method
public synchronized E remove(){
E item;
while(count == 0){ //버퍼가 비어있으면
try{
wait();
}catch{
InterruptedException ie
}
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
notify();
return item;
}
}
}
- 만약 한 method가 synchronized로 선언되어있다면, 그 method를 호출한 스레드가 그 object에 대한 lock을 얻어야 한다.
- 만약 다른 스레드가 그 lock을 실행하고 있다면, 호출한 스레드는 그 스레드가 끝날때까지 대기해야 한다.
- Locks are released when the owning thread exits the synchronized method
- 이용불가능한 lock을 얻길 시도하는 스레드는 객체의 entry set에 들어간다.
- 만약 lock을 가진 스레드가 계속할 수 없으면 wait()를 호출한다.
- 한 스레드가 wait()함수을 호출했을 때:
- 그 스레드는 객체에 lock을 푼다.
- 그 스레스의 상태가 '차단'으로 설정된다.
- 그 스레드는 wait set으로 배치된다.
- 한 스레드가 wait()함수을 호출했을 때:
- 스레드는 대기하고 있는 스레드를 깨우기 위해 notify()를 호출 할 수 있다.
- 한 스레드가 notify()를 호출했을 때:
- 임의의 스레드 T가 wait set으로 부터 선택된다.
- T는 wait set에서 entry set으로 이동한다.
- T의 상태는 차단됨에서 runnable상태로 바뀜
- 그러면 T는 이제 condition이 true인지 확인하는 lock들과 경쟁할 수 있다.
- 한 스레드가 notify()를 호출했을 때:
2) Java Reentrant Locks
Reentrant Lock은 mutex로 생각하면 된다.
Lock key = new ReentrantLock();
key.lock();
try{
//critical section
}finally{
key.unlock();
}
여기서 finally 부분은 예외가 try block에서 발생한 경우에 lock이 release 될 것이라는 것을 보장한다.
3) Java Semaphores
- Constructor
Semaphore(int value);
- Usage
Semaphore sem = new Semaphore(1);
try{
sem.acquire();
//critical section
}
catch(InterruptedException ie){ }
finally{
sem.release();
}
4) Java Condition Variables
- Condition variables은 ReentrantLock과 관련이 있다.
- ReentrantLock의 'newCondition()' method를 이용하여 condition variable을 만들 수 있다.
Lock key = new ReentrantLock();
Condition condVar = key.newCondition();
- 한 스레드는 'await()'라는 메소드를 호출함으로써 대기하며, 'signal()' 메소드를 호출함으로써 신호를 보낸다.
- Example
- 0~4까지의 5가지 스레드가 존재한다고 하자.
- Shared variable인 'turn'이 존재한다. 이는 어떤 스레드의 순서인지 가리킨다.
- 스레드는 어떤 작업을 하고 싶을 때, dowork()를 호출한다. (하지만 이것은 오직 그 스레드의 순서일때만 그 스레드가 작업을 할 수 있다. )
- 만일 그 스레드의 turn이 아니라면 대기한다.
- 만약 그 스레드의 turn이라면, 잠시 그 일을 수행한다.
- 그 일이 끝났다면, 그 턴의 다음인 스레드에게 notify한다.
- 필수적인 data structure들은 아래와 같다.
Lock key = new ReentrantLock();
Condition[] condVars = new Condition[5];
for(int i = 0; i < 5; i++)
condVars[i] = lock.newCondition();
public void doWork(int threadNumber){
lock.lock();
try{
//If it's not my turn, then wait until I'm signaled
if(threadNumber != turn)
condVars[threadNumbers].await();
//Do some work for awhile...
//Now signal to the next thread
turn = (turn + 1) % 5;
condVars[turn].signal();
}
catch(InterruptedException ie){ }
finally{
lock.unlock();
}
}
5. Alternative Approaches(대안적 접근)
- Memory transaction
- 불러오기와 저장하기 명령(a sequence of memory read-write operations)의 집합이 atomic하게 수행될 수 있게 함으로서 병행성 프로그래밍을 단순하게 하는 방식이다.
- tramsaction은 atomic{s}( S에 있는 statement가 atomic하게 실행되도록 보장해주는) 를 추가해줌으로써 실현될 수 있다.
//Tranditional implementation
void update(){
acquire();
/* modify shared data */
release();
}
//Memory transaction-based implementation
void update(){
atomic{
/* modify shared data */
}
}
- OpenMP(Open Multi-Processing)
- 공유 메모리 다중 처리 프로그래밍 API로 "#pragma omp critical"을 포함된 코드는 critical section으로 다뤄지며 atomic하게 수행된다.
void update(int value){
#pragma omp critical
{
count += value;
}
}
'CS > Operating System' 카테고리의 다른 글
[Operating System] 운영체제 Caching [2] (0) | 2024.03.09 |
---|---|
[Operating System] 운영체제 Caching [1] (0) | 2024.03.07 |
[운영체제] Chapter6. Synchronization Tools(동기화) (0) | 2022.05.30 |