środa, 9 listopada 2011

Oracle mass updates - doing it faster (and in a right way)

Change is inevitable in the IT world - applications or services are upgraded to newer versions on regular basis.
Business logic changes often have an impact on structure or semantics of information stored in the application database, so a new client-side software most likely comes along with a need to perform mass data updates, to ensure that the database conforms with those changes. Frequently this is done in maintenance windows, when the service is taken off-line and is unavailable for some period of time. And because time is money, you have to do that updates as fast as possible, considering that the enterprise-class databases can be extremely large. Besides, maybe even more important thing is to leave that data in consistent state after the whole process.
Let's assume that no one will modify an application data except the maintenance staff - the application is shut down and the database server (Oracle in this case) is ready to perform an upgrade. Are there any caveats you can encounter?

Procedural approach vs. single statement
One of the most frequent mistakes made by Oracle developers I have seen (probably caused by old habits taken from imperative languages) is to do something procedurally, what can be done in a single SQL statement. Not only the latter case tends to execute faster than procedural approach, but also use significantly fewer resources. Consider the example (big_table is a copy of dba_objects with some extra data pumped in, and has 500 000 records):

(I've also seen other variations of that loop, for example with SELECT FOR UPDATE cursor and UPDATE using CURRENT OF clause, which does basically the same but with row locking, so it is much slower - which makes no sense on the assumption that no other concurrent transactions will modify that data anyway).
The problem with that kind of loop is a context switching between pl/sql and sql engines - every loop iteration involves a switch to sql engine (binding variables, if any), sql statement execution and switch back to pl/sql (again, binding return variables, if any). [1, 2]

With a single statement (which can also be boosted by Parallel DML feature) we get:

Of course not every job can be completed in a single statement - in the case of complicated problem it may be not possible (some people say it's always possible) - or it is possible, but awkward, so for some reason you must do it procedurally. But even a procedural approach can run faster than "ordinary" loop - just use array processing in conjunction with bulk SQL [3] (employ FETCH BULK COLLECT with LIMIT and FORALL to reduce context switching issues). Optimal LIMIT X value can be choosen experimentally, through testing -  don't blindly presume that "the bigger is the better":

For example:

One can ask why don't just fetch all data, process it and do the bulk update in a single statement. The answer is simple - you can exhaust the process memory and get ORA-04030 when you fetch all records without reasonable limit. Also keep in mind that this is a simple example, normally you wouldn't implement loop processing just to apply the trivial LOWER function to the table column.

Create instead update?
Consider re-creating the table using CREATE AS SELECT, doing necessary data transformations in the select statement. For example:

Of course, you need extra free space to achieve this (as much as original table size in that case) and you will also have to re-create constraints, indexes etc. on the new table. Furthermore, some of those operations can be parallelized (Parallel DDL feature) or done without logging if necessary. You can also try to create a new empty table and populate it with data using direct-path insert (INSERT /*+append */ INTO ... SELECT ...).

Other considerations
In the event of mass data updates, where DML statements can take hours, I bet you could probably hear people saying: "But what about undo? We don't have sufficient undo space to handle such a big updates". Their answer is to commit frequently, so they break a transaction into smaller chunks, that can be commited independently, or they commit every N rows in a procedural loop (hopefully they write the code in a way that allows the whole task to be re-executed in case of error, yet leave data in consistent state).

My answer to previously stated question is: "You don't have enough undo? Then size your undo space accordingly to the task that have to be done". People tend to think about undo like a somewhat limited resource (or worse - like an overhead), but it's really a configuration issue that need to be resolved to handle long-running transactions correctly.  You can always allocate more undo space for that big task, execute it, and if you don't need that extra undo space any more, you can revert to the previous undo size. It requires some ahead-of-time planning work to be done, not to mention close cooperation between developers and database administrator, but it is certainly possible.

Of course, there are situations with limited maintenance window time (you must complete the task within fixed X-hours window, and the time is critical). There is always a possibility for that big update to be unexpectedly interrupted - for example, you are executing computational intensive ten-million rows update on a table, only to discover that processing of the last row somehow cause an exception, and the whole statement is rolled back. You can apply a loop with array processing here (maybe even with those infamous cyclic commits), with an exception handler that will cope with any kind of errors, but remember that you can leave the system in inconsistent state - you've "done" your job, but you haven't modified all necessary data after all, so you must somehow deal with those errors and data incosistency, sooner or later. Which strategy would you use to achieve your goals (with all its advantages and disadvantages) - is a matter of your carefully thought out decision (preferably backed by performing authoritative tests).

Parallel execution
Parallel execution features of Oracle DBMS are methods that can cause a significant speed-up of certain operations on big data sets (if applied correctly) - especially on multi-core and multi-processor systems. Many of those features were not meant for use in OLTP environments, as they attempt to use all available resources (by default), thus limiting scalability in heavy-transactional systems. But if we are talking about mass data updates during system maintenance downtime, there will (or should) be no other concurrent transactions taking place, and the processing power of database server will be right there at our demand.

Maybe the simplest way to achieve simultaneous execution is so called "do-it-yourself" parallelism (I often call this "parallelism for the poor"). If you have a couple of independent tasks - for example, updating few non-related tables or processing non-overlapping set of rows in one table - you can submit them in a group of jobs (yes, i'm talking about dbms_job or dbms_scheduler facility), which will automatically execute them in concurrent sessions, if the job queue has sufficient free processes to handle them all. You can also simply execute them by hand (or using script) in many sessions. Of course you have to be sure that those tasks are truly independent and will not cause concurrency-related issues (such as blocking other transactions with locks, or other resource contention problems that result with wait events). Will it be faster than the serial process? Hopefully yes, but it depends on the kind of operations you are running and available resources, so you must always test and measure it on representative data sets before going into production.

Dividing the job into smaller sub-tasks - this is basically what the parallel-enabled Oracle features do - break the table (or index) into several pieces that can be handled independently by many concurrent processes (so called parallel execution servers). Oracle has many features related to parallel execution (though some of them are available only with Enterprise Edition license [4]), but they will be the subject of another post:
  • Parallel query
  • Parallel DML
  • Parallel DDL
  • Parallel pipelined functions
  • DBMS_PARALLEL_EXECUTE (new in 11g release 2) [5]

piątek, 29 kwietnia 2011

Prawdy objawione

Po długiej przerwie, spowodowanej przyczynami obiektywnymi ;) postanowiłem napisać coś z zupełnie innej beczki - historię z życia wziętą.

Niedawno przedstawiono mi wyniki audytu kodu pewnego systemu, którym mam przyjemność(?) się zajmować, przygotowanego przy pomocy Automatycznego Narzędzia Weryfikującego Jakość Kodu.

Oprócz skądinąd słusznych uwag dotyczących formy czy złożoności kodu (metryki McCabe'a czy Halstead'a), ku mojemu zdziwieniu, znalazły się tam rzeczy, które zaliczyć można raczej do "dobrych praktyk programowania", czyli zasad, których bezwzględne i bezkrytyczne stosowanie przyczyni się do osiągnięcia wysokiej jakości, skalowalności i wydajności aplikacji (tak, to była lekka ironia).

Wśród tych dobrych praktyk znalazły się pewne wytyczne, które nazywam "prawdami objawionymi". Dziwnym trafem, z reguły są to stwierdzenia zaczynające się od słów "zawsze należy...", "nigdy nie stosuj...", "najlepiej będzie, gdy...", "najgorsze rozwiązanie to..." - jestem wyczulony na takie zwroty i coś mi zaczyna, za przeproszeniem, śmierdzieć, gdy takowe czytam lub słyszę. Nie wiem dlaczego (żeby było jasne, mówię to w swoim imieniu i na podstawie subiektywnych doświadczeń) spora część z nich to zwykłe bzdury.

Oczywiście, takie "uniwersalne reguły" nie wzięły się znikąd, w każdej z nich być może znajdzie się ziarno prawdy, ale pod warunkiem, że taka teza zostanie postawiona w jakimś kontekście, przy założeniu pewnych warunków brzegowych ("w wersji X zwróć uwagę na użycie konstrukcji Y, ponieważ w niektórych sytuacjach (patrz przykład Z) może się okazać zbyt kosztowna" itp. itd.) - ale "zawsze i wszędzie"?
Przykład, który najbardziej przypadł mi do gustu - "nigdy nie stosuj złączeń kartezjańskich, bo zawsze wiążą się one z niską wydajnością systemu". Przyznam, że w momencie, w którym to przeczytałem lekko opadły mi członki - ale to być może jest temat na osobnego posta (o wydajności, nie o członkach).


Nie twierdzę bynajmniej, że nie znajdą się reguły, które być może będą miały zastosowanie "zawsze i wszędzie" - tyle, że jak na złość IT ma to do siebie, że się zmienia - pojawiają się nowe wersje, nowe funkcjonalności z nowymi błędami, stare są poprawiane, usuwane, przerabiane - i może się okazać, że taka złota zasada, która sprawdzała się jako tako w wersji 8.5.1.9 z 1987 roku nadaje się już dawno na śmietnik.
Jeśli jednak ktoś bezkrytycznie wymaga stosowania się do takich zasad i uzależnia od wyniku audytu odbiór produktu - to może być pewien problem... W opisywanym tu przypadku na szczęście tak nie będzie(?), ale wyobrażam sobie i takie sytuacje.

To co napisałem, oczywiście nie odnosi się tylko do jakichś sformalizowanych reguł implementacji, obowiązujących w danej firmie czy ogólnie stosowanych "najlepszych praktyk" w danej technologii czy języku programowania, ale również do tzw. "wiedzy powszechnej", funkcjonującej w świadomości wielu z nas w postaci Jedynie Słusznych i Uniwersalnych Sposobów na wykonanie danego zadania (nie będę ściemniał, że sam się na tym wiele razy nie przejechałem - no cóż, uczymy się na własnych i cudzych błędach).

Jeden z moich ulubionych przykładów (uwaga: stary i oklepany, przynajmniej dla mnie) - wstawianie LOBów do tabeli w Oracle. Większość "wyguglanych" przeze mnie przykładów na forach, blogach itp. zawierało z gruntu złe informacje na temat tego, w jaki sposób można to zrobić poprawnie. Co więcej, w 90% przypadków, które widziałem w "prawdziwych" aplikacjach, robiono to dokładnie w sposób tamże podany.
Tymczasem w oficjalnej dokumentacji (zainteresowanych odsyłam do niej)) łatwo znaleźć takie informacje. Co więcej, łatwo sprawdzić, czy sposób podany w dokumentacji będzie w naszym przypadku, przy spełnieniu naszych założeń, przy użyciu dostępnej nam wersji, rzeczywiście optymalny - pisząc prosty test porównawczy.
Ale przecież łatwiej przekopiować kawałek kodu z sieci - skoro ktoś go tam umieścił, to musi być dobry, bo przecież "działa", prawda?
Mam wrażenie, że część specjalistów (you know who you are) od jakości czy klepania kodu opiera się właśnie na takich nierzetelnych informacjach, pochodzących z mało wiarygodnych źródeł. Rzetelnych specjalistów na wszelki wypadek przepraszam - ale przecież nie uogólniam :)

Zatem, mam następujące propozycje (nie zasady! ;)):
Szanowny konsultancie ds. zapewnienia jakości:
  1. Bądź specjalistą w dziedzinie, której weryfikacją się zajmujesz. Jeśli takowym nie jesteś, to skonsultuj się z przyjaznym manualem lub kimś, kto zna się na tym lepiej od Ciebie - to chyba nie jest powód do wstydu.
  2. Pamiętaj o tym, że tezy, które stawiasz, są w większości przypadków łatwo weryfikowalne. Staraj się więc je sprawdzić (na początek - w oficjalnej dokumentacji) i udowodnić, zanim zasypiesz kogoś miliardem uwag, nie mających pokrycia w rzeczywistości. Głupio będzie przecież, jeśli podniosłeś alarm na pół firmy, a ktoś udowodni Ci, że nie masz racji?
  3. Jeśli developer twierdzi, że się mylisz - masz prawo zweryfikować przedstawiony przez niego punkt widzenia. Może jednak Twoje uwagi nie były zasadne? A może będzie na odwrót i przedstawisz niepodważalny dowód na poparcie swoich tez, dzięki czemu będziesz mógł chodzić w glorii chwały? :) Na pewno obaj dojdziecie w końcu do konstruktywnych wniosków i do porozumienia (pod warunkiem, że z obu stron padają rzeczowe, poparte dowodami argumenty, a nie "prawdy objawione").
  4. "Najlepsze praktyki" to nie jest dekalog, dany raz na zawsze ciemnemu ludowi przez zespół oświeconych ekspertów. Taki zbiór wiedzy nie powinien być niepodważalny i niezmienny, powinien żyć, być stale poddawany weryfikacji, ocenie, modyfikacjom (niewykluczone, że również dzięki uwagom drugiej strony). Jeśli tak nie będzie, to po pewnym czasie stanie się skamieliną. Co więcej, zastanów się, czy taki zbiór reguł powinien dotyczyć technologii jako takiej (np. Dobre Praktyki dla PL/SQL), czy może raczej konkretnego jej zastosowania, konkretnego systemu? Może aplikacja jest na tyle specyficzna, że wymaga zupełnie odmiennego podejścia od tego, które Ty zawsze stosowałeś?
  5. Automatyczne narzędzia, wspomagające proces oceny kodu to... tyko narzędzia i jako takie mogą zawierać błędy i generować fałszywe alarmy. One mają jedynie pomóc w przeprowadzeniu tego procesu - nic nie zastąpi myślącego człowieka.

Szanowny developerze:
  1. QA nie jest Twoim wrogiem, choć Ty często dajesz im ku temu powody :) Ich zadaniem jest zapewnienie oczekiwanego przez klienta poziomu jakości. Ale to nie znaczy, że są oni wyrocznią, której powinieneś bezkrytycznie słuchać tyko po to, żeby oczekiwane przez management Wskaźniki Jakości były większe lub równe założonej wartości progowej (z całym szacunkiem do management'u).
  2. Staraj się sprawdzać we własnym zakresie tezy, stawiane przez QA czy informacje znalezione na forach, blogach (także niniejszym! sic!). Jeżeli mają rację to chwała im za to, natomiast jeśli tak nie jest - powiedz o tym "bez kozery" - przecież wszystkim zależy na dostarczaniu dobrego softu. Ale przedstaw argumenty na poparcie swoich racji.
  3. Współpracuj przy opracowywaniu i modyfikacji zbioru dobrych praktyk - jeśli jesteś ekspertem w danej dziedzinie, to Twoje uwagi mogą być nieocenione i przyczynić się do zwiększenia poziomu "wiedzy powszechnej".
  4. Wykaż trochę własnej inicjatywy podczas implementacji ważnych funkcjonalności. Może się okazać, że Jedyne Słuszne Rozwiązanie, wynikające z bezkrytycznego zastosowania Złotej Reguły nr 1 ze Zbioru Dobrych Praktyk jest całkowicie bezsensowne w tym konkretnym przypadku, którym się zajmujesz. Prosty, szybki test, poparty powtarzalnymi wynikami, pomoże wybrać optymalne w danej sytuacji rozwiązanie.
  5. Uwaga na źródła, z których czerpiesz wiedzę. To, że ktoś wkleił na forum X jakiś snippet nie znaczy, że masz przekopiować go bezmyślnie do swojego kodu tylko dlatego, że "u niego działa".

Tak, wiem, łatwo się to wszystko pisze :)

PS. Wszelkie podobieństwo do prawdziwych osób i wydarzeń jest całkowicie przypadkowe.

czwartek, 1 stycznia 2009

Revactor - współbieżność bez wątków

Nie od dziś wiadomo, że implementacja wątków (green threads) w Rubym 1.8 nie pozwala tak naprawdę na osiągnięcie szczególnie spektakularnych efektów, jeśli chodzi o wydajność programów wielowątkowych (napisano już na ten temat wiele, np. tu i nie zamierzam się nad tym rozwodzić).
Dość powiedzieć, że najwydajniejsze serwery sieciowe pisane w Rubym, są oparte o tzw. pętlę zdarzeń (event loop) oraz wzorzec reactor, a nie o wielowątkowość. Najpopularniejszą biblioteką, opartą o wzorzec reactor, jest obecnie eventmachine (przynajmniej dla Ruby 1.8).

Ruby 1.9 wychodzi nieco na przeciw tym problemom, dodając obsługę wątków systemu operacyjnego oraz klasę Fiber. O "włóknach" w Rubym i sposobie ich użycia również pisano dużo - można powiedzieć, że w kontekście programowania współbieżnego włókna to swego rodzaju lekkie wątki, których działanie nie jest przerywane (wywłaszczane) przez zewnętrzny proces/scheduler (czy to systemu operacyjnego, czy to, jak w przypadku "zielonych wątków", interpretera Ruby), a musi być zawieszane (Fiber.yield) jawnie przez programistę w celu umożliwienia działania innych włókien. Klasa Fiber pozwala de facto na tworzenie własnych konstrukcji współbieżnych.

Na możliwościach, jakie Ruby 1.9 daje na tym polu, oraz w oparciu o pętlę zdarzeń Rev (dodatkowo doprawionych zaczerpniętym z języka Erlang modelem współbieżności), oparto ciekawy framework Revactor. Nie ukrywam, że właśnie model współbieżności lansowany przez Erlang (actor model) przemawia do mnie najbardziej (zapewne do autora omawianego framework'a również), o czym pisałem również w poprzednich postach.

Filozofia współbieżności została żywcem przeniesiona z języka Erlang. A zatem:

  • podstawowym bytem reprezentującym pojedyncze zadanie, wykonujące się współbieżnie z innymi zadaniami, jest aktor (odpowiednik procesu w Erlang'u)

  • stan całego przetwarzania reprezentowany jest przez stan poszczególnych aktorów biorących w nim udział

  • stan danego aktora nie jest współdzielony z innymi aktorami

  • jedynym sposobem wymiany danych pomiędzy aktorami jest wysyłanie i odbieranie komunikatów; w tym celu każdy aktor posiada własną "skrzynkę odbiorczą" (mailbox)

Oczywiście ostatni punkt jest czysto umowny - w języku takim jak Ruby, który nie posiada zmiennych jednokrotnego przypisania, nikt nie może zagwarantować, że aktorzy nie będą odwoływać się do pamięci współdzielonej (i w związku z tym nie może dać głowy, że program nie jest pozbawiony efektów ubocznych - jeśli nawet ufamy swojemu kodowi, to i tak nie można mieć pewności, że używany kod autorstwa kogoś trzeciego jest "bezpieczny wątkowo").


Jeśli chodzi o możliwości, to Revactor daje nam prawie wszystko to co Erlang. Porównanie wybranych konstrukcji zebrałem w poniższej tabelce.



FunkcjonalnośćRevactorErlang
Tworzenie aktora/procesu
actor = Actor.spawn do
...
end
Pid = spawn(fun() -> ... end)
Bieżący aktor/proces
Actor.current
self()
Wysłanie komunikatu
actor << message
Pid ! Message
Odebranie komunikatu
Actor.receive do |filter|
filter.when(pattern1) { ... }
filter.when(pattern2) { ... }
...
end
receive
pattern1 -> ...;
pattern2 -> ...;
...
end
Odebranie komunikatu z timeout'em
Actor.receive do |filter|
...
filter.after(time) { ... }
end
receive
...
after Time -> ...
end
Wiązanie aktorów/procesów (link)
actor = Actor.spawn { ... }
Actor.link(actor)
lub
Actor.spawn_link { ... }
Pid = spawn(...),
link(Pid)
lub
spawn_link(...)
Przechwytywanie wyjątków z połączonych aktorów/procesów
Actor.current.trap_exit = true
actor = Actor.spawn_link { ... }
Actor.receive do |filter|
filter.when(
Case[:exit,actor,Object])
do |reason|
...
end
end
process_flag(trap_exit, true),
Pid = spawn_link(...),
receive
{'EXIT', Pid, Reason} -> ...
end
Rejestracja procesów
-
register(Name, Pid)
unregister(Name)

Ale to nie wszystko. Generalnie Revactor powstał jako framework do budowania aplikacji sieciowych. Dlatego umożliwia również asynchroniczny sposób wykonywania operacji we/wy.
Normalnie, każda operacja i/o w Rubym jest blokująca, tzn. wywołanie metody (np. IO#read) kończy się dopiero wtedy, gdy otrzymała/wysłała dane (np. odczytała porcję bajtów z socket'a).
Przeważnie jednak oczekiwanie na dane nie zajmuje całej mocy procesora dostępnej w danej chwili dla procesu interpretera, więc cpu w tym czasie pozostaje niewykorzystany. Najgorsze jednak jest to, że interpreter Ruby'ego, wykonując kod zewnętrzny (wywołania systemowe również się tutaj zaliczają), blokuje wykonywanie wszystkich "zielonych wątków" należących do procesu interpretera. Jak widać przy współbieżności Ruby'ego z użyciem green threads słusznie stawia się już od dawna duży znak zapytania, o czym wspomniałem na początku posta.


Na szczęście biblioteka Rev pozwala na nieblokujące wykonywanie operacji i/o. Revactor udostępnia własną implementację standardowej klasy TCPSocket. Okazuje się, że wywołanie IO#read ...blokuje, dopóki porcja danych z socket'a nie zostanie odczytana. Blokowanie jest jednak tylko pozorne, a właściwie rzecz ujmując - tylko bieżący Aktor zostaje "zablokowany". Pod spodem, Revactor wykonuje zawieszenie wykonywanego włókna (Fiber.yield), pozwalając pętli zdarzeń na przetworzenie kolejnych komunikatów - w tym czasie mogą wykonywać się inne włókna (a więc inni Aktorzy) oraz może odbywać się komunikacja pomiędzy nimi - aż do momentu, w którym nie zajdzie odpowiednie zdarzenie i/o, pozwalające na wzbudzenie (Fiber.resume) zawieszonego Aktora i kontynuowanie jego przetwarzania.
Dodatkowym plusem jest zgodność z interfejsem standardowej klasy IO, co pozwala na w miarę przezroczystą podmianę używanych socketów na wersje "nieblokujące".


Pora na przykład. Tym razem będzie to proste zliczanie wystąpień wyrazów na wybranych stronach (w tym przypadku zawierających dokumentację klas na www.ruby-doc.org), oparte na algorytmie MapReduce. Może niezbyt praktyczne, ale chodzi tylko o pokazanie sposobu wykorzystania aktorów. Na początek klasa bazowa:

#! /usr/bin/ruby19

class MapReduce
attr_reader :input, :output
attr_accessor :map, :reduce

def initialize(map, reduce)
@map, @reduce = map, reduce
end

def run(input)
raise "Override this method in child classes"
end

end
Następnie implementacja z wykorzystaniem aktorów:
#! /usr/bin/ruby19

require 'revactor'
require 'mapreduce'

class Concurrent < MapReduce
def run(input)
@input = input
current = Actor.current
Actor.spawn { do_reduce(current) }
Actor.receive do |filter|
filter.when(T[current, Object]) {|_, obj| @output = obj}
end
end

#######
private
#######
def do_reduce(parent)
reduce_actor = Actor.current
reduce_actor.trap_exit = true
@input.each do |element|
Actor.spawn_link { @map.call(reduce_actor, element) }
end
dictionary = collect_partials
result = {}
dictionary.each do |key, value|
result[key] = @reduce.call(key, value)[1]
end
parent << [parent, result]
end

def collect_partials
n = @input.size
dictionary = {}
while n > 0
Actor.receive do |filter|
filter.when(Case[:exit, Actor, Object]) { n -= 1 }
filter.when(T[String, Fixnum]) do |key, value|
dictionary[key] ||= []
dictionary[key] << value
end
end
end
dictionary
end
end
Metoda run tworzy aktora, który będzie się zajmował redukcją danych otrzymanych od innych aktorów. Dla każdego elementu wejściowej listy tworzymy nowego aktora, który wykonuje metodę map na danym elemencie. Następnie zbieramy odpowiedzi od poszczególnych aktorów (collect_partials) i po zebraniu wszystkich odpowiedzi, dokonujemy redukcji otrzymanych danych (reduce). Jak widać jest to pewien szablon - na tyle ogólny, że można go wykorzystać do realizacji różnego rodzaju zadań.
Warto zwrócić uwagę na mechanizm łączenia ze sobą aktorów (link). Wywołanie Actor.spawn_link tworzy nowego aktora, powiązanego z bieżacym. Takie powiązanie oznacza, że w przypadku "śmierci" jednego z aktorów, pozostali, powiązani z nim aktorzy - również "umierają". Dzieje się tak zarówno w przypadku, gdy aktor rzuci wyjątek, jak i wtedy, gdy się poprawnie zakończy. Jest jednak możliwość, by sygnał o śmierci jednego z aktorów nie zabił wybranego przez nas aktora - wystarczy ustawić jego atrybut trap_exit na true. Wtedy ten wybrany aktor, w przypadku śmierci innego powiązanego aktora, otrzymuje komunikat w postaci listy [:exit, actor, object], gdzie actor to aktor, który rzucił wyjątkiem bądź zakończył działanie, a object zawiera sam obiekt wyjątku lub nil w przypadku normalnego zakończenia. Umożliwia to tworzenie hierarchii aktorów, w której wybrani aktorzy są "zarządcami", tzn. mogą decydować o podjęciu jakiegoś działania w przypadku śmierci aktorów-pracowników, np. o utworzeniu nowego aktora. Taki model jest w 100% zgodny z tym, co daje nam Erlang.
Dla kontrastu implementacja działająca sekwencyjnie (co prawda nie można wtedy nazwać jej MapReduce, ale umieściłem ją dla porównania):
#! /usr/bin/ruby19

class Sequential < MapReduce
def run(input)
@input = input
dictionary = {}
@input.each do |element|
partial = []
@map.call(partial, element)
partial.each do |key, value|
dictionary[key] ||= []
dictionary[key] << value
end
end
result = {}
dictionary.each do |key, value|
result[key] = @reduce.call(key, value)[1]
end
@output = result
end
end
I na koniec sama klasa testowa zliczająca ilość wystąpień wyrazów. Każdy aktor, realizujący operację map wysyła do aktora redukującego parę [w, 1], gdzie w jest każdym napotkanym na stronie wyrazem. Następnie aktor zajmujący się redukcją wyników wyznacza ilość wystąpień danego wyrazu na podstawie komunikatów częściowych.
#! /usr/bin/ruby19

resources = []
ObjectSpace.each_object(Class) do |klass|
resources << "http://www.ruby-doc.org/core-1.9/classes/#{klass.name.gsub('::','/')}.html"
end

require 'concurrent'
require 'sequential'
require 'benchmark'
require 'cgi'
require 'uri'

include Benchmark

class WordCount
def initialize(method)
@method = method
end

def run(input)
@method.run(input)
end

def output
@method.output
end

def self.each_word(uri)
words = []
uri = URI.parse(uri)

sock = Revactor::TCP.connect(uri.host, 80)
sock.write [ "GET #{uri.path} HTTP/1.0", "Host: #{CGI.escape(uri.host)}",
"\r\n" ].join("\r\n")
begin
loop do
sock.read.scan(/[a-z]+/i) {|w| yield w}
end
rescue EOFError
end
end
end

if $0 == __FILE__
resources = resources[0...(ARGV[0].to_i)]
map = -> target, uri do
WordCount.each_word(uri) {|w| target << T[w, 1]}
end
reduce = -> key, values { T[key, values.size] }

test_seq = WordCount.new(Sequential.new(map, reduce))
test_actor = WordCount.new(Concurrent.new(map, reduce))
Benchmark.bm(12) do |b|
b.report("sequential") { test_seq.run(resources) }
b.report("#{resources.size} actors") { test_actor.run(resources) }
end

%w(test_seq test_actor).each do |t|
File.open(t, "w") do |f|
f.puts eval(t).output
end
end
end

Wynik odpalenia testu dla 50 aktorów:
                  user     system      total        real
sequential 0.890000 0.100000 0.990000 ( 44.523738)
50 actors 3.060000 0.150000 3.210000 ( 10.127858)

A więc ponad 4 razy szybciej na korzyść implementacji współbieżnej (bez wątków!). Dla porównania jeszcze wyniki dla 100 i 200 aktorów. Dla 200 nie jest aż tak dobrze, ale jest nadal nieco ponad 2 razy szybciej. W tym przypadku kluczowe jest to, że operacja odczytu danych z socket'a trwa stosunkowo długo - ten czas jest wykorzystywany na współbieżne działanie aktorów. Jasne jest, że w przypadku, gdy operacja i/o jest krótkotrwała, program działający sekwencyjnie okaże się szybszy.
                  user     system      total        real
sequential 1.010000 0.120000 1.130000 ( 65.234548)
100 actors 3.610000 0.090000 3.700000 ( 15.162739)

sequential 1.890000 0.230000 2.120000 (125.002007)
200 actors 8.170000 0.410000 8.580000 ( 56.959498)

Ruby/Revactor nie może się równać wydajnością z Erlangiem, w którym utworzenie wielkiej ilości procesów wymieniających masę komunikatów nie stanowi dużego problemu. Nic również nie zdziała, jeśli chodzi o wykorzystanie wielu procesorów (lub procesorów wielordzeniowych). Nadal poruszamy się w kontekście jednego procesu systemu operacyjnego. Ale i tak jest ciekawą alternatywą dla współbieżności opartej na wątkach. Poza tym nic nie stoi na przeszkodzie, by pisać programy działające w oparciu o wiele procesów (w którym każdy z procesów może korzystać ze współbieżności opartej o włókna lub green threads), które komunikują się między sobą z użyciem jednego z wielu dostępnych obecnie mechanizmów wymiany komunikatów (kolejek). Można również sięgnąć po JRuby, który na dzień dzisiejszy na tyle okrzepł, że wydaje się poważną alternatywą dla MRI.

Ale o tym może innym razem.

update: kod jest dostępny na githubie