동시성을 구현하는 재료
동시성을 구현하는 재료
운영체제는 실행(execution)을 실제 하드웨어에 배정하는 역할을 한다. 동시성을 구현하기 위한 기본 재료는 프로세스와 스레드이며, 이 둘의 특성을 이해하는 것이 동시성 프로그래밍의 출발점이다.
1. 프로세스 (Process)
1.1 프로세스란?
프로세스는 컴퓨터에서 실행 중인 프로그램의 한 인스턴스를 가리킨다. 각 프로세스는 하나 또는 그 이상의 실행 스레드를 가지며, 프로세스에 포함되지 않은 스레드는 존재할 수 없다.
프로그램이 디스크에 저장된 정적인 코드라면, 프로세스는 그 코드가 메모리에 올라와 실행 중인 동적 상태이다.
┌──────────────────────────────────────────────────────────────┐
│ 프로그램 vs 프로세스 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 디스크 메모리 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ program.exe │ ── 실행 ──→ │ 프로세스 A │ │
│ │ (정적 코드) │ │ (실행 상태) │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 같은 프로그램을 ┌──────────────┐ │
│ 여러 번 실행 가능 ────→ │ 프로세스 B │ │
│ └──────────────┘ │
│ │
│ 하나의 프로그램에서 여러 프로세스(인스턴스)를 만들 수 있다 │
└──────────────────────────────────────────────────────────────┘1.2 프로세스의 메모리 구조
운영체제는 각 프로세스에 독립된 주소 공간을 할당한다.
┌──────────────────────────────────────────────────────────────┐
│ 프로세스 메모리 레이아웃 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 높은 주소 │
│ ┌──────────────────────┐ │
│ │ Stack (스택) │ 지역 변수, 함수 호출 정보 │
│ │ ↓ │ (위에서 아래로 성장) │
│ ├──────────────────────┤ │
│ │ (빈 공간) │ │
│ ├──────────────────────┤ │
│ │ ↑ │ (아래에서 위로 성장) │
│ │ Heap (힙) │ 동적 할당 (new, malloc) │
│ ├──────────────────────┤ │
│ │ Data (데이터) │ 전역 변수, 정적 변수 │
│ ├──────────────────────┤ │
│ │ Code (텍스트) │ 실행할 기계어 명령어 │
│ └──────────────────────┘ │
│ 낮은 주소 │
│ │
└──────────────────────────────────────────────────────────────┘1.3 프로세스의 특성
| 특성 | 설명 |
|---|---|
| 독립된 주소 공간 | 각 프로세스는 별도의 메모리 영역을 가짐 |
| 격리성 | 한 프로세스의 오류가 다른 프로세스에 영향을 주지 않음 |
| 자원 소유 | 메모리, 파일 핸들, 소켓 등 독립적인 자원 보유 |
| 높은 생성 비용 | 새 주소 공간 할당, 메모리 복사 등 비용이 큼 |
1.4 프로세스 간 통신 (IPC)
프로세스끼리는 거의 독립적이므로 데이터를 직접 공유할 수 없다. 운영체제가 제공하는 IPC(Inter-Process Communication) 메커니즘을 사용해야 한다.
| IPC 방식 | 설명 | 특성 |
|---|---|---|
| 파이프 (Pipe) | 한 프로세스의 출력을 다른 프로세스의 입력으로 연결 | 단방향, 부모-자식 관계 |
| 소켓 (Socket) | 네트워크 프로토콜을 통한 통신 | 양방향, 원격 가능 |
| 공유 메모리 | 같은 물리 메모리 영역을 여러 프로세스가 매핑 | 가장 빠름, 동기화 필요 |
| 메시지 큐 | 메시지를 큐에 넣고 꺼내는 비동기 통신 | 느슨한 결합 |
┌──────────────────────────────────────────────────────────────┐
│ 프로세스 간 통신 (IPC) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 프로세스 A │ │ 프로세스 B │ │
│ │ │ ─── 파이프/소켓 ───→ │ │ │
│ │ 주소공간A │ ←── 메시지 큐 ──── │ 주소공간B │ │
│ │ │ │ │ │
│ └─────┬────┘ └─────┬────┘ │
│ │ ┌──────────┐ │ │
│ └─────────→│ 공유 메모리│←────────────┘ │
│ └──────────┘ │
│ (운영체제가 관리) │
│ │
└──────────────────────────────────────────────────────────────┘Java 예제 - ProcessBuilder로 프로세스 생성:
import java.io.*;
public class ProcessExample {
public static void main(String[] args) throws Exception {
System.out.println("=== 프로세스 생성 예제 ===\n");
System.out.println("현재 프로세스 PID: " + ProcessHandle.current().pid());
// 새 프로세스 생성 (자식 프로세스)
ProcessBuilder pb = new ProcessBuilder("java", "-version");
pb.redirectErrorStream(true);
Process process = pb.start();
// 자식 프로세스의 출력 읽기 (파이프를 통한 IPC)
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("자식 프로세스 출력: " + line);
}
}
int exitCode = process.waitFor();
System.out.println("자식 프로세스 종료 코드: " + exitCode);
}
}2. 스레드 (Thread)
2.1 스레드란?
스레드는 어떤 특정한 결과를 내기 위해 만들어진 독립적인 인스트럭션의 집합이며 실행의 단위다. 또한 운영체제가 독립적으로 실행하고 관리한다.
프로세스가 “작업장” 이라면, 스레드는 그 작업장에서 일하는 “작업자” 에 해당한다. 한 작업장(프로세스)에 여러 작업자(스레드)가 있으면 도구와 자재(메모리)를 공유하면서 동시에 작업할 수 있다.
2.2 스레드의 메모리 구조
같은 프로세스의 스레드끼리 Code, Data, Heap을 공유하지만, 각 스레드는 독립적인 Stack을 가진다.
┌──────────────────────────────────────────────────────────────┐
│ 프로세스 내 스레드 구조 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 공유 영역 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Code │ │ Data │ │ Heap │ │ │
│ │ │ (명령어) │ │ (전역변수) │ │ (동적 할당) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 비공유 영역 (스레드별) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Stack T1 │ │ Stack T2 │ │ Stack T3 │ │ │
│ │ │ PC T1 │ │ PC T2 │ │ PC T3 │ │ │
│ │ │ 레지스터 │ │ 레지스터 │ │ 레지스터 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘2.3 공유 영역과 비공유 영역
| 구분 | 공유 (프로세스 전체) | 비공유 (스레드별) |
|---|---|---|
| 메모리 | Code, Data, Heap | Stack |
| 레지스터 | - | PC(프로그램 카운터), SP(스택 포인터) |
| 기타 | 파일 핸들, 소켓, 시그널 핸들러 | 스레드 ID, 우선순위, 에러 번호 |
2.4 Java에서 스레드 생성
방법 1: Thread 상속
public class MyThread extends Thread {
@Override
public void run() {
System.out.println(getName() + " 실행 중");
}
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.start(); // 새 스레드에서 run() 실행
t2.start();
}
}방법 2: Runnable 구현 (권장)
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println(
Thread.currentThread().getName() + " 실행 중");
}
public static void main(String[] args) {
Thread t1 = new Thread(new MyRunnable(), "작업자-1");
Thread t2 = new Thread(new MyRunnable(), "작업자-2");
t1.start();
t2.start();
}
}방법 3: 람다식 (Java 8+)
public class LambdaThread {
public static void main(String[] args) {
Thread t = new Thread(() ->
System.out.println(
Thread.currentThread().getName() + " 실행 중"),
"람다-스레드"
);
t.start();
}
}
start()는 새 스레드를 만들어run()을 실행하고,run()을 직접 호출하면 현재 스레드에서 그냥 메서드만 실행된다.
3. 프로세스 vs 스레드
3.1 핵심 비교
┌──────────────────────────────────────────────────────────────┐
│ 프로세스 vs 스레드 비교 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 프로세스 스레드 │
│ ────────── ────── │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
│ │ 프로세스A│ │ 프로세스B│ │ 프로세스 C │ │
│ │ │ │ │ │ ┌───┐┌───┐┌───┐ │ │
│ │ [메모리] │ │ [메모리] │ │ │T1 ││T2 ││T3 │ │ │
│ │ [파일] │ │ [파일] │ │ └───┘└───┘└───┘ │ │
│ └─────────┘ └─────────┘ │ [공유 메모리] │ │
│ 완전 분리 완전 분리 └─────────────────┘ │
│ 자원 공유 │
│ │
│ 장점: 안전, 격리 장점: 빠름, 공유 쉬움 │
│ 단점: 느림, 통신 비용 단점: 동기화 필요, 오류 전파 │
│ │
└──────────────────────────────────────────────────────────────┘3.2 상세 비교표
| 구분 | 프로세스 | 스레드 |
|---|---|---|
| 정의 | 실행 중인 프로그램 인스턴스 | 프로세스 내 실행 단위 |
| 주소 공간 | 독립 | 프로세스 내 공유 |
| 생성 비용 | 높음 (새 주소 공간 할당) | 낮음 (스택만 할당) |
| 전환 비용 | 높음 (TLB 플러시 등) | 낮음 (레지스터만 교체) |
| 데이터 공유 | IPC 필요 (느림) | 직접 접근 (빠름) |
| 안정성 | 높음 (한 프로세스 죽어도 다른 프로세스 무관) | 낮음 (한 스레드 오류가 프로세스 전체에 영향) |
| 동기화 | 일반적으로 불필요 | 반드시 필요 (공유 자원 보호) |
| 디버깅 | 상대적으로 쉬움 | 어려움 (비결정적 동작) |
3.3 Java 예제 - 스레드의 자원 공유 확인
public class SharedResourceDemo {
// 힙 영역 - 모든 스레드가 공유
private static int sharedCounter = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("=== 스레드의 자원 공유 확인 ===\n");
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
sharedCounter++; // 공유 변수 접근
}
System.out.println("T1 완료");
}, "Thread-1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
sharedCounter++; // 같은 공유 변수 접근
}
System.out.println("T2 완료");
}, "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
// 기대값: 2000, 실제값: 2000 이하 (경쟁 조건!)
System.out.println("기대값: 2000");
System.out.println("실제값: " + sharedCounter);
System.out.println("\n→ 동기화 없이 공유 자원에 접근하면 데이터 오염이 발생한다");
}
}4. 컨텍스트 스위칭 (Context Switching)
4.1 컨텍스트 스위칭이란?
CPU가 현재 실행 중인 작업 단위에서 다른 작업 단위로 전환할 때, 현재 상태를 저장하고 다음 상태를 복원하는 과정이다.
┌──────────────────────────────────────────────────────────────┐
│ 컨텍스트 스위칭 과정 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 시간 ──────────────────────────────────────→ │
│ │
│ Thread A ████████░░░░░░░░░░████████░░░░ │
│ Thread B ░░░░░░░░████████░░░░░░░░░████ │
│ ↑ ↑ │
│ 전환(저장/복원) 전환(저장/복원) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 전환 시 수행하는 작업 │ │
│ │ ① 현재 스레드의 PC, 레지스터, 스택 포인터 저장 │ │
│ │ ② 다음 스레드의 PC, 레지스터, 스택 포인터 복원 │ │
│ │ ③ (프로세스 전환 시) 메모리 매핑 교체, TLB 플러시 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘4.2 스레드 전환 vs 프로세스 전환
| 비교 항목 | 스레드 전환 | 프로세스 전환 |
|---|---|---|
| 저장/복원 대상 | 레지스터, PC, SP | 레지스터 + 메모리 매핑 정보 |
| 메모리 매핑 | 변경 불필요 (같은 주소 공간) | 교체 필요 |
| TLB (캐시) | 유지 | 플러시 (무효화) |
| 비용 | 낮음 | 높음 |
스레드끼리는 같은 주소 공간을 공유하므로 전환 시 메모리 관련 작업이 필요 없어 프로세스 전환보다 훨씬 가볍다. 이것이 동시적 애플리케이션에서 스레드를 선호하는 핵심 이유다.
4.3 컨텍스트 스위칭의 비용
컨텍스트 스위칭은 “공짜"가 아니다. 전환 자체에 CPU 시간을 소비하며, 캐시 미스가 발생하면 추가 지연이 생긴다.
public class ContextSwitchDemo {
public static void main(String[] args) throws InterruptedException {
int iterations = 100_000_000;
// 단일 스레드: 컨텍스트 스위칭 없음
long start = System.currentTimeMillis();
long sum = 0;
for (int i = 0; i < iterations; i++) {
sum += i;
}
long singleTime = System.currentTimeMillis() - start;
// 두 스레드: 컨텍스트 스위칭 발생
start = System.currentTimeMillis();
final long[] sums = new long[2];
Thread t1 = new Thread(() -> {
for (int i = 0; i < iterations / 2; i++) {
sums[0] += i;
}
});
Thread t2 = new Thread(() -> {
for (int i = iterations / 2; i < iterations; i++) {
sums[1] += i;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
long multiTime = System.currentTimeMillis() - start;
System.out.println("=== 컨텍스트 스위칭 비용 관찰 ===\n");
System.out.println("단일 스레드: " + singleTime + "ms");
System.out.println("두 스레드: " + multiTime + "ms");
System.out.println("\n→ 단순 연산은 스레드를 나눠도 빨라지지 않을 수 있다");
System.out.println(" (컨텍스트 스위칭 오버헤드 + 캐시 미스)");
}
}5. 스레드 생명주기
5.1 상태 전이
운영체제와 JVM은 스레드의 상태를 관리한다. Java의 Thread.State는 다음 6가지 상태를 정의한다.
┌──────────────────────────────────────────────────────────────┐
│ 스레드 생명주기 │
├──────────────────────────────────────────────────────────────┤
│ │
│ new Thread() │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ NEW │ │
│ └────┬─────┘ │
│ │ start() │
│ ▼ │
│ ┌──────────┐ │
│ ┌──→ │ RUNNABLE │ ←──────────────────────┐ │
│ │ └────┬─────┘ │ │
│ │ │ │ │
│ │ ┌────┴─────────────────────────┐ │ │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌────────┐ ┌─────────┐ ┌──────────┐ │ │
│ │ │BLOCKED │ │WAITING │ │ TIMED │ │ │
│ │ │ │ │ │ │ WAITING │ │ │
│ │ │ 락 대기 │ │ 알림대기 │ │ 시간대기 │ │ │
│ │ └────┬───┘ └────┬────┘ └─────┬────┘ │ │
│ │ │ │ │ │ │
│ │ └──────────┴────────────┘ │ │
│ │ 복귀 ────────────────────┘ │
│ │ │
│ │ run() 완료 │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────┐ │
│ │ │TERMINATED │ │
│ │ └───────────┘ │
│ │ │
└───────────┴──────────────────────────────────────────────────┘5.2 상태 설명
| 상태 | 설명 | 진입 조건 |
|---|---|---|
NEW | 생성됨, 아직 시작 전 | new Thread() |
RUNNABLE | 실행 가능 (CPU 대기 또는 실행 중) | start() 호출 |
BLOCKED | 모니터 락 획득 대기 | synchronized 진입 시도 |
WAITING | 무기한 대기 | wait(), join(), park() |
TIMED_WAITING | 지정 시간 대기 | sleep(ms), wait(ms) |
TERMINATED | 실행 완료 | run() 종료 또는 예외 |
Java 예제 - 스레드 상태 관찰:
public class ThreadStateDemo {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Thread thread = new Thread(() -> {
try {
// TIMED_WAITING 상태
Thread.sleep(1000);
synchronized (lock) {
// WAITING 상태
lock.wait();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "관찰-스레드");
// NEW 상태
System.out.println("생성 후: " + thread.getState());
thread.start();
Thread.sleep(100);
// TIMED_WAITING 상태 (sleep 중)
System.out.println("sleep 중: " + thread.getState());
Thread.sleep(1100);
// WAITING 상태 (wait 중)
System.out.println("wait 중: " + thread.getState());
// notify로 깨우기
synchronized (lock) {
lock.notify();
}
thread.join();
// TERMINATED 상태
System.out.println("종료 후: " + thread.getState());
}
}6. 스레드의 위험성
6.1 왜 위험한가?
한 프로세스 안에서 여러 실행 스레드가 서로 자원을 공유할 수 있다. 스레드끼리는 주소 공간을 공유하므로 데이터 공유 속도가 훨씬 빠르다. 그러나 그만큼 접근 제어나 동기화에 주의하지 않으면 데이터 오염을 일으키기도 쉽다.
6.2 세 가지 주요 위험
┌──────────────────────────────────────────────────────────────┐
│ 스레드의 세 가지 위험 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ① 경쟁 조건 (Race Condition) │
│ ──────────────────────────────── │
│ 여러 스레드가 동시에 같은 데이터를 수정할 때 │
│ 결과가 실행 순서에 따라 달라지는 문제 │
│ │
│ ② 가시성 문제 (Visibility) │
│ ──────────────────────────── │
│ 한 스레드가 변경한 값을 다른 스레드가 보지 못하는 문제 │
│ CPU 캐시, 컴파일러 최적화로 인해 발생 │
│ │
│ ③ 재배치 (Reordering) │
│ ──────────────────────── │
│ 컴파일러/프로세서가 성능을 위해 명령어 순서를 바꾸는 문제 │
│ 단일 스레드에서는 문제없지만, 멀티스레드에서 예상 밖의 결과 │
│ │
└──────────────────────────────────────────────────────────────┘6.3 경쟁 조건 예제
public class RaceConditionDemo {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("=== 경쟁 조건 시연 ===\n");
for (int trial = 1; trial <= 5; trial++) {
counter = 0;
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) counter++;
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) counter++;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("시도 " + trial
+ " → 기대: 200000, 실제: " + counter);
}
System.out.println("\n→ 매번 다른 결과가 나온다 (비결정적 동작)");
}
}시도 1 → 기대: 200000, 실제: 153847
시도 2 → 기대: 200000, 실제: 167231
시도 3 → 기대: 200000, 실제: 189456
시도 4 → 기대: 200000, 실제: 145892
시도 5 → 기대: 200000, 실제: 1720646.4 해결 방향
| 위험 | 해결 방법 |
|---|---|
| 경쟁 조건 | synchronized, Lock, Atomic 클래스 |
| 가시성 문제 | volatile, synchronized |
| 데이터 오염 | 불변 객체, 스레드 한정(Thread Confinement) |
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounterDemo {
// AtomicInteger로 경쟁 조건 해결
private static AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
counter.incrementAndGet(); // 원자적 연산
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
counter.incrementAndGet(); // 원자적 연산
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("결과: " + counter.get()); // 항상 200000
}
}7. 정리
핵심 개념 요약
| 개념 | 설명 | 핵심 포인트 |
|---|---|---|
| 프로세스 | 실행 중인 프로그램 인스턴스 | 독립 주소 공간, 격리, IPC 필요 |
| 스레드 | 프로세스 내 실행 단위 | 메모리 공유, 생성/전환 비용 낮음 |
| 컨텍스트 스위칭 | 실행 단위 전환 시 상태 저장/복원 | 스레드 전환이 프로세스 전환보다 가벼움 |
| 스레드 생명주기 | NEW → RUNNABLE → TERMINATED | BLOCKED, WAITING 등 대기 상태 존재 |
| 경쟁 조건 | 공유 자원 동시 접근 시 데이터 오염 | 동기화로 해결 |
스레드를 쓰는 이유와 대가
┌──────────────────────────────────────────────────────────────┐
│ 스레드의 장점과 대가 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 장점 (왜 스레드를 쓰는가) 대가 (무엇을 치러야 하는가) │
│ ───────────────────── ───────────────────────── │
│ ✓ 전환 비용이 낮다 ✗ 동기화가 필요하다 │
│ ✓ 데이터 공유가 빠르다 ✗ 경쟁 조건 위험이 있다 │
│ ✓ 생성 비용이 적다 ✗ 가시성 문제가 발생한다 │
│ ✓ 응답성이 향상된다 ✗ 디버깅이 어렵다 │
│ │
└──────────────────────────────────────────────────────────────┘핵심 정리:
- 프로세스는 독립된 주소 공간을 가지며, 통신에 IPC가 필요하다
- 스레드는 프로세스 내에서 메모리를 공유하며, 생성과 전환이 가벼워 동시성 구현에 적합하다
- 컨텍스트 스위칭은 공짜가 아니며, 스레드 전환이 프로세스 전환보다 가볍다
- 스레드의 편리함을 얻으려면 동기화라는 대가를 반드시 치러야 한다
- 경쟁 조건, 가시성, 재배치 문제를 이해하는 것이 안전한 동시성 코드의 시작이다
참고 자료
- “Grokking Concurrency” by Kirill Bobrov, Chapter 4