Multi Threading #
Concurrency dan parallelism adalah dua konsep yang sering dikacaukan, dan Ruby punya posisi unik di antara keduanya. Thread di Ruby memang berjalan secara concurrent — bergantian menggunakan CPU — tapi bukan parallel secara penuh pada CPU-bound task, karena GIL (Global Interpreter Lock) di CRuby memastikan hanya satu thread yang menjalankan kode Ruby pada satu waktu. Tapi ini bukan berarti threading di Ruby tidak berguna: untuk I/O-bound tasks seperti request HTTP, query database, dan baca/tulis file, threading memberikan keuntungan nyata karena GIL dilepas saat menunggu I/O. Sejak Ruby 3.0, ada Ractor yang memungkinkan true parallelism. Artikel ini membahas semua mekanisme concurrency Ruby dari Thread dasar hingga Ractor modern.
GIL — Memahami Batasan Fundamental Ruby #
Sebelum menulis kode threading, penting memahami GIL (Global Interpreter Lock) — juga disebut GVL (Global VM Lock) di dokumentasi modern CRuby.
flowchart TD
A[Program Ruby dengan 4 Thread] --> B{Jenis Task?}
B --> C["CPU-bound\n(komputasi berat)"]
B --> D["I/O-bound\n(network, file, DB)"]
C --> E["GIL mencegah eksekusi paralel\nHanya 1 thread jalan di satu waktu\nPerforma = seperti single-thread"]
D --> F["GIL dilepas saat menunggu I/O\nThread lain bisa jalan\nPerforma meningkat signifikan"]
E --> G["Solusi: Ractor atau fork\nuntuk true parallelism"]
F --> H["Threading sudah cukup\nuntuk I/O concurrency"]require 'benchmark'
# Demonstrasi GIL pada CPU-bound task
def hitung_intensif(n)
n.times { Math.sqrt(rand) }
end
n = 5_000_000
# Sequential — satu per satu
waktu_sequential = Benchmark.realtime do
4.times { hitung_intensif(n) }
end
# Multi-threaded — 4 thread bersamaan
waktu_threaded = Benchmark.realtime do
thread = 4.times.map { Thread.new { hitung_intensif(n) } }
thread.each(&:join)
end
puts "Sequential: #{waktu_sequential.round(2)}s"
puts "4 threads: #{waktu_threaded.round(2)}s"
# Hasilnya hampir sama — GIL mencegah parallelism sejati untuk CPU-bound!
Kapan threading di Ruby bermanfaat:
✓ Request HTTP ke banyak API secara bersamaan
✓ Query database bersamaan
✓ Membaca/menulis banyak file
✓ Web server melayani banyak request sekaligus (Puma!)
✓ Producer-consumer pipeline dengan I/O di setiap tahap
✗ Komputasi matematika berat
✗ Pemrosesan gambar atau video (CPU-intensive)
✗ Enkripsi atau hashing intensif
→ Untuk ini: gunakan Ractor, fork, atau proses terpisah
Membuat dan Mengelola Thread #
Thread.new — Membuat Thread Baru #
# Thread paling sederhana
t = Thread.new { puts "Halo dari thread baru!" }
t.join # tunggu thread selesai sebelum lanjut
# Thread dengan blok multi-baris
thread = Thread.new do
puts "Thread dimulai"
sleep 1
puts "Thread selesai"
end
puts "Main thread tetap jalan"
thread.join
puts "Main thread menunggu selesai"
join dan value — Menunggu dan Mengambil Hasil #
join menunggu thread selesai. value menunggu sekaligus mengembalikan nilai dari ekspresi terakhir di blok:
# value — seperti join tapi kembalikan hasil kalkulasi
thread = Thread.new { (1..100).sum }
hasil = thread.value # tunggu dan ambil hasilnya
puts hasil # => 5050
# join dengan timeout — jangan tunggu selamanya
t = Thread.new { sleep 10 }
selesai = t.join(2) # tunggu maksimal 2 detik
if selesai.nil?
puts "Thread timeout — masih berjalan"
t.kill # paksa hentikan
else
puts "Thread selesai tepat waktu"
end
# Menjalankan banyak thread dan mengumpulkan hasilnya
urls = ["https://api1.com", "https://api2.com", "https://api3.com"]
# ANTI-PATTERN: sequential — lambat
# hasil = urls.map { |url| ambil_data(url) }
# BENAR: parallel I/O dengan thread
hasil = urls.map { |url| Thread.new { ambil_data(url) } }
.map(&:value) # collect semua hasil
puts hasil.inspect
Thread Lifecycle dan Status #
t = Thread.new { sleep 2 }
puts t.status # => "sleep" (sedang sleep)
puts t.alive? # => true
puts t.stop? # => true (stop juga true saat sleep!)
sleep 1
puts t.status # => "sleep"
t.join
puts t.status # => false (selesai normal)
puts t.alive? # => false
# Thread yang crash
t_crash = Thread.new { raise "Sengaja crash" }
t_crash.join rescue nil
puts t_crash.status # => nil (selesai karena exception)
# Status yang mungkin: "run", "sleep", "aborting", false, nil
Thread Variables — Variabel Lokal per Thread #
Variabel instance biasa di dalam thread tidak thread-safe karena berbagi dengan objek lain. Thread-local variable tersimpan per-thread:
# Thread.current[] — menyimpan nilai per thread
Thread.new do
Thread.current[:nama] = "Thread A"
sleep 0.1
puts Thread.current[:nama] # => "Thread A"
end
Thread.new do
Thread.current[:nama] = "Thread B"
sleep 0.1
puts Thread.current[:nama] # => "Thread B" (tidak terpengaruh Thread A)
end.join
# Thread.current[] berguna untuk menyimpan context per request
# di framework web seperti Rails (misalnya current_user)
Sinkronisasi — Mencegah Race Condition #
Race condition terjadi ketika dua atau lebih thread mengakses dan memodifikasi data yang sama secara bersamaan tanpa koordinasi, menghasilkan output yang tidak bisa diprediksi.
# ANTI-PATTERN: race condition klasik
counter = 0
threads = 10.times.map do
Thread.new do
1000.times { counter += 1 } # counter += 1 bukan atomic!
end
end
threads.each(&:join)
puts counter # Seharusnya 10000, tapi mungkin kurang!
# counter += 1 sebenarnya: baca nilai, tambah 1, tulis kembali
# thread lain bisa masuk di antara langkah-langkah ini
Mutex — Mutual Exclusion #
Mutex memastikan hanya satu thread yang mengeksekusi blok kritis pada satu waktu:
counter = 0
mutex = Mutex.new
threads = 10.times.map do
Thread.new do
1000.times do
mutex.synchronize do
counter += 1 # sekarang aman — hanya satu thread masuk blok ini
end
end
end
end
threads.each(&:join)
puts counter # => selalu 10000
# Mutex juga punya try_lock — tidak memblokir jika sudah di-lock
mutex = Mutex.new
berhasil = mutex.try_lock
if berhasil
begin
# kerjakan sesuatu
ensure
mutex.unlock
end
else
puts "Mutex sedang digunakan thread lain, lewati"
end
Deadlock — Jebakan Paling Berbahaya #
Deadlock terjadi ketika dua thread saling menunggu satu sama lain melepas lock:
mutex_a = Mutex.new
mutex_b = Mutex.new
# Thread 1: kunci A dulu, lalu B
thread1 = Thread.new do
mutex_a.synchronize do
sleep 0.1 # jeda agar thread 2 sempat kunci B
mutex_b.synchronize do
puts "Thread 1 selesai"
end
end
end
# Thread 2: kunci B dulu, lalu A — DEADLOCK!
thread2 = Thread.new do
mutex_b.synchronize do
sleep 0.1
mutex_a.synchronize do # ← menunggu thread 1 lepas A, tapi thread 1 menunggu B
puts "Thread 2 selesai"
end
end
end
[thread1, thread2].each(&:join)
# Program hang selamanya — deadlock!
# Mencegah deadlock: selalu kunci mutex dalam urutan yang sama
mutex_a = Mutex.new
mutex_b = Mutex.new
# Kedua thread mengunci A dulu, baru B — tidak ada deadlock
thread1 = Thread.new do
mutex_a.synchronize do
mutex_b.synchronize do
puts "Thread 1 selesai"
end
end
end
thread2 = Thread.new do
mutex_a.synchronize do # ← urutan sama: A dulu, baru B
mutex_b.synchronize do
puts "Thread 2 selesai"
end
end
end
[thread1, thread2].each(&:join)
ConditionVariable — Koordinasi Antar Thread #
ConditionVariable digunakan bersama Mutex untuk membuat thread menunggu kondisi tertentu terpenuhi:
mutex = Mutex.new
cond = ConditionVariable.new
siap = false
# Consumer — menunggu producer siap
consumer = Thread.new do
mutex.synchronize do
cond.wait(mutex) until siap # tunggu sampai siap = true
puts "Consumer: data diterima!"
end
end
# Producer — siapkan data lalu beri sinyal
producer = Thread.new do
sleep 1
mutex.synchronize do
siap = true
cond.signal # bangunkan consumer yang sedang menunggu
puts "Producer: data dikirim!"
end
end
[producer, consumer].each(&:join)
Queue — Komunikasi Thread-Safe #
Queue adalah struktur data yang dirancang khusus untuk komunikasi antar thread — semua operasi sudah thread-safe tanpa perlu mutex manual:
require 'thread' # tidak diperlukan di Ruby >= 3.2, sudah built-in
antrian = Queue.new
# Producer — menghasilkan data
producer = Thread.new do
10.times do |i|
sleep rand(0.1..0.3)
antrian << "item-#{i}"
puts "Diproduksi: item-#{i}"
end
antrian << :selesai # sentinel value
end
# Consumer — memproses data
consumer = Thread.new do
loop do
item = antrian.pop # block sampai ada item
break if item == :selesai
puts "Diproses: #{item}"
sleep rand(0.1..0.2)
end
end
[producer, consumer].each(&:join)
SizedQueue — Antrian dengan Batas Kapasitas #
SizedQueue membatasi jumlah item dalam antrian — producer otomatis menunggu jika antrian penuh:
antrian = SizedQueue.new(5) # maksimal 5 item
# Producer — akan berhenti jika antrian penuh
producer = Thread.new do
20.times do |i|
antrian.push("item-#{i}") # block jika antrian sudah 5 item
puts "Diproduksi: item-#{i} | Antrian: #{antrian.size}/5"
end
end
# Consumer — memproses lebih lambat dari producer
consumer = Thread.new do
20.times do
item = antrian.pop
sleep 0.2 # lebih lambat dari producer
puts "Diproses: #{item}"
end
end
[producer, consumer].each(&:join)
Thread Pool Manual dengan Queue #
Thread pool membatasi jumlah thread yang berjalan bersamaan — mencegah pembuatan terlalu banyak thread yang justru memboroskan memori:
class ThreadPool
def initialize(ukuran)
@ukuran = ukuran
@antrian = Queue.new
@workers = ukuran.times.map do
Thread.new do
loop do
tugas = @antrian.pop
break if tugas == :shutdown
begin
tugas.call
rescue => e
puts "Worker error: #{e.message}"
end
end
end
end
end
def submit(&blok)
@antrian << blok
end
def shutdown
@ukuran.times { @antrian << :shutdown }
@workers.each(&:join)
end
end
# Penggunaan
pool = ThreadPool.new(4) # 4 worker thread
20.times do |i|
pool.submit do
sleep rand(0.1..0.5)
puts "Tugas #{i} selesai oleh #{Thread.current.object_id}"
end
end
pool.shutdown
puts "Semua tugas selesai"
Fiber — Concurrency Kooperatif #
Fiber adalah lightweight cooperative coroutine — berbeda dari thread yang di-schedule oleh OS, Fiber dijadwalkan secara manual oleh developer menggunakan Fiber.yield dan resume:
# Fiber dasar — kontrol eksplisit kapan berpindah
fiber = Fiber.new do
puts "Fiber: langkah 1"
Fiber.yield # serahkan kontrol kembali ke pemanggil
puts "Fiber: langkah 2"
Fiber.yield
puts "Fiber: langkah 3"
end
puts "Main: mulai"
fiber.resume # => "Fiber: langkah 1"
puts "Main: kembali"
fiber.resume # => "Fiber: langkah 2"
puts "Main: kembali lagi"
fiber.resume # => "Fiber: langkah 3"
# Fiber dengan nilai bolak-balik
generator = Fiber.new do
nilai = 0
loop do
nilai += 1
Fiber.yield nilai # kirim nilai ke pemanggil
end
end
puts generator.resume # => 1
puts generator.resume # => 2
puts generator.resume # => 3
Fiber vs Thread #
Fiber:
✓ Sangat ringan — bisa buat jutaan Fiber
✓ Tidak ada race condition — satu Fiber jalan di satu waktu
✓ Kontrol eksplisit kapan berpindah (cooperative)
✓ Cocok untuk generator, lazy sequence, state machine
✗ Tidak ada parallelism — harus yield secara manual
✗ Satu Fiber yang blocking akan memblokir semua
Thread:
✓ Preemptive scheduling — OS yang mengatur pergantian
✓ Berguna untuk I/O concurrent yang sesungguhnya
✗ Lebih berat — setiap thread menggunakan lebih banyak memori
✗ Perlu sinkronisasi untuk shared data
Ractor — True Parallelism di Ruby 3.x #
Ractor (diperkenalkan Ruby 3.0) adalah cara untuk mencapai parallelism sejati di CRuby tanpa GIL. Setiap Ractor memiliki memori yang terisolasi — tidak ada shared mutable state antar Ractor.
# Ractor — true parallelism
# Setiap Ractor punya GIL-nya sendiri → berjalan benar-benar paralel
# Komputasi berat yang bisa di-parallelkan dengan Ractor
def komputasi_berat(n)
(1..n).sum { |i| Math.sqrt(i) }
end
# Sequential — lambat
waktu_seq = Process.clock_gettime(Process::CLOCK_MONOTONIC)
hasil_seq = 4.times.map { komputasi_berat(1_000_000) }
waktu_seq = Process.clock_gettime(Process::CLOCK_MONOTONIC) - waktu_seq
# Parallel dengan Ractor — lebih cepat di mesin multi-core!
waktu_ractor = Process.clock_gettime(Process::CLOCK_MONOTONIC)
ractors = 4.times.map do
Ractor.new { komputasi_berat(1_000_000) }
end
hasil_ractor = ractors.map(&:take)
waktu_ractor = Process.clock_gettime(Process::CLOCK_MONOTONIC) - waktu_ractor
puts "Sequential: #{waktu_seq.round(2)}s"
puts "Ractor: #{waktu_ractor.round(2)}s"
# Di mesin 4-core: Ractor ~4x lebih cepat!
# Komunikasi antar Ractor melalui message passing
r = Ractor.new do
pesan = Ractor.receive # tunggu pesan masuk
"Diproses: #{pesan}"
end
r.send("Halo dari main!")
puts r.take # => "Diproses: Halo dari main!"
# Ractor Pipeline — serangkaian Ractor yang mengolah data
pipeline = (1..5).map do |tahap|
Ractor.new(tahap) do |n|
loop do
data = Ractor.receive
hasil = data * n # transformasi berbeda di setiap tahap
Ractor.yield hasil
end
end
end
# Kirim data ke pipeline pertama
pipeline[0].send(10)
# Data mengalir melalui setiap Ractor
Ractor masih dalam tahap eksperimental di Ruby 3.x — beberapa gem belum kompatibel karena menggunakan shared mutable state. Periksa kompatibilitas gem yang kamu gunakan sebelum mengadopsi Ractor di produksi.
Mengatasi GIL — Strategi Lengkap #
flowchart TD
A[Butuh parallelism sejati?] --> B{Jenis workload?}
B --> C["I/O-bound\nnetwork, file, DB"]
B --> D["CPU-bound\nkomputasi berat"]
C --> E["Thread sudah cukup\nGIL dilepas saat I/O"]
D --> F{Pilih strategi}
F --> G["Ractor\nRuby 3.0+\nTrue parallelism\ntanpa fork"]
F --> H["fork + Process\nIsolasi penuh\ncocok untuk batch job"]
F --> I["JRuby / TruffleRuby\nImplementasi tanpa GIL\nCompatible dengan MRI gems"]
F --> J["Parallel gem\nWrapper fork/thread\nMudah digunakan"]# Pendekatan 1: fork untuk CPU-bound task
pids = 4.times.map do |i|
fork do
hasil = komputasi_berat(1_000_000)
# Kirim hasil ke proses induk melalui pipe atau file
puts "Worker #{i}: #{hasil}"
exit
end
end
pids.each { |pid| Process.wait(pid) }
# Pendekatan 2: gem Parallel — abstraksi yang nyaman
# gem install parallel
require 'parallel'
data = (1..20).to_a
# Parallel.map — gunakan fork secara otomatis
hasil = Parallel.map(data, in_processes: 4) do |n|
komputasi_berat(n * 100_000)
end
# Parallel dengan thread (untuk I/O-bound)
hasil = Parallel.map(urls, in_threads: 10) do |url|
HTTP.get(url).body
end
Best Practice Threading di Ruby #
# 1. Gunakan objek thread-safe dari standard library
require 'thread'
# Queue, SizedQueue — sudah thread-safe
antrian = Queue.new
# Hash dan Array biasa TIDAK thread-safe untuk write
# ANTI-PATTERN:
hasil = {}
threads = urls.map do |url|
Thread.new { hasil[url] = fetch(url) } # ← race condition!
end
threads.each(&:join)
# BENAR: kumpulkan dengan join/value dan assign setelah selesai
hasil = urls.map { |url| Thread.new { [url, fetch(url)] } }
.map(&:value)
.to_h
# 2. Minimalisir shared mutable state
# Semakin sedikit data yang dibagikan antar thread, semakin sedikit
# potensi race condition dan kebutuhan sinkronisasi
# 3. Tangani exception di dalam thread
thread = Thread.new do
begin
# kode yang mungkin gagal
hasil = proses_berisiko()
rescue => e
puts "Thread error: #{e.message}"
nil # kembalikan nil sebagai fallback
end
end
# Tanpa rescue di dalam thread, exception diam-diam diabaikan!
# Thread.abort_on_exception = true ← aktifkan untuk debugging
# 4. Set abort_on_exception untuk development
Thread.abort_on_exception = true # program crash jika ada thread yang exception
# 5. Gunakan timeout untuk thread yang mungkin hang
require 'timeout'
begin
Timeout.timeout(5) do
thread.join # tunggu maksimal 5 detik
end
rescue Timeout::Error
thread.kill
puts "Thread timeout!"
end
Ringkasan #
- GIL membatasi parallelism CPU-bound di CRuby — untuk I/O-bound tasks (network, file, database), threading tetap memberikan keuntungan nyata karena GIL dilepas saat menunggu I/O.
- Thread.value untuk mengumpulkan hasil — lebih idiomatik dari menyimpan ke shared array;
threads.map(&:value)menunggu semua thread dan mengembalikan hasilnya.- Mutex.synchronize untuk critical section — selalu gunakan
synchronizedaripadalock/unlockmanual untuk memastikan lock selalu dilepas meskipun ada exception.- Selalu lock mutex dalam urutan yang sama — untuk mencegah deadlock ketika beberapa mutex digunakan bersama.
- Queue dan SizedQueue sudah thread-safe — gunakan untuk komunikasi antar thread daripada shared Array atau Hash biasa.
- Thread pool untuk membatasi jumlah thread — membuat terlalu banyak thread memboroskan memori; pool membatasi concurrency ke angka yang terkendali.
- Tangani exception di dalam thread — exception yang tidak di-rescue di dalam thread diam-diam diabaikan; gunakan
Thread.abort_on_exception = truesaat development.- Fiber untuk cooperative multitasking — sangat ringan, cocok untuk generator dan state machine, tapi memerlukan yield manual.
- Ractor untuk true parallelism — setiap Ractor punya memori terisolasi dan tidak terikat GIL; cocok untuk komputasi berat di Ruby 3.x, tapi masih eksperimental.
- Pertimbangkan
Parallelgem atau fork untuk CPU-bound parallelism yang sudah matang dan production-ready.