Ruby Concurrency and Parallelism: Tutorial Praktis

Diterbitkan: 2022-03-11

Mari kita mulai dengan menyelesaikan titik kebingungan yang terlalu umum di antara pengembang Ruby; yaitu: Konkurensi dan paralelisme bukanlah hal yang sama (yaitu, bersamaan != paralel).

Secara khusus, Ruby concurrency adalah ketika dua tugas dapat dimulai, dijalankan, dan diselesaikan dalam periode waktu yang tumpang tindih . Namun, itu tidak berarti bahwa keduanya akan berjalan pada saat yang sama (misalnya, banyak utas pada mesin inti tunggal). Sebaliknya, paralelisme adalah ketika dua tugas benar-benar berjalan pada waktu yang sama (misalnya, beberapa utas pada prosesor multicore).

Poin kuncinya di sini adalah bahwa utas dan/atau proses bersamaan tidak harus berjalan secara paralel.

Tutorial ini memberikan perlakuan praktis (bukan teoritis) dari berbagai teknik dan pendekatan yang tersedia untuk konkurensi dan paralelisme di Ruby.

Untuk contoh Ruby dunia nyata lainnya, lihat artikel kami tentang Ruby Interpreters and Runtimes.

Kasus Uji kami

Untuk kasus uji sederhana, saya akan membuat kelas Mailer dan menambahkan fungsi Fibonacci (bukan metode sleep() ) untuk membuat setiap permintaan lebih intensif menggunakan CPU, sebagai berikut:

 class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end

Kami kemudian dapat memanggil kelas Mailer ini sebagai berikut untuk mengirim email:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(Catatan: Kode sumber untuk kasus uji ini tersedia di sini di github.)

Untuk menetapkan garis dasar untuk tujuan perbandingan, mari kita mulai dengan melakukan benchmark sederhana, memanggil mailer 100 kali:

 puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }

Ini menghasilkan hasil berikut pada prosesor quad-core dengan MRI Ruby 2.0.0p353:

 15.250000 0.020000 15.270000 ( 15.304447)

Beberapa Proses vs. Multithreading

Tidak ada jawaban “satu ukuran cocok untuk semua” ketika memutuskan apakah akan menggunakan banyak proses atau multithread aplikasi Ruby Anda. Tabel di bawah ini merangkum beberapa faktor utama yang perlu dipertimbangkan.

Proses Utas
Menggunakan lebih banyak memori Menggunakan lebih sedikit memori
Jika orang tua meninggal sebelum anak-anak keluar, anak-anak bisa menjadi proses zombie Semua utas mati saat proses mati (tidak ada kemungkinan zombie)
Lebih mahal untuk proses bercabang untuk beralih konteks karena OS perlu menyimpan dan memuat ulang semuanya Utas memiliki overhead yang jauh lebih sedikit karena mereka berbagi ruang alamat dan memori
Proses bercabang diberi ruang memori virtual baru (isolasi proses) Utas berbagi memori yang sama, jadi perlu mengontrol dan menangani masalah memori bersamaan
Memerlukan komunikasi antar proses Dapat "berkomunikasi" melalui antrian dan memori bersama
Lebih lambat untuk membuat dan menghancurkan Lebih cepat untuk membuat dan menghancurkan
Lebih mudah untuk membuat kode dan debug Dapat secara signifikan lebih kompleks untuk dikodekan dan di-debug

Contoh solusi Ruby yang menggunakan banyak proses:

  • Resque: Pustaka Ruby yang didukung Redis untuk membuat pekerjaan latar belakang, menempatkannya di beberapa antrian, dan memprosesnya nanti.
  • Unicorn: Server HTTP untuk aplikasi Rack yang dirancang hanya untuk melayani klien cepat pada koneksi berlatensi rendah, bandwidth tinggi, dan memanfaatkan fitur di kernel mirip Unix/Unix.

Contoh solusi Ruby yang menggunakan multithreading:

  • Sidekiq: Kerangka kerja pemrosesan latar belakang berfitur lengkap untuk Ruby. Ini bertujuan agar mudah diintegrasikan dengan aplikasi Rails modern dan kinerja yang jauh lebih tinggi daripada solusi lain yang ada.
  • Puma: Server web Ruby yang dibuat untuk konkurensi.
  • Tipis: Server web Ruby yang sangat cepat dan sederhana.

Beberapa Proses

Sebelum kita melihat opsi multithreading Ruby, mari kita jelajahi jalur yang lebih mudah untuk memunculkan banyak proses.

Di Ruby, panggilan sistem fork() digunakan untuk membuat "salinan" dari proses saat ini. Proses baru ini dijadwalkan pada tingkat sistem operasi, sehingga dapat berjalan secara bersamaan dengan proses asli, sama seperti proses independen lainnya. ( Catatan: fork() adalah panggilan sistem POSIX dan oleh karena itu tidak tersedia jika Anda menjalankan Ruby pada platform Windows.)

Oke, jadi mari kita jalankan test case kita, tapi kali ini menggunakan fork() untuk menggunakan beberapa proses:

 puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }

( Process.waitall menunggu semua proses anak keluar dan mengembalikan larik status proses.)

Kode ini sekarang menghasilkan hasil berikut (sekali lagi, pada prosesor quad-core dengan MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

Tidak terlalu buruk! Kami membuat mailer ~5x lebih cepat dengan hanya memodifikasi beberapa baris kode (yaitu, menggunakan fork() ).

Namun, jangan terlalu bersemangat. Meskipun mungkin tergoda untuk menggunakan forking karena ini adalah solusi yang mudah untuk konkurensi Ruby, ia memiliki kelemahan utama yaitu jumlah memori yang akan dikonsumsi. Forking agak mahal, terutama jika Copy-on-Write (CoW) tidak digunakan oleh interpreter Ruby yang Anda gunakan. Jika aplikasi Anda menggunakan memori 20MB, misalnya, melakukan forking 100 kali berpotensi menghabiskan memori sebanyak 2GB!

Selain itu, meskipun multithreading juga memiliki kerumitannya sendiri, ada sejumlah kerumitan yang perlu dipertimbangkan saat menggunakan fork() , seperti deskriptor file bersama dan semafor (antara proses bercabang induk dan anak), kebutuhan untuk berkomunikasi melalui pipa , dan seterusnya.

Ruby Multithreading

Oke, jadi sekarang mari kita coba membuat program yang sama lebih cepat menggunakan teknik multithreading Ruby.

Beberapa utas dalam satu proses memiliki overhead yang jauh lebih sedikit daripada jumlah proses yang sesuai karena mereka berbagi ruang alamat dan memori.

Dengan mengingat hal itu, mari kita tinjau kembali kasus pengujian kita, tetapi kali ini menggunakan kelas Thread Ruby:

 threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Kode ini sekarang menghasilkan hasil berikut (sekali lagi, pada prosesor quad-core dengan MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

Kekecewaan. Itu pasti tidak terlalu mengesankan! Jadi apa yang terjadi? Mengapa ini menghasilkan hasil yang hampir sama dengan yang kami dapatkan ketika kami menjalankan kode secara sinkron?

Jawabannya, yang menjadi kutukan bagi banyak programmer Ruby, adalah Global Interpreter Lock (GIL) . Berkat GIL, CRuby (implementasi MRI) tidak terlalu mendukung threading.

Global Interpreter Lock adalah mekanisme yang digunakan dalam penerjemah bahasa komputer untuk menyinkronkan eksekusi utas sehingga hanya satu utas yang dapat dieksekusi pada satu waktu. Seorang juru bahasa yang menggunakan GIL akan selalu mengizinkan tepat satu utas dan satu utas hanya untuk dieksekusi pada satu waktu , bahkan jika dijalankan pada prosesor multi-core. Ruby MRI dan CPython adalah dua contoh paling umum dari penerjemah populer yang memiliki GIL.

Jadi kembali ke masalah kita, bagaimana kita bisa mengeksploitasi multithreading di Ruby untuk meningkatkan kinerja mengingat GIL?

Nah, di MRI (CRuby), jawaban yang disayangkan adalah bahwa pada dasarnya Anda terjebak dan sangat sedikit yang dapat dilakukan multithreading untuk Anda.

Konkurensi Ruby tanpa paralelisme masih bisa sangat berguna, meskipun, untuk tugas-tugas yang IO-berat (misalnya, tugas-tugas yang harus sering menunggu di jaringan). Jadi utas masih bisa berguna di MRI, untuk tugas berat IO. Ada alasan mengapa thread diciptakan dan digunakan bahkan sebelum server multi-core menjadi umum.

Namun demikian, jika Anda memiliki opsi untuk menggunakan versi selain CRuby, Anda dapat menggunakan implementasi Ruby alternatif seperti JRuby atau Rubinius, karena mereka tidak memiliki GIL dan mereka mendukung threading Ruby paralel nyata.

dijalin dengan JRuby

Untuk membuktikannya, berikut adalah hasil yang kami dapatkan ketika kami menjalankan versi kode yang sama persis seperti sebelumnya, tetapi kali ini jalankan di JRuby (bukan CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

Sekarang kita bicara!

Tetapi…

Utas Tidak Gratis

Peningkatan kinerja dengan banyak utas mungkin membuat orang percaya bahwa kami dapat terus menambahkan lebih banyak utas – pada dasarnya tanpa batas – untuk terus membuat kode kami berjalan lebih cepat dan lebih cepat. Itu memang bagus jika itu benar, tetapi kenyataannya adalah bahwa utas tidak gratis dan, cepat atau lambat, Anda akan kehabisan sumber daya.

Katakanlah, misalnya, kita ingin menjalankan sample mailer kita bukan 100 kali, tapi 10.000 kali. Mari lihat apa yang terjadi:

 threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Ledakan! Saya mendapat kesalahan dengan OS X 10.8 saya setelah menelurkan sekitar 2.000 utas:

 can't create Thread: Resource temporarily unavailable (ThreadError)

Seperti yang diharapkan, cepat atau lambat kita mulai meronta-ronta atau kehabisan sumber daya sepenuhnya. Jadi skalabilitas pendekatan ini jelas terbatas.

Pengumpulan Benang

Untungnya, ada cara yang lebih baik; yaitu, penggabungan benang.

Kumpulan utas adalah sekelompok utas yang telah dibuat sebelumnya dan dapat digunakan kembali yang tersedia untuk melakukan pekerjaan sesuai kebutuhan. Kumpulan utas sangat berguna ketika ada banyak tugas pendek yang harus dilakukan daripada sejumlah kecil tugas yang lebih panjang. Ini mencegah harus mengeluarkan biaya tambahan untuk membuat utas berkali-kali.

Parameter konfigurasi kunci untuk kumpulan utas biasanya adalah jumlah utas di kumpulan. Utas ini dapat dipakai sekaligus (yaitu, ketika kumpulan dibuat) atau dengan malas (yaitu, sesuai kebutuhan hingga jumlah maksimum utas di kumpulan telah dibuat).

Saat kumpulan diberikan tugas untuk dilakukan, kumpulan itu menetapkan tugas ke salah satu utas yang saat ini tidak digunakan. Jika tidak ada utas yang menganggur (dan jumlah maksimum utas telah dibuat) ia menunggu utas untuk menyelesaikan pekerjaannya dan menjadi menganggur dan kemudian menetapkan tugas ke utas itu.

Pengumpulan Benang

Jadi, kembali ke contoh kita, kita akan mulai dengan menggunakan Queue (karena ini adalah tipe data yang aman untuk utas) dan menerapkan implementasi sederhana dari kumpulan utas:

membutuhkan "./lib/mailer" membutuhkan "benchmark" membutuhkan 'utas'

 POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)

Dalam kode di atas, kami mulai dengan membuat antrian jobs untuk pekerjaan yang perlu dilakukan. Kami menggunakan Queue untuk tujuan ini karena thread-safe (jadi jika beberapa utas mengaksesnya pada saat yang sama, itu akan menjaga konsistensi) yang menghindari perlunya implementasi yang lebih rumit yang memerlukan penggunaan mutex.

Kami kemudian mendorong ID pengirim surat ke antrian pekerjaan dan membuat kumpulan 10 utas pekerja kami.

Dalam setiap utas pekerja, kami mengeluarkan item dari antrean pekerjaan.

Dengan demikian, siklus hidup utas pekerja adalah terus menunggu tugas dimasukkan ke dalam Antrian pekerjaan dan menjalankannya.

Jadi kabar baiknya adalah ini berfungsi dan menskala tanpa masalah. Sayangnya, ini cukup rumit bahkan untuk tutorial sederhana kami.

Seluloida

Berkat ekosistem Ruby Gem, sebagian besar kerumitan multithreading dikemas dengan rapi dalam sejumlah Ruby Gems yang mudah digunakan.

Contoh yang bagus adalah Celluloid, salah satu permata ruby ​​​​favorit saya. Kerangka kerja seluloid adalah cara sederhana dan bersih untuk mengimplementasikan sistem konkuren berbasis aktor di Ruby. Seluloid memungkinkan orang untuk membangun program bersamaan dari objek bersamaan semudah mereka membangun program berurutan dari objek berurutan.

Dalam konteks diskusi kami di posting ini, saya secara khusus berfokus pada fitur Pools, tetapi bantulah diri Anda sendiri dan periksa lebih detail. Menggunakan Celluloid Anda akan dapat membangun program Ruby multithreaded tanpa khawatir tentang masalah buruk seperti kebuntuan, dan Anda akan merasa sepele untuk menggunakan fitur lain yang lebih canggih seperti Futures dan Promises.

Inilah cara sederhana versi multithreaded dari program mailer kami menggunakan Celluloid:

 require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end

Bersih, mudah, terukur, dan kuat. Apa lagi yang bisa Anda minta?

Pekerjaan Latar Belakang

Tentu saja, alternatif lain yang berpotensi layak, tergantung pada persyaratan dan kendala operasional Anda adalah mempekerjakan pekerjaan latar belakang. Sejumlah Permata Ruby ada untuk mendukung pemrosesan latar belakang (yaitu, menyimpan pekerjaan dalam antrian dan memprosesnya nanti tanpa memblokir utas saat ini). Contoh penting termasuk Sidekiq, Resque, Pekerjaan Tertunda, dan Pohon Kacang.

Untuk posting ini, saya akan menggunakan Sidekiq dan Redis (cache dan penyimpanan nilai kunci sumber terbuka).

Pertama, mari kita instal Redis dan jalankan secara lokal:

 brew install redis redis-server /usr/local/etc/redis.conf

Dengan menjalankan instance Redis lokal, mari kita lihat versi contoh program mailer kami ( mail_worker.rb ) menggunakan Sidekiq:

 require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end

Kami dapat memicu Sidekiq dengan file mail_worker.rb :

 sidekiq -r ./mail_worker.rb

Dan kemudian dari IRB:

 ⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100

Luar biasa sederhana. Dan itu dapat ditingkatkan dengan mudah hanya dengan mengubah jumlah pekerja.

Pilihan lainnya adalah menggunakan Sucker Punch, salah satu perpustakaan pemrosesan RoR asinkron favorit saya. Implementasi menggunakan Sucker Punch akan sangat mirip. Kita hanya perlu menyertakan SuckerPunch::Job daripada Sidekiq::Worker , dan MailWorker.new.async.perform() bukan MailWorker.perform_async() .

Kesimpulan

Konkurensi tinggi tidak hanya dapat dicapai di Ruby, tetapi juga lebih sederhana dari yang Anda kira.

Salah satu pendekatan yang layak adalah dengan memotong proses yang sedang berjalan untuk melipatgandakan kekuatan pemrosesannya. Teknik lain adalah dengan memanfaatkan multithreading. Meskipun utas lebih ringan daripada proses, membutuhkan lebih sedikit overhead, Anda masih bisa kehabisan sumber daya jika Anda memulai terlalu banyak utas secara bersamaan. Pada titik tertentu, Anda mungkin merasa perlu menggunakan kumpulan utas. Untungnya, banyak kerumitan multithreading menjadi lebih mudah dengan memanfaatkan sejumlah permata yang tersedia, seperti Celluloid dan model Aktornya.

Cara lain untuk menangani proses yang memakan waktu adalah dengan menggunakan pemrosesan latar belakang. Ada banyak perpustakaan dan layanan yang memungkinkan Anda untuk mengimplementasikan pekerjaan latar belakang dalam aplikasi Anda. Beberapa alat populer termasuk kerangka kerja yang didukung database dan antrian pesan.

Forking, threading, dan pemrosesan latar belakang adalah semua alternatif yang layak. Keputusan yang mana yang akan digunakan tergantung pada sifat aplikasi Anda, lingkungan operasional Anda, dan persyaratan. Semoga tutorial ini memberikan pengenalan yang berguna untuk opsi yang tersedia.