Workspace IndexAlgorithms › Day 42

Event Loop vs. Threads vs. the Actor Model TODO

Algorithms · Day 42 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

The three models differ in what unit they slice concurrency into and how they share state. An event loop runs a single thread that non-blockingly cycles through ready I/O events and runs callbacks/tasks, so there's almost no context switching or locking — but one CPU-bound task blocking the loop stalls everything. The thread model lets the OS do preemptive scheduling, so you can write blocking code as-is and naturally use multiple cores, but shared memory has to be guarded with locks, which brings contention, deadlocks, and false sharing. The actor model confines state inside each actor and only allows communication through asynchronous messages, eliminating shared memory itself — the tradeoffs shift to message-copy cost, mailbox backpressure, and how much ordering is guaranteed. Real-world runtimes are usually hybrids: Go's goroutines, for instance, use a user-level scheduler that multiplexes many lightweight tasks onto a small number of OS threads, with an event-based I/O poller underneath.

A large share of concurrency bugs and latency spikes come from violating the assumptions behind whichever model was chosen — don't block the loop, guard shared state with locks, mailboxes aren't unbounded.

Code & Formula

# 이벤트 루프 vs 스레드 vs 액터 모델 — 상태를 액터 안에 가두고 메시지 큐로만 통신하는 최소 액터를 구현한다.
# 공유 메모리가 없으니 락도 필요 없고, 순서 보장은 "한 액터는 메일박스 메시지를 하나씩만 처리한다"에서 나온다.

import threading
import queue

class Actor:
    def __init__(self, name, handle_fn):
        self.name = name
        self.mailbox = queue.Queue()      # 오직 메시지로만 상태에 접근 — 공유 메모리 자체가 없다
        self._handle_fn = handle_fn
        self._state = {}
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def send(self, msg):
        self.mailbox.put(msg)              # 비동기 전송 — 보내는 쪽은 블록되지 않는다

    def _run(self):
        while True:
            msg = self.mailbox.get()        # 메시지를 하나씩만 순차 처리 -> 액터 내부 상태는 절대 경합하지 않음
            if msg is None:                 # 종료 신호
                break
            self._handle_fn(self._state, msg)

    def stop_and_join(self):
        self.send(None)
        self._thread.join()

def counter_handler(state, msg):
    op, payload = msg
    if op == "incr":
        state["value"] = state.get("value", 0) + payload
    elif op == "get":
        reply_queue = payload
        reply_queue.put(state.get("value", 0))   # 결과는 회신용 큐로 되돌려줌 — 여기도 메시지 전달일 뿐

account = Actor("counter", counter_handler)

# 여러 "클라이언트" 가 동시에 메시지를 보내도, 액터 내부 값은 큐를 통해 직렬화되어 안전하다.
def client(n):
    for _ in range(100):
        account.send(("incr", n))

clients = [threading.Thread(target=client, args=(i,)) for i in (1, 2, 3, 4)]
for t in clients:
    t.start()
for t in clients:
    t.join()

reply_queue = queue.Queue()
account.send(("get", reply_queue))
total = reply_queue.get(timeout=5)   # get 메시지가 처리되어 회신이 올 때까지 블로킹 대기(결정론적 동기화)

account.stop_and_join()

expected = (1 + 2 + 3 + 4) * 100
print("expected total:", expected)
print("actor-computed total:", total)
print("no locks used, no data race possible:", total == expected)

Exercise

Build the same HTTP echo server in Node.js (event loop) and Go (goroutines), put a several-hundred-millisecond CPU computation into the handler, and measure with a load tool how p99 latency changes.

Practical Connection

A blockchain indexer or an order book matching engine typically confines matching state to a single actor/single thread and only runs I/O asynchronously, which gets you ordering guarantees and throughput at the same time.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

이벤트 루프 vs 스레드 vs 액터 모델 TODO

Algorithms · Day 42 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

개념

세 모델은 동시성을 어떤 단위로 쪼개고 상태를 어떻게 공유하느냐가 다르다. 이벤트 루프는 단일 스레드가 준비된 I/O 이벤트를 논블로킹으로 순회하며 콜백/태스크를 실행하므로 컨텍스트 스위치와 락이 거의 없지만, 하나의 CPU 바운드 작업이 루프를 막으면 전체가 멈춘다. 스레드 모델은 OS가 선점 스케줄링을 해주어 블로킹 코드를 그대로 쓸 수 있고 멀티코어를 자연히 활용하지만, 공유 메모리를 락으로 지켜야 해서 경합·데드락·false sharing 같은 비용이 생긴다. 액터 모델은 상태를 액터 내부에 가두고 오직 비동기 메시지로만 통신하게 해 공유 메모리 자체를 없애며, 대신 메시지 복사 비용과 메일박스 backpressure, 순서 보장 범위가 설계 이슈가 된다. 실무 런타임은 대개 혼합형으로, 예를 들어 Go의 goroutine은 사용자 수준 스케줄러가 다수의 경량 태스크를 소수 OS 스레드에 다중화하고 내부적으로 이벤트 기반 I/O 폴러를 쓴다.

동시성 버그와 지연 스파이크의 상당수는 선택한 모델의 전제(루프를 막지 마라, 공유 상태를 락으로 지켜라, 메일박스가 무한하지 않다)를 깨는 데서 나온다.

코드 · 수식

# 이벤트 루프 vs 스레드 vs 액터 모델 — 상태를 액터 안에 가두고 메시지 큐로만 통신하는 최소 액터를 구현한다.
# 공유 메모리가 없으니 락도 필요 없고, 순서 보장은 "한 액터는 메일박스 메시지를 하나씩만 처리한다"에서 나온다.

import threading
import queue

class Actor:
    def __init__(self, name, handle_fn):
        self.name = name
        self.mailbox = queue.Queue()      # 오직 메시지로만 상태에 접근 — 공유 메모리 자체가 없다
        self._handle_fn = handle_fn
        self._state = {}
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def send(self, msg):
        self.mailbox.put(msg)              # 비동기 전송 — 보내는 쪽은 블록되지 않는다

    def _run(self):
        while True:
            msg = self.mailbox.get()        # 메시지를 하나씩만 순차 처리 -> 액터 내부 상태는 절대 경합하지 않음
            if msg is None:                 # 종료 신호
                break
            self._handle_fn(self._state, msg)

    def stop_and_join(self):
        self.send(None)
        self._thread.join()

def counter_handler(state, msg):
    op, payload = msg
    if op == "incr":
        state["value"] = state.get("value", 0) + payload
    elif op == "get":
        reply_queue = payload
        reply_queue.put(state.get("value", 0))   # 결과는 회신용 큐로 되돌려줌 — 여기도 메시지 전달일 뿐

account = Actor("counter", counter_handler)

# 여러 "클라이언트" 가 동시에 메시지를 보내도, 액터 내부 값은 큐를 통해 직렬화되어 안전하다.
def client(n):
    for _ in range(100):
        account.send(("incr", n))

clients = [threading.Thread(target=client, args=(i,)) for i in (1, 2, 3, 4)]
for t in clients:
    t.start()
for t in clients:
    t.join()

reply_queue = queue.Queue()
account.send(("get", reply_queue))
total = reply_queue.get(timeout=5)   # get 메시지가 처리되어 회신이 올 때까지 블로킹 대기(결정론적 동기화)

account.stop_and_join()

expected = (1 + 2 + 3 + 4) * 100
print("expected total:", expected)
print("actor-computed total:", total)
print("no locks used, no data race possible:", total == expected)

연습

같은 HTTP 에코 서버를 Node.js(이벤트 루프)와 Go(고루틴)로 각각 만들고, 핸들러에 수백 밀리초짜리 CPU 연산을 넣었을 때 p99 지연이 어떻게 달라지는지 부하 도구로 측정해 볼 것.

실무 · Verex 연결

블록체인 인덱서나 오더북 매칭 엔진은 보통 매칭 상태를 단일 액터/단일 스레드에 가두고 I/O만 비동기로 돌려 순서 보장과 처리량을 동시에 얻는다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 41. 커널 바이패스와 zero-copy43. 백프레셔와 큐 이론 →