앞선 글에서 CPU 스케줄링과 프로세스의 실행 흐름을 정리했다.
이번엔 여러 프로세스 혹은 스레드가 동시에 실행되는 환경에선, "공유 자원을 어떻게 안전하게 나눠 쓰게 할지?" 이러한 병행성 문제와 동기화가 반드시 따라온다. 안전하게 공유자원에 접근하기 위해 이번 글에서는 동기화와 병행성 처리에 관해 다루고자 한다.
그전에 실제 OS에서 프로세스/스레드의 동작 흐름을 이해하려면,
동기·비동기, 블로킹·논블로킹이라는 실행 제어의 큰 맥락을 다뤄보겠다.
동기: 요청과 결과가 같은 자리에서 동시에 일어남 (작업 완료까지 대기)
비동기: 요청 후 결과를 기다리지 않고 바로 다음 작업 수행 (대기 시간 중 다른 일 가능)
블로킹: 함수가 제어권을 호출된 함수에게 넘기고, 결과가 나올 때까지 대기
논블로킹: 함수가 제어권을 계속 가지고 있음. 호출된 함수는 백그라운드에서 실행됨
다른 요청의 작업을 처리하기 위해 현재 작업을 Block(차단,대기)한다.
다른 요청의 작업을 처리하기 위해 현재 작업을 Block(차단,대기)하지 않는다.
아니다. 동기/비동기 와 블로킹/논블로킹 이 두 개념은 표현 형태는 비슷해 보일지라도, 서로 다른 차원에서 작업의 수행 방식을 설명하는 개념이다. 동기/비동기는 요청한 작업에 대해 완료 여부를 신경 써서 작업을 순차적으로 수행할지 아닌지에 대한 관점이고,블로킹/논블록킹은 단어 그대로 현재 작업이 block(차단, 대기) 되느냐 아니냐에 따라 다른 작업을 수행할 수 있는지에 대한 관점이다.
즉 한번 더 정리 하자면
두 개념은 완전히 독립적이진 않다.
동기/비동기가 더 상위(큰 관점)의 개념으로 이해하면 좋다.
동기/비동기가 더 상위(큰 관점) 개념으로 이해하면 좋다.
“작업을 언제/누가 끝내냐” → 동기/비동기
“기다릴 때 딴짓 가능하냐” → 블로킹/논블로킹
위에서 동기와 비동기, 블로킹과 논블로킹의 차이에 대해 알아봤다. 프로그램 아키텍처에서는 이 두개념을 함께 조합해서 4가지 조합을 만들어서 실제로 사용한다.
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("다음 작업 실행!");
}
}
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);
});
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);
// 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) |
현대 운영체제에서 여러 프로세스(혹은 스레드)가 공유 자원(메모리, 변수, 파일 등)에 동시에 접근할 때, 동기화 문제가 필연적으로 발생합니다. 동기화가 제대로 이뤄지지 않으면 프로그램의 동작이 예측 불가해지고, 데이터 손상이나 치명적 버그로 이어질 수 있습니다.
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()
를 호출합니다.count++
는 3단계 연산(count 읽기 → 1 증가 → 저장)이기 때문에객체마다 각각 다른 방식으로 동시 접근과 대기/진입을 관리한다.
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
}
}
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
}
}
구분 | 뮤텍스(Mutex) | 세마포어(Semaphore) |
---|---|---|
주요 목적 | 상호 배제(1개만 진입 허용) | 동시 접근 가능 개수 제한 |
값의 범위 | locked/unlocked (0 또는 1) | 0~N(카운팅), 0/1(바이너리) |
소유권 | 락을 획득한 스레드만 해제 가능 | 누구든 signal(V) 호출 가능 |
예시/비유 | 화장실 잠금장치(내가 잠그고 내가 풀음) | 주차장 자리 카운트, 화장실 칸 표시 |
대표 사용 | 공유 변수/임계구역 보호 | DB 연결 제한, 생산자-소비자 문제 |
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
}
}