백과 블로그
홈블로그소개

백과의 개인 블로그 입니다. Contact : ksu9801@gmail.com

운영체제Java

프로세스&스레드의 공유자원 동기화

백과
2025년 7월 10일
5분 읽기
목차
📚 프로세스&스레드의 공유 자원 동기화
✅ 동기화 이슈 이해하기
📌 용어 정의
📌 동기화 문제 예시
📌 Java 코드 예시
📌 레이스 컨디션 극복
✅ 동기화 기법
📌뮤텍스 락
📌 세마포
📌 모니터 ( 조건 변수 )
📌 모니터
📌 비교
✅ 스레드 안전
쓰레드 안전(thread safe) 란?

목차

📚 프로세스&스레드의 공유 자원 동기화
✅ 동기화 이슈 이해하기
📌 용어 정의
📌 동기화 문제 예시
📌 Java 코드 예시
📌 레이스 컨디션 극복
✅ 동기화 기법
📌뮤텍스 락
📌 세마포
📌 모니터 ( 조건 변수 )
📌 모니터
📌 비교
✅ 스레드 안전
쓰레드 안전(thread safe) 란?

image

📚 프로세스&스레드의 공유 자원 동기화

기본적으로 자바 코드를 기반으로 설명됩니다.

✅ 동기화 이슈 이해하기


📌 용어 정의

  • 프로세스와 스레드는 공유 자원을 사용해서 소통(통신)을 할 수 있다. 관련글
  • 공유 메모리, 전역 변수, 입출력 장치 등이 공유 자원이라고 한다.
  • 이 공유 자원에 여러 프로세스 or 스레드가 접근하는 경우, 실행에 문제가 될 수 있습니다.
  • 이때 공유 자원에 접근하는 코드 중, 문제가 발생할 수 있는 코드가 임계 구역(critical section) 이라고 한다.
  • 2개 이상의 프로세스 or 스레드가임계 구역의 코드를 실행해 문제가 발생하는 것을 레이스 컨디션 이라고 한다.

📌 동기화 문제 예시

  • 다음과 같은 공유 자원에 접근해 +1 증가 연산을 하는 작업 (쓰레드) 2개가 있습니다.

image

  • 이 작업은 동시에 요청되어서 진행된다.
  • 따라서 다음과 같은 동시에 진행될 것이다.

실제로는, 엄청 미세한 타이밍으로 엄청 빠르게 처리하기 때문에 사진과 완전 동일하게 처리한다고 할 수는 없다. 설명을 위한 예시

image

  • 증가를 하는 작업이 두번 (A, B 한번씩) 실행 되었기 때문에, 작업 요청자는 val = 2 라고 생각하겠지만, 1이 나오게 된다.
  • 이유는 간단하다, 둘다 val을 메모리에서 읽을때, 0으로 읽고 증가하여 저장하였기 때문이다.
  • 용어와 함께 정리하면, 작업A, B는 공유자원인 val에 접근하는데, 읽고 증감하여 저장하는 임계 구역의 코드가 문제되어 레이스 컨디션이 발생 하였다.

image

📌 Java 코드 예시

더 극단적으로 동기화 문제를 보여주기 위해서, 하나의 스레드는 공유 데이터 static int count 를 10,000 만큼 증가 시키도록 구성하였다.

Counter

public class Counter implements Runnable {
 
	@Override
	public void run() {
		for(int i=0; i<100_000; i++) {
			Main.count = Main.count + 1;
		}
	}
}

Main

public class Main {
	public static int count = 0;
	public static final int THREAD_COUNT = 2;
 
	public static void main(String[] args) throws InterruptedException {
		Counter counter = new Counter();
		ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
 
		for(int i=0; i<THREAD_COUNT; i++) {
			executor.submit(counter);
		}
		executor.shutdown();
 
		while (!executor.isTerminated()) {
			Thread.sleep(10);
		}
 
		System.out.println("최종 val 값: " + count);
	}
}

결과

최종 val 값: 133684 //이 값은 랜덤임

📌 레이스 컨디션 극복

  • 이러한 레이스 컨디션문제는 멀티쓰레드 환경의 구조를 가지고 있는 여러 개발 환경에서 문제가 된다.
  • 즉, 이러한 문제를 해결하기 위해서는 임계 구역을 정의하고 해당 구역은 순차적으로실행 되게 해야 한다.
  • 즉, 프로세스 or 스레드 간 동기화(synchronization)을 진행해줘야 한다.
  • 이러한 동기화를 하는 여러가지 기법을 소개한다.

✅ 동기화 기법


📌뮤텍스 락

  • 뮤텍스 락(mutex lock)은 동시에 접근해서는 안되는 자원에 동시 접근이 불가능하도록 상호 배제를 보장하는 동기화 도구를 말합니다.
  • 공유 자원에 접근 가능한 작업은, 해당 자원의 열쇠(락 lock)을 가져야 접근이 가능하도록 하는 것이 핵심 아이디어 이다.
  • 즉 두 가지 원칙을 지켜 접근을 제어 한다.
  1. 임계 영역에 접근하고자 한다면 반드시 락(Lock)을 획득(acquire) 해야 한다
  2. 임계 구역에서의 작업이 끝났다면 락을 해제(release) 해야 한다.
  • 전형적으로 뮤텍스 락은, 락 변수 하나와, 두개의 함수(락 획득, 락 반납)으로 구성되어있다.
  • 이때 acquire() 함수는 락 획득을 위한 함수, release()는 락 반납을 위한 함수이다.
  • 어떤 한 쓰레드가 acquire()을 시도하는데, 이미 다른 쓰레드에서 공유 자원의 락을 가지고 있다면, 락 획득을 할때 까지 대기 한다.

당연히, 무한 대기를 하게 된다면 의미 없이 쓰레드가 계속 자원을 소모하게 되어 낭비가 된다. 보통 시간 혹은 횟수를 제한 두어 timeout 등을 발생시킨다.

  • 다양한 프로그래밍 언어에서는 acquire()와 release()를 제공하고 있어, 직접 구현할 필요가 없다.

  • Java 에서는, ReentrantLock을 통해 뮤텍스 락을 지원한다.

  • 다음은 Java의 ReentrantLock을 활용하여 뮤텍스 락을 구현한 모습이다. MutexCounter

public class MutexCounter implements Runnable{
	private final ReentrantLock lock;
 
	public MutexCounter(ReentrantLock lock) {
		this.lock = lock;
	}
 
	@Override
	public void run() {
		for (int i = 0; i < 100_000; i++) {
			lock.lock();
			try {
				Main.count = Main.count + 1;
			} finally {
				lock.unlock();
			}
		}
	}
}

Main

public class Main {
	public static int count = 0;
	public static final int THREAD_COUNT = 2;
 
	public static void main(String[] args) throws InterruptedException {
		ReentrantLock lock = new ReentrantLock();
		MutexCounter counter = new MutexCounter(lock);
		ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
 
		for(int i=0; i<THREAD_COUNT; i++) {
			executor.submit(counter);
		}
		executor.shutdown();
 
		while (!executor.isTerminated()) {
			Thread.sleep(10);
		}
 
		System.out.println("최종 val 값: " + count);
	}
}

결과

최종 val 값: 200000

📌 세마포

  • 세마포는, 한번에 여러개의 작업이 까지 공유 자원에 접근하는 것을 허용하는 방법 입니다.
  • 뮤텍스와 유사하게 변수 하나와 함수 두개 (wait(), signal()) 을 사용한다.
  • 자바에서는 Semaphore 를 직접적으로 지원해주고, wait(), signal() 과 동일한 기능을 하는 acquire(), release()를 제공한다.

Main

public class Main {
	public static int count = 0;
	public static final int THREAD_COUNT = 2;
 
	public static void main(String[] args) throws InterruptedException {
		Semaphore semaphore = new Semaphore(1);
		SemaphoreCounter counter = new SemaphoreCounter(semaphore);
		ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
 
		for(int i=0; i<THREAD_COUNT; i++) {
			executor.submit(counter);
		}
		executor.shutdown();
 
		while (!executor.isTerminated()) {
			Thread.sleep(10);
		}
 
		System.out.println("최종 val 값: " + count);
	}
}

SemaphoreCounter

public class SemaphoreCounter implements Runnable {
	private final Semaphore semaphore;
 
	public SemaphoreCounter(Semaphore semaphore) {
		this.semaphore = semaphore;
	}
 
	@Override
	public void run() {
		for (int i = 0; i < 100_000; i++) {
			try {
				semaphore.acquire();
				Main.count = Main.count + 1;
			} catch (InterruptedException e) {
				throw new RuntimeException(e);
			} finally {
				semaphore.release();
			}
		}
	}
}

📌 모니터 ( 조건 변수 )

  • 모니터 기법은, 먼저 조건 변수를 이해해야 한다.
  • 조건 변수란, 실행 순서를 제어 하기 위한 동기화 도구이다.
  • 즉, 조건 변수는 지금 살펴보고 있는 동기화 기법이 아닌, 스레드 동기화를 위한 고급 기능이다.
  • 기존의 세마포 방식과 뮤텍스방식은 락이 획득 가능한가 만 판단하여 처리를 하였다면, 조건 변수는 signal(), wait(), notify() 등을 통해 특정 조건을 통해 대기/실행 상태를 변경해주는 고급 동기화 도구다
    • wait() : 호출한 프로세스 및 스레드의 상태를 대기 상태로 전환하는 함수
    • signal() : wait()로 일시 중지된 프로세스 및 스레드의 실행을 재개하는 함수

image

  • 가장 잘 사용되는 예시는 생산자 소비자 패턴이다. 대표적인 예가 생산자-소비자 패턴이다.
    • 생산자는 버퍼에 공간이 있을 때만 데이터를 추가하고, 공간이 없으면 대기한다.
    • 소비자는 버퍼에 데이터가 있을 때만 꺼내고, 데이터가 없으면 대기한다.
  • 이런 경우, 반복적으로 buffer의 현황을 보면서 무한반복 돌 수도 있지만, 조건 변수를 활용하여 실행 순서를 제어하면 정확히 필요한 순간에만 쓰레드를 깨워 효율적으로 동기화가 가능하다.
  • java 에서는, Condition 을 통해 조건 변수를 사용할 수 있다.

ConditionExample

public class ConditionExample {
	private final Queue<Integer> buffer = new LinkedList<>();
	private final int CAPACITY = 2;
	private final int WORK_COUNT = 5;
 
	private final ReentrantLock lock = new ReentrantLock();
	private final Condition notEmpty = lock.newCondition();
	private final Condition notFull = lock.newCondition();
 
	// 로그 출력 메서드
	private void log(String msg) {
		String time = new SimpleDateFormat("HH:mm:ss.SSS").format(new Date());
		Thread t = Thread.currentThread();
		String threadName = t.getName();
		Thread.State state = t.getState();
		System.out.println("[" + time + "][" + threadName + "][" + state + "] " + msg);
	}
 
	// 생산자
	class Producer extends Thread {
		@Override
		public void run() {
			for (int i = 1; i <= WORK_COUNT; i++) {
				lock.lock();
				try {
					while (buffer.size() == CAPACITY) {
						log("버퍼가 가득 참, Producer 대기상태 진입");
						notFull.await();
					}
					buffer.offer(i);
					log("생산: " + i + " (버퍼: " + buffer.size() + ")");
					notEmpty.signal();
				} catch (InterruptedException e) {
					log("생산자 인터럽트 발생");
					throw new RuntimeException(e);
				} finally {
					lock.unlock();
				}
			}
			System.out.println("=====Producer 작업 완료=====");
		}
	}
 
	// 소비자
	class Consumer extends Thread {
		@Override
		public void run() {
			for (int i = 1; i <= WORK_COUNT; i++) {
				lock.lock();
				try {
					while (buffer.isEmpty()) {
						log("버퍼가 빔, Consumer 대기상태 진입");
						notEmpty.await();
					}
					int value = buffer.poll();
					log("소비: " + value + " (버퍼: " + buffer.size() + ")");
					notFull.signal();
				} catch (InterruptedException e) {
					log("소비자 인터럽트 발생");
					throw new RuntimeException(e);
				} finally {
					lock.unlock();
				}
			}
			System.out.println("=====Consumer 작업 완료=====");
		}
	}
 
	public void runExample() throws InterruptedException {
		Producer producer = new Producer();
		Consumer consumer = new Consumer();
 
		producer.setName("Producer");
		consumer.setName("Consumer");
 
		producer.start();
		consumer.start();
 
		producer.join();
		consumer.join();
 
		log("종료");
	}
}

Main

    public static void main(String[] args) throws InterruptedException {
		ConditionExample example = new ConditionExample();
		example.runExample();
	}

실행 결과

[21:42:03.267][Producer][RUNNABLE] 생산: 1 (버퍼: 1)
[21:42:03.276][Producer][RUNNABLE] 생산: 2 (버퍼: 2)
[21:42:03.276][Producer][RUNNABLE] 버퍼가 가득 참, Producer 대기상태 진입
[21:42:03.277][Consumer][RUNNABLE] 소비: 1 (버퍼: 1)
[21:42:03.277][Consumer][RUNNABLE] 소비: 2 (버퍼: 0)
[21:42:03.277][Consumer][RUNNABLE] 버퍼가 빔, Consumer 대기상태 진입
[21:42:03.277][Producer][RUNNABLE] 생산: 3 (버퍼: 1)
[21:42:03.278][Producer][RUNNABLE] 생산: 4 (버퍼: 2)
[21:42:03.278][Producer][RUNNABLE] 버퍼가 가득 참, Producer 대기상태 진입
[21:42:03.278][Consumer][RUNNABLE] 소비: 3 (버퍼: 1)
[21:42:03.278][Consumer][RUNNABLE] 소비: 4 (버퍼: 0)
[21:42:03.278][Consumer][RUNNABLE] 버퍼가 빔, Consumer 대기상태 진입
[21:42:03.279][Producer][RUNNABLE] 생산: 5 (버퍼: 1)
=====Producer 작업 완료=====
[21:42:03.279][Consumer][RUNNABLE] 소비: 5 (버퍼: 0)
=====Consumer 작업 완료=====
[21:42:03.279][main][RUNNABLE] 종료

TMI) Condition 은 Thread safe한 자료구조 등을 만들때 사용된다. 실제로 ArrayBlockingQueue 에는 Condtion을 활용하여 멀티 스레드에서 안정적인 동작을 하도록 만들어 두었다. image

📌 모니터

  • 방금 전에 다뤄본 조건 변수를 활용하여 동기화를 시키는 기법이 바로 모니터 기법 이다
  • 모니터는, 공유 자원과 그 공유 자원을 다루는 함수로 구성된 동기화 도구
  • 상호 배제를 위해 동기화 뿐만 아니라, 실행 순서 제어를 위한 동기화 까지 가능.
  • 즉, 특정 공유 자원에 접근하기 위해서는, 정해진 공유 자원 연산을 통해 모니터 내로 진입하고, 이 모니터 내에서 실행 중인 프로세스 및 쓰레드는 큐에 쌓여 하나씩 실행된다.
    • 정확히는 여러 스레드가 “모니터 진입을 시도”할 수 있지만, 실제 모니터 내부에서는 오직 하나의 스레드만 실행
  • 특히 조건 변수와 함께 활용되면 실행 순서 제어를 위한 동기화도 구현이 가능하다
  • 자바에서는 대표적으로 synchronized 키워드와 synchronized 블록으로 이를 구현할 수 있다.
  • 위의 생산자 소비자문제를 synchronized 로 푼다면 다음과 같다. MonitorExample
public class MonitorExample {
	private final Queue<Integer> buffer = new LinkedList<>();
	private final int CAPACITY = 2;
	private final int WORK_COUNT = 5;
 
	// 로그 출력 메서드
	private void log(String msg) {
		String time = new SimpleDateFormat("HH:mm:ss.SSS").format(new Date());
		Thread t = Thread.currentThread();
		String threadName = t.getName();
		Thread.State state = t.getState();
		System.out.println("[" + time + "][" + threadName + "][" + state + "] " + msg);
	}
 
	// 생산자
	class Producer extends Thread {
		@Override
		public void run() {
			for (int i = 1; i <= WORK_COUNT; i++) {
				synchronized (buffer) {
					while (buffer.size() == CAPACITY) {
						log("버퍼가 가득 참, Producer 대기상태 진입");
						try {
							buffer.wait();
						} catch (InterruptedException e) {
							log("생산자 인터럽트 발생");
							throw new RuntimeException(e);
						}
					}
					buffer.offer(i);
					log("생산: " + i + " (버퍼: " + buffer.size() + ")");
					buffer.notifyAll(); // 상태 변화 알림
				}
			}
			System.out.println("=====Producer 작업 완료=====");
		}
	}
 
	// 소비자
	class Consumer extends Thread {
		@Override
		public void run() {
			for (int i = 1; i <= WORK_COUNT; i++) {
				synchronized (buffer) {
					while (buffer.isEmpty()) {
						log("버퍼가 빔, Consumer 대기상태 진입");
						try {
							buffer.wait();
						} catch (InterruptedException e) {
							log("소비자 인터럽트 발생");
							throw new RuntimeException(e);
						}
					}
					int value = buffer.poll();
					log("소비: " + value + " (버퍼: " + buffer.size() + ")");
					buffer.notifyAll(); // 상태 변화 알림
				}
			}
			System.out.println("=====Consumer 작업 완료=====");
		}
	}
 
	public void runExample() throws InterruptedException {
		Producer producer = new Producer();
		Consumer consumer = new Consumer();
 
		producer.setName("Producer");
		consumer.setName("Consumer");
 
		producer.start();
		consumer.start();
 
		producer.join();
		consumer.join();
 
		log("종료");
	}
}
  • 기존에는, ReentrantLock으로 임계 영역을 보호하고, Conditon을 활용해 조건 변수를 구현하였다.
  • 현재는 synchronized 블록을 사용해 임계 영역 보호와 조건 변수를 동시에 활용하였다.

📌 비교

구분모니터(Monitor)세마포어(Semaphore)뮤텍스(Mutex)
목적상호배제 + 조건 동기화(순서 제어)동시 접근 허용(카운터 기반)상호배제(1명만 진입)
구성락 + 조건변수카운터락
동작1명만 진입, 조건 대기/신호 가능여러 명 동시 진입(카운터만큼)1명만 진입
자바 예시synchronized/wait/notifyjava.util.concurrent.SemaphoreReentrantLock 등

✅ 스레드 안전

쓰레드 안전(thread safe) 란?

  • 스레드 안전이란, 멀티 스레드 환경에서 동시 접근이 이루어져도 실행에 문제가 없는 상태를 의미한다.
  • 즉, 어떤 함수가 스레드 안전 하다면, 이는 여러 쓰레드에서 동시에 접근해도 레이스 컨디션 이 발생하지 않는 것을 의미한다.
  • 자바에서는 다양한 클래스를 제공하는데 공식 문서를 통해 thread-safe 한지 아닌지 확인이 가능하다.
  • 대표적으로, ArrayList, Integer, Long, HashMap 등 자주 사용되는 대부분의 자료 구조등의 class는 thread-safe 하지 않다.
  • 매칭되는 thread-safe 자료구조는, Vector, Collections.synchronizedList(), ConcurrentHashMap, AtomicInteger, AtomicLong 등이 있다.

TMI) AtomicInteger와 AtomicLong 등은 CAS(Compare-And-Swap, 비교 후 교환) 기반의 원자적 연산을 통해 thread-safe를 보장한다.
관련된 내용은 (Java) 멀티 스레드 동기화와 원자적 연산 (CAS)에서 더 자세히 확인할 수 있다.
또한, CAS는 “낙관적 락(Optimistic Lock)” 방식이기 때문에, 이번에 다룬 락 기법(뮤텍스, 모니터 등)과는 동작 방식이 조금 다르다.