0

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

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 xử lý concurrency như thế nào?

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ờ.


Coroutine — Cơ chế nền tảng

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.

Coroutine khác Thread ở điểm gì?

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.

Tại sao Fiber nhẹ hơn Thread đến vậy?

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.rb áp dụng fiber như thế nào?

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.


Fiber.await — Parallel I/O trong 1 request

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.


Rage hiệu quả nhất trong trường hợp nào?

✅ I/O-bound workloads — đây là sân nhà của Rage

# 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

✅ High-concurrency API — nhiều requests đồng thời

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.

✅ Khi muốn đơn giản hóa infrastructure

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 Redis/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

❌ Rage không phải silver bullet — CPU-bound tasks

# 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.

❌ Gems dùng blocking I/O cũ

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 vs Rails — Điểm khác biệt thực tế khi code

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 ý:

Strong Parameters không tồn tại

# ❌ 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

Logger khác

# Rails
Rails.logger.info "message"

# Rage
Rage.logger.info "message"

Controller base class khác

# Rails
class PostsController < ApplicationController

# Rage
class PostsController < RageController::API

Routes namespace khác

# 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


Khi nào nên cân nhắc migrate sang Rage?

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.

Kết

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.

reference: https://dev.to/cuongnc0211/ragerb-khi-ruby-hoc-cach-khong-cho-doi-4k43


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí