Ruby 동시성에 관한 모든 ‘무슨 일이 일어나는가’ 질문에 대해 다이어그램과 함께 답합니다.
블로그 검색
RubyConcurrencyAsyncFibersPerformance
2026년 4월 28일

제가 async Ruby와 fibers를 지원하도록 Solid Queue를 패치한 것에 대해 글을 쓴 이후로, 사람들은 계속 같은 질문을 합니다. fiber가 블로킹되면 무슨 일이 일어날까요? 여전히 thread가 필요하지 않나요? 데이터베이스 트랜잭션은요? Ractor는요?
이 글은 그 모든 것에 답합니다. 가장 기초부터요.
Ruby는 네 가지 동시성 기본 요소를 제공합니다: process, thread, fiber, 그리고 Ractor입니다. 이들은 중첩됩니다. 모든 process에는 기본적으로 코드가 실행되는 암묵적인 “main Ractor”가 있어서, Ractor를 명시적으로 만들지 않는 한 Ractor를 신경 쓸 필요가 없습니다. Ractor가 없으면 계층은 단순히 process – thread – fiber입니다. Ractor가 있으면 다음과 같이 됩니다:
graph TD P[Process] --> R1["Ractor 1 (GVL 1)"] P --> R2["Ractor 2 (GVL 2)"] R1 --> T1[Thread 1] R1 --> T2[Thread 2] R2 --> T3[Thread 3] T1 --> F1[Fiber A] T1 --> F2[Fiber B] T2 --> F3[Fiber C] T3 --> F4[Fiber D] T3 --> F5[Fiber E] style P fill:#4a90a4,color:#fff style R1 fill:#c084fc,color:#fff style R2 fill:#c084fc,color:#fff style T1 fill:#7fb069,color:#fff style T2 fill:#7fb069,color:#fff style T3 fill:#7fb069,color:#fff style F1 fill:#e8a87c,color:#fff style F2 fill:#e8a87c,color:#fff style F3 fill:#e8a87c,color:#fff style F4 fill:#e8a87c,color:#fff style F5 fill:#e8a87c,color:#fff
컴퓨터를 사무실 건물이라고 생각해 봅시다.
Process는 완전히 격리되어 있습니다. 각각 잠긴 문, 가구, 파일을 가진 별도의 사무실입니다. 각 process는 자신만의 메모리, 자신만의 Ruby VM, 자신만의 GVL을 가집니다. Puma를 worker 3개로 실행하면 process 3개가 생깁니다. 메모리를 공유하지 않으므로 서로의 상태를 망가뜨릴 수 없습니다. OS는 이들을 독립적으로 스케줄링합니다. 비용은 무엇일까요? 각 process가 전체 애플리케이션을 메모리에 로드합니다.
Ractor는 process와 thread 사이에 있습니다. 우편실은 공유하지만 서류 캐비닛은 공유하지 않는 사무실입니다. 각 Ractor는 자신만의 GVL을 가지므로, 서로 다른 Ractor의 thread들은 Ruby 코드를 진짜로 병렬 실행할 수 있습니다. 하지만 서로에게 쪽지만 전달할 수 있을 뿐이며, 변경 가능한 객체는 공유할 수 없습니다. 메시지 패싱을 통해 통신하며, 데이터를 복사하거나 이동시킵니다. 모든 Ruby process에는 기본적으로 모든 코드가 실행되는 “main Ractor”가 있습니다. 추가 Ractor 생성은 선택 사항입니다.
Thread는 process 내부에 살며 메모리를 공유합니다. 같은 사무실을 공유하면서 같은 서류 캐비닛에 접근하고, 충돌을 피하려고 협업하는 직원들입니다. CRuby에서 이것들은 네이티브 thread이며, GVL이 어느 thread가 한 번에 Ruby 코드를 실행할 수 있는지 결정합니다. Ruby가 언제 thread 사이를 전환할지는 여러분이 제어하지 못합니다. GVL은 I/O 중에는 해제되므로, 두 thread가 서로 다른 두 네트워크 호출을 동시에 기다릴 수는 있습니다. 하지만 동시에 숫자 계산을 하지는 못합니다.
Fiber는 thread 내부에 살며 협력적으로 스케줄링됩니다. 책상에 앉은 한 직원이 여러 작업을 번갈아 처리하는 모습입니다. 무언가를 기다릴 때, 예를 들어 전화, 팩스, 응답 같은 것을 기다릴 때 그 작업을 내려놓고 다음 작업을 집습니다. fiber는 명시적으로 yield할 때까지 실행됩니다. I/O, 즉 네트워크 호출, 데이터베이스 쿼리, 파일 읽기 같은 지점에 도달하면 reactor에 yield하고, 다른 fiber가 이어서 실행됩니다. fiber 자체에 대한 OS thread 컨텍스트 스위치는 없고, 선점도 없습니다. 하나의 thread가 수천 개의 fiber를 실행할 수 있습니다.
이것이 비용 측면에서 의미하는 바는 다음과 같습니다:
| Process | Ractor | Thread | Fiber | |
|---|---|---|---|---|
| 메모리 | 전체 앱 복사본 | ~thread + Ractor 상태 | ~8MB 가상 스택 예약 | ~4KB 초기 가상 스택, 필요 시 증가 |
| 생성 시간 | ~ms | ~80μs | ~80μs | ~3μs |
| 컨텍스트 스위치 | 커널 | 커널 (내부 thread) | ~1.3μs (커널) | ~0.1μs (사용자 공간) |
| 격리 | 완전함 (독립 메모리) | 비공유 (메시지) | 공유 메모리 | 공유 thread |
| 병렬성 | 예 | 예 (독립 GVL) | 아니요 (공유 GVL) | 아니요 |
| I/O 동시성 | 예 | 예 | 예 | 예 |
| Rails 호환 | 예 | 아니요 | 예 | 예 |
생성과 전환 벤치마크는 Samuel Williams의 fiber-vs-thread 성능 비교에서 가져왔습니다. fiber는 thread보다 20배 빠르게 생성되고 10배 빠르게 전환됩니다. 메모리 행은 플랫폼/런타임이 예약하는 가상 주소 공간에 관한 것이지 실제 상주 메모리에 대한 것이 아닙니다. 벤치마크는 실제 RSS를 보고하는데, 여기서는 가상 스택 수치가 암시하는 것보다 격차가 훨씬 작습니다. 하지만 전체적인 형태는 여전히 사실입니다. 각 thread는 스케줄러 상태와 스택 예약을 가진 커널 객체이고, 각 fiber는 사용자 공간에서 스케줄링됩니다. Ractor 역시 병렬성을 제공하지만 Rails를 실행할 수는 없습니다. 모든 것은 절충입니다.
대부분의 혼란은 여기에서 생깁니다. 실제로 무슨 일이 일어나는지 보여드리겠습니다.
CRuby thread는 네이티브 thread이지만, 어떤 thread가 Ruby 코드를 실행할 수 있는지는 GVL이 결정합니다. 여러분의 코드는 여기에 발언권이 없습니다. thread는 계산 도중, 대입 도중, 무엇이든 도중에 멈출 수 있습니다.
sequenceDiagram participant VM as CRuby / OS participant T1 as Thread 1 participant T2 as Thread 2 participant LLM as LLM API VM->>T1: T1 실행 T1->>LLM: 요청 전송 Note over T1: I/O에서 블로킹됨 (parked) VM->>T2: T2 실행 T2->>LLM: 요청 전송 Note over T2: I/O에서 블로킹됨 (parked) Note over VM: 두 thread 모두 parked 상태 LLM-->>T1: 응답 준비 완료 LLM-->>T2: 응답 준비 완료 VM->>T1: 깨우고 실행 Note over T1: 응답 처리 중 VM->>VM: 타임 슬라이스 만료 VM->>T2: T1을 선점하고 T2 실행 Note over T2: 응답 처리 중 VM->>VM: 타임 슬라이스 만료 VM->>T1: T1 재개 Note over T1: 응답 마무리 VM->>T2: T2 재개 Note over T2: 응답 마무리
CRuby는 실행 가능한 thread를 타임 슬라이스 기준으로 전환할 수 있지만, I/O에서 블로킹된 thread는 소켓이 준비될 때까지 parked 상태가 됩니다. 이 부분이 중요합니다. thread는 토큰을 기다리는 동안 쓸모없이 계속 돌지 않습니다. 전환은 thread가 실행 가능할 때 일어나며, 여기에는 응답 처리 중간, 객체 할당 중간, 대입 중간, 또는 다른 어떤 Ruby 코드 실행 중간도 포함됩니다.
I/O를 수행하는 thread 두 개라면 이것은 잘 동작합니다. 오버헤드는 잡음 수준입니다. 하지만 LLM 토큰을 주로 기다리는 thread가 200개라면 문제는 작업 하나당 thread 하나라는 형태에 있습니다. 커널 thread 200개, 스택 예약 200개, 스케줄러 엔트리 200개, 그리고 보통 worker가 들고 있는 thread별 애플리케이션 리소스의 복사본 200개가 필요합니다.
이것이 또한 Solid Queue의 현재 thread 모드와 제가 패치한 fiber 모드에서 worker limit가 서로 다른 의미를 갖는 이유이기도 합니다.
threads: 25
는 “동시에 25개 job 실행”이면서 동시에 “커널 thread 25개 생성”이기도 합니다. 25개 job이 모두 토큰을 스트리밍 중이면 26번째 job은 기다려야 합니다.
fibers: 250
는 주로 reactor에 대한 admission limit입니다. 같은 thread 위에서 최대 250개 job을 fiber로 실행하고, I/O를 기다리는 것들은 parked한 뒤 준비되면 재개합니다. 물론 여전히 제한은 필요합니다. API, 소켓, 메모리, 데이터베이스에는 한계가 있기 때문입니다. 하지만 이제 이 상한은 job 하나당 커널 thread 하나에 묶이지 않습니다.
fiber는 스스로 선택할 때만 전환됩니다. 실제로는 async gem이 이것을 자동으로 처리합니다. 여러분이 특별한 코드를 작성하지 않아도 I/O 경계에서 코드가 yield합니다.
sequenceDiagram participant R as Reactor participant F1 as Fiber 1 participant F2 as Fiber 2 participant LLM as LLM API R->>F1: F1 실행 F1->>LLM: 요청 전송 Note over F1: Yields (I/O 대기) R->>F2: F2 실행 F2->>LLM: 요청 전송 Note over F2: Yields (I/O 대기) Note over R: 둘 다 대기 중, reactor는 sleep LLM-->>F1: 응답 준비 완료 R->>F1: 즉시 재개 Note over F1: 응답 처리 F1->>R: 완료 LLM-->>F2: 응답 준비 완료 R->>F2: 즉시 재개 Note over F2: 응답 처리 F2->>R: 완료
fiber마다 OS thread 컨텍스트 스위치가 있는 것이 아닙니다. fiber 사이에 타이머 기반 선점도 없습니다. fiber가 yield하면 reactor는 어떤 fiber의 I/O가 준비되었는지 확인하고 그것들을 재개합니다. 준비된 것이 아무것도 없으면 reactor는 뭔가가 준비될 때까지 OS 안에서 sleep합니다. 커널은 여전히 I/O 준비 상태 작업을 수행하고, Ruby는 단지 대기 하나당 커널 thread 하나를 피할 뿐입니다.
이 부분이 thread 기반 Ruby와 fiber 기반 Ruby가 처음 보이는 것보다 덜 다르게 만드는 핵심입니다.
GVL은 한 번에 하나의 thread만 Ruby 코드를 실행할 수 있다는 뜻입니다. thread는 I/O 동안, 즉 GVL이 해제될 때만 병렬로 실행됩니다. 그래서 작업 부하가 I/O 중심이라면, 예를 들어 HTTP 호출, 데이터베이스 쿼리, LLM 스트리밍이라면, thread가 제공하는 것은 병렬성이 아니라 I/O 동시성입니다.
fiber도 같은 I/O 동시성을 제공합니다. 한 fiber가 I/O에서 yield하면 다른 fiber가 이어받습니다. 차이는 이렇습니다. fiber는 커널 thread 오버헤드 없이, thread 스택의 메모리 비용 없이, 그리고 job 동시성 자체가 job당 worker thread 하나 또는 데이터베이스 슬롯 하나를 의미하게 만들지 않으면서 이를 수행합니다.
어차피 thread가 I/O에만 도움이 된다면, 왜 그 오버헤드를 감수해야 할까요?
thread가 이기는 경우는 하나 있습니다. GVL을 해제하는 CPU 중심 작업입니다. 일부 C extension은 무거운 계산을 하는 동안 GVL을 해제합니다. 그러면 여러 thread가 그 C extension을 병렬로 실행할 수 있습니다. fiber는 그렇게 할 수 없습니다. fiber는 하나의 thread를 공유합니다.
실제 Ruby 수준의 CPU 병렬성이 필요하다면 process나 Ractor가 필요합니다. process는 프로덕션에서 준비되어 있고 Rails와 호환됩니다. Ractor는 process보다 가볍지만 여전히 실험적입니다.
이것이 행복한 경로이며 가장 흔한 질문입니다.
# Inside a fiber
response = Net::HTTP.get(URI("https://api.example.com/v1/completions"))
전체 흐름은 다음과 같습니다:
Net::HTTP
가 소켓을 열고 요청을 전송합니다
2. 소켓은 아직 읽을 수 없습니다 (서버가 아직 응답하지 않았습니다)
3. Ruby가 소켓에 대해
```plaintext
rb_io_wait
를 호출합니다 4. async gem의
Fiber.scheduler
가 이 호출을 가로챕니다 5. scheduler가 현재 fiber를 일시 중지하고 소켓을 event loop에 등록합니다 6. 이 fiber가 잠들어 있는 동안 reactor는 다른 fiber를 실행합니다 7. 소켓이 읽기 가능해지면 reactor가 이 fiber를 재개합니다 8. ```plaintext Net::HTTP
는 아무 일도 없었던 것처럼 응답을 읽습니다
여러분의 코드는 바뀌지 않습니다.
```plaintext
await
도 없고, callback도 없고, promise도 없습니다. thread에서 동작하는 똑같은
Net::HTTP.get
호출이 fiber에서도 동작합니다. yield는 보이지 않습니다.
Bob Nystrom은 2015년에 이것을 function color problem이라고 불렀습니다. async/await가 있는 언어에서는 모든 함수가 sync이거나 async입니다. async 함수는
await
와 함께만 호출할 수 있고,
await
는 또 다른 async 함수 안에서만 쓸 수 있습니다. 그 색깔은 호출 스택 전체를 타고 위로 퍼집니다.
Python:
# Python: the color spreads, and you need different libraries
async def get_user(id):
async with aiohttp.ClientSession() as session: # can't use requests
response = await session.get(f"/users/{id}") # must await
return await response.json() # must await
async def handle_request(): # must be async because it calls get_user
user = await get_user(1) # must await
async Python에서는 event loop를 블로킹하지 않고
requests
를 사용할 수 없습니다.
aiohttp
, async 모드의
httpx
, 또는 thread wrapper가 필요합니다. 블로킹하는
psycopg2
API를 async I/O처럼 사용할 수도 없습니다.
asyncpg
나 Psycopg의 async API가 필요합니다. 생태계는 분리됩니다. 같은 일을 다르게 하는 sync 라이브러리와 async 라이브러리로요.
JavaScript:
// JavaScript: same problem, less severe (Node has fewer library splits)
async function getUser(id) {
const response = await fetch(`/users/${id}`); // must await
return await response.json(); // must await
}
async function handleRequest() { // must be async
const user = await getUser(1); // must await
}
Ruby:
# Ruby: no color
def get_user(id)
response = Net::HTTP.get(URI("https://api.example.com/users/#{id}")) # just a normal call
JSON.parse(response) # just a normal call
end
def handle_request
user = get_user(1) # just a normal call
end
같은
Net::HTTP
입니다. 같은
pg
입니다. 그리고 라이브러리가 scheduler-aware Ruby I/O를 사용하기만 한다면 같은 호출 스택입니다. fiber scheduler는 여러분의 코드 아래, Ruby 런타임 수준에서 I/O를 가로챕니다. 여러분의 메서드는 자신이 thread에서 실행 중인지 fiber에서 실행 중인지 알지도 못하고 신경 쓰지도 않습니다.
# Inside a fiber
100_000.times { Digest::SHA256.hexdigest("work") }
이것은 reactor를 블로킹합니다. 끝날 때까지 다른 fiber는 실행되지 않습니다. yield할 I/O 경계가 없기 때문에, 이 fiber가 thread를 계속 점유합니다.
sequenceDiagram participant R as Reactor participant F1 as Fiber 1 (CPU) participant F2 as Fiber 2 (I/O) R->>F1: 실행 Note over F1,F2: F1이 CPU 작업 수행 중... Note over F2: 실행 대기 중 Note over F1,F2: F1이 여전히 계산 중... Note over F2: 계속 대기 중 F1->>R: 완료 R->>F2: 그제서야 실행
이것은 버그가 아닙니다. 이것이 협력적 스케줄링의 현재 절충입니다. fiber는 I/O 중심 작업을 위해 설계되었고, CPU 중심 작업은 thread에 두어야 합니다. 그쪽에서는 CRuby가 선점할 수 있기 때문입니다.
제가 Solid Queue를 위해 만든 fiber-mode 패치에서는 이것이 설정 선택입니다:
workers:
- queues: [ chat, turbo, notifications ]
fibers: 50 # I/O-bound: use fibers
- queues: [ cpu ]
threads: 2 # CPU-bound: use threads
하나의 backend, 두 가지 모드, 그리고 작업 부하에 맞춘 동시성 모델입니다.
pg gem은 v1.3.0부터
Fiber.scheduler
를 지원합니다. fiber가 쿼리를 실행하면 pg gem은
PQsendQuery
를 통해 논블로킹 방식으로 쿼리를 보내고, 그 다음 PostgreSQL 소켓에 대해
rb_io_wait
를 호출합니다. scheduler는 이것을 가로채서 fiber를 일시 중지하고, PostgreSQL이 쿼리를 처리하는 동안 다른 작업을 실행하게 합니다.
# Inside a fiber
user = User.find(42) # yields while waiting for PostgreSQL
fiber는 yield합니다. 다른 fiber가 실행됩니다. PostgreSQL이 응답하면 reactor가 그 fiber를 재개합니다. 여러분의 코드는 차이를 모릅니다.
데이터베이스 연결은 쿼리가 끝날 때까지 바쁩니다. PostgreSQL이 작업하는 동안 Ruby는 다른 일을 할 수 있습니다. 다른 thread나, reactor 위의 다른 fiber를 실행할 수 있습니다. 하지만 그 연결은 여전히 checkout된 상태로 남아 있습니다.
LLM job의 경우 전체 경과 시간 대부분은 데이터베이스 시간이 아닙니다. 한 행을 읽고, API를 호출하고, 토큰을 스트리밍하고, 상태 업데이트를 기록합니다. 데이터베이스 접촉은 짧습니다. 긴 대기는 외부 HTTP입니다. 따라서 100개 job이 진행 중이라고 해서 100개 job이 같은 순간 PostgreSQL을 치고 있다는 뜻은 아닙니다.
reactor는 fiber를 선점하지 않습니다. fiber가 I/O 경계에서 yield할 때만 전환합니다:
sequenceDiagram participant R as Reactor participant F1 as Fiber A participant F2 as Fiber B participant Pool as DB Pool (1 conn) participant PG as PostgreSQL participant HTTP as HTTP API R->>F1: 실행 F1->>Pool: Check out F1->>PG: SELECT * FROM users Note over F1: Yields (PG 대기) R->>F2: 실행 F2->>HTTP: GET /api/data Note over F2: Yields (HTTP 대기) PG-->>R: F1 결과 준비 완료 R->>F1: 재개 F1->>Pool: Return F1->>R: 완료 HTTP-->>R: F2 결과 준비 완료 R->>F2: 재개 F2->>Pool: Check out F2->>PG: UPDATE messages SET ... Note over F2: Yields (PG 대기) PG-->>R: F2 결과 준비 완료 R->>F2: 재개 F2->>Pool: Return F2->>R: 완료
이것은 타임라인으로 읽으십시오. Fiber A는 자신의 쿼리를 위해 유일한 연결을 사용합니다. PostgreSQL이 작업하는 동안 Fiber B는 HTTP를 기다립니다. Fiber A가 연결을 반환한 뒤에야 Fiber B가 자신의 업데이트에 그 연결을 사용할 수 있습니다. 두 fiber가 동시에 쿼리하려 했다면, 풀에 다른 연결이 없는 한 하나는 기다려야 합니다.
Active Record는 두 경우 모두 같은 checkout 규칙을 따릅니다. 현재 Solid Queue의 차이는 안전장치입니다. thread 모드는 process당
threads + 2
개의 연결을 기대하므로, 5개 연결짜리 풀에 실행 thread 50개를 붙이지 않게 합니다. fiber 모드는 더 작은 기본값을 사용할 수 있습니다.
fibers: 100
이 “100개 job이 대기할 수 있게 하라”는 뜻이지 “실행 thread 100개를 만들라”는 뜻은 아니기 때문입니다. 제 패치에서는 I/O 비중이 큰 worker가 process당 보통 연결 3개에서 시작합니다 (실행 1개 + worker 오버헤드 2개). job이 DB 비중이 높다면 더 늘리면 됩니다.
트랜잭션은 타임라인을 바꿉니다. 트랜잭션 상태는 그 연결에 살아 있기 때문에, 각 statement 뒤에 연결을 반환할 수 없습니다.
fiber가 트랜잭션을 시작하면,
BEGIN
부터
COMMIT
또는
ROLLBACK
까지 전체 기간 동안 checkout한 연결을 계속 유지합니다. 트랜잭션 도중에는 연결이 해제되지 않습니다. 데이터베이스가 필요한 다른 fiber는 그 연결이 반환될 때까지 기다립니다.
sequenceDiagram participant R as Reactor participant F1 as Fiber A participant F2 as Fiber B participant Pool as DB Pool (1 conn) participant PG as PostgreSQL R->>F1: 실행 F1->>Pool: Check out F1->>PG: BEGIN F1->>PG: UPDATE accounts SET ... Note over F1: Yields (PG 대기) R->>F2: 실행 F2->>Pool: Check out Note over F2: 대기 (연결을 F1이 보유 중) PG-->>F1: 결과 R->>F1: 재개 F1->>PG: COMMIT F1->>Pool: Return F1->>R: 완료 Pool->>F2: 연결 사용 가능 F2->>PG: SELECT * FROM accounts Note over F2: Yields (PG 대기) PG-->>F2: 결과 R->>F2: 재개 F2->>Pool: Return F2->>R: 완료
fiber 격리(
config.active_support.isolation_level = :fiber
) 하에서는 Active Support의 실행 상태가 fiber 범위로 지정되므로, Active Record의 lease는 주변 thread 대신 현재 fiber와 연결됩니다. 그 연결에는 여전히 실제
Monitor
락이 걸립니다. 트랜잭션 중에는 다른 어떤 fiber도 그 연결을 건드릴 수 없습니다.
안전합니다. interleaving은 없습니다. Fiber B는 그저 기다릴 뿐입니다.
대상 작업 부하, 즉 LLM 스트리밍과 HTTP 호출에서는 데이터베이스 접촉이 짧은 읽기와 상태 업데이트입니다. 트랜잭션도 짧습니다. 대기 시간은 무시할 만합니다. 여러분의 job이 긴 트랜잭션을 수행한다면, 그런 job은 thread 기반 worker에 속합니다.
fiber가 공짜는 아닙니다. 각각 메모리(~4KB)를 사용하고, 외부 서비스와의 연결을 열어둘 수도 있습니다. 같은 API를 치는 fiber 10,000개를 생성하면, 그 API에 연결 10,000개를 여는 셈입니다. 그 API는 좋아하지 않을 것입니다.
Async는 리소스 한계를 없애지 않습니다. 한계가 나타나는 위치를 바꿀 뿐입니다. thread에서는 한계가 명시적입니다. thread 25개, 동시 job 25개입니다. fiber에서는 한계가 암묵적입니다. 다른 무언가가 먼저 깨질 때까지 계속 갈 수 있습니다.
해결책은 semaphore입니다. 제 Solid Queue 패치의
FiberPool
은 이것을 사용합니다:
semaphore = Async::Semaphore.new(size)
# Only `size` fibers run concurrently
semaphore.async do
perform_job
end
패치에서
fibers: 100
으로 설정한다고 해서 “무제한 fiber”를 뜻하는 것은 아닙니다. 이것은 동시성을 100으로 제한하는 semaphore입니다. 상한은 여러분이 제어합니다.
일반적인 Ruby에서는 thread를 더 늘리는 것이 합리적일 수 있습니다. 하지만 Solid Queue thread 모드에서
threads: 200
은 단순히 “I/O를 기다리는 200개 job 허용” 이상의 뜻을 가집니다.
커널 thread가 비싼 단위입니다. fiber가 I/O를 더 빨리 끝내 주는 것은 아닙니다. 단지 훨씬 더 많은 I/O를 훨씬 낮은 비용으로 동시에 기다릴 수 있게 해줄 뿐입니다. Samuel Williams의 벤치마크는 fiber가 thread보다 20배 빠르게 할당되고 (~3μs 대 ~80μs), 10배 빠르게 전환된다고 (~0.1μs 대 ~1.3μs) 보여줍니다. OS는 수천 개의 thread를 관리할 수 있지만, 스케줄러 상태, 스택 예약, wakeup, GVL 조정 때문에 그것이 좋은 기본 동시성 조절 손잡이는 아닙니다.
Solid Queue는 현재 데이터베이스 풀 안전장치를 강제합니다. 오늘 기준으로 process당
threads + 2
개의 데이터베이스 연결을 기대하므로, process 2개에 thread 200개면 풀이 최소 404가 아니면 부팅되지 않습니다. 이 안전장치는 I/O 비중이 큰 job에는 보수적일 수 있습니다. 이것을 advisory로 바꾸거나 우회 가능하게 만들자는 열린 이슈가 있습니다. 하지만 오늘 실제로 부딪히는 안전장치인 것은 맞습니다.
블로킹된 job도 여전히 worker thread를 점유합니다. OS는 LLM 스트리밍 thread를 소켓이 준비될 때까지 parked 상태로 둘 수 있습니다. 하지만 Solid Queue thread 모드에서는 여전히 설정된 thread worker 하나를 소비합니다. 25개가 모두 토큰을 스트리밍 중이면 26번째 job은 기다립니다.
fiber는 Solid Queue의 제한이 “몇 개 job이 동시에 기다릴 수 있는가”를 뜻하게 만듭니다. “커널 thread를 몇 개 만들어야 하는가”가 아니라요. 여전히 제한은 필요하지만, 이제 대기 중인 job 하나당 커널 thread 하나일 필요는 없습니다.
Ractor는 다른 문제를 해결합니다. fiber는 I/O 동시성, 즉 많은 일이 동시에 대기하는 상황을 제공합니다. Ractor는 CPU 병렬성, 즉 많은 일이 동시에 계산하는 상황을 제공합니다.
이런 모습입니다:
# Two Ractors computing fibonacci in parallel
r1 = Ractor.new { fibonacci(38) }
r2 = Ractor.new { fibonacci(38) }
r1.value # Ruby 4.0+
r2.value # Both ran in parallel, each with their own GVL
각 Ractor는 자신만의 GVL을 가지므로 CPU 코어 전반에서 Ruby 코드를 진짜로 병렬 실행할 수 있습니다. 절충점은 엄격한 격리입니다. 불변(frozen) 객체만 공유할 수 있습니다. 그 외의 모든 것은 메시지 패싱을 통해 Ractor 사이에서 복사되거나 이동됩니다. 바깥 스코프의 변경 가능한 변수에 접근한다고요?
Ractor::IsolationError
입니다.
Ractor가 이길 때는 크게 이깁니다. Fibonacci(38)을 다섯 번 계산하면, 순차 실행은 2.26초인데 Ractor는 0.68초입니다. 3.3배 빨라집니다. 진짜 병렬성입니다.
하지만 아직 Rails job에 대한 실용적인 답은 아닙니다:
Ractor::IsolationError
를 만날 수 있습니다.
I/O 동시성에는 Ractor가 전혀 도움이 되지 않습니다. 각 Ractor 안에서도 thread는 여전히 자기 GVL에 제약을 받습니다. 그 thread 내부의 fiber가 여전히 실제 I/O 멀티플렉싱을 수행합니다. Ractor가 추가하는 것은 CPU 병렬성인데, 그것은 LLM 스트리밍에 필요한 것이 아닙니다.
오늘날 Rails job에서 CPU 병렬성이 필요하다면 여전히 뻔하지만 확실한 답은 process입니다. Puma는 웹 worker에 이미 그 모델을 사용합니다. Ractor는 고립된 CPU 집약적 Ruby 작업에는 언젠가 유용해질 수 있지만, 이 Solid Queue I/O 문제의 답은 아닙니다.
아닙니다. 위에서 코드 비교를 보여드렸습니다. JavaScript의 async/await는 색이 있는 동시성 모델입니다.
async
키워드가 모든 호출자 쪽으로 위로 퍼집니다. Ruby의 fiber는 색이 없습니다. 기존 코드가 수정 없이 그대로 동작하고, scheduler가 코드 아래에서 yield를 처리합니다.
더 깊은 차이도 있습니다. JavaScript의 async/await는 event loop 위에서 동작합니다. Ruby의 fiber는 멀티스레드 런타임 위에서 동작합니다. 각각 자신의 reactor와 fiber를 실행하는 여러 Ruby thread를 둘 수 있고, 하나의 애플리케이션 안에서 fiber와 thread를 섞어 쓸 수도 있습니다. Node는
worker_threads
로 JavaScript를 병렬 실행할 수 있지만, 그것은 worker/isolate 모델이지, 일반 애플리케이션 thread 안에 여러 reactor를 넣는 것과는 같은 개념이 아닙니다.
더 가깝습니다. goroutine은 가볍고, 런타임이 스케줄링하며, OS thread 위에 멀티플렉싱됩니다. 개념적으로는 Ruby fiber와 비슷합니다. 하지만 Go의 scheduler는 goroutine을 선점할 수도 있습니다.
차이점은 두 가지입니다:
Go는 진짜 병렬성을 가집니다. goroutine은 GVL에 해당하는 것 없이 여러 OS thread에 걸쳐 실행됩니다. CPU 중심 goroutine은 병렬로 실행됩니다. Ruby fiber는 그렇지 않습니다.
Ruby에는 기존 코드가 있습니다. 수십만 줄짜리 Rails 애플리케이션이 있다면 아무것도 다시 쓰지 않고 fiber 기반 동시성을 추가할 수 있습니다. 모델, 컨트롤러, 뷰, gem 모두 그대로 동작합니다. Go에서는 다시 써야 합니다.
처음부터 시작하고 I/O 동시성과 CPU 병렬성이 모두 필요하다면 Go는 강력한 선택입니다. Ruby 애플리케이션이 이미 있고 I/O 동시성이 필요하다면, fiber는 재작성 없이 그것을 제공합니다.
Async do
블록이 필요하잖아. 그것도 결국 새 문법 아닌가?”
Hacker News에서 누군가 이 점을 지적했습니다. 제가 “async/await는 없다”라고 말했지만 예제에는
Async do
와
.wait
가 나온다고요.
실제 변화는 이렇습니다:
# Before
chat = RubyLLM.chat
response = chat.ask("Hello")
# After
Async do
chat = RubyLLM.chat
response = chat.ask("Hello")
end
래핑하는 두 줄입니다. 내부의 애플리케이션 코드는 바뀌지 않습니다. 모델도 바뀌지 않습니다. gem도 바뀌지 않습니다. 그 어떤 것도 새 키워드를 갖지 않습니다.
Python에서 async를 도입한다는 것은 호출 체인 전체의 모든 함수 시그니처를
async def
로 다시 쓰고, 모든 호출에
await
를 추가하며, 블로킹 라이브러리를 교체하거나 감싸는 것을 의미합니다.
requests
는
aiohttp
또는 async
httpx
로 바뀝니다. 블로킹 데이터베이스 API는 async 데이터베이스 API가 됩니다. 테스트 프레임워크도 바뀝니다. middleware도 바뀝니다. 사실상 재작성입니다.
두 줄로 감싸기 vs 스택 전체 다시 쓰기. 이것은 같은 대화가 아닙니다.
flowchart TD A[어떤 종류의 작업인가?] --> B{CPU 중심인가?} B -->|예| C{병렬성이 필요한가?} C -->|예| D{Rails인가?} D -->|예| E[Process] D -->|아니요| H[Ractors] C -->|아니요| F[Threads] B -->|아니요| I[Fibers] style E fill:#4a90a4,color:#fff style H fill:#c084fc,color:#fff style F fill:#7fb069,color:#fff style I fill:#e8a87c,color:#fff
# Solid Queue with the fiber-mode patch: all three working together
workers:
- queues: [ chat, turbo ]
fibers: 50 # I/O-bound: fibers
processes: 2 # parallelism: processes
- queues: [ pdf, images ]
threads: 4 # CPU-bound: threads
processes: 1
어떤 단일 모델도 보편적으로 더 낫지는 않습니다. 올바른 답은 작업 부하에 모델을 맞추는 것입니다.
지금까지 제가 받은 모든 “무슨 일이 일어나는가” 질문을 여기서 다뤘습니다. 여러분의 질문이 빠졌다면, Twitter에서 저를 찾아주세요. 이 글을 업데이트하거나 후속 글을 쓰겠습니다.
자주 글을 올리진 않지만, 올릴 때는 대체로 이런 글입니다. 알고리즘이 대신 결정하지 못하게 구독하세요.
이메일 구독
저는 Chat with Work와 RubyLLM에서 AI 도구를 만듭니다. Freshflow를 공동 창업했습니다. 기술 밖에서는 음악을 만들고, Floppy Disco를 운영하고, 사진을 찍습니다.
AIAPI StandardsAgentsAnthropicAsyncBackupsBootstrappingCareerChat UICluster HeadachesConcurrencyDeepSeekDeveloper ExperienceDocumentationFalconFibersGeminiGoGoogleHealthHyprlandIndieJekyllKamalLLMLeadershipLinuxMCPMistralMulti-tenancyOllamaOmarchyOpen SourceOpenAIOpenRouterParseraPerformancePerplexityProduct StrategyProductivityRailsRubyRubyLLMRustSaaSSolid QueueStartupsStructured OutputTUITool CallingVitePressVoice
2026년 5월 5일
2026년 4월 28일
2026년 4월 21일
모든 글을, 알고리즘 없이.
이메일 구독
소셜
프로젝트
Chat with WorkRubyLLMRubyLLM WorkshopsSpeakingFounder AdvisoryFloppy DiscoMindscape ProductionsCluster Headache TrackerhyprmoncfgJekyll VitePressKamal Backup
2026 © Carmine Paolino