Split View: 동시성과 병렬성 완전 정복: 멀티스레딩, 비동기, 이벤트 루프의 모든 것
동시성과 병렬성 완전 정복: 멀티스레딩, 비동기, 이벤트 루프의 모든 것
- 도입: 동시성은 왜 어려운가?
- 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)
Concurrency & Parallelism Deep Dive: Multithreading, Async, and Event Loops Explained
- Introduction: Why Is Concurrency So Hard?
- 1. Threading Models
- 2. Language-by-Language Concurrency
- 3. Event Loop Deep Dive
- 4. Coroutines and Structured Concurrency
- 5. Actor Model
- 6. Synchronization Primitives
- 7. Lock-Free Programming
- 8. Common Concurrency Bugs
- 9. Practical Patterns
- 10. Performance
- 11. Interview Questions (15)
- 12. Practice Quiz
- References
Introduction: Why Is Concurrency So Hard?
One of the major causes of the 2024 AWS outage was a concurrency bug. Cloudflare's large-scale incident also originated from a race condition. Concurrency programming creates impossible-to-debug bugs if not properly understood.
Rob Pike (co-creator of Go) made the distinction clear:
"Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.
Concurrency is about structure.
Parallelism is about execution."
— Rob Pike
Concurrency: Parallelism:
One person juggling two tasks Two people each doing one task
Task A ━━━━━━╸ Task A ━━━━━━━━━━━━
╻ Task B ━━━━━━━━━━━━
Task B ━━━━━━╸
╻ CPU Core 1 ━━━━━━━━
Task A ━━━━━━╸ CPU Core 2 ━━━━━━━━
╻
CPU Core 1 ━━━━━━━━
(time-slicing) (physically simultaneous)
This guide systematically covers the theoretical foundations of concurrency and parallelism, language-specific implementations, practical patterns, and common bugs with their solutions.
1. Threading Models
1.1 1:1 Model (Kernel Threads)
Threads managed directly by the OS. Each user thread maps to one kernel thread.
User threads: T1 T2 T3 T4
│ │ │ │
Kernel threads: KT1 KT2 KT3 KT4
│ │ │ │
CPU cores: Core1 Core2 Core1 Core2
Languages: Java (traditional), C/C++ (pthread), Rust
1.2 N:1 Model (Green Threads)
Runtime maps multiple user threads to a single OS thread.
User threads: T1 T2 T3 T4 T5 T6
└──┬──┘ └──┬──┘ └──┬──┘
│ │ │
Kernel threads: KT1 KT1 KT1
│
CPU cores: Core1
Pros: Lightweight, fast context switching Cons: Cannot use multiple cores Used by: Early Java, early Ruby
1.3 M:N Model (Hybrid)
Maps M user threads to N OS threads — the optimal model:
User threads: G1 G2 G3 G4 G5 G6 G7 G8
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │
Kernel threads: KT1 KT2 KT3 KT4
│ │ │ │
CPU cores: Core1 Core2 Core3 Core4
Languages: Go (goroutines), Java 21+ (Virtual Threads), Erlang (processes)
1.4 Threading Model Comparison
┌─────────────────┬──────────────┬──────────────┬──────────────┐
│ Category │ 1:1 │ N:1 │ M:N │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ Thread cost │ ~1MB stack │ ~KB stack │ ~KB stack │
│ Creation speed │ Slow (~ms) │ Fast (~us) │ Fast (~us) │
│ Context switch │ Expensive │ Cheap (user) │ Cheap (user) │
│ Multi-core │ Yes │ No │ Yes │
│ I/O blocking │ Thread only │ All blocked │ Thread only │
│ Max threads │ ~thousands │ ~millions │ ~millions │
│ Complexity │ Low │ Medium │ High │
│ Examples │ Java, C++, │ Early Ruby │ Go, Erlang, │
│ │ Rust │ │ Java 21+ │
└─────────────────┴──────────────┴──────────────┴──────────────┘
2. Language-by-Language Concurrency
2.1 Go — Goroutines and Channels
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()
results <- fmt.Sprintf("Fetched: %s", u)
}(url)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println(result)
}
}
Characteristics: CSP (Communicating Sequential Processes) based. "Don't communicate by sharing memory; share memory by communicating."
2.2 Java 21+ — Virtual Threads
import java.util.concurrent.*;
import java.util.List;
public class VirtualThreadExample {
public static void main(String[] args) throws Exception {
// Million concurrent tasks with Virtual Threads
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());
}
}
}
Characteristics: Virtual threads added via Project Loom. Millions of concurrent tasks with minimal code changes.
2.3 Python — asyncio and the 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:
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())
Characteristics: The GIL (Global Interpreter Lock) prevents true parallelism for CPU-bound work. asyncio is effective for I/O-bound tasks. Use multiprocessing for CPU-bound work.
2.4 Rust — tokio Async Runtime
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(())
}
Characteristics: Ownership system prevents data races at compile time. Send/Sync traits guarantee thread safety.
2.5 JavaScript — Event Loop
async function fetchAll() {
const urls = [
'https://api.example.com/users',
'https://api.example.com/orders',
'https://api.example.com/products',
];
// Concurrent execution with Promise.all
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
// Promise.allSettled allows failures
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}`);
}
});
}
Characteristics: Single thread + event loop. CPU-bound work requires Worker Threads. Optimized for I/O.
2.6 Erlang/Elixir — Actor Model
%% Erlang processes (lightweight actors)
-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.
Characteristics: About 300 bytes per process. Millions of concurrent processes. "Let it crash" philosophy for fault tolerance.
2.7 Language Comparison Table
┌─────────────┬─────────────┬───────────────┬──────────────┬──────────────┐
│ Language │ Model │ Unit │ Scheduling │ Key Feature │
├─────────────┼─────────────┼───────────────┼──────────────┼──────────────┤
│ Go │ CSP (M:N) │ Goroutine │ Runtime │ Channels │
│ Java 21+ │ M:N │ Virtual Thread│ JVM │ Backward compat│
│ Python │ Event loop │ Coroutine │ asyncio │ GIL limit │
│ Rust │ async/await │ Future/Task │ tokio etc. │ Zero-cost │
│ JavaScript │ Event loop │ Promise │ libuv/V8 │ Single thread│
│ Erlang │ Actor (M:N) │ Process │ BEAM VM │ Fault tolerant│
│ Kotlin │ Coroutines │ Coroutine │ Dispatcher │ Structured │
└─────────────┴─────────────┴───────────────┴──────────────┴──────────────┘
3. Event Loop Deep Dive
3.1 Node.js Event Loop Phases
┌───────────────────────────┐
│ timers │ ← setTimeout, setInterval
│ (execute timer cbs) │
├───────────────────────────┤
│ pending callbacks │ ← system error callbacks
│ (deferred I/O cbs) │
├───────────────────────────┤
│ idle, prepare │ ← internal use
├───────────────────────────┤
│ poll │ ← I/O callbacks (most)
│ (wait for new I/O) │
├───────────────────────────┤
│ check │ ← setImmediate callbacks
├───────────────────────────┤
│ close callbacks │ ← socket.on('close')
└───────────────────────────┘
Between each phase: microtask queue is drained
(Promise.then, queueMicrotask, process.nextTick)
3.2 Microtasks vs Macrotasks
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');
// Output order:
// 1: sync
// 5: sync
// 3: micro (Promise)
// 4: micro (queueMicrotask)
// 2: macro (setTimeout)
Rule: Synchronous code first, then all microtasks, then one macrotask, repeat.
3.3 libuv Architecture
┌──────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────┘
File I/O and DNS are handled via the thread pool, while network I/O uses the OS's async mechanisms (epoll, kqueue, IOCP).
4. Coroutines and Structured Concurrency
4.1 Python async/await
import asyncio
async def process_item(item: str) -> str:
await asyncio.sleep(1) # I/O simulation
return f"Processed: {item}"
async def main():
items = ["a", "b", "c", "d", "e"]
# Concurrent execution
tasks = [process_item(item) for item in items]
results = await asyncio.gather(*tasks)
print(results) # All complete in 1 second
# Timeout
try:
result = await asyncio.wait_for(
process_item("slow"),
timeout=0.5
)
except asyncio.TimeoutError:
print("Timeout!")
# Semaphore to limit concurrency
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 Coroutines and Structured Concurrency
import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): User {
delay(1000)
return User(id, "User $id")
}
fun main() = runBlocking {
// Structured concurrency: parent cancel propagates to children
coroutineScope {
val user = async { fetchUser(1) }
val orders = async { fetchOrders(1) }
println("User: ${user.await()}")
println("Orders: ${orders.await()}")
} // Waits for all child coroutines
// SupervisorScope: child failure doesn't affect siblings
supervisorScope {
val job1 = launch {
delay(100)
throw RuntimeException("Failed!")
}
val job2 = launch {
delay(200)
println("Job2 completed") // Still runs
}
}
}
4.3 Structured Concurrency Principles
Traditional concurrency: Structured concurrency:
┌─────────────┐ ┌─────────────┐
│ Parent │ │ Parent │
│ ┌───┐ ┌───┐│ │ ┌───┐ ┌───┐│
│ │ A │ │ B ││ │ │ A │ │ B ││
│ └─┬─┘ └─┬─┘│ │ └─┬─┘ └─┬─┘│
└────┼─────┼───┘ └────┼─────┼───┘
│ │ ← Who manages? │ │ ← Parent manages
▼ ▼ Leak possible! ▼ ▼ Auto cleanup!
Rules:
1. All concurrent work starts within a scope
2. Scope waits for all child tasks to complete
3. Parent cancellation cancels all children
4. Child errors propagate to parent
5. Actor Model
5.1 Concept
In the actor model, each actor:
- Has its own state (not shared with other actors)
- Sends and receives messages asynchronously
- Can create new actors, change state, or send messages when processing a message
┌──────────┐ message ┌──────────┐
│ Actor A │ ──────────────→ │ Actor B │
│ │ │ │
│ State: x │ message │ State: y │
│ Mailbox │ ←────────────── │ Mailbox │
└──────────┘ └──────────┘
│
│ message
▼
┌──────────┐
│ Actor C │
│ State: z │
│ Mailbox │
└──────────┘
Key: No shared state, no locks, only message passing
5.2 Erlang/OTP Supervision Trees
┌──────────────┐
│ Supervisor │
│ (one_for_one)│
└──────┬───────┘
┌────┼────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│ W1 │ │ W2 │ │ W3 │
└─────┘ └─────┘ └─────┘
When W2 crashes:
1. Supervisor detects it
2. Only W2 restarts (one_for_one strategy)
3. W1, W3 are unaffected
4. If restarted 5+ times in 5 minutes, Supervisor also terminates
5.3 When to Use the Actor Model
Good fit:
- Managing millions of independent states (chat rooms, IoT devices)
- Distributed systems (inter-node communication)
- Fault-tolerance required (telecom, finance)
- Real-time systems (game servers, trading)
Poor fit:
- Simple CRUD applications
- Strong consistency required
- Transaction processing is key
- Order guarantees critical in pipelines
6. Synchronization Primitives
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 (Read-Write Lock)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
// Multiple goroutines can read simultaneously
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// Write requires exclusive lock
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():
semaphore = asyncio.Semaphore(3) # max 3 concurrent
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() // wait until space available
}
q.items = append(q.items, item)
q.notEmpty.Signal() // wake consumer
}
func (q *BoundedQueue) Take() interface{} {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.notEmpty.Wait() // wait until item available
}
item := q.items[0]
q.items = q.items[1:]
q.notFull.Signal() // wake producer
return item
}
7. Lock-Free Programming
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 failed, retry (another goroutine changed it first)
}
}
func (c *LockFreeCounter) Get() int64 {
return atomic.LoadInt64(&c.value)
}
7.2 The ABA Problem
Thread A: reads value A
Thread B: changes A to B
Thread B: changes B back to A
Thread A: CAS(A -> C) succeeds! (but value changed in between)
Solutions:
- Add version numbers (A,v1 -> B,v2 -> A,v3)
- Tagged pointers (AtomicStampedReference in Java)
- Hazard Pointers
7.3 When NOT to Use Lock-Free
Lock-free is good for:
- Very high contention scenarios
- Real-time systems (avoiding priority inversion)
- Simple data structures (counters, stacks, queues)
Avoid lock-free when (most cases):
- Regular applications (Mutex is sufficient)
- Complex data structures (trees, graphs)
- Correctness verification is difficult
- Team is not experienced with lock-free
"If a Mutex works, use a Mutex."
8. Common Concurrency Bugs
8.1 Race Condition
// Bug: multiple goroutines modifying counter simultaneously
var counter int
func buggyIncrement() {
for i := 0; i < 1000; i++ {
go func() {
counter++ // read-modify-write is not atomic
}()
}
}
// Fix: use atomic or mutex
var safeCounter int64
func safeIncrement() {
for i := 0; i < 1000; i++ {
go func() {
atomic.AddInt64(&safeCounter, 1)
}()
}
}
8.2 Deadlock
Four necessary conditions (all must hold for deadlock):
1. Mutual Exclusion: resource used by only one at a time
2. Hold and Wait: holding resource while waiting for another
3. No Preemption: cannot forcibly take held resource
4. Circular Wait: A waits for B, B waits for C, C waits for A
// Deadlock example
var mu1, mu2 sync.Mutex
func goroutine1() {
mu1.Lock()
defer mu1.Unlock()
time.Sleep(time.Millisecond)
mu2.Lock() // waiting for mu2 (held by goroutine2)
defer mu2.Unlock()
}
func goroutine2() {
mu2.Lock()
defer mu2.Unlock()
time.Sleep(time.Millisecond)
mu1.Lock() // waiting for mu1 (held by goroutine1)
defer mu1.Unlock()
}
// Fix: always acquire locks in the same order
func fixed1() {
mu1.Lock()
defer mu1.Unlock()
mu2.Lock()
defer mu2.Unlock()
}
func fixed2() {
mu1.Lock() // mu1 first (same order)
defer mu1.Unlock()
mu2.Lock()
defer mu2.Unlock()
}
8.3 Livelock
A and B meet in a narrow corridor:
A: steps left <-> B: steps right (same direction!)
A: steps right <-> B: steps left (same direction again!)
... (infinite loop, nobody progresses)
vs Deadlock: doing nothing
vs Livelock: constantly moving but no progress
8.4 Starvation
High-priority threads continuously hold resources, preventing low-priority threads from ever executing.
8.5 Priority Inversion
1997 Mars Pathfinder incident:
Low-priority task holds mutex
|
Medium-priority task preempts Low (unrelated to mutex)
|
High-priority task waits for mutex... forever!
(Low needs to run to release mutex, but Medium keeps preempting)
Solution: Priority Inheritance Protocol
Low temporarily inherits High's priority while holding the mutex
8.6 Detection Tools
# Go: Race Detector
go test -race ./...
go run -race main.go
# Java: Thread Sanitizer
java -XX:+UseThreadSanitizer MyApp
# Rust: Compiler detects at compile time!
# Send/Sync trait violations = compile error
# Python: asyncio debug mode
PYTHONASYNCIODEBUG=1 python app.py
# General: Helgrind (Valgrind tool)
valgrind --tool=helgrind ./program
9. Practical Patterns
9.1 Producer-Consumer
func producerConsumer() {
ch := make(chan int, 100) // buffered channel
// Producers
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)
}()
// Consumers
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 Thread Pool / Worker Pool
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)
# Start 5 workers
workers = [
asyncio.create_task(worker(f"worker-{i}", queue))
for i in range(5)
]
# Add tasks
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
Traditional thread pool: Work Stealing:
┌─────────────┐ ┌─────────────┐
│ Shared queue │ │ Worker 1 │
│ [T1|T2|T3] │ │ local:[T1] │
│ │ ├─────────────┤
│ All workers │ │ Worker 2 │
│ contend! │ │ local:[T2] │ ← steals from W1 when empty
│ │ ├─────────────┤
└─────────────┘ │ Worker 3 │
│ local:[T3] │
└─────────────┘
Benefits: Better cache locality, reduced contention
Used by: Java ForkJoinPool, Go runtime, Tokio (Rust)
10. Performance
10.1 Amdahl's Law
Speedup = 1 / ((1 - P) + P/N)
P = fraction that can be parallelized
N = number of processors
Example: 95% parallelizable program on 64 cores
Speedup = 1 / (0.05 + 0.95/64) = 1 / 0.0648 = 15.4x
Key insight: No matter how many cores, the serial portion (5%)
is the bottleneck! Maximum theoretical speedup = 1/0.05 = 20x
10.2 Gustafson's Law
Speedup = N - alpha * (N - 1)
alpha = fraction of serial work
N = number of processors
Key difference:
- Amdahl: "Fixed problem size, add more cores?"
- Gustafson: "Fixed cores, grow the problem?"
In reality, as data grows, the parallel portion also grows.
Gustafson is more optimistic and realistic.
10.3 Context Switch Cost
Context Switch Cost Comparison:
┌───────────────────────────┬──────────────┐
│ Switch Type │ Approx Cost │
├───────────────────────────┼──────────────┤
│ Function call │ ~1-5 ns │
│ Goroutine switch │ ~100-200 ns │
│ Coroutine switch (Python) │ ~1-5 us │
│ Thread switch (same proc) │ ~1-10 us │
│ Process switch │ ~10-50 us │
│ VM switch │ ~100+ us │
└───────────────────────────┴──────────────┘
10.4 False Sharing (Cache Line Problem)
Problem: Different variables on the same cache line
CPU Core 1 CPU Core 2
| |
+---v-------------------v---+
| Cache Line (64 bytes) |
| [counter_A] [counter_B] | <- Different variables but
| | same cache line!
+---------------------------+
Core 1 modifies counter_A -> invalidates cache line -> Core 2 must reload
Core 2 modifies counter_B -> invalidates cache line -> Core 1 must reload
Fix: Padding to separate cache lines
// Preventing False Sharing in Go
type PaddedCounter struct {
value int64
_pad [56]byte // 64-byte cache line padding
}
type Counters struct {
c1 PaddedCounter
c2 PaddedCounter
}
11. Interview Questions (15)
-
What is the difference between concurrency and parallelism? Concurrency is about structuring tasks to alternate. Parallelism is physically executing simultaneously. Concurrency is possible on a single core; parallelism is not.
-
What is the difference between a process and a thread? Processes have independent memory spaces; threads share memory within a process. Threads are cheaper to create/switch but require synchronization.
-
What are the 4 necessary conditions for deadlock and how to solve each? Mutual exclusion, hold and wait, no preemption, circular wait. Ordered lock acquisition (preventing circular wait) is most practical.
-
Why are Go goroutines lighter than OS threads? About 2KB stack (OS threads 1MB+), M:N scheduler maps millions of goroutines to few OS threads, user-space context switching.
-
What is Python's GIL and why does it exist? Global Interpreter Lock. CPython's reference counting memory management is not thread-safe. Use multiprocessing for CPU-bound parallel work.
-
Pros and cons of event loops vs multithreading? Event loop: simpler model, no context switch overhead, poor for CPU-bound. Multithreading: maximizes CPU utilization, complex synchronization, deadlock risk.
-
How is async/await different from threads? async/await is cooperative multitasking, switching only at await points. Threads are preemptive, OS can switch at any time.
-
Pros and cons of the actor model? Pros: no shared state, no deadlocks, natural for distributed systems. Cons: message ordering hard, debugging complex, performance overhead.
-
What is CAS? When to use instead of Mutex? Compare-And-Swap is an atomic compare-exchange operation. Faster than Mutex for simple counters or pointer updates, but unsuitable for complex logic.
-
What is false sharing and how to fix it? Performance degradation when different cores modify different variables on the same cache line. Fix by padding variables onto separate cache lines.
-
What does Amdahl's Law tell us? The serial portion determines the upper bound on speedup. Reducing the serial fraction may matter more than optimizing parallel work.
-
Difference between Java Virtual Threads and Go goroutines? Both are M:N models, but Go uses channel-based CSP, Java maintains existing Thread API compatibility. Go introduced first, Java 21 made it official.
-
What is Structured Concurrency? Binding concurrent tasks to a scope for lifecycle management. Parent cancel propagates to children, parent waits for all children. Prevents goroutine/thread leaks.
-
Explain Erlang's "Let it crash" philosophy. Rather than defensively handling errors, quickly terminate the process and let the supervisor restart it. Eliminates complex error recovery code.
-
What is the right thread pool size? CPU-bound: number of CPU cores. I/O-bound: CPU cores * (1 + wait_time/compute_time). Can estimate using Little's Law (L = lambda * W).
12. Practice Quiz
Q1: What is the output order of this JavaScript code?
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
Answer: A, D, C, B. Synchronous code (A, D) first, then microtasks (Promise: C), then macrotasks (setTimeout: B).
Q2: Why does this Go code deadlock?
func main() {
ch := make(chan int)
ch <- 42
fmt.Println(<-ch)
}
Answer: An unbuffered channel requires both sender and receiver to be present simultaneously. The main goroutine blocks at ch <- 42 waiting for a receiver and can never reach <-ch.
Q3: What is the difference between asyncio.gather and asyncio.wait in Python?
Answer: gather returns all results in order and raises an exception if one fails (configurable with return_exceptions=True). wait returns sets of completed/pending tasks with FIRST_COMPLETED, FIRST_EXCEPTION, and ALL_COMPLETED options. wait is more flexible.
Q4: According to Amdahl's Law, what is the maximum speedup for a program that is 90% parallelizable?
Answer: Even with infinite cores, maximum speedup = 1/(1-0.9) = 1/0.1 = 10x. The 10% serial portion becomes the bottleneck. With 64 cores: 1/(0.1 + 0.9/64) = approximately 8.8x.
Q5: How does Rust prevent data races at compile time?
Answer: Rust's ownership system and Send/Sync traits are the key. Send means ownership can be transferred between threads; Sync means references can be shared between threads. Only one mutable reference can exist at a time (borrow checker), which prevents data races at compile time entirely.
References
- 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)