본문 바로가기
Java

[Thread] 뮤텍스, 세마포어, 모니터의 스레드 동기화와 구현 예제

by Jayson Jeong 2023. 6. 20.

 

1. 스레드(Thread)

싱글 스레드와 멀티 스레드

 

스레드는 프로그램 또는 프로세스 내에서 실제로 작업을 수행하는 주체를 말한다.

모든 프로세스에는 한 개 이상의 스레드가 존재한다. 둘 이상의 스레드를 동시에 실행시키는 방식을 멀티스레드 라고 한다.

 

1.1. Race Condition(경쟁 조건)

멀티 스레드 프로그래밍에서 자주 발생하는 문제로 두 개 이상의 프로세스 또는 스레드가 공유된 자원에 동시에 접근할 경우 접근 순서에 따라 실행 결과가 달라지는 상황이 자주 발생한다. 공유 자원의 데이터 변경 뿐만 아니라 변경된 공유 자원으로 인한 실행 실패까지도 발생하기도 한다. 

이러한 상황을 경쟁 조건(Race Condition) 또는 동시성 문제(Concurrency Problem)라고 한다.

즉, 여러 프로세스 또는 스레드가 동시에 같은 데이터를 조작할 때 타이밍이나 접근 순서에 따라 결과가 달라질 수 있는 상황이다.

 

1.2. Critical section(임계 영역)

경쟁 조건을 발생시키는 코드 블록 또는 그러한 영역을 임계 영역(Critical Section)이라고 하며

공유 데이터의 일관성을 보장하기 위해 하나의 프로세스 또는 스레드만 진입해서 실행 가능한 영역이라고 볼 수 도 있다.

 

1.3. 스레드의 상태

구분 상태 설명
NEW 생성 스레드 객체가 생성, 아직 start() 되지 않은 상태
RUNNABLE 실행 대기 실행 상태로 언제든지 갈 수 있는 상태
WATING 실행 대기 다른 스레드가 깨워줄 때 까지 기다리는 상태
TIME_WATING 일시 정지 주어진 시간 동안 기다리는 상태
BLOCKED 일시 정지 사용하고자 하는 공유 자원의 락이 풀릴 때까지 기다리는 상태
TERMINATED 종료 실행을 마친 상태

 

스레드 상태 제어

1.4. 스레드 관련 용어

구분 설명
Fairness(공정성) 둘 이상의 프로세스 또는 스레드들이 자원을 점유할 기회를 공정하게 제공하는 것. 작업할 기회를 공평하게 갖는 것
Starvation(기아 상태) 둘 이상의 프로세스 또는 스레드들이 자원을 점유하기 위해 경쟁할 때 특정 프로세스 또는 스레드가 영원히 자원을 점유하지 못하는 상태
Deadlock(교착 상태) 둘 이상의 프로세스 또는 스레드들이 서로 상대방이 사용중인 자원을 쓰기 위해 대기하고 있는 상태
Bottleneck(병목 현상) 둘 이상의 프로세스 또는 스레드들이 동시에 실행될 때 가장 느린 쪽의 속도에 맞추기 위해 전체 시스템이 느려지는 상황

 


2. 동기화(Synchronization)

둘 이상의 프로세스 또는 스레드들을 동시에 실행하더라도 타이밍이나 실행 순서에 상관 없이 항상 같은 실행 결과를 동일하게 유지하는 동기화(synchronization) 과정에서 임계 영역(Critical Section)에 접근하기 위해 다음과 같은 조건을 만족시켜 프로세스나 스레드의 동기화를 해 줄 수 있다.

 

  1. mutual exclusion(상호 배제) : 한 프로세스 또는 스레드가 임계 영역에서 실행하고 있으면 어떠한 프로세스 또는 스레드도 임계 구역에 진입할 수 없도록 하는 것.
  2. bounded waiting(한정 대기) : 임계 영역에 진입하고자 요청을 한 후부터 임계 구역에 진입할 때 까지 다른 프로세스가 임계 구역에 진입할 수 있는 횟수를 제한한다.
  3. progress(진행) : 임계 구역에서 실행중인 프로세스가 없고 일부 프로세스가 임계 구역에 진입하고 자 할 때 서로 진입을 양보하다가 아무도 진입하지 못하는 무한루프에 빠져서는 안 된다.

 

대표적인 동기화 기법으로 뮤텍스, 세마포, 모니터가 있다.

 


3. 뮤텍스(Mutex)

뮤텍스는 Mutual exclusion의 약자로 Lock 이라고도 한다.

동기화를 위한 방법으로 Lock을 활용하여 Lock이 되지 않은 공유 데이터에만 접근할 수 있고 공유 데이터를 다른 프로세스 또는 스레드에서 선점하여 Lock이 걸린 상황에선 Entry Queue에서 대기하며 Lock이 풀릴때까지 기다린 후 공유 데이터에 접근할 때 Lock을 발생시킨다. 

한 번에 하나의 스레드만 임계 영역에 접근 할 수 있다.

 

  • lock : 프로세스 또는 스레드가 임계 영역에 접근할 때 lock을 걸어 다른 프로세스 또는 스레드가 접근하지 못하게 함.
  • unlock : 작업 끝난 후 임계 영역에서 빠져나올 때 lock을 반환하여 다른 프로세스 또는 스레드가 lock을 획득할 수 있도록 함.
//synchronized 메소드를 이용한 구현
public class UsingMutex {
   private Object mutex = new Object();
   
   public void something(){
      synchronized(mutex) {
         ...
         ...
         ...
         
      }
   }
}

※synchronized 키워드를 이용한 동기화는 작업이 끝난 스레드가 lock을 반환할 때 다음으로 lock을 획득할 스레드가 어떤 스레드일지에 대한 보장이 없어 특정 스레드에 기아 상태가 발생할 수 있음.

 

이러한 문제를 해결하기 위해 synchronized가 아닌 Lock 객체를 적용할 수 있다. 대표적으로 ReentrantLock이 있다.

  • synchronized와 달리 수동으로 lock을 잠그고 해제해야 함.
  • 암묵적인 락으로 해결할 수 없는 코드가 단일 블록 형태를 넘어서는 복잡한 경우에도 사용 가능
  • 타임 아웃을 지정할 수 있음
  • Condition을 적용해서 대기 중인 쓰레드를 선별적으로 깨울 수 있음
  • lockInterruptably()를 이용해 lock 획득을 위해 waiting pool에 있는 쓰레드에게 인터럽트를 걸 수 있음
//ReentrantLock 클래스를 이용한 구현
public class UsingReentrantLock {
    
    private ReentrantLock lock = new ReentrantLock();

    public void something() {
        try {
            lock.lock();
            ...
            ...
            ...
        } finally {
            lock.unlock();
        }
    }
}

 


4. 세마포어(Semaphore)

 

semaphore

 

뮤텍스는 lock을 기반으로 mutex lock을 보유한 프로세스(또는 스레드)만 임계 영역에 접근할 수 있고, 작업이 끝나면 lock을 반환하여 대기 중인 프로세스(또는 스레드)가 lock을 취득한 후 임계 영역에 접근하는 방식이지만

→ key를 기반으로 한 상호 배제 방식

 

세마포어는 Signaling mechanism(신호 전달 메커니즘). 즉, 현재 공유 자원에 접근할 수 있는 프로세스(또는 스레드)의 수를 나타내는 값을 두어 그 수를 만족하는 경우에만 임계 영역에 접근할 수 있도록 하는 상호 배제 방식이다.

 

세마포어는 공유 자원에 접근할 수 있는 프로세스(또는 스레드)의 최대 허용치 만큼 동시에 임계 영역에 접근 할 수 있다.

세마포어에는 이진 세마포어(Binary Semaphore), 계수형 세마포어(Counting Semaphore)가 있다.

 

  • Binary Semaphore(이진 세마포어) : 임계 영역에 접근할 수 있는 값을 0과 1만 가지는 세마포어이다. 세마포어가 0이면 임계 영역에 접근할 수 없고(잠금 상태), 1이면 접근할 수 있다.(잠금 해제)
  • Counting Semaphore(계수형 세마포어) : 0과 1 뿐만 아니라 더 큰 limit를 사용하여 limit 만큼 임계 영역에 접근할 수 있다.

 

세마포어에서 임계 영역에 접근 가능한 프로세스(또는 스레드)의 수를 증감하여 임계 구역 접근 권한을 취득 및 반환하는 동작은 다음과 같다.

  • wait(acquire) : 임계 영역에 진입함을 나타내며, 진입이 불가능할 경우 스레드 자신을 큐에 담은 후 대기 상태로 전환한다.
  • signal(release) : 임계 영역에서 작업이 끝났을 때 세마포어의 값을 접근 가능하도록 변경한다. 큐에서 대기 중인 스레드들 중 하나를 실행한다. 

 

public class UsingSemaphore {

   private Semaphore semaphore;
   
   public UsingSemaphore(int limit){
      semaphore = new Semaphore(limit);
   }
   
    public void something() {
        try {
        
            semaphore.acquire();
            ...
            ...
            ...
        } catch (InterruptedException e) {
            // exception handling code
            
        } finally {
            semaphore.release();
        }
        
    }
}

 

뮤텍스와 세마포어의 차이

뮤텍스 세마포어
잠금 메커니즘을 기반으로 동작 신호 전달 메커니즘을 기반으로 동작
동기화 대상이 오직 한 개 일 때만 가능하다. 동기화 대상이 하나 이상일 경우도 가능하다.
락을 획득한 스레드만이 락을 해제할 수 있다. 현재 스레드보다 우선순위가 높은 스레드가 세마포어를 잠그거나 해제할 수 있다. 
락을 소유한 스레드만 잠금을 해제할 수 있으므로 소유권이 있다. 다른 스레드를 깨울 수 없다. 스레드 자신이 변경한 세마포어의 값으로 인해 다른 스레드가 접근 권한을 획득 할 수 있으므로 소유권이 없다. 또한 그렇기 때문에 다른 스레드를 깨울 수 있다.
뮤텍스는 priority inheritance를 가진다. 즉 프로세스나 스레드의 우선 순위에 따라 실행한다. priority inheritance가 없다. 우선 순위 역전이 발생할 수 있다. 대신 방법을 이용해 작업 간의 실행 순서를 동기화 할 수 있다.
뮤텍스는 세마포어가 될 수 없다. 세마포어는 뮤텍스가 될 수 있다.

※ priority inheritance 속성을 통해 CPU에서 context switching을 통해 실행시킬 주체에 대해 스케줄링을 진행할 때 스레드나 프로세스의 우선 순위에 따라 실행시킬 수 있다.

세마포어는 riority inheritance가 없기때문에 우선순위 역전이 발생할 수 있다. 우선순위 계승 프로토콜, 우선순위 상한 프로토콜을 통해 우선 순위에 따라 실행할 수 있다.

 


5. 모니터(Monitor)

모니터는 임계 영역에 대한 Mutual exclusion 보장과 공유데이터에 접근할 수 있는 조건을 동시에 적용하여 동기화하는 방식을 말한다. 스레드가 어떤 공유데이터에 접근하는 지 모니터링 할 수 있기 때문에 모니터라고 부른다.

모니터는 대부분 프레임워크나 라이브러리 자체에서 제공하기 때문에 프로그래밍 언어 차원에서 동시 접근과 공유 데이터에 대한 접근을 자동으로 해결시켜 준다. 때문에 C언어에서는 모니터가 없고 Java언어는 모니터가 있다. 

 

모니터는 본질적으로 공유 데이터를 캡슐화하고 일련의 절차를 통해 해당 데이터에 대한 액세스를 제공하는 모듈이며

모니터는 mutex와 condition variable로 구성되어 있다. 

 

  • Mutex Lock : 임계 영역(critical section)에 접근할 수 있는 권한으로 mutual exclusion을 보장하기 위해 사용한다.
  • Entry Queue : mutex lock 을 획득하지 못한 스레드가 대기하는 큐
  • Condition Variable : waiting queue를 갖고 있으며 스레드를 waiting queue에 넣거나(wait) 꺼내는(signal) 역할을 수행
  • Wating Queue : condition variable 에서 관리하는 큐로 공유 데이터 접근 조건이 충족되지 않았을 때 스레드가 대기하는 큐

 

모니터의 condition variable의 주요 동작은 다음과 같다.

 

  • wait : 스레드 자기 자신을 condition variable의 wating queue에 넣고 대기 상태로 전환한다
  • notify(signal) : wating queue에서 대기 중인 스레드 중 하나를 깨운다.
  • notifyAll(broadcast) : waiting queue에서 대기 중인 스레드를 전부 깨운다.

 

모니터는 다음과 같은 절차를 통해 공유 데이터에 대한 액세스를 제공한다.

 

  1. 임계 영역(critical section)에 접근하기 위한 mutex lock을 취득
  2. mutex lock을 취득하지 못 하면 enrty queue에 들어간 후 대기 상태로 전환
  3. mutex lock을 쥔 스레드가 lock을 반환하면 enrty queue에 대기 중인 스레드 중 하나를 실행
  4. 공유 데이터 접근 조건이 충족되지 않았다면 waiting queue에 넣고 대기 상태로 전환
  5. 접근 조건이 충족되어 작업이 끝난 스레드가 wating queue에서 대기 중인 스레드 중 하나를 깨움

 

그래서 모니터는 Mutual exclusion을 보장 받을 수 있으며 여러 스레드와의 협업(Cooperation)이 가능하다.

 

자바의 모든 객체는 내부적으로 모니터를 갖으며, 자바의 모니터는 condition variable 한 개를 갖는다. 

※모니터의 condition variable은 꼭 한 개일 필요는 없다. 한 개 이상의 condition variable을 적용할 수 있다.

 

자바에서는 synchronized 메소드가 선언된 객체와 synchronized 블럭에 의해 동기화되는 모든 객체에 모니터가 결합이 되어 동기화 작업을 수행한다.

public class Buffer{
   
   private final int[] buffer = new int[5];
   private int count = 0;
   
   public synchronized void produce(int item) throws InterruptedException {
   
      while (buffer_full()){
         wait();
      }

      buffer[size++] = item;
      notifyAll();

    }
   
   public void consume() throws InterruptedException {
      int item;
      synchronized (this){
      
         while (buffer_empty()){
            wait();
         }

         item = buffer[--size];
         notifyAll();
      }
   }
  
  
   public boolean buffer_full(){
      return size == 5;
   }

   public boolean buffer_empty(){
      return size == 0;
   }
  
}

 

※ 하지만 이러한 방식은 컴파일러가 모니터에 대한 코드를 생성해야 한다. 이로 인해 컴파일러는 동시 프로세스의 중요 섹션에 대한 엑세스를 제어하기 위해 어떤 운영체제 기능을 사용할 수 있는지 알아야 하는 등 추가적인 부담을 갖게 된다. 

이러한 문제를 해결하기 위해 java.util.concurrent에서 제공하는 기능을 이용해 어느정도 나마 개선할 수 있다. 또한 보다 유연하게 모니터 패턴을 사용할 수 있다.

 

import java.util.concurrent.locks.*;

public class buffer {
   private ReentrantLock monitorlock = new ReentrantLock();
   private Condition items_available = monitorlock.newCondition();
   private Condition slots_available = monitorlock.newCondition();

   public void produce(item i) {
      monitorlock.lock();
      try {
         while (buffer_full()){
            slots_available.await();
         }
         buffer[head++] = i;
         items_available.signal();
         
      } finally {
         monitorlock.unlock();
      }
      
   }
   
   public void consume(){
      monitorlock.lock();
      try {
         while(buffer_empty()){
            items_available.await();
         }
         
         item i = buffer[--head];
         slots_available.signal();
      
      } finally {
         monitorlock.unlock();
      }
   }
}