프로세스 동기화
여러 프로세스·스레드가 공유 자원에 동시에 접근하면 Race Condition이 발생합니다. 프로세스 동기화는 임계 구역(Critical Section)을 보호해 실행 순서와 무관하게 항상 올바른 결과를 보장하는 기법입니다. Mutex와 Semaphore가 대표적인 해결 도구입니다.
핵심 개념
임계 구역 (Critical Section)
공유 자원에 접근하는 코드 구간입니다. 올바른 동기화는 상호 배제(Mutual Exclusion) · 진행(Progress) · 한정 대기(Bounded Waiting) 세 조건을 모두 만족해야 합니다.
핵심 개념
Race Condition
둘 이상의 스레드가 공유 자원을 동시에 읽고 쓸 때 실행 순서에 따라 결과가 달라지는 현상입니다. counter++ 같은 비원자적 연산이 대표적인 발생 지점입니다.
핵심 개념
Mutex (상호 배제 락)
한 번에 하나의 스레드만 Lock을 획득하고 임계 구역에 진입할 수 있습니다. 일반적으로 Lock을 보유한 스레드가 Unlock합니다(Java의 ReentrantLock은 락을 갖지 않은 스레드가 풀려 하면 예외를 던집니다). 소유권(Ownership) 개념이 있어 이진 Semaphore와 구별됩니다.
핵심 개념
Semaphore
정수 카운터(S)로 자원 접근을 제어합니다. wait(P): S가 0이면 블로킹, 0 초과면 S-- 후 진입. signal(V): S++하여 대기 중인 프로세스를 깨웁니다. 이진(0/1)과 계수형(N)으로 나뉩니다.
핵심 개념
Monitor
상호 배제와 조건 동기화를 언어 레벨에서 제공하는 고수준 추상화입니다. Java의 synchronized + wait()/notify()가 대표적인 예이며, Python에서는 threading.Condition이 이에 대응합니다(단순 with Lock은 상호 배제만 제공). 내부적으로 Mutex + Condition Variable로 구성됩니다.
핵심 개념
Spinlock vs Blocking
Spinlock은 Lock이 해제될 때까지 CPU를 점유하며 바쁜 대기(Busy Waiting)합니다. 컨텍스트 스위치 비용이 없어 짧은 대기에 유리하고, 멀티코어에서 의미가 있습니다. Blocking은 스레드를 Sleep 시켜 CPU를 양보하므로 긴 대기에 적합합니다.
왜 동기화가 필요한가
여러 스레드나 프로세스가 같은 데이터를 동시에 바꾸면 실행 순서에 따라 결과가 달라질 수 있습니다. 동기화는 이런 접근의 순서를 통제해 결과를 항상 같게 만드는 일입니다. 상호 배제 문제는 1960년대에 Dijkstra의 논문 'Solution of a Problem in Concurrent Programming Control'(1965) 등으로 연구되었고, 세마포어도 Dijkstra가 제안한 도구로 알려져 있습니다.
숫자로 확인하기
자주 하는 오해
i = i + 1 같은 여러 단계 연산이 중간에 끊기는 것은 막지 않습니다. 필요하면 threading.Lock을 씁니다.
면접에서 이렇게 물어요
면접 질문
임계 구역 문제의 해결 조건 세 가지는 무엇인가요?
상호 배제는 한 번에 하나만 들어가는 것, 진행은 들어갈 수 있는데도 아무도 못 들어가는 일이 없게 하는 것, 한정 대기는 기다리는 시간에 상한이 있어 기아가 없게 하는 것입니다.
면접 질문
Mutex와 Semaphore는 어떻게 다른가요?
Mutex는 상호 배제를 위한 락으로 잡은 스레드가 소유자가 되어 풀어야 합니다. Semaphore는 카운터로 동시에 접근할 수 있는 수를 제한하거나 스레드 사이에 신호를 전달하는 데 쓰고 소유자 개념이 없습니다.
면접 질문
Spinlock은 언제 쓰나요?
락을 기다리는 동안 CPU를 쓰며 반복 확인하므로, 기다리는 시간이 컨텍스트 스위치 비용보다 짧을 때 유리하고 멀티코어에서 의미가 있습니다. 대기가 길면 스레드를 재우는 블로킹 락이 낫습니다.
면접 질문
락 없이 동기화할 수 있나요?
하드웨어가 제공하는 원자 명령인 CAS로 값을 비교하고 바꾸는 락 프리 방식이 있습니다. Java의 AtomicInteger가 대표적이고, 실패하면 다시 시도하는 반복이 필요합니다.
실제 시스템에서는
synchronized, ReentrantLock, java.util.concurrent.atomic 패키지를 제공합니다. 메모리 가시성과 happens-before 규칙은 Java 언어 명세 17장이 정의합니다.
pthread_mutex_lock은 POSIX 표준이 정의하며, 소유하지 않은 뮤텍스를 푸는 동작은 뮤텍스 종류에 따라 정의가 달라집니다.
함께 보면 좋은 토픽
참고 자료
C — Mutex로 임계 구역 보호
#include <stdio.h> #include <pthread.h> static int counter = 0; static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; void *worker(void *arg) { for (int i = 0; i < 100000; i++) { pthread_mutex_lock(&mtx); // 임계 구역 진입 counter++; // 읽기 → 증가 → 쓰기 (원자적 보장) pthread_mutex_unlock(&mtx); // 임계 구역 탈출 } return NULL; } int main() { pthread_t t1, t2; pthread_create(&t1, NULL, worker, NULL); pthread_create(&t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("counter = %d (expected 200000)\n", counter); pthread_mutex_destroy(&mtx); return 0; }
counter++ 연산은 load→add→store 세 단계로 이루어져 비원자적입니다. Mutex 없이 실행하면 갱신 분실(Lost Update)이 발생해 최종값이 200000보다 작게 나옵니다.
pthread_mutex_lock()은 Mutex가 이미 잠겨 있으면 호출 스레드가 블로킹되어 일반적으로 Sleep 상태로 CPU를 양보합니다(Blocking). 다만 POSIX는 "블로킹"만 규정하므로 대기 중 CPU 점유 여부는 구현에 따라 다릅니다. glibc 기본 Mutex는 Sleep 전 짧게 Spin할 수 있고, ADAPTIVE 타입은 명시적으로 Spin합니다. 이는 바쁜 대기(Busy Waiting)로 CPU를 계속 점유하는 Spinlock과 대비됩니다.
Lock과 Unlock은 반드시 쌍으로 호출해야 합니다. 예외 발생 등으로 Unlock을 빠뜨리면 데드락이 됩니다. C에서는 cleanup handler, C++에서는 RAII(lock_guard)로 자동 해제를 보장합니다.
C — Semaphore로 생산자-소비자 문제 해결
#include <stdio.h> #include <pthread.h> #include <semaphore.h> #define BUF_SIZE 5 int buffer[BUF_SIZE]; int in = 0, out = 0; sem_t empty; // 빈 슬롯 수 (초기값: BUF_SIZE) sem_t full; // 찬 슬롯 수 (초기값: 0) pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; void *producer(void *arg) { for (int i = 0; i < 10; i++) { sem_wait(&empty); // 빈 슬롯 대기 (empty--) pthread_mutex_lock(&mtx); buffer[in] = i; in = (in + 1) % BUF_SIZE; pthread_mutex_unlock(&mtx); sem_post(&full); // 찬 슬롯 증가 (full++) } return NULL; } void *consumer(void *arg) { for (int i = 0; i < 10; i++) { sem_wait(&full); // 찬 슬롯 대기 (full--) pthread_mutex_lock(&mtx); int item = buffer[out]; out = (out + 1) % BUF_SIZE; pthread_mutex_unlock(&mtx); sem_post(&empty); // 빈 슬롯 증가 (empty++) printf("consumed: %d\n", item); } return NULL; } int main() { sem_init(&empty, 0, BUF_SIZE); // 빈 슬롯 5개 sem_init(&full, 0, 0); // 찬 슬롯 0개 pthread_t p, c; pthread_create(&p, NULL, producer, NULL); pthread_create(&c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); sem_destroy(&empty); sem_destroy(&full); return 0; }
생산자-소비자 문제는 세 개의 동기화 변수를 조합해 해결합니다. empty(빈 슬롯)와 full(찬 슬롯)은 Semaphore로 버퍼 상태를 관리하고, mtx(Mutex)는 버퍼 인덱스(in/out)에 대한 상호 배제를 보장합니다.
sem_wait()은 P 연산(Proberen, "검사/시도")으로 카운터를 감소시키며 0이면 블로킹합니다. sem_post()는 V 연산(Verhogen, "증가")으로 카운터를 증가시켜 대기 중인 스레드를 깨웁니다.
Semaphore의 wait/signal 순서가 바뀌면 데드락이 발생합니다. 반드시 Semaphore를 먼저 획득하고 Mutex를 나중에 획득하는 순서를 지켜야 합니다.
Java — synchronized / ReentrantLock / AtomicInteger
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.atomic.AtomicInteger; public class SyncDemo { // 방법 1: synchronized — Monitor 기반, 가장 단순 private int counter1 = 0; public synchronized void increment1() { counter1++; // 메서드 전체가 임계 구역 } // 방법 2: ReentrantLock — 세밀한 제어, tryLock / fairness 지원 private int counter2 = 0; private final ReentrantLock lock = new ReentrantLock(true); // fair lock public void increment2() { lock.lock(); try { counter2++; // 임계 구역 } finally { lock.unlock(); // 예외 발생해도 반드시 해제 (RAII 대체) } } // 방법 3: AtomicInteger — CAS 기반 Lock-Free, 가장 고성능 private final AtomicInteger counter3 = new AtomicInteger(0); public void increment3() { counter3.incrementAndGet(); // CAS 원자 명령어 → 락 불필요 } public static void main(String[] args) throws Exception { SyncDemo demo = new SyncDemo(); // 두 스레드가 각 메서드를 10만 번씩 호출 Runnable r1 = () -> { for (int i=0;i<100_000;i++) demo.increment1(); }; Runnable r2 = () -> { for (int i=0;i<100_000;i++) demo.increment2(); }; Runnable r3 = () -> { for (int i=0;i<100_000;i++) demo.increment3(); }; Thread[] ts = { new Thread(r1), new Thread(r1), new Thread(r2), new Thread(r2), new Thread(r3), new Thread(r3), }; for (Thread t : ts) t.start(); for (Thread t : ts) t.join(); System.out.println("synchronized : " + demo.counter1); // 200000 System.out.println("ReentrantLock: " + demo.counter2); // 200000 System.out.println("AtomicInteger: " + demo.counter3.get()); // 200000 } }
synchronized는 Java Monitor를 사용하는 가장 간단한 방법입니다. 메서드나 블록 단위로 적용할 수 있으며 예외 시 자동으로 Lock이 해제됩니다.
ReentrantLock은 tryLock(timeout), lockInterruptibly(), 공정성(fairness) 옵션을 지원합니다. 반드시 finally 블록에서 unlock()을 호출해야 합니다.
AtomicInteger는 CAS(Compare-And-Swap) 하드웨어 명령어를 사용해 OS 레벨의 Mutex 없이도 원자적 연산을 보장합니다. 단순 카운터에서는 가장 빠릅니다.
Q1
Race Condition(경쟁 상태)이 발생하는 상황은?
Q2
Mutex(상호배제)의 역할은?
Q3
세마포어(Semaphore)와 뮤텍스의 차이로 가장 적절한 것은?