놀라운 운영체제 #4 – 프로세스 동기화

전하윤·2025년 7월 22일
0

OS(operating system)

목록 보기
4/6
post-thumbnail

앞선 글에서 CPU 스케줄링과 프로세스의 실행 흐름을 정리했다.
이번엔 여러 프로세스 혹은 스레드가 동시에 실행되는 환경에선, "공유 자원을 어떻게 안전하게 나눠 쓰게 할지?" 이러한 병행성 문제와 동기화가 반드시 따라온다. 안전하게 공유자원에 접근하기 위해 이번 글에서는 동기화와 병행성 처리에 관해 다루고자 한다.

그전에 실제 OS에서 프로세스/스레드의 동작 흐름을 이해하려면,
동기·비동기, 블로킹·논블로킹이라는 실행 제어의 큰 맥락을 다뤄보겠다.


목차


동기(Synchronous) vs 비동기(Asynchronous)

동기: 요청과 결과가 같은 자리에서 동시에 일어남 (작업 완료까지 대기)

비동기: 요청 후 결과를 기다리지 않고 바로 다음 작업 수행 (대기 시간 중 다른 일 가능)

  • 동기
    • 간단, 직관적
    • 결과를 받을 때까지 다른 작업 X
    • (작업 완료 여부를 호출한 쪽에서 신경 씀)
    • 작업을 순서대로 처리함
  • 비동기
    • 구조 복잡, 자원 효율적 활용
    • 결과 기다리는 동안 다른 일 가능
    • (작업 완료 여부를 호출된 쪽에서 신경 씀)
    • 작업을 순서대로 처리하지 않음

블로킹(Blocking) vs 논블로킹(Non-Blocking)

블로킹: 함수가 제어권을 호출된 함수에게 넘기고, 결과가 나올 때까지 대기
논블로킹: 함수가 제어권을 계속 가지고 있음. 호출된 함수는 백그라운드에서 실행됨

  • 블로킹: 제어권을 잃고, 함수 작업이 끝날 때까지 대기

다른 요청의 작업을 처리하기 위해 현재 작업을 Block(차단,대기)한다.

  • 논블로킹: 호출 후 바로 자기 일을 계속 진행, 결과가 준비되면 통보받음

다른 요청의 작업을 처리하기 위해 현재 작업을 Block(차단,대기)하지 않는다.


그렇다면 동기/비동기 와 블로킹/논블로킹이 같은 개념 아니야?

아니다. 동기/비동기 와 블로킹/논블로킹 이 두 개념은 표현 형태는 비슷해 보일지라도, 서로 다른 차원에서 작업의 수행 방식을 설명하는 개념이다. 동기/비동기는 요청한 작업에 대해 완료 여부를 신경 써서 작업을 순차적으로 수행할지 아닌지에 대한 관점이고,블로킹/논블록킹은 단어 그대로 현재 작업이 block(차단, 대기) 되느냐 아니냐에 따라 다른 작업을 수행할 수 있는지에 대한 관점이다.

즉 한번 더 정리 하자면

  1. 동기/비동기
  • 작업 완료를 누가 책임지고, 언제 다음 작업을 하느냐? 에 대한 관점
  • 내가 결과를 직접 챙기며 순차적으로 한다. -> 동기
  • 결과가 나오는건 신경스지 않고, 완료 됐다고 알려주기 -> 비동기
  1. 블로킹/논블로킹
  • 기다리는 동안 호출자의 작업이 멈추냐, 계속되냐에 대한 관점
  • 함수가 Block(차단/대기) 상태가 되느냐
  • 아니면 기다리는 동안 계속 다른 일(논 블로킹)을 할 수 있냐의 차이

두 개념은 완전히 독립적이진 않다.
동기/비동기가 더 상위(큰 관점)의 개념으로 이해하면 좋다.

동기/비동기가 더 상위(큰 관점) 개념으로 이해하면 좋다.
“작업을 언제/누가 끝내냐” → 동기/비동기
“기다릴 때 딴짓 가능하냐” → 블로킹/논블로킹


동기/비동기 + 블로킹/논블로킹 조합

위에서 동기와 비동기, 블로킹과 논블로킹의 차이에 대해 알아봤다. 프로그램 아키텍처에서는 이 두개념을 함께 조합해서 4가지 조합을 만들어서 실제로 사용한다.

  1. Sync Blocking (동기 + 블로킹)
  2. Async Blocking (비동기 + 블로킹)
  3. Sync Non-Blocking (동기 + 논블로킹) 
  4. Async Non-Blocking (비동기 + 논블로킹)

동기 + 블로킹 (Synchronous + Blocking)

개념

  • 동기(Synchronous): 함수를 호출한 후, 결과가 나올 때까지 다음 코드로 진행하지 않음.
  • 블로킹(Blocking): 호출된 함수가 작업을 완료할 때까지 현재 스레드는 아무것도 못하고 멈춰 있음.

특징

  • 가장 단순하고 직관적이며, 예측 가능한 동작을 함.
  • 느린 작업(네트워크 요청, 파일 I/O 등)을 수행하면 프로그램 전체가 멈추는 것처럼 보임.
  • CPU 자원을 낭비할 수 있고, 프로그램의 반응성이 저하될 수 있음.

예시


import java.util.Scanner;

public class SyncBlockingExample {
    public static void main(String[] args) {
        System.out.println("아무 숫자나 입력하세요: ");
        Scanner scanner = new Scanner(System.in);

        // (1) read()는 동기적/블로킹 방식
        int n = scanner.nextInt(); // 사용자가 입력할 때까지 프로그램(메인스레드) 멈춤

        System.out.println("입력한 숫자: " + n);
        System.out.println("다음 작업 실행!");
    }
}

비동기 + 논블로킹 (Asynchronous + Non-Blocking)

개념

  • 요청을 맡긴 후, 바로 다음 작업을 진행(논블로킹)
  • 요청한 작업이 끝나면 콜백/이벤트로 결과를 알려준다(비동기)

특징

  • 여러 작업이 동시에 비동기로 동작. 작업의 순서가 보장되지 않음
  • 긴 작업 중에도 다른 코드가 실행됨 → 반응성/효율성 UP
  • 대용량 데이터/네트워크 서비스, 서버 등에서 많이 활용

예시

const fs = require('fs');

fs.readFile('file1.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log('file1:', data);
});

fs.readFile('file2.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log('file2:', data);
});

console.log('done'); // 가장 먼저 출력됨

// 콜백 대신 Promise/async-await도 가능
Promise.all([
  fs.promises.readFile('file1.txt', 'utf8'),
  fs.promises.readFile('file2.txt', 'utf8')
]).then(([data1, data2]) => {
  console.log('all done:', data1, data2);
});

실제 활용 예시 프로그램

  • 웹 브라우저의 파일 다운로드
    - 여러 파일을 동시에 다운로드 하면서 사용자는 자유롭게 다른 작업을 할 수 있음

동기 + 논블로킹 (Synchronous + Non-Blocking)

개념

  • 함수를 호출하면, 제어권은 계속 자신이 가지고 있음(논블로킹)
  • 하지만 작업이 끝났는지 계속 직접 확인(폴링)함(동기)
  • 즉, 결과가 준비될 때까지 프로그램이 반복적으로 체크함.

특징

  • 프로그램이 멈추지 않고 계속 진행하지만, 중간중간 직접 "끝났는지" 검사
  • 결과가 필요할 때마다 계속 "상태 체크" -> CPU 낭비 발생
  • 주로 I/O 장치 polling, 타이머 체크 등에 사용된다.

예시

let isDone = false;
let result = null;

// (비동기 작업 시작)
setTimeout(() => {
  result = '완료!';
  isDone = true;
}, 2000);

// 폴링(동기적 논블로킹)으로 결과 체크
const interval = setInterval(() => {
  if (isDone) {
    console.log(result); // "완료!"
    clearInterval(interval);
  } else {
    console.log('아직 처리중...'); // 2초 동안 계속 출력
  }
}, 300);

비동기 + 블로킹 (Asynchronous + Blocking)

개념

  • 비동기로 요청(결과 순서 X)
  • 하지만 작업이 끝날 때까지 그 함수는 블로킹됨(대기)
  • 호출한 쪽이 대기해야 결과를 받을 수 있음

특징

  • 요청 자체는 비동기이나, 결과를 받을때까지 CPU/쓰레드가 대기
  • 한 번에 한 작업만 처리 → 효율 떨어짐
  • 실무에서는 잘 쓰이지 않음(비효율적 구조) -> 안티패턴
  • 일부 옛날 라이브러리, IO/DB 드라이버 등에서 볼 수 있음

예시


// async/await로 블로킹 효과 연출
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function main() {
  console.log('시작');
  await delay(2000); // 2초 동안 대기(블로킹)
  console.log('2초 후 실행');
  await delay(1000); // 또 대기
  console.log('1초 후 실행');
}

main();

동기/비동기 & 블로킹/논블로킹 조합

Blocking (제어권 X)Non-Blocking (제어권 O)
Sync작업 순서/결과 대기, 제어권 X (ex. Scanner.nextInt())폴링/반복확인 (중간중간 결과 체크)
Async작업 순서 자유, 제어권 X (잘 안씀/안티패턴)콜백/이벤트: 비동기 + 논블로킹 (ex. JS setTimeout)
  • Sync-Blocking: 전통적인 입출력 방식(입력 기다릴 때까지 멈춤)
  • Sync-NonBlocking: 폴링 방식(중간중간 결과 직접 체크)
  • Async-NonBlocking: 대표적 비동기 콜백 패턴(주로 JS 등에서 활용)
  • Async-Blocking: 실제로는 거의 사용되지 않는 비효율 조합

프로세스 동기화와 경쟁 상태

현대 운영체제에서 여러 프로세스(혹은 스레드)가 공유 자원(메모리, 변수, 파일 등)에 동시에 접근할 때, 동기화 문제가 필연적으로 발생합니다. 동기화가 제대로 이뤄지지 않으면 프로그램의 동작이 예측 불가해지고, 데이터 손상이나 치명적 버그로 이어질 수 있습니다.

프로세스 동기화란?

  • 여러 프로세스가 공유하는 자원의 일관성을 유지하는것.
  • 여러 프로세스가 서로 협력해 공유자원을 사용하는 상황에서 경쟁조건이 발생하면 공유 자원의 신뢰성이 떨어진다. 이를 방지하기 위해 프로세스들이 공유자원을 사용할 때 특별한 규칙을 만드는 것이다.

경쟁 상태(Race Condition)와 임계 구역(Cirtical Section)

경쟁 상태(Race Condition)

  • 여러 프로세스(스레드)가 동시에 공유 자원에 접근해서,
    실행 순서에 따라 결과가 달라지는 예측 불가 상황
  • 예: 두 스레드가 같은 변수 값을 동시에 증가시키면 최종 결과는 실행 타이밍에 따라 달라질 수 있음

임계 구역(Critical Section):

  • 경쟁 상태가 발생할 수 있는, 공유 자원을 접근·변경하는 코드 구간
  • 안전하게 동기화하려면 반드시 아래 3가지 조건을 만족해야 함
    1. 상호배제(Mutual Exclusion): 한 번에 하나만 진입
    2. 진행의 융통성(Progress): 불필요한 대기 없음
    3. 한정 대기(Bounded Waiting): 무한 대기 방지 (기아 X)

예시: 경쟁 상태 (코드)

public class Counter {
    private int count = 0;
    public void increment() { count++; }
    public int getCount() { return count; }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println("Final count: " + counter.getCount()); // 예측 불가!
    }
}

코드 설명

  • count 변수는 여러 스레드가 공유하는 자원입니다.
  • t1, t2 두 스레드가 동시에 1000번씩 counter.increment()를 호출합니다.
    • 정상적으로 동작하면 최종 값은 2000이어야 합니다.
  • 하지만, count++3단계 연산(count 읽기 → 1 증가 → 저장)이기 때문에
    여러 스레드가 동시에 접근하면 중간에 값이 꼬이는 Race Condition이 발생합니다.
  • 실제로 실행하면 2000보다 작은 값(1900~1999 등)이 자주 나옵니다.
  • 이런 예측 불가 현상을 막으려면 임계 구역에 동기화(뮤텍스, 락 등)를 반드시 적용해야 합니다.

동기화 객체의 종류 (Lock, Mutex, Semaphore, Monitor)

객체마다 각각 다른 방식으로 동시 접근대기/진입을 관리한다.

대기 방식

busy-waiting (스핀락)

  • 자원이 풀릴 때까지 CPU 점유하며 계속 확인 (while, polling)
  • 효율↓, 실무에서는 block 방식 선호

block-wakeup:

  • 자원이 점유 중이면 대기(잠듦)
  • 자원이 해제되면 다른 스레드가 깨워줌 (notify/wakeup)
  • Java의 synchronized, wait/notify 등이 대표적

2️⃣ 세마포어(Semaphore)

  • 동시 접근 가능한 프로세스/스레드의 개수를 카운팅
  • 정수값으로 관리. P/V연산(혹은 wait/signal)로 진입/해제
  • Binary(0/1) 세마포어: 뮤텍스와 동등하게 동작 (1명만 접근 가능)
  • Counting 세마포어: N명까지 동시 접근 허용
import java.util.concurrent.Semaphore;

public class Counter {
    private int count = 0;
    private final Semaphore semaphore = new Semaphore(1); // Binary Semaphore(뮤텍스 역할)

    public void increment() {
        try {
            semaphore.acquire();    // P연산: 세마포어 획득(임계구역 진입)
            count++;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release();    // V연산: 세마포어 해제(임계구역 종료)
        }
    }
    public int getCount() { return count; }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println("Final count: " + counter.getCount()); // 항상 2000
    }
}
  • Semaphore(1) : 한 번에 한 스레드만 임계구역 진입 허용 (뮤텍스와 동일)
  • acquire()로 진입, release()로 해제 (자원 1개 → 임계구역 보장)
  • 모든 스레드가 순차적으로 접근 → Race Condition 방지

3️⃣ 뮤텍스(Mutex)

  • 오직 1개의 스레드만 임계 구역에 진입할 수 있도록 보장
  • 소유권 명확(잠근 놈만 풀 수 있음)
  • Java의 ReentrantLock 등으로 구현
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();
    public void increment() {
        lock.lock();        // 락 획득(임계구역 진입)
        try {
            count++;
        } finally {
            lock.unlock();  // 락 해제(임계구역 종료)
        }
    }
    public int getCount() { return count; }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println("Final count: " + counter.getCount()); // 항상 2000
    }
}
  • ReentrantLock(뮤텍스): 한 번에 한 스레드만 임계구역 실행
  • lock.lock()/unlock()으로 락 관리
  • 반드시 finally에서 unlock() 호출 (예외로 인한 데드락 방지)
  • 역시 Race Condition 방지

⚖️ 뮤텍스 vs 세마포어 비교표

구분뮤텍스(Mutex)세마포어(Semaphore)
주요 목적상호 배제(1개만 진입 허용)동시 접근 가능 개수 제한
값의 범위locked/unlocked (0 또는 1)0~N(카운팅), 0/1(바이너리)
소유권락을 획득한 스레드만 해제 가능누구든 signal(V) 호출 가능
예시/비유화장실 잠금장치(내가 잠그고 내가 풀음)주차장 자리 카운트, 화장실 칸 표시
대표 사용공유 변수/임계구역 보호DB 연결 제한, 생산자-소비자 문제

모니터(Monitor)

  • 공유자원에 대한 접근을 자동화/은닉
  • 세마포어/락의 실수 방지 (예: 락을 두번 걸거나 해제 누락 등)
  • wait/signal 대신 모니터가 자원 접근과 순서 제어
  • 자바에서 모니터는 보통 synchronized 블록, 메서드 등으로 제공

예시

public class Counter {
    private int count = 0;

    // synchronized 메서드: 이 메서드 전체가 "임계 구역"
    public synchronized void increment() {
        count++;
    }
    public int getCount() { return count; }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println("Final count: " + counter.getCount()); // 항상 2000
    }
}
  • synchronized 키워드가 붙은 메서드(혹은 블록)는 “동시에 한 스레드만” 진입 가능
    → Java 런타임이 내부적으로 “모니터 락”을 자동으로 관리
  • 실수로 락 해제/중복, 예외 처리 등 신경 쓸 필요 없이, 코드 블록 단위로 임계구역을 보장
  • 여러 스레드가 동시에 접근해도 경쟁 상태 없이 항상 일관된 결과(여기선 2000)

Reference

profile
개발에 대한 고민과 성장의 기록을 일기장처럼 성찰하며 남기는 공간

0개의 댓글