Ruby Eşzamanlılık ve Paralellik: Pratik Bir Eğitim

Yayınlanan: 2022-03-11

Ruby geliştiricileri arasında çok yaygın bir kafa karışıklığı noktasını ortadan kaldırarak başlayalım; yani: Eşzamanlılık ve paralellik aynı şey değildir (yani, eşzamanlı != paralel).

Özellikle, Ruby eşzamanlılığı , iki görevin çakışan zaman dilimlerinde başlatılabileceği, çalıştırılabileceği ve tamamlanabileceği zamandır. Yine de bu, her ikisinin de aynı anda çalışacakları anlamına gelmez (örneğin, tek çekirdekli bir makinede birden çok iş parçacığı). Buna karşılık, paralellik , iki görevin tam anlamıyla aynı anda çalışmasıdır (örneğin, çok çekirdekli bir işlemcide birden çok iş parçacığı).

Buradaki kilit nokta, eşzamanlı iş parçacıklarının ve/veya işlemlerin mutlaka paralel olarak çalışmayacak olmasıdır.

Bu öğretici, Ruby'de eşzamanlılık ve paralellik için mevcut olan çeşitli tekniklerin ve yaklaşımların (teorik olmaktan ziyade) pratik bir tedavisini sağlar.

Daha gerçek dünya Ruby örnekleri için Ruby Yorumlayıcıları ve Çalışma Zamanları hakkındaki makalemize bakın.

Test Durumumuz

Basit bir test senaryosu için, bir Mailer sınıfı oluşturacağım ve her isteği CPU'yu daha yoğun hale getirmek için aşağıdaki gibi bir Fibonacci işlevi ( sleep() yöntemi yerine) ekleyeceğim:

 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

Daha sonra posta göndermek için bu Mailer sınıfını aşağıdaki gibi çağırabiliriz:

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

(Not: Bu test senaryosunun kaynak kodu burada github'da mevcuttur.)

Karşılaştırma amacıyla bir temel oluşturmak için, postayı 100 kez çağırarak basit bir kıyaslama yaparak başlayalım:

 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 }

Bu, MRI Ruby 2.0.0p353 ile dört çekirdekli bir işlemcide aşağıdaki sonuçları verdi:

 15.250000 0.020000 15.270000 ( 15.304447)

Çoklu İşlemler ve Çoklu İş Parçacığı

Birden fazla işlem mi kullanacağınıza veya Ruby uygulamanızı çoklu iş parçacığından mı kullanacağınıza karar vermek söz konusu olduğunda, "herkese uyan tek bir boyut" yanıtı yoktur. Aşağıdaki tablo dikkate alınması gereken bazı önemli faktörleri özetlemektedir.

süreçler İş Parçacığı
Daha fazla bellek kullanır Daha az bellek kullanır
Ebeveynler, çocuklar çıkmadan önce ölürse, çocuklar zombi süreçlere dönüşebilir. İşlem öldüğünde tüm iş parçacıkları ölür (zombi şansı yok)
İşletim sisteminin her şeyi kaydetmesi ve yeniden yüklemesi gerektiğinden, çatallı süreçlerin bağlamı değiştirmesi için daha pahalı Adres alanını ve belleği paylaştıklarından, iş parçacıklarının ek yükü önemli ölçüde daha azdır.
Çatallı işlemlere yeni bir sanal bellek alanı verilir (işlem yalıtımı) İş parçacıkları aynı belleği paylaşır, bu nedenle eşzamanlı bellek sorunlarını kontrol etmeniz ve bunlarla ilgilenmeniz gerekir
Süreçler arası iletişim gerektirir Kuyruklar ve paylaşılan bellek aracılığıyla "iletişim kurabilir"
Oluşturmak ve yok etmek için daha yavaş Oluşturmak ve yok etmek için daha hızlı
Kodlamak ve hata ayıklamak daha kolay Kodlamak ve hata ayıklamak önemli ölçüde daha karmaşık olabilir

Birden çok işlem kullanan Ruby çözümlerine örnekler:

  • Resque: Arka plan işleri oluşturmak, bunları birden çok kuyruğa yerleştirmek ve daha sonra işlemek için Redis destekli bir Ruby kitaplığı.
  • Unicorn: Yalnızca düşük gecikmeli, yüksek bant genişliğine sahip bağlantılarda hızlı istemcilere hizmet vermek ve Unix/Unix benzeri çekirdeklerdeki özelliklerden yararlanmak için tasarlanmış Raf uygulamaları için bir HTTP sunucusu.

Çoklu iş parçacığı kullanan Ruby çözümlerine örnekler:

  • Sidekiq: Ruby için tam özellikli bir arka plan işleme çerçevesi. Herhangi bir modern Rails uygulamasıyla entegrasyonu basit olmayı ve mevcut diğer çözümlerden çok daha yüksek performans sağlamayı amaçlar.
  • Puma: Eşzamanlılık için oluşturulmuş bir Ruby web sunucusu.
  • İnce: Çok hızlı ve basit bir Ruby web sunucusu.

Çoklu Süreçler

Ruby çoklu iş parçacığı seçeneklerine bakmadan önce, birden çok işlemi oluşturmanın daha kolay yolunu keşfedelim.

Ruby'de, mevcut sürecin bir “kopyasını” oluşturmak için fork() sistem çağrısı kullanılır. Bu yeni süreç, işletim sistemi düzeyinde planlanmıştır, böylece diğer herhangi bir bağımsız süreç gibi orijinal süreçle aynı anda çalışabilir. ( Not: fork() bir POSIX sistem çağrısıdır ve bu nedenle Ruby'yi bir Windows platformunda çalıştırıyorsanız kullanılamaz.)

Tamam, şimdi test senaryomuzu çalıştıralım, ancak bu sefer birden çok işlemi kullanmak için fork() 'u kullanıyoruz:

 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 , tüm alt süreçlerin çıkmasını bekler ve bir dizi süreç durumu döndürür.)

Bu kod şimdi aşağıdaki sonuçları veriyor (yine, MRI Ruby 2.0.0p353 ile dört çekirdekli bir işlemcide):

 0.000000 0.030000 27.000000 ( 3.788106)

O kadar da eski püskü değil! Yalnızca birkaç kod satırını değiştirerek (yani, fork() kullanarak) posta göndericisini ~5 kat daha hızlı hale getirdik.

Yine de fazla heyecanlanma. Ruby eşzamanlılığı için kolay bir çözüm olduğu için çatallamayı kullanmak cazip gelse de, tüketeceği bellek miktarı olan önemli bir dezavantajı vardır. Forking, özellikle kullandığınız Ruby yorumlayıcısı tarafından Yazma Üzerine Kopyalama (CoW) kullanılmıyorsa, biraz pahalıdır. Örneğin, uygulamanız 20 MB bellek kullanıyorsa, onu 100 kez çatallamak potansiyel olarak 2 GB'a kadar bellek tüketebilir!

Ayrıca, çoklu iş parçacığının da kendi karmaşıklıkları olmasına rağmen, paylaşılan dosya tanımlayıcıları ve semaforlar (üst ve alt çatallı süreçler arasında), borular aracılığıyla iletişim ihtiyacı gibi fork() kullanılırken dikkate alınması gereken bir takım karmaşıklıklar vardır. , ve bunun gibi.

Ruby Çoklu İş Parçacığı

Tamam, şimdi aynı programı Ruby multithreading tekniklerini kullanarak daha hızlı yapmaya çalışalım.

Tek bir işlem içindeki birden çok iş parçacığı, adres alanını ve belleği paylaştıklarından, karşılık gelen sayıda işlemden önemli ölçüde daha az ek yüke sahiptir.

Bunu akılda tutarak, test senaryomuzu tekrar gözden geçirelim, ancak bu sefer Ruby's Thread sınıfını kullanarak:

 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) }

Bu kod şimdi aşağıdaki sonuçları veriyor (yine, MRI Ruby 2.0.0p353 ile dört çekirdekli bir işlemcide):

 13.710000 0.040000 13.750000 ( 13.740204)

Serseri. Bu kesinlikle çok etkileyici değil! Yani, ne oluyor? Bu neden kodu eşzamanlı olarak çalıştırdığımızda elde ettiğimiz sonuçların hemen hemen aynısını üretiyor?

Pek çok Ruby programcısının başına bela olan cevap, Global Interpreter Lock (GIL)' dir. GIL sayesinde, CRuby (MRI uygulaması) diş açmayı gerçekten desteklemiyor.

Global Tercüman Kilidi, bir seferde yalnızca bir iş parçacığının yürütülebilmesi için iş parçacıklarının yürütülmesini senkronize etmek için bilgisayar dili yorumlayıcılarında kullanılan bir mekanizmadır. GIL kullanan bir yorumlayıcı, çok çekirdekli bir işlemcide çalıştırılsa bile, her zaman tam olarak bir iş parçacığının ve bir iş parçacığının yalnızca bir seferde yürütülmesine izin verir. Ruby MRI ve CPython, GIL'ye sahip popüler tercümanların en yaygın örneklerinden ikisidir.

Sorunumuza geri dönersek, GIL ışığında performansı artırmak için Ruby'de çoklu iş parçacığından nasıl yararlanabiliriz?

Eh, MRI'da (CRuby), talihsiz cevap, temelde takılıp kalmanız ve çoklu iş parçacığının sizin için yapabileceği çok az şey olmasıdır.

Paralellik olmadan Ruby eşzamanlılığı, IO-ağır görevler (örneğin, ağda sık sık beklemesi gereken görevler) için yine de çok yararlı olabilir. Bu nedenle, iplikler, IO-ağır görevler için MRI'da hala faydalı olabilir . Sonuçta, çok çekirdekli sunucular yaygın olmadan önce bile iş parçacıklarının icat edilmesinin ve kullanılmasının bir nedeni var.

Ancak bununla birlikte, CRuby dışında bir sürüm kullanma seçeneğiniz varsa, GIL'leri olmadığı ve gerçek paralel Ruby iş parçacığını destekledikleri için JRuby veya Rubinius gibi alternatif bir Ruby uygulaması kullanabilirsiniz.

JRuby ile dişli

Bu noktayı kanıtlamak için, kodun öncekiyle aynı iş parçacıklı sürümünü çalıştırdığımızda, ancak bu sefer onu JRuby'de (CRuby yerine) çalıştırdığımızda elde ettiğimiz sonuçlar:

 43.240000 0.140000 43.380000 ( 5.655000)

Şimdi konuşuyoruz!

Ancak…

Konular Ücretsiz Değil

Birden çok iş parçacığı ile geliştirilmiş performans, kodumuzu daha hızlı ve daha hızlı çalıştırmaya devam etmek için daha fazla iş parçacığı eklemeye devam edebileceğimize - temelde sonsuz olarak - inandırabilir. Bu gerçekten doğru olsaydı iyi olurdu, ama gerçek şu ki, iş parçacıkları özgür değil ve bu nedenle, er ya da geç kaynaklarınız tükenecek.

Örneğin, örnek postamızı 100 kez değil, 10.000 kez çalıştırmak istediğimizi varsayalım. Bakalım neler olacak:

 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) }

Boom! Yaklaşık 2.000 iş parçacığı oluşturduktan sonra OS X 10.8'imde bir hatayla karşılaştım:

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

Beklendiği gibi, er ya da geç, çöpe atmaya başlıyoruz ya da kaynaklarımız tamamen tükeniyor. Dolayısıyla bu yaklaşımın ölçeklenebilirliği açıkça sınırlıdır.

Konu Birleştirme

Neyse ki, daha iyi bir yol var; yani, iş parçacığı havuzu.

İş parçacığı havuzu, gerektiği gibi iş yapmak için kullanılabilen, önceden oluşturulmuş, yeniden kullanılabilir bir iş parçacığı grubudur. İş parçacığı havuzları, az sayıda uzun görev yerine gerçekleştirilecek çok sayıda kısa görev olduğunda özellikle yararlıdır. Bu, çok sayıda iş parçacığı oluşturma ek yüküne maruz kalmayı önler.

Bir iş parçacığı havuzu için bir anahtar yapılandırma parametresi, tipik olarak havuzdaki iş parçacığı sayısıdır. Bu evreler ya bir kerede (yani, havuz oluşturulduğunda) veya tembel olarak (yani, havuzdaki maksimum iş parçacığı sayısı oluşturulana kadar gerektiği gibi) somutlaştırılabilir.

Havuza gerçekleştirmesi için bir görev verildiğinde, görevi o anda boşta olan iş parçacıklarından birine atar. Hiçbir iş parçacığı boşta değilse (ve maksimum iş parçacığı sayısı zaten oluşturulmuşsa), bir iş parçacığının işini tamamlamasını ve boşta kalmasını bekler ve ardından görevi o iş parçacığına atar.

Konu Birleştirme

Örneğimize dönersek, Queue (iş parçacığı için güvenli bir veri türü olduğundan) kullanarak başlayacağız ve iş parçacığı havuzunun basit bir uygulamasını kullanacağız:

“./lib/mailer” gerektirir “kıyaslama” gerektirir 'iş parçacığı' gerektirir

 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)

Yukarıdaki kodda, yapılması gereken işler için bir jobs kuyruğu oluşturarak başladık. İş parçacığı için güvenli olduğundan (bu nedenle, aynı anda birden çok iş parçacığı ona erişirse, tutarlılığı koruyacaktır), bu da bir muteks kullanımını gerektiren daha karmaşık bir uygulamaya duyulan ihtiyacı ortadan kaldırdığı için Queue bu amaç için kullandık.

Daha sonra posta gönderenlerin kimliklerini iş kuyruğuna ittik ve 10 işçi iş parçacığından oluşan havuzumuzu oluşturduk.

Her bir çalışan iş parçacığında, iş kuyruğundan öğeler çıkarıyoruz.

Bu nedenle, bir çalışan iş parçacığının yaşam döngüsü, görevlerin iş Kuyruğuna koyulmasını ve bunları yürütmesini sürekli olarak beklemektir.

İyi haber şu ki, bu sorunsuz çalışıyor ve ölçekleniyor. Ne yazık ki, bu basit öğreticimiz için bile oldukça karmaşık.

Selüloit

Ruby Gem ekosistemi sayesinde, çoklu kullanım karmaşıklığının çoğu, kullanıma hazır bir dizi kullanımı kolay Ruby Gems içinde düzgün bir şekilde kapsüllenmiştir.

Harika bir örnek, en sevdiğim yakut mücevherlerimden biri olan Selüloit. Selüloit çerçeve, Ruby'de aktör tabanlı eşzamanlı sistemleri uygulamanın basit ve temiz bir yoludur. Selüloit, insanların sıralı nesnelerden sıralı programlar oluşturdukları gibi, eşzamanlı nesnelerden eşzamanlı programlar oluşturmalarını sağlar.

Bu gönderideki tartışmamız bağlamında, özellikle Havuzlar özelliğine odaklanıyorum, ancak kendinize bir iyilik yapın ve daha ayrıntılı olarak kontrol edin. Selüloit kullanarak, kilitlenmeler gibi kötü sorunlar hakkında endişelenmeden çok iş parçacıklı Ruby programları oluşturabileceksiniz ve Vadeli İşlemler ve Sözler gibi daha karmaşık özellikleri kullanmayı önemsiz bulacaksınız.

Mailler programımızın çok iş parçacıklı bir sürümünün Selüloit'i ne kadar basit kullandığı aşağıda açıklanmıştır:

 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

Temiz, kolay, ölçeklenebilir ve sağlam. Daha ne isteyebilirsiniz ki?

Arka Plan İşleri

Elbette, operasyonel gereksinimlerinize ve kısıtlamalarınıza bağlı olarak, potansiyel olarak uygulanabilir bir başka alternatif, arka plan işlerini kullanmak olacaktır. Arka planda işlemeyi desteklemek için bir dizi Ruby Gem mevcuttur (yani, işleri bir kuyruğa kaydetmek ve mevcut iş parçacığını engellemeden daha sonra işlemek). Kayda değer örnekler Sidekiq, Resque, Delayed Job ve Beanstalkd'dir.

Bu gönderi için Sidekiq ve Redis (açık kaynaklı bir anahtar/değer önbelleği ve deposu) kullanacağım.

İlk önce Redis'i yükleyelim ve yerel olarak çalıştıralım:

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

Yerel Redis örneğimiz çalışırken, Sidekiq kullanarak örnek postalama programımızın ( mail_worker.rb ) bir sürümüne bir göz atalım:

 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

Sidekiq'i mail_worker.rb dosyasıyla tetikleyebiliriz:

 sidekiq -r ./mail_worker.rb

Ve sonra IRB'den:

 ⇒ 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

Müthiş basit. Ve sadece işçi sayısını değiştirerek kolayca ölçeklenebilir.

Başka bir seçenek de, en sevdiğim asenkron RoR işleme kitaplıklarından biri olan Sucker Punch'ı kullanmaktır. Sucker Punch kullanarak uygulama çok benzer olacaktır. Sidekiq::Worker yerine SuckerPunch::Job ve MailWorker.new.async.perform() yerine MailWorker.perform_async() .

Çözüm

Ruby'de yüksek eşzamanlılık sadece elde edilebilir değil, aynı zamanda düşündüğünüzden daha basittir.

Uygulanabilir bir yaklaşım, işlem gücünü çoğaltmak için çalışan bir işlemi çatallamaktır. Başka bir teknik, çoklu iş parçacığından yararlanmaktır. İş parçacıkları işlemlerden daha hafif olmasına ve daha az ek yük gerektirmesine rağmen, aynı anda çok fazla iş parçacığı başlatırsanız kaynaklarınız tükenebilir. Bir noktada, bir iş parçacığı havuzu kullanmayı gerekli bulabilirsiniz. Neyse ki, çoklu iş parçacığının karmaşıklığının çoğu, Selüloit ve Aktör modeli gibi bir dizi mevcut değerli taştan yararlanılarak kolaylaştırılmıştır.

Zaman alan süreçleri ele almanın başka bir yolu da arka planda işleme kullanmaktır. Uygulamalarınızda arka plan işlerini uygulamanıza izin veren birçok kitaplık ve hizmet vardır. Bazı popüler araçlar, veritabanı destekli iş çerçevelerini ve mesaj kuyruklarını içerir.

Çatal oluşturma, iş parçacığı oluşturma ve arka planda işleme, tümü geçerli alternatiflerdir. Hangisinin kullanılacağına ilişkin karar, uygulamanızın doğasına, operasyonel ortamınıza ve gereksinimlerinize bağlıdır. Umarım bu eğitim, mevcut seçeneklere faydalı bir giriş sağlamıştır.