- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 도입: 동시성은 왜 어려운가?
- 1. 스레딩 모델
- 2. 언어별 동시성 비교
- 3. 이벤트 루프 심층 분석
- 4. 코루틴과 구조적 동시성
- 5. 액터 모델
- 6. 동기화 프리미티브
- 7. Lock-Free 프로그래밍
- 8. 흔한 동시성 버그
- 9. 실전 패턴
- 10. 성능
- 11. 면접 질문 15선
- 12. 실전 퀴즈
- 참고 자료
도입: 동시성은 왜 어려운가?
2024년 AWS 장애의 주요 원인 중 하나는 동시성 버그였습니다. Cloudflare의 대규모 장애도 레이스 컨디션에서 비롯되었습니다. 동시성 프로그래밍은 올바르게 이해하지 않으면 디버깅이 불가능한 버그를 만들어냅니다.
Rob Pike(Go 언어 공동 창시자)는 명확하게 구분했습니다:
"동시성(Concurrency)은 독립적으로 실행되는 작업들을 다루는 것이고,
병렬성(Parallelism)은 여러 작업을 동시에 실행하는 것이다.
동시성은 구조(structure)에 관한 것이고,
병렬성은 실행(execution)에 관한 것이다."
— Rob Pike
동시성 (Concurrency): 병렬성 (Parallelism):
한 사람이 두 가지 일을 번갈아 두 사람이 각각 한 가지 일을 동시에
Task A ━━━━━━╸ Task A ━━━━━━━━━━━━
╻ Task B ━━━━━━━━━━━━
Task B ━━━━━━╸
╻ CPU Core 1 ━━━━━━━━
Task A ━━━━━━╸ CPU Core 2 ━━━━━━━━
╻
CPU Core 1 ━━━━━━━━
(시분할로 번갈아 실행) (물리적으로 동시 실행)
이 가이드에서는 동시성과 병렬성의 이론적 기초부터 언어별 구현, 실전 패턴, 그리고 흔한 버그와 해결책까지 체계적으로 다룹니다.
1. 스레딩 모델
1.1 1:1 모델 (커널 스레드)
OS가 직접 관리하는 스레드. 각 사용자 스레드가 하나의 커널 스레드에 매핑됩니다.
사용자 스레드: T1 T2 T3 T4
│ │ │ │
커널 스레드: KT1 KT2 KT3 KT4
│ │ │ │
CPU 코어: Core1 Core2 Core1 Core2
사용 언어: Java (전통적), C/C++ (pthread), Rust
1.2 N:1 모델 (그린 스레드)
런타임이 여러 사용자 스레드를 하나의 OS 스레드에 매핑합니다.
사용자 스레드: T1 T2 T3 T4 T5 T6
└──┬──┘ └──┬──┘ └──┬──┘
│ │ │
커널 스레드: KT1 KT1 KT1
│
CPU 코어: Core1
장점: 경량, 빠른 컨텍스트 스위칭 단점: 멀티코어 활용 불가 사용: 초기 Java, 초기 Ruby
1.3 M:N 모델 (하이브리드)
M개의 사용자 스레드를 N개의 OS 스레드에 매핑하는 최적의 모델:
사용자 스레드: G1 G2 G3 G4 G5 G6 G7 G8
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │
커널 스레드: KT1 KT2 KT3 KT4
│ │ │ │
CPU 코어: Core1 Core2 Core3 Core4
사용 언어: Go (고루틴), Java 21+ (Virtual Thread), Erlang (프로세스)
1.4 스레딩 모델 비교
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ 항목 │ 1:1 │ N:1 │ M:N │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ 스레드 비용 │ ~1MB 스택 │ ~KB 스택 │ ~KB 스택 │
│ 생성 속도 │ 느림 (~ms) │ 빠름 (~us) │ 빠름 (~us) │
│ 컨텍스트 스위치 │ 비쌈 (커널) │ 저렴 (유저) │ 저렴 (유저) │
│ 멀티코어 활용 │ O │ X │ O │
│ I/O 블로킹 │ 스레드만 블록│ 전체 블록 │ 스레드만 블록│
│ 최대 스레드 수 │ ~수천 │ ~수백만 │ ~수백만 │
│ 복잡도 │ 낮음 │ 중간 │ 높음 │
│ 예시 │ Java, C++, │ 초기 Ruby │ Go, Erlang, │
│ │ Rust │ │ Java 21+ │
└─────────────────┴──────────────┴──────────────┴──────────────┘
2. 언어별 동시성 비교
2.1 Go — 고루틴과 채널
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make(chan string, 10)
urls := []string{
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/products",
}
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
// HTTP 요청 시뮬레이션
results <- fmt.Sprintf("Fetched: %s", u)
}(url)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println(result)
}
}
특징: CSP(Communicating Sequential Processes) 기반. "메모리를 공유하지 말고, 통신으로 메모리를 공유하라."
2.2 Java 21+ — Virtual Thread
import java.util.concurrent.*;
import java.util.List;
public class VirtualThreadExample {
public static void main(String[] args) throws Exception {
// Virtual Thread로 100만 동시 작업
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = List.of(
executor.submit(() -> fetchURL("https://api.example.com/users")),
executor.submit(() -> fetchURL("https://api.example.com/orders")),
executor.submit(() -> fetchURL("https://api.example.com/products"))
);
for (var future : futures) {
System.out.println(future.get());
}
}
// Structured Concurrency (Preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser(1));
var order = scope.fork(() -> fetchOrder(1));
scope.join();
scope.throwIfFailed();
System.out.println(user.get() + ", " + order.get());
}
}
}
특징: Project Loom으로 추가된 가상 스레드. 기존 코드 변경 최소화하면서 수백만 동시 작업 처리.
2.3 Python — asyncio와 GIL
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/products",
]
async with aiohttp.ClientSession() as session:
# 동시에 모든 URL 요청
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
for url, result in zip(urls, results):
print(f"Fetched {url}: {len(result)} bytes")
asyncio.run(main())
특징: GIL(Global Interpreter Lock) 때문에 CPU 바운드 작업은 진정한 병렬 처리 불가. I/O 바운드 작업에는 asyncio 효과적. CPU 바운드는 multiprocessing 사용.
2.4 Rust — tokio 비동기 런타임
use tokio;
use reqwest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let urls = vec![
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/products",
];
let mut handles = vec![];
for url in urls {
let handle = tokio::spawn(async move {
let resp = reqwest::get(url).await?;
let body = resp.text().await?;
Ok::<String, reqwest::Error>(body)
});
handles.push(handle);
}
for handle in handles {
match handle.await? {
Ok(body) => println!("Fetched: {} bytes", body.len()),
Err(e) => eprintln!("Error: {}", e),
}
}
Ok(())
}
특징: 소유권 시스템으로 컴파일 타임에 데이터 레이스 방지. Send/Sync 트레잇으로 스레드 안전성 보장.
2.5 JavaScript — 이벤트 루프
// Node.js 비동기 패턴
async function fetchAll() {
const urls = [
'https://api.example.com/users',
'https://api.example.com/orders',
'https://api.example.com/products',
];
// Promise.all로 동시 실행
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
// Promise.allSettled로 실패 허용
const settled = await Promise.allSettled(
urls.map(url => fetch(url).then(r => r.json()))
);
settled.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`Success: ${urls[i]}`);
} else {
console.log(`Failed: ${urls[i]} - ${result.reason}`);
}
});
}
특징: 단일 스레드 + 이벤트 루프. CPU 바운드 작업은 Worker Thread 필요. I/O에 최적화.
2.6 Erlang/Elixir — 액터 모델
%% Erlang 프로세스 (경량 액터)
-module(counter).
-export([start/0, increment/1, get/1]).
start() ->
spawn(fun() -> loop(0) end).
loop(Count) ->
receive
{increment, From} ->
From ! {ok, Count + 1},
loop(Count + 1);
{get, From} ->
From ! {value, Count},
loop(Count);
stop ->
ok
end.
increment(Pid) ->
Pid ! {increment, self()},
receive {ok, NewCount} -> NewCount end.
get(Pid) ->
Pid ! {get, self()},
receive {value, Count} -> Count end.
특징: 프로세스당 약 300바이트. 수백만 프로세스 동시 실행. "Let it crash" 철학으로 내결함성(fault-tolerance) 달성.
2.7 언어별 동시성 비교표
┌─────────────┬─────────────┬───────────────┬──────────────┬──────────────┐
│ 언어 │ 모델 │ 단위 │ 스케줄링 │ 특징 │
├─────────────┼─────────────┼───────────────┼──────────────┼──────────────┤
│ Go │ CSP (M:N) │ 고루틴 │ 런타임 │ 채널 통신 │
│ Java 21+ │ M:N │ Virtual Thread│ JVM │ 기존호환 │
│ Python │ 이벤트루프 │ 코루틴 │ asyncio │ GIL 제약 │
│ Rust │ async/await │ Future/Task │ tokio 등 │ 제로코스트 │
│ JavaScript │ 이벤트루프 │ Promise │ libuv/V8 │ 단일스레드 │
│ Erlang │ Actor(M:N) │ 프로세스 │ BEAM VM │ 내결함성 │
│ Kotlin │ 코루틴 │ Coroutine │ Dispatcher │ 구조적동시성 │
└─────────────┴─────────────┴───────────────┴──────────────┴──────────────┘
3. 이벤트 루프 심층 분석
3.1 Node.js 이벤트 루프 단계
┌───────────────────────────┐
│ timers │ ← setTimeout, setInterval
│ (타이머 콜백 실행) │
├───────────────────────────┤
│ pending callbacks │ ← 시스템 에러 콜백
│ (지연된 I/O 콜백) │
├───────────────────────────┤
│ idle, prepare │ ← 내부용
│ (내부 전용) │
├───────────────────────────┤
│ poll │ ← I/O 콜백 (대부분의 콜백)
│ (새 I/O 이벤트 대기) │
├───────────────────────────┤
│ check │ ← setImmediate 콜백
│ (setImmediate 실행) │
├───────────────────────────┤
│ close callbacks │ ← socket.on('close')
│ (종료 콜백) │
└───────────────────────────┘
각 단계 사이: 마이크로태스크 큐 처리
(Promise.then, queueMicrotask, process.nextTick)
3.2 마이크로태스크 vs 매크로태스크
console.log('1: sync');
setTimeout(() => console.log('2: macro (setTimeout)'), 0);
Promise.resolve().then(() => console.log('3: micro (Promise)'));
queueMicrotask(() => console.log('4: micro (queueMicrotask)'));
console.log('5: sync');
// 출력 순서:
// 1: sync
// 5: sync
// 3: micro (Promise)
// 4: micro (queueMicrotask)
// 2: macro (setTimeout)
규칙: 동기 코드 먼저 실행 → 마이크로태스크 전부 실행 → 매크로태스크 하나 실행 → 반복
3.3 libuv 아키텍처
┌──────────────────────────────────────────┐
│ Node.js │
│ ┌─────────────────────────────────┐ │
│ │ JavaScript (V8 Engine) │ │
│ │ ┌───────────┐ ┌────────────┐ │ │
│ │ │ Async │ │ Event │ │ │
│ │ │ APIs │ │ Queue │ │ │
│ │ └─────┬─────┘ └──────┬─────┘ │ │
│ └────────┼────────────────┼────────┘ │
│ │ Event Loop │ │
│ ┌────────▼────────────────▼────────┐ │
│ │ libuv │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Thread │ │ epoll/kqueue │ │ │
│ │ │ Pool │ │ /IOCP │ │ │
│ │ │ (4 thds) │ │ (non-block) │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────┘ │
│ │ │ │
│ File I/O Network I/O │
│ DNS lookup TCP/UDP │
│ Crypto HTTP │
└──────────────────────────────────────────┘
파일 I/O와 DNS는 스레드 풀에서 처리되고, 네트워크 I/O는 OS의 비동기 메커니즘(epoll, kqueue, IOCP)으로 처리됩니다.
4. 코루틴과 구조적 동시성
4.1 Python async/await
import asyncio
async def process_item(item: str) -> str:
await asyncio.sleep(1) # I/O 시뮬레이션
return f"Processed: {item}"
async def main():
items = ["a", "b", "c", "d", "e"]
# 동시 실행
tasks = [process_item(item) for item in items]
results = await asyncio.gather(*tasks)
print(results) # 1초만에 모두 완료
# 타임아웃
try:
result = await asyncio.wait_for(
process_item("slow"),
timeout=0.5
)
except asyncio.TimeoutError:
print("Timeout!")
# 세마포어로 동시 실행 수 제한
semaphore = asyncio.Semaphore(3)
async def limited_process(item):
async with semaphore:
return await process_item(item)
results = await asyncio.gather(*[limited_process(i) for i in items])
asyncio.run(main())
4.2 Kotlin 코루틴과 구조적 동시성
import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): User {
delay(1000) // I/O 시뮬레이션
return User(id, "User $id")
}
fun main() = runBlocking {
// 구조적 동시성: 부모가 취소되면 자식도 취소
coroutineScope {
val user = async { fetchUser(1) }
val orders = async { fetchOrders(1) }
println("User: ${user.await()}")
println("Orders: ${orders.await()}")
} // 모든 자식 코루틴 완료까지 대기
// SupervisorScope: 자식 실패가 다른 자식에 영향 안 줌
supervisorScope {
val job1 = launch {
delay(100)
throw RuntimeException("Failed!")
}
val job2 = launch {
delay(200)
println("Job2 completed") // 정상 실행
}
}
}
4.3 구조적 동시성 원칙
전통적 동시성: 구조적 동시성:
┌─────────────┐ ┌─────────────┐
│ Parent │ │ Parent │
│ ┌───┐ ┌───┐│ │ ┌───┐ ┌───┐│
│ │ A │ │ B ││ │ │ A │ │ B ││
│ └─┬─┘ └─┬─┘│ │ └─┬─┘ └─┬─┘│
└────┼─────┼───┘ └────┼─────┼───┘
│ │ ← 누가 관리? │ │ ← Parent가 관리
▼ ▼ 누수 가능! ▼ ▼ 자동 정리!
규칙:
1. 모든 동시 작업은 스코프 내에서 시작
2. 스코프는 모든 자식 작업 완료까지 대기
3. 부모 취소 시 모든 자식도 취소
4. 자식 에러는 부모에게 전파
5. 액터 모델
5.1 개념
액터 모델에서 각 액터는:
- 자신만의 상태를 가짐 (다른 액터와 공유하지 않음)
- 메시지를 비동기로 주고받음
- 메시지를 처리하며 새 액터를 만들거나, 상태를 변경하거나, 메시지를 보낼 수 있음
┌──────────┐ 메시지 ┌──────────┐
│ Actor A │ ─────────────→ │ Actor B │
│ │ │ │
│ State: x │ 메시지 │ State: y │
│ Mailbox │ ←───────────── │ Mailbox │
└──────────┘ └──────────┘
│
│ 메시지
▼
┌──────────┐
│ Actor C │
│ State: z │
│ Mailbox │
└──────────┘
핵심: 공유 상태 없음, 락 없음, 메시지 전달만
5.2 Erlang/OTP 감독 트리
┌──────────────┐
│ Supervisor │
│ (one_for_one)│
└──────┬───────┘
┌────┼────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ W1 │ │ W2 │ │ W3 │
└─────┘ └─────┘ └─────┘
W2가 크래시하면:
1. Supervisor가 감지
2. W2만 재시작 (one_for_one 전략)
3. W1, W3는 영향 없음
4. 5분 안에 5번 이상 재시작하면 Supervisor도 종료
5.3 언제 액터 모델을 사용하나?
적합한 경우:
- 수백만 독립적 상태 관리 (채팅방, IoT 디바이스)
- 분산 시스템 (노드 간 통신)
- 내결함성이 필수인 시스템 (통신, 금융)
- 실시간 시스템 (게임 서버, 트레이딩)
부적합한 경우:
- 단순한 CRUD 애플리케이션
- 강한 일관성(strong consistency) 필요
- 트랜잭션 처리가 핵심
- 순서 보장이 중요한 파이프라인
6. 동기화 프리미티브
6.1 뮤텍스 (Mutex)
// Go - Mutex
type SafeMap struct {
mu sync.Mutex
data map[string]int
}
func (m *SafeMap) Set(key string, val int) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = val
}
func (m *SafeMap) Get(key string) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.data[key]
return val, ok
}
// Java - ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
public class SafeCounter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
6.2 RWLock (읽기-쓰기 잠금)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
// 여러 고루틴이 동시에 읽기 가능
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// 쓰기는 배타적 잠금
func (c *Cache) Set(key, val string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = val
}
6.3 세마포어 (Semaphore)
import asyncio
async def worker(name, semaphore):
async with semaphore:
print(f"{name} acquired semaphore")
await asyncio.sleep(1)
print(f"{name} released semaphore")
async def main():
# 최대 3개 동시 실행
semaphore = asyncio.Semaphore(3)
tasks = [worker(f"Worker-{i}", semaphore) for i in range(10)]
await asyncio.gather(*tasks)
asyncio.run(main())
6.4 조건 변수 (Condition Variable)
type BoundedQueue struct {
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
items []interface{}
maxSize int
}
func NewBoundedQueue(maxSize int) *BoundedQueue {
q := &BoundedQueue{maxSize: maxSize}
q.notEmpty = sync.NewCond(&q.mu)
q.notFull = sync.NewCond(&q.mu)
return q
}
func (q *BoundedQueue) Put(item interface{}) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == q.maxSize {
q.notFull.Wait() // 공간이 생길 때까지 대기
}
q.items = append(q.items, item)
q.notEmpty.Signal() // 소비자 깨우기
}
func (q *BoundedQueue) Take() interface{} {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.notEmpty.Wait() // 아이템이 올 때까지 대기
}
item := q.items[0]
q.items = q.items[1:]
q.notFull.Signal() // 생산자 깨우기
return item
}
7. Lock-Free 프로그래밍
7.1 CAS (Compare-And-Swap)
import "sync/atomic"
type LockFreeCounter struct {
value int64
}
func (c *LockFreeCounter) Increment() int64 {
for {
old := atomic.LoadInt64(&c.value)
new := old + 1
if atomic.CompareAndSwapInt64(&c.value, old, new) {
return new
}
// CAS 실패 시 재시도 (다른 고루틴이 먼저 변경)
}
}
func (c *LockFreeCounter) Get() int64 {
return atomic.LoadInt64(&c.value)
}
7.2 ABA 문제
스레드 A: 값 읽기 → A
스레드 B: A → B 변경
스레드 B: B → A 변경 (원래 값으로 복원)
스레드 A: CAS(A → C) 성공! (하지만 중간에 값이 변했었음)
해결책:
- 버전 번호 추가 (A,v1 → B,v2 → A,v3)
- 태그된 포인터 (AtomicStampedReference in Java)
- Hazard Pointer
7.3 Lock-Free를 사용하지 말아야 할 때
Lock-Free가 적합한 경우:
- 매우 높은 경합(contention) 상황
- 실시간 시스템 (우선순위 역전 방지)
- 단순한 자료구조 (카운터, 스택, 큐)
Lock-Free를 피해야 하는 경우 (대부분의 경우):
- 일반적인 애플리케이션 (Mutex로 충분)
- 복잡한 자료구조 (트리, 그래프)
- 정확성 검증이 어려운 상황
- 팀원이 Lock-Free에 익숙하지 않은 경우
"Mutex가 작동하면 Mutex를 쓰세요."
8. 흔한 동시성 버그
8.1 레이스 컨디션 (Race Condition)
// 버그: 여러 고루틴이 동시에 counter 수정
var counter int
func buggyIncrement() {
for i := 0; i < 1000; i++ {
go func() {
counter++ // 읽기-수정-쓰기가 원자적이지 않음
}()
}
}
// 해결: atomic 또는 mutex 사용
var safeCounter int64
func safeIncrement() {
for i := 0; i < 1000; i++ {
go func() {
atomic.AddInt64(&safeCounter, 1)
}()
}
}
8.2 데드락 (Deadlock)
4가지 필요조건 (모두 만족해야 데드락):
1. 상호 배제 (Mutual Exclusion): 리소스를 한 번에 하나만 사용
2. 점유와 대기 (Hold and Wait): 리소스 점유하면서 다른 리소스 대기
3. 비선점 (No Preemption): 점유된 리소스를 강제로 빼앗을 수 없음
4. 순환 대기 (Circular Wait): A→B→C→A 순환 대기
// 데드락 예시
var mu1, mu2 sync.Mutex
func goroutine1() {
mu1.Lock()
defer mu1.Unlock()
time.Sleep(time.Millisecond)
mu2.Lock() // mu2 대기 (goroutine2가 점유)
defer mu2.Unlock()
}
func goroutine2() {
mu2.Lock()
defer mu2.Unlock()
time.Sleep(time.Millisecond)
mu1.Lock() // mu1 대기 (goroutine1이 점유)
defer mu1.Unlock()
}
// 해결: 항상 같은 순서로 잠금 획득
func fixed1() {
mu1.Lock()
defer mu1.Unlock()
mu2.Lock()
defer mu2.Unlock()
}
func fixed2() {
mu1.Lock() // mu1을 먼저 (같은 순서)
defer mu1.Unlock()
mu2.Lock()
defer mu2.Unlock()
}
8.3 라이브락 (Livelock)
A와 B가 좁은 복도에서 만남:
A: 왼쪽으로 비킴 ← → B: 오른쪽으로 비킴 (같은 방향!)
A: 오른쪽으로 비킴 ← → B: 왼쪽으로 비킴 (또 같은 방향!)
... (무한 반복, 아무도 진행 못함)
vs 데드락: 아무것도 안 함
vs 라이브락: 계속 움직이지만 진행 없음
8.4 기아 (Starvation)
우선순위가 높은 스레드가 계속 리소스를 차지하여, 낮은 우선순위 스레드가 영원히 실행되지 못하는 상태.
8.5 우선순위 역전 (Priority Inversion)
1997년 화성 탐사선 Mars Pathfinder 사건:
Low-priority 태스크가 뮤텍스 점유
↓
Medium-priority 태스크가 Low를 선점 (뮤텍스 무관)
↓
High-priority 태스크가 뮤텍스 대기... 영원히!
(Low가 실행돼야 뮤텍스 해제되는데, Medium이 계속 선점)
해결: 우선순위 상속 프로토콜
Low가 뮤텍스를 점유하면 High의 우선순위를 일시적으로 상속
8.6 감지 도구
# Go: 레이스 디텍터
go test -race ./...
go run -race main.go
# Java: Thread Sanitizer
java -XX:+UseThreadSanitizer MyApp
# Rust: 컴파일러가 컴파일 타임에 감지!
# Send/Sync 트레잇 위반 시 컴파일 에러
# Python: ThreadSanitizer (C extension)
# 또는 asyncio.debug 모드
PYTHONASYNCIODEBUG=1 python app.py
# 일반: Helgrind (Valgrind 도구)
valgrind --tool=helgrind ./program
9. 실전 패턴
9.1 생산자-소비자
func producerConsumer() {
ch := make(chan int, 100) // 버퍼 있는 채널
// 생산자들
var prodWg sync.WaitGroup
for i := 0; i < 3; i++ {
prodWg.Add(1)
go func(id int) {
defer prodWg.Done()
for j := 0; j < 10; j++ {
ch <- id*100 + j
}
}(i)
}
// 채널 닫기
go func() {
prodWg.Wait()
close(ch)
}()
// 소비자들
var consWg sync.WaitGroup
for i := 0; i < 2; i++ {
consWg.Add(1)
go func(id int) {
defer consWg.Done()
for item := range ch {
fmt.Printf("Consumer %d: %d\n", id, item)
}
}(i)
}
consWg.Wait()
}
9.2 스레드 풀 / 워커 풀
import asyncio
from asyncio import Queue
async def worker(name: str, queue: Queue):
while True:
item = await queue.get()
try:
await process(item)
finally:
queue.task_done()
async def main():
queue = Queue(maxsize=100)
# 워커 5개 시작
workers = [
asyncio.create_task(worker(f"worker-{i}", queue))
for i in range(5)
]
# 작업 추가
for item in range(50):
await queue.put(item)
# 모든 작업 완료 대기
await queue.join()
# 워커 종료
for w in workers:
w.cancel()
asyncio.run(main())
9.3 Work Stealing
전통적 스레드 풀: Work Stealing:
┌─────────────┐ ┌─────────────┐
│ 공유 큐 │ │ Worker 1 │
│ [T1|T2|T3] │ │ 로컬큐:[T1] │
│ │ ├─────────────┤
│ 모든 워커가 │ │ Worker 2 │
│ 경합! │ │ 로컬큐:[T2] │ ← 비면 Worker 1에서 훔침
│ │ ├─────────────┤
└─────────────┘ │ Worker 3 │
│ 로컬큐:[T3] │
└─────────────┘
장점: 캐시 지역성 향상, 경합 감소
사용: Java ForkJoinPool, Go 런타임, Tokio(Rust)
10. 성능
10.1 암달의 법칙 (Amdahl's Law)
속도 향상 = 1 / ((1 - P) + P/N)
P = 병렬화 가능한 비율
N = 프로세서 수
예: 프로그램의 95%가 병렬화 가능, 64코어일 때
속도 향상 = 1 / (0.05 + 0.95/64) = 1 / 0.0648 = 15.4배
→ 아무리 코어를 늘려도 직렬 부분(5%)이 병목!
→ 최대 이론적 속도 향상 = 1/0.05 = 20배
10.2 구스타프슨의 법칙 (Gustafson's Law)
속도 향상 = N - alpha * (N - 1)
alpha = 직렬 부분의 비율
N = 프로세서 수
핵심 차이:
- 암달: "문제 크기 고정, 코어를 늘리면?"
- 구스타프슨: "코어 고정, 문제 크기를 늘리면?"
→ 현실에서는 데이터가 늘어나면 병렬 부분도 늘어남
→ 구스타프슨이 더 낙관적이고 현실적
10.3 컨텍스트 스위치 비용
컨텍스트 스위치 비용 비교:
┌───────────────────────────┬──────────────┐
│ 스위치 유형 │ 대략적 비용 │
├───────────────────────────┼──────────────┤
│ 함수 호출 │ ~1-5 ns │
│ 고루틴 스위치 │ ~100-200 ns │
│ 코루틴 스위치 (Python) │ ~1-5 us │
│ 스레드 스위치 (같은 프로세스)│ ~1-10 us │
│ 프로세스 스위치 │ ~10-50 us │
│ 가상머신 스위치 │ ~100+ us │
└───────────────────────────┴──────────────┘
10.4 False Sharing (캐시 라인 문제)
문제: 서로 다른 변수가 같은 캐시 라인에 위치
CPU Core 1 CPU Core 2
│ │
┌───▼───────────────────▼───┐
│ Cache Line (64 bytes) │
│ [counter_A] [counter_B] │ ← 서로 다른 변수인데
│ │ 같은 캐시 라인!
└───────────────────────────┘
Core 1이 counter_A 수정 → Cache Line 무효화 → Core 2도 재로드 필요
Core 2가 counter_B 수정 → Cache Line 무효화 → Core 1도 재로드 필요
해결: 패딩으로 캐시 라인 분리
// Go에서 False Sharing 방지
type PaddedCounter struct {
value int64
_pad [56]byte // 64바이트 캐시 라인 패딩
}
type Counters struct {
c1 PaddedCounter
c2 PaddedCounter
}
11. 면접 질문 15선
-
동시성(Concurrency)과 병렬성(Parallelism)의 차이는? 동시성은 여러 작업을 번갈아 처리하는 구조(structure), 병렬성은 물리적으로 동시에 실행(execution)하는 것. 단일 코어에서도 동시성은 가능하지만 병렬성은 불가.
-
프로세스와 스레드의 차이는? 프로세스는 독립된 메모리 공간, 스레드는 프로세스 내 메모리 공유. 스레드가 생성/전환 비용이 낮지만 동기화가 필요.
-
데드락의 4가지 필요조건과 각각의 해결법은? 상호배제, 점유와대기, 비선점, 순환대기. 순서화된 잠금 획득(순환대기 방지)이 가장 실용적.
-
Go 고루틴이 OS 스레드보다 가벼운 이유는? 약 2KB 스택(OS 스레드는 1MB+), M:N 스케줄러로 소수 OS 스레드에 수백만 고루틴 매핑, 사용자 공간 컨텍스트 스위칭.
-
Python의 GIL이란 무엇이며 왜 존재하는가? Global Interpreter Lock. CPython의 메모리 관리(참조 카운팅)가 스레드 안전하지 않아서 필요. CPU 바운드 병렬 처리를 위해서는 multiprocessing 사용.
-
이벤트 루프와 멀티스레딩의 장단점은? 이벤트 루프: 단순한 모델, 컨텍스트 스위칭 비용 없음, CPU 바운드에 부적합. 멀티스레딩: CPU 활용 극대화, 동기화 복잡, 데드락 위험.
-
async/await가 스레드와 다른 점은? async/await는 협력적(cooperative) 멀티태스킹으로, await 지점에서만 전환. 스레드는 선점적(preemptive)으로 OS가 언제든 전환 가능.
-
액터 모델의 장점과 단점은? 장점: 공유 상태 없음, 데드락 없음, 분산 시스템에 자연스러움. 단점: 메시지 순서 보장 어려움, 디버깅 복잡, 성능 오버헤드.
-
CAS 연산이란? 언제 Mutex 대신 사용하나? Compare-And-Swap은 원자적 비교-교환 연산. 단순한 카운터나 포인터 업데이트에서 Mutex보다 빠르지만, 복잡한 로직에는 부적합.
-
False Sharing이란? 어떻게 해결하나? 서로 다른 코어가 같은 캐시 라인의 다른 변수를 수정할 때 발생하는 성능 저하. 패딩으로 변수를 별도 캐시 라인에 배치하여 해결.
-
암달의 법칙이 시사하는 바는? 직렬 부분이 전체 속도 향상의 상한을 결정. 병렬화 최적화보다 직렬 부분 축소가 더 중요할 수 있음.
-
Java Virtual Thread와 Go 고루틴의 차이는? 둘 다 M:N 모델이지만, Go는 채널 기반 CSP 모델, Java는 기존 Thread API 호환성 유지. Go가 먼저 도입, Java 21에서 정식 지원.
-
구조적 동시성(Structured Concurrency)이란? 동시 작업을 스코프에 묶어 생명주기를 관리. 부모 취소 시 자식 자동 취소, 자식 완료까지 부모 대기. 고루틴/스레드 누수 방지.
-
Erlang의 "Let it crash" 철학은? 에러를 방어적으로 처리하기보다, 프로세스를 빠르게 종료하고 감독자가 재시작하는 것이 더 안전. 복잡한 에러 복구 코드 불필요.
-
스레드 풀의 적절한 크기는? CPU 바운드: CPU 코어 수. I/O 바운드: CPU 코어 수 * (1 + 대기시간/처리시간). Little's Law(L = lambda * W)로 추정 가능.
12. 실전 퀴즈
Q1: 다음 JavaScript 코드의 출력 순서는?
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
답: A, D, C, B. 동기 코드(A, D) 먼저 → 마이크로태스크(Promise: C) → 매크로태스크(setTimeout: B).
Q2: Go에서 이 코드가 데드락인 이유는?
func main() {
ch := make(chan int)
ch <- 42
fmt.Println(<-ch)
}
답: 언버퍼 채널은 송신자와 수신자가 동시에 있어야 합니다. 메인 고루틴이 ch <- 42에서 수신자를 기다리며 블록되어 <-ch에 도달할 수 없습니다.
Q3: Python에서 asyncio.gather와 asyncio.wait의 차이는?
답: gather는 모든 결과를 순서대로 반환하고, 하나가 실패하면 예외를 발생시킵니다(return_exceptions=True로 변경 가능). wait는 완료/미완료 세트를 반환하고, FIRST_COMPLETED, FIRST_EXCEPTION, ALL_COMPLETED 옵션을 제공합니다. wait가 더 유연합니다.
Q4: 암달의 법칙에 따르면, 90%를 병렬화할 수 있는 프로그램의 최대 속도 향상은?
답: 코어를 무한으로 늘려도 최대 속도 향상 = 1/(1-0.9) = 1/0.1 = 10배입니다. 10%의 직렬 부분이 병목이 됩니다. 64코어에서는 1/(0.1 + 0.9/64) = 약 8.8배입니다.
Q5: Rust가 컴파일 타임에 데이터 레이스를 방지하는 원리는?
답: Rust의 소유권(ownership) 시스템과 Send/Sync 트레잇이 핵심입니다. Send는 스레드 간 소유권 이전 가능, Sync는 스레드 간 참조 공유 가능을 의미합니다. 가변 참조는 동시에 하나만 존재할 수 있어(borrow checker), 컴파일 타임에 데이터 레이스를 원천 차단합니다.
참고 자료
- Rob Pike - Concurrency Is Not Parallelism (Talk)
- The Art of Multiprocessor Programming (Herlihy & Shavit)
- Java Concurrency in Practice (Goetz et al.)
- JEP 444: Virtual Threads
- Node.js Event Loop Documentation
- Python asyncio Documentation
- Tokio Tutorial (Rust)
- Erlang/OTP Design Principles
- Go Concurrency Patterns
- Kotlin Coroutines Guide
- Lock-Free Programming (Preshing)
- Amdahl's Law (Wikipedia)
- LMAX Disruptor (Lock-Free Queue)
- Structured Concurrency (Wikipedia)
- Thread Sanitizer (Google)