Rage.rb — Khi Ruby Học Cách Không Chờ Đợi

Published: (March 20, 2026 at 02:14 AM EDT)
8 min read
Source: Dev.to

Source: Dev.to

Vấn đề muôn thuở của Rails dưới tải cao

Bạn có một Rails API. Mọi thứ chạy tốt ở môi trường dev, production cũng ổn ở lượng traffic vừa phải. Rồi một ngày đẹp trời, traffic tăng đột biến — và bạn bắt đầu thấy: Response time leo thang Memory usage phình to Sidekiq queue tắc nghẽn Infra team hỏi “có cần thêm worker không?” Vấn đề không phải ở code logic. Vấn đề nằm sâu hơn — ở cách Rails xử lý concurrency. Rails với Puma dùng mô hình multi-threaded. Mỗi HTTP request được xử lý bởi một thread riêng. Request 1 ──→ Thread 1: [connect DB] [======chờ 50ms======] [process] [respond] Request 2 ──→ Thread 2: [connect DB] [======chờ 50ms======] [process] [respond] Request 3 ──→ Thread 3: [connect DB] [======chờ 50ms======] [process] [respond] … Request N ──→ Thread N: phải chờ thread trống

Trong 50ms chờ database, thread đó không làm gì cả — nó block hoàn toàn. Đây gọi là blocking I/O. Giải pháp của Puma: tăng số thread. Nhưng mỗi thread tốn 1–8MB RAM và OS phải liên tục context switch giữa chúng. Với 1000 concurrent requests, bạn cần 1000 threads — tức là hàng GB RAM chỉ để… chờ. Trước khi nói về Rage, cần hiểu coroutine — khái niệm mà Rage xây dựng lên trên đó. Function thông thường chạy theo mô hình một chiều: gọi → chạy hết → trả về. Không thể dừng giữa chừng. Coroutine thì khác: nó có thể tự pause, nhường quyền điều khiển, rồi resume lại từ đúng chỗ đã dừng — với toàn bộ local state còn nguyên vẹn. barista = Fiber.new do puts “Xay cà phê…” Fiber.yield “Đang chờ nước sôi” # pause, trả quyền về caller

puts “Pha cà phê…” Fiber.yield “Đang rót” # pause lần 2

puts “Hoàn thành!” “Cà phê của bạn đây” # kết thúc end

puts barista.resume # “Xay cà phê…” → “Đang chờ nước sôi” puts “Làm việc khác trong lúc chờ…” puts barista.resume # “Pha cà phê…” → “Đang rót” puts barista.resume # “Hoàn thành!” → “Cà phê của bạn đây”

Điểm mấu chốt: sau mỗi Fiber.yield, local variables và vị trí đang thực thi được giữ nguyên trên stack của fiber đó. Caller tiếp tục làm việc khác, rồi resume lại bất cứ lúc nào. Thread: OS quyết định khi nào switch → preemptive (bị ngắt bất ngờ) Coroutine: Code quyết định khi nào yield → cooperative (tự nguyện nhường)

Hệ quả trực tiếp: Thread cần mutex/lock để tránh race condition. Coroutine thì không — vì chỉ có 1 coroutine chạy tại một thời điểm, và switch chỉ xảy ra tại những điểm xác định. Thread stack: 1MB – 8MB (OS cấp phát, không thể thay đổi) Fiber stack: ~4KB (Ruby quản lý trong heap, co giãn được)

Tạo 10,000 threads → ~80GB RAM → không khả thi.

Tạo 10,000 fibers → ~40MB RAM → hoàn toàn bình thường. Rage dùng Iodine làm web server — một C extension tích hợp event loop và Fiber Scheduler của Ruby. Mỗi incoming request được wrap trong một fiber riêng. Event Loop (1 thread duy nhất) │ ├── Fiber A: request GET /posts │ └── SELECT * FROM posts… → YIELD (chờ PG) │ ├── Fiber B: request POST /posts ← tiếp nhận trong lúc A chờ │ └── INSERT INTO posts… → YIELD (chờ PG) │ ├── Fiber C: request GET /users ← tiếp nhận trong lúc A, B chờ │ └── SELECT * FROM users… → YIELD (chờ PG) │ │ [PG trả kết quả cho A] ├── Fiber A: RESUME → render json → response ✓ │ │ [PG trả kết quả cho B] ├── Fiber B: RESUME → render json → response ✓ …

Toàn bộ chạy trên 1 thread duy nhất, không có blocking, không có race condition. Magic xảy ra nhờ Ruby Fiber Scheduler interface (có từ Ruby 3.0). Rage patch các thư viện I/O chuẩn để tự động yield:

Bạn viết code này — trông hoàn toàn synchronous

class PostsController < RageController::API def index posts = Post.all.to_a # ← fiber tự động YIELD khi chờ PG render json: posts # ← fiber RESUME khi có kết quả end end

Không cần async/await, không cần callback, không cần thay đổi gì

Rage tự xử lý phần còn lại

Rage tự động patch: Net::HTTP, driver pg, driver mysql2, Thread.join, Ractor.join, và sleep. Khi một request cần gọi nhiều nguồn dữ liệu, Rage cho phép chạy song song: class DashboardController < RageController::API def show # ❌ Sequential — tổng ~300ms posts = Post.published.to_a users = User.active.to_a comments = Comment.recent.to_a

# ✅ Parallel với Fiber.await — tổng ~100ms (bằng cái chậm nhất)
posts, users, comments = Fiber.await([
  Fiber.schedule { Post.published.to_a },
  Fiber.schedule { User.active.to_a },
  Fiber.schedule { Comment.recent.to_a }
])

render json: { posts:, users:, comments: }

end end

Với Rails thuần, để đạt kết quả tương tự bạn phải dùng concurrent-ruby, async gem, hoặc tự quản lý threads — tất cả đều phức tạp hơn và dễ mắc lỗi.

Gọi external API

def fetch_weather response = Net::HTTP.get(URI(“https://api.weather.com/data”)) render json: JSON.parse(response) end

Heavy database queries

def reports data = Report.joins(:user).where(created_at: 1.month.ago..).to_a render json: data end

Fan-out requests — gọi nhiều services cùng lúc

def aggregated_data results = Fiber.await([ Fiber.schedule { UserService.fetch_profile(params[:id]) }, Fiber.schedule { OrderService.fetch_history(params[:id]) }, Fiber.schedule { NotificationService.fetch_unread(params[:id]) } ]) render json: results end

Rage outperforms Rails 81–219% trên database benchmarks (TechEmpower Round 23). Với I/O-heavy workloads, khoảng cách càng lớn hơn khi số concurrent requests tăng. Thay vì stack quen thuộc: Rails app + Puma

  • Sidekiq workers
  • Redis (cho Sidekiq + Action Cable)
  • Separate cable server

Rage gộp lại thành: Rage app (1 process) ├── HTTP requests (fiber per request) ├── Background jobs (Rage::Deferred, in-process) ├── WebSockets (Rage::Cable, không cần Redis) └── Domain events (Rage::Events)

class OrdersController < RageController::API def create order = Order.create!(order_params)

# Background job — chạy in-process, không cần R

edis/Sidekiq SendConfirmationEmail.enqueue(order.id)

# Broadcast WebSocket — không cần Action Cable/Redis
Rage::Cable.broadcast("orders", { id: order.id, status: "created" })

render json: order, status: :created

end end

I/O-bound → Rage xử lý xuất sắc, fiber yield khi chờ

Post.where(status: “published”).to_a

CPU-bound → Rage không cải thiện được, fiber không yield

def generate_report records.each { |r| complex_calculation(r) } # block cả event loop end

Với heavy CPU workloads, thread-based model của Puma thực ra tốt hơn vì OS có thể phân phối các threads lên nhiều CPU cores. Không phải tất cả gems đều fiber-aware. Gem nào dùng blocking I/O thuần sẽ block toàn bộ event loop thay vì chỉ yield fiber. Cần kiểm tra compatibility trước khi migrate. Rage được thiết kế để syntax gần giống Rails nhất có thể. Nhưng có vài điểm cần lưu ý:

❌ Rails — không hoạt động trong Rage

def post_params params.require(:post).permit(:title, :content) end

✅ Rage — params là plain Hash

def post_params (params[:post] || {}).transform_keys(&:to_s).slice(“title”, “content”) end

Rails

Rails.logger.info “message”

Rage

Rage.logger.info “message”

Rails

class PostsController < ApplicationController

Rage

class PostsController < RageController::API

Rails

Rails.application.routes.draw { resources :posts }

Rage

Rage.routes.draw { resources :posts }

Đó là một số điểm khác nhau giữa Rage và Rails mà tôi khám phá ra trong khi thử implement một blog app nhỏ, có thể sẽ còn một số điểm khác biệt khác nữa. Nhưng nhìn chung là rất dễ học cho các Ruby on Rails developers Nên dùng Rage nếu: App là API-only, không có server-side rendering Workload chủ yếu là I/O-bound (DB queries, external API calls) Muốn reduce infrastructure complexity (bỏ Redis, Sidekiq) Cần handle lượng lớn concurrent connections (WebSocket, long-polling) Team muốn giữ Rails ergonomics nhưng cần performance cao hơn Chưa nên dùng Rage nếu: App có nhiều CPU-bound processing Đang dùng nhiều gems chưa được test với fiber scheduler App có views, helpers, assets (Rage là API-only) Team cần ecosystem rộng và mature của Rails Chiến lược hybrid — Rails Integration mode: Rage không yêu cầu rewrite toàn bộ. Có thể tích hợp vào Rails app hiện có để Rage xử lý HTTP requests, Rails giữ phần còn lại:

Gemfile

gem “rage-rb”

config/application.rb

require “rage/rails”

Rage xử lý requests, Rails lo code loading, ActiveRecord, v.v.

Rage.rb không phải là “Rails killer” hay framework bạn phải dùng cho mọi dự án. Nó là câu trả lời cho một vấn đề cụ thể: Ruby applications cần handle high-concurrency I/O-bound workloads mà không muốn trả giá bằng infrastructure complexity hay rewrite codebase. Điểm thú vị nhất của Rage không phải benchmark numbers — mà là triết lý thiết kế: bạn viết code synchronous như bình thường, framework lo phần async. Không có callback hell, không có async/await lan tràn khắp codebase, không có mental overhead của concurrent programming. Đó là một trade-off đáng cân nhắc.

0 views
Back to Blog

Related posts

Read more »