Lärande genom att bygga, ett Bakgrundsbehandlingssystem i Ruby

i dagens inlägg kommer vi att implementera ett naivt bakgrundsbehandlingssystem för skojs skull! Vi kan lära oss några saker på vägen som en titt på internalerna i populära bakgrundsbehandlingssystem som Sidekiq. Produkten av denna roliga är inte alls avsedd för produktionsanvändning.

låt oss föreställa oss att vi har en uppgift i vår applikation som laddar en eller flera webbplatser och extraherar deras titlar. Eftersom vi inte har något inflytande på prestandan på dessa webbplatser vill vi utföra uppgiften utanför vår huvudtråd (eller den aktuella begäran—om vi bygger en webbapplikation), men i bakgrunden.

inkapsla en uppgift

innan vi går in i bakgrundsbehandling, låt oss bygga ett serviceobjekt för att utföra uppgiften. Vi använder OpenURI och Nokogiri för att extrahera innehållet i titeltaggen.

1 2 3 4 5 6 7 8 9 10 11 12
require 'open-uri' require 'nokogiri' class TitleExtractorService def call(url) document = Nokogiri::HTML(open(url)) title = document.css('html > head > title').first.content puts title.gsub(/]+/, ' ').strip rescue puts "Unable to find a title for #{url}" end end 

Calling the service prints the title of the given URL.

1 2
TitleExtractorService.new.call('https://appsignal.com') # AppSignal: Application Performance Monitoring for Ruby on Rails and Elixir 

detta fungerar som förväntat, men låt oss se om vi kan förbättra syntaxen lite så att den ser ut och känns lite mer som andra bakgrundsbehandlingssystem. Genom att skapa en Magique::Worker – modul kan vi lägga till lite syntaktiskt socker i serviceobjektet.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
module Magique module Worker def self.included(base) base.extend(ClassMethods) end module ClassMethods def perform_now(*args) new.perform(*args) end end def perform(*) raise NotImplementedError end end end 

modulen lägger till en perform metod till arbetaren och en perform_now metod till arbetaren arbetarklass för att göra Anropet lite bättre.

Låt oss inkludera modulen i vårt serviceobjekt. While we’re at it, let’s also rename it to TitleExtractorWorker and change the call method to perform.

1 2 3 4 5 6 7 8 9 10 11
class TitleExtractorWorker include Magique::Worker def perform(url) document = Nokogiri::HTML(open(url)) title = document.css('html > head > title').first.content puts title.gsub(/]+/, ' ').strip rescue puts "Unable to find a title for #{url}" end end 

The invocation still has the same result, but it’s a bit clearer what’s going on.

1 2
TitleExtractorWorker.perform_now('https://appsignal.com') # AppSignal: Application Performance Monitoring for Ruby on Rails and Elixir 

implementera asynkron bearbetning

nu när vi har titeln extraktion fungerar kan vi ta alla titlar från tidigare Ruby Magic-artiklar. För att göra detta, låt oss anta att vi har en RUBYMAGIC konstant med en lista över alla webbadresser från tidigare artiklar.

1 2 3 4 5 6 7 8 9
RUBYMAGIC.each do |url| TitleExtractorWorker.perform_now(url) end # Unraveling Classes, Instances and Metaclasses in Ruby | AppSignal Blog # Bindings and Lexical Scope in Ruby | AppSignal Blog # Building a Ruby C Extension From Scratch | AppSignal Blog # Closures in Ruby: Blocks, Procs and Lambdas | AppSignal Blog # ... 

vi får titlarna på tidigare artiklar, men det tar ett tag att extrahera dem alla. Det beror på att vi väntar tills varje förfrågan är klar innan vi går vidare till nästa.

Låt oss förbättra det genom att införa enperform_async metod till vår arbetarmodul. För att påskynda saker, det skapar en ny tråd för varje URL.

1 2 3 4 5 6 7 8 9
module Magique module Worker module ClassMethods def perform_async(*args) Thread.new { new.perform(*args) } end end end end 

Efter att ha ändrat anropet till TitleExtractorWorker.perform_async(url) får vi alla titlar nästan på en gång. Men det betyder också att vi öppnar mer än 20 anslutningar till Ruby Magic-bloggen på en gång. (Ursäkta att jag strular med din blogg, gott folk! om du följer med din egen implementering och testar detta utanför en långvarig process (som en webbserver), glöm inte att lägga till något som loop { sleep 1 } till slutet av ditt skript för att se till att processen inte omedelbart avslutas.

köa upp uppgifter

med tillvägagångssättet att skapa en ny tråd för varje anrop kommer vi så småningom att träffa resursgränser (både på vår sida och på de webbplatser Vi har tillgång till). Eftersom vi skulle vilja vara trevliga medborgare, låt oss ändra implementeringen till något som är asynkront men inte känns som en denial-of-service attack.

ett vanligt sätt att lösa detta problem är att använda producent / konsumentmönstret. En eller flera producenter Driver uppgifter på en kö medan en eller flera konsumenter tar uppgifter från kön och bearbetar dem.

en kö är i grunden en lista med element. I teorin skulle en enkel array göra jobbet. Men eftersom vi har att göra med samtidighet måste vi se till att endast en producent eller konsument kan komma åt kön åt gången. Om vi inte är försiktiga med detta kommer saker att sluta i kaos – precis som två personer som försöker klämma in genom en dörr på en gång.

detta problem är känt som producent-konsumentproblemet och det finns flera lösningar på det. Lyckligtvis är det ett mycket vanligt problem och Ruby levereras med en riktigQueue implementering som vi kan använda utan att behöva oroa oss för trådsynkronisering.

för att använda den, låt oss se till att både producenter och konsumenter kan komma åt kön. Vi gör detta genom att lägga till en klassmetod till vår Magique – modul och tilldela en instans av Queue till den.

1 2 3 4 5 6 7 8 9 10 11
module Magique def self.backend @backend end def self.backend=(backend) @backend = backend end end Magique.backend = Queue.new 

därefter ändrar vi vårt perform_async implementering för att driva en uppgift i kön istället för att skapa en egen ny tråd. En uppgift representeras som en hash inklusive en hänvisning till arbetarklassen samt argumenten som skickas till metoden perform_async.

1 2 3 4 5 6 7 8 9
module Magique module Worker module ClassMethods def perform_async(*args) Magique.backend.push(worker: self, args: args) end end end end 

med det är vi färdiga med producentsidan av saker. Låt oss sedan ta en titt på konsumentsidan.

varje konsument är en separat tråd som tar uppgifter från kön och utför dem. Istället för att stoppa efter en uppgift, som tråden, tar konsumenten sedan en annan uppgift från kön och utför den, och så vidare. Här är en grundläggande implementering av en konsument som heter Magique::Processor. Varje processor skapar en ny tråd som slingrar oändligt. För varje iteration försöker den ta en ny uppgift från kön, skapar en ny instans av arbetarklassen och kallar sin perform – metod med de givna argumenten.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
module Magique class Processor def self.start(concurrency = 1) concurrency.times { |n| new("Processor #{n}") } end def initialize(name) thread = Thread.new do loop do payload = Magique.backend.pop worker_class = payload worker_class.new.perform(*payload) end end thread.name = name end end end 

förutom bearbetningsslingan lägger vi till en bekvämlighetsmetod som heter Magique::Processor.start. Detta gör att vi kan snurra upp flera processorer samtidigt. Medan det inte är nödvändigt att namnge tråden, kommer det att göra det möjligt för oss att se om saker verkligen fungerar som förväntat.

låt oss justera utmatningen från vårt TitleExtractorWorker för att inkludera namnet på den aktuella tråden.

1
puts " #{title.gsub(/]+/, ' ').strip}" 

för att testa vår inställning för bakgrundsbehandling måste vi först snurra upp en uppsättning processorer innan vi frågar våra uppgifter.

1 2 3 4 5 6 7 8 9 10 11 12 13 14
Magique.backend = Queue.new Magique::Processor.start(5) RUBYMAGIC.each do |url| TitleExtractorWorker.perform_async(url) end # Bindings and Lexical Scope in Ruby | AppSignal Blog # Building a Ruby C Extension From Scratch | AppSignal Blog # Unraveling Classes, Instances and Metaclasses in Ruby | AppSignal Blog # Ruby's Hidden Gems, StringScanner | AppSignal Blog # Fibers and Enumerators in Ruby: Turning Blocks Inside Out | AppSignal Blog # Closures in Ruby: Blocks, Procs and Lambdas | AppSignal Blog # ... 

När detta körs får vi fortfarande titlarna på alla artiklar. Även om det inte är så snabbt som att bara använda en separat tråd för varje uppgift, är det fortfarande snabbare än den ursprungliga implementeringen som inte hade någon bakgrundsbehandling. Tack vare de tillagda processornamnen kan vi också bekräfta att alla processorer arbetar genom kön. Genom att justera antalet samtidiga processorer är det möjligt att hitta en balans mellan bearbetningshastighet och befintliga resursbegränsningar.

expanderar till flera processer och maskiner

hittills fungerar den nuvarande implementeringen av vårt bakgrundsbehandlingssystem tillräckligt bra. Det är dock fortfarande begränsat till samma process. Resurshungriga uppgifter kommer fortfarande att påverka hela processens prestanda. Som ett sista steg, låt oss titta på att fördela arbetsbelastningen över flera processer och kanske till och med flera maskiner.

kön är den enda kopplingen mellan producenter och konsumenter. Just nu använder den en implementering i minnet. Låt oss ta mer inspiration från Sidekiq och implementera en kö med Redis.

Redis har stöd för listor som tillåter oss att driva och hämta uppgifter från. Dessutom är Redis Ruby-pärlan trådsäker och Redis-kommandona för att ändra listor är atomära. Dessa egenskaper gör det möjligt att använda det för vårt asynkrona bakgrundsbehandlingssystem utan att stöta på synkroniseringsproblem.

Låt oss skapa en Redis-backad kö som implementerarpush ochshift metoder precis somQueue vi använde tidigare.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
require 'json' require 'redis' module Magique module Backend class Redis def initialize(connection = ::Redis.new) @connection = connection end def push(job) @connection.lpush('magique:queue', JSON.dump(job)) end def shift _queue, job = @connection.brpop('magique:queue') payload = JSON.parse(job, symbolize_names: true) payload = Object.const_get(payload) payload end end end end 

eftersom Redis inte vet någonting om Ruby-objekt måste vi serialisera våra uppgifter i JSON innan vi lagrar dem i databasen med kommandot lpush som lägger till ett element på framsidan av listan.

för att hämta en uppgift från kön använder vi kommandotbrpop, som får det sista elementet från en lista. Om listan är tom blockeras den tills ett nytt element är tillgängligt. Detta är ett trevligt sätt att pausa våra processorer när inga uppgifter är tillgängliga. Slutligen, efter att ha fått en uppgift ur Redis, måste vi leta upp den verkliga Ruby-klassen baserat på arbetarens namn med Object.const_get.

som ett sista steg, låt oss dela upp saker i flera processer. På producentsidan av saker är det enda vi behöver göra att ändra backend till vår nyligen implementerade Redis-kö.

1 2 3 4 5 6 7
# ... Magique.backend = Magique::Backend::Redis.new RUBYMAGIC.each do |url| TitleExtractorWorker.perform_async(url) end 

On the consumer side of things, we can get away with a few lines like this:

1 2 3 4 5 6
# ... Magique.backend = Magique::Backend::Redis.new Magique::Processor.start(5) loop { sleep 1 } 

Vid körning väntar konsumentprocessen på att nytt arbete kommer fram i kön. När vi startar producentprocessen som driver uppgifter i kön kan vi se att de behandlas omedelbart.

Njut ansvarsfullt och Använd inte detta i produktion

medan vi höll det långt ifrån en verklig världsuppsättning som du skulle använda i produktionen (så gör det inte!), tog vi några steg i att bygga en bakgrundsprocessor. Vi började med att göra en process som en bakgrundstjänst. Sedan gjorde vi det async och använde Queue för att lösa producent-konsumentproblemet. Sedan utvidgade vi processen till flera processer eller maskiner med hjälp av Redis snarare än en implementering i minnet.

som tidigare nämnts är detta en förenklad implementering av ett bakgrundsbehandlingssystem. Det saknas en hel del saker och inte uttryckligen behandlas. Dessa inkluderar (men är inte begränsade till) felhantering, flera köer, schemaläggning, anslutning pooling och signalhantering.

ändå hade vi kul att skriva detta och hoppas att du njöt av en titt under huven på ett bakgrundsbehandlingssystem. Kanske tog du till och med bort en sak eller två.

gästförfattare Benedikt Deicke är en mjukvaruingenjör och CTO av Userlist.io. på sidan skriver han en bok om att bygga SaaS-applikationer i Ruby on Rails. Du kan nå ut till Benedikt via Twitter.

Lämna ett svar

Din e-postadress kommer inte publiceras.