Multi Threading

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 synchronize daripada lock/unlock manual 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 = true saat 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 Parallel gem atau fork untuk CPU-bound parallelism yang sudah matang dan production-ready.

← Sebelumnya: RubyGems   Berikutnya: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact