컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 04

프로세스 동기화

OS동기화동시성

여러 프로세스·스레드가 공유 자원에 동시에 접근하면 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를 양보하므로 긴 대기에 적합합니다.

상호 배제 (Mutual Exclusion) — 임계 구역에는 한 번에 하나의 프로세스만 진입해야 합니다. 동기화 메커니즘이 보장해야 하는 가장 기본 조건입니다.
Deadlock 위험 — Mutex 두 개를 서로 다른 순서로 획득하면 교착 상태가 발생합니다. Lock 획득 순서를 전역적으로 통일하거나, tryLock + timeout 패턴으로 방지합니다.
메모리 가시성 (Visibility) — 한 스레드의 쓰기가 다른 스레드에 즉시 보이지 않을 수 있습니다. Java의 volatile, C의 memory barrier(fence)로 가시성과 CPU·컴파일러의 재정렬을 제어합니다.
생산자-소비자 문제 — Semaphore의 대표 응용. empty(N)·full(0) 두 Semaphore와 mutex(1) 하나를 조합해 버퍼 크기 제한과 상호 배제를 동시에 해결합니다.
CAS (Compare-And-Swap) — 하드웨어 원자 명령어로 락 없이 공유 변수를 안전하게 갱신합니다. Java의 AtomicInteger는 락 없는 스레드 안전 연산을 제공하고, GCC에는 __atomic_compare_exchange 내장 함수가 있습니다. Lock-Free 알고리즘의 기반입니다.

왜 동기화가 필요한가

여러 스레드나 프로세스가 같은 데이터를 동시에 바꾸면 실행 순서에 따라 결과가 달라질 수 있습니다. 동기화는 이런 접근의 순서를 통제해 결과를 항상 같게 만드는 일입니다. 상호 배제 문제는 1960년대에 Dijkstra의 논문 'Solution of a Problem in Concurrent Programming Control'(1965) 등으로 연구되었고, 세마포어도 Dijkstra가 제안한 도구로 알려져 있습니다.

숫자로 확인하기

출금 경쟁 — 잔액이 100원일 때 두 스레드가 동시에 30원씩 출금합니다. 둘 다 100을 읽고 각자 70을 저장하면 결과는 70원입니다. 순서대로 처리했다면 40원이어야 합니다.
세마포어 값 따라가기 — 자원이 2개라서 S = 2로 시작하면 wait 한 번에 S는 1, 두 번째 wait에 0이 됩니다. 세 번째 wait는 S가 0이라서 블로킹되고, 누군가 signal을 호출할 때 풀립니다.
생산자-소비자(버퍼 5칸) — empty = 5, full = 0, mutex = 1로 시작합니다. 생산자가 3개를 넣으면 empty는 2, full은 3이 되고, 소비자가 1개를 꺼내면 empty는 3, full은 2가 됩니다. empty가 0이면 생산자가, full이 0이면 소비자가 기다립니다.
임계 구역의 비용 — 작업 시간 중 락으로 보호한 임계 구역이 10%라면 이 부분은 한 번에 하나만 실행하므로, 코어를 아무리 늘려도 속도 향상은 최대 10배입니다. 임계 구역은 짧게 둘수록 좋습니다.

자주 하는 오해

volatile이면 count++도 안전하다 — Java의 volatile은 다른 스레드에 쓰기가 보이게 하고 순서를 정할 뿐, 읽기-더하기-쓰기를 하나의 원자 연산으로 만들지 않습니다. 원자성은 synchronized, 락, AtomicInteger 같은 도구가 줍니다.
Mutex와 이진 세마포어는 같다 — 둘 다 0과 1처럼 쓸 수 있지만 뮤텍스는 락을 잡은 스레드가 풀어야 하고, 세마포어는 다른 스레드가 signal을 호출할 수 있어 신호 전달 용도로도 쓰입니다.
Python은 GIL이 있어서 경쟁 상태가 없다 — GIL은 인터프리터 내부를 보호할 뿐, i = i + 1 같은 여러 단계 연산이 중간에 끊기는 것은 막지 않습니다. 필요하면 threading.Lock을 씁니다.
락을 많이 걸수록 안전하다 — 락이 많거나 범위가 넓으면 성능이 떨어지고 교착 상태가 생길 위험도 커집니다. 보호할 데이터와 범위를 정확히 잡아야 합니다.

면접에서 이렇게 물어요

면접 질문

임계 구역 문제의 해결 조건 세 가지는 무엇인가요?

상호 배제는 한 번에 하나만 들어가는 것, 진행은 들어갈 수 있는데도 아무도 못 들어가는 일이 없게 하는 것, 한정 대기는 기다리는 시간에 상한이 있어 기아가 없게 하는 것입니다.

면접 질문

Mutex와 Semaphore는 어떻게 다른가요?

Mutex는 상호 배제를 위한 락으로 잡은 스레드가 소유자가 되어 풀어야 합니다. Semaphore는 카운터로 동시에 접근할 수 있는 수를 제한하거나 스레드 사이에 신호를 전달하는 데 쓰고 소유자 개념이 없습니다.

면접 질문

Spinlock은 언제 쓰나요?

락을 기다리는 동안 CPU를 쓰며 반복 확인하므로, 기다리는 시간이 컨텍스트 스위치 비용보다 짧을 때 유리하고 멀티코어에서 의미가 있습니다. 대기가 길면 스레드를 재우는 블로킹 락이 낫습니다.

면접 질문

락 없이 동기화할 수 있나요?

하드웨어가 제공하는 원자 명령인 CAS로 값을 비교하고 바꾸는 락 프리 방식이 있습니다. Java의 AtomicInteger가 대표적이고, 실패하면 다시 시도하는 반복이 필요합니다.

실제 시스템에서는

Java의 도구 — Java는 synchronized, ReentrantLock, java.util.concurrent.atomic 패키지를 제공합니다. 메모리 가시성과 happens-before 규칙은 Java 언어 명세 17장이 정의합니다.
POSIX 뮤텍스 — pthread_mutex_lock은 POSIX 표준이 정의하며, 소유하지 않은 뮤텍스를 푸는 동작은 뮤텍스 종류에 따라 정의가 달라집니다.
futex — futex(2) 매뉴얼은 리눅스의 futex가 빠른 사용자 공간 락의 바탕이 되는 시스템 콜이라고 설명합니다. 경쟁이 없을 때는 시스템 콜 없이 처리하고 기다려야 할 때만 커널의 도움을 받는 구조입니다.

함께 보면 좋은 토픽

스레드 — 공유 데이터에 동시에 접근하는 실행 단위를 봅니다.
교착 상태 — 락을 여러 개 잡을 때 생기는 문제를 봅니다.
CPU 스케줄링 — 선점이 동기화 문제와 만나는 지점을 봅니다.
프로세스 — 프로세스 사이의 통신과 자원 공유를 봅니다.
락 / 동시성 — 데이터베이스의 락과 동시성 제어를 비교합니다.

참고 자료

Linux futex(2) — futex(2) — Linux manual page
논문·교재 — E. W. Dijkstra, "Solution of a Problem in Concurrent Programming Control", Communications of the ACM, 1965 / E. W. Dijkstra, "Cooperating Sequential Processes", EWD 123 (세마포어를 도입한 글) / Silberschatz, Galvin, Gagne, "Operating System Concepts", 10th Edition, "Synchronization Tools" 장

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(경쟁 상태)이 발생하는 상황은?

실행 순서에 따라 결과가 달라진다는 게 핵심입니다.
Race Condition은 둘 이상의 실행 흐름이 공유 데이터에 동시에 접근하고 그 결과가 접근 순서(타이밍)에 따라 달라질 때 발생합니다.

Q2

Mutex(상호배제)의 역할은?

'Mutual Exclusion'의 줄임말입니다.
Mutex는 한 번에 하나의 스레드만 임계구역(critical section)에 진입하도록 해 공유 자원에 대한 동시 접근을 막습니다.

Q3

세마포어(Semaphore)와 뮤텍스의 차이로 가장 적절한 것은?

세마포어는 정수 카운터를 가집니다.
뮤텍스는 0/1(잠김/열림)만 표현하는 반면, 세마포어는 카운트 값을 가져 정해진 개수만큼의 스레드가 동시에 접근하도록 허용할 수 있습니다.