Όπως πιθανώς γνωρίζετε, η Ruby έχει αρκετές υλοποιήσεις, όπως οι MRI, JRuby, Rubinius, Opal, RubyMotion κ.λπ., και κάθε μία από αυτές μπορεί να χρησιμοποιεί διαφορετικό μοτίβο εκτέλεσης κώδικα. Αυτό το άρθρο θα επικεντρωθεί στις τρεις πρώτες από αυτές και θα συγκρίνει την MRI
Όπως πιθανώς γνωρίζετε, η Ruby έχει μερικές υλοποιήσεις, όπως οι MRI, JRuby, Rubinius, Opal, RubyMotion κ.α., και κάθε μία από αυτές μπορεί να χρησιμοποιεί ένα διαφορετικό μοτίβο της κωδικός εκτέλεση. Αυτό το άρθρο θα επικεντρωθεί στις τρεις πρώτες από αυτές και θα συγκρίνει την MRI (την πιο δημοφιλή υλοποίηση αυτή τη στιγμή) με την JRuby και την Rubinius εκτελώντας μερικά σενάρια-δείγματα, τα οποία υποτίθεται ότι αξιολογούν την καταλληλότητα του forking και του threading σε διάφορες καταστάσεις, όπως η επεξεργασία αλγορίθμων έντασης CPU, η αντιγραφή αρχείων κ.λπ.
Πιρούνι
είναι μια νέα διεργασία-παιδί (αντίγραφο της μητρικής)
επικοινωνεί με άλλους μέσω καναλιών επικοινωνίας μεταξύ διεργασιών (IPC), όπως ουρές μηνυμάτων, αρχεία, υποδοχές κ.λπ.
υπάρχει ακόμη και όταν η γονική διαδικασία τερματίζεται
είναι μια κλήση POSIX - λειτουργεί κυρίως σε πλατφόρμες Unix
Νήμα
είναι "μόνο" ένα πλαίσιο εκτέλεσης, που λειτουργεί στο πλαίσιο μιας διεργασίας
μοιράζεται όλη τη μνήμη με άλλους (από προεπιλογή χρησιμοποιεί λιγότερη μνήμη από ένα fork)
επικοινωνεί με άλλους μέσω αντικειμένων κοινής μνήμης
πεθαίνει με μια διαδικασία
εισάγει τυπικά προβλήματα πολλαπλών νημάτων, όπως πείνα, αδιέξοδα κ.λπ.
Υπάρχουν πολλά εργαλεία που χρησιμοποιούν forks και threads, τα οποία χρησιμοποιούνται σε καθημερινή βάση, π.χ. Unicorn (forks) και Puma (threads) σε επίπεδο διακομιστών εφαρμογών, Resque (forks) και Sidekiq (threads) σε επίπεδο εργασιών παρασκηνίου, κ.λπ.
Ο παρακάτω πίνακας παρουσιάζει την υποστήριξη forking και threading στις κυριότερες υλοποιήσεις της Ruby.
Υλοποίηση Ruby
Διακλάδωση
Σπείρωμα
ΜΑΓΝΗΤΙΚΉ ΤΟΜΟΓΡΑΦΊΑ
Ναι
Ναι (περιορίζεται από το GIL**)
JRuby
–
Ναι
Rubinius
Ναι
Ναι
Δύο ακόμη μαγικές λέξεις επιστρέφουν σαν μπούμερανγκ σε αυτό το θέμα - ο παραλληλισμός και ο συγχρονισμός - πρέπει να τις εξηγήσουμε λίγο. Πρώτα απ' όλα, οι όροι αυτοί δεν μπορούν να χρησιμοποιούνται εναλλακτικά. Με λίγα λόγια - μπορούμε να μιλάμε για παραλληλισμό όταν δύο ή περισσότερες εργασίες υποβάλλονται σε επεξεργασία ακριβώς την ίδια στιγμή. Η ταυτόχρονη εκτέλεση λαμβάνει χώρα όταν δύο ή περισσότερες εργασίες υποβάλλονται σε επεξεργασία σε επικαλυπτόμενες χρονικές περιόδους (όχι απαραίτητα ταυτόχρονα). Ναι, είναι μια ευρεία εξήγηση, αλλά αρκετά καλή για να σας βοηθήσει να παρατηρήσετε τη διαφορά και να κατανοήσετε το υπόλοιπο του άρθρου.
Στον παρακάτω πίνακα παρουσιάζεται η υποστήριξη παραλληλισμού και ταυτόχρονης εκτέλεσης.
Υλοποίηση Ruby
Παραλληλισμός (μέσω διακλαδώσεων)
Παραλληλισμός (μέσω νημάτων)
Παράλληλη χρήση
ΜΑΓΝΗΤΙΚΉ ΤΟΜΟΓΡΑΦΊΑ
Ναι
Όχι
Ναι
JRuby
–
Ναι
Ναι
Rubinius
Ναι
Ναι (από την έκδοση 2.X)
Ναι
Τελειώσαμε με τη θεωρία - ας το δούμε στην πράξη!
Η ξεχωριστή μνήμη δεν είναι απαραίτητο να καταναλώνει την ίδια ποσότητα μνήμης με τη μητρική διεργασία. Υπάρχουν ορισμένες τεχνικές βελτιστοποίησης της μνήμης. Μία από αυτές είναι η Copy on Write (CoW), η οποία επιτρέπει στη γονική διεργασία να μοιράζεται την κατανεμημένη μνήμη με τη θυγατρική χωρίς να την αντιγράφει. Με την CoW πρόσθετη μνήμη απαιτείται μόνο στην περίπτωση τροποποίησης της κοινόχρηστης μνήμης από μια διεργασία-παιδί. Στο πλαίσιο της Ruby, δεν είναι όλες οι υλοποιήσεις φιλικές προς το CoW, π.χ. η MRI το υποστηρίζει πλήρως από την έκδοση 2.X. Πριν από αυτή την έκδοση κάθε fork κατανάλωνε τόση μνήμη όση και μια γονική διεργασία.
Ένα από τα μεγαλύτερα πλεονεκτήματα/μειονεκτήματα της μαγνητικής τομογραφίας (διαγράψτε την ακατάλληλη εναλλακτική λύση) είναι η χρήση του GIL (Global Interpreter Lock). Με λίγα λόγια, ο μηχανισμός αυτός είναι υπεύθυνος για τον συγχρονισμό της εκτέλεσης των νημάτων, πράγμα που σημαίνει ότι μόνο ένα νήμα μπορεί να εκτελείται κάθε φορά. Αλλά περιμένετε... Μήπως αυτό σημαίνει ότι δεν υπάρχει κανένας λόγος να χρησιμοποιούμε νήματα στην MRI; Η απάντηση έρχεται με την κατανόηση των εσωτερικών στοιχείων του GIL... ή τουλάχιστον ρίχνοντας μια ματιά στα δείγματα κώδικα σε αυτό το άρθρο.
Περίπτωση δοκιμής
Προκειμένου να παρουσιάσω πώς λειτουργεί η διακλάδωση και το threading στις υλοποιήσεις της Ruby, δημιούργησα μια απλή κλάση που ονομάζεται Δοκιμή και μερικά άλλα που κληρονομούν από αυτό. Κάθε κλάση έχει μια διαφορετική εργασία να επεξεργαστεί. Από προεπιλογή, κάθε εργασία εκτελείται τέσσερις φορές σε έναν βρόχο. Επίσης, κάθε εργασία εκτελείται έναντι τριών τύπων εκτέλεσης κώδικα: διαδοχική, με διακλαδώσεις και με νήματα. Επιπλέον, Benchmark.bmbm εκτελεί το μπλοκ κώδικα δύο φορές - την πρώτη φορά για να τεθεί σε λειτουργία το περιβάλλον χρόνου εκτέλεσης και τη δεύτερη φορά για να μετρήσει. Όλα τα αποτελέσματα που παρουσιάζονται σε αυτό το άρθρο προέκυψαν κατά τη δεύτερη εκτέλεση. Φυσικά, ακόμη και bmbm μέθοδος δεν εγγυάται τέλεια απομόνωση, αλλά οι διαφορές μεταξύ πολλαπλών εκτελέσεων κώδικα είναι ασήμαντες.
απαιτούν "benchmark"
κλάση Test
AMOUNT = 4
def run
Benchmark.bmbm do |b|
b.report("sequential") { sequential }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
private
def sequential
AMOUNT.times { perform }
end
def forking
AMOUNT.times do
fork do
perform
end
end
Process.waitall
rescue NotImplementedError => e
# Η μέθοδος fork δεν είναι διαθέσιμη στο JRuby
puts e
end
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
perform
end
end
threads.map(&:join)
end
def perform
raise "not implemented"
end
end
Δοκιμή φορτίου
Εκτελεί υπολογισμούς σε βρόχο για να δημιουργήσει μεγάλο φορτίο CPU.
class LoadTest < Δοκιμή
def perform
1000.times { 1000.times { 2**3**4 } }
end
end
Ας το τρέξουμε...
LoadTest.new.run
...και ελέγξτε τα αποτελέσματα
ΜΑΓΝΗΤΙΚΉ ΤΟΜΟΓΡΑΦΊΑ
JRuby
Rubinius
διαδοχική
1.862928
2.089000
1.918873
διχάλα
0.945018
–
1.178322
σπείρωμα
1.913982
1.107000
1.213315
Όπως μπορείτε να δείτε, τα αποτελέσματα από τις διαδοχικές εκτελέσεις είναι παρόμοια. Φυσικά υπάρχει μια μικρή διαφορά μεταξύ των λύσεων, αλλά αυτή οφείλεται στην υποκείμενη υλοποίηση των επιλεγμένων μεθόδων στους διάφορους διερμηνείς.
Η διακλάδωση, σε αυτό το παράδειγμα, έχει σημαντικό κέρδος απόδοσης (ο κώδικας εκτελείται σχεδόν δύο φορές ταχύτερα).
Το threading δίνει παρόμοια αποτελέσματα με το forking, αλλά μόνο για τα JRuby και Rubinius. Η εκτέλεση του δείγματος με νήματα στην MRI καταναλώνει λίγο περισσότερο χρόνο από τη διαδοχική μέθοδο. Υπάρχουν τουλάχιστον δύο λόγοι. Πρώτον, η GIL επιβάλλει την εκτέλεση με διαδοχικά νήματα, επομένως σε έναν τέλειο κόσμο ο χρόνος εκτέλεσης θα έπρεπε να είναι ο ίδιος με αυτόν της διαδοχικής εκτέλεσης, αλλά υπάρχει επίσης απώλεια χρόνου για τις λειτουργίες της GIL (εναλλαγή μεταξύ νημάτων κ.λπ.). Δεύτερον, απαιτείται επίσης κάποιος χρόνος επιβάρυνσης για τη δημιουργία νημάτων.
Αυτό το παράδειγμα δεν μας δίνει απάντηση στο ερώτημα σχετικά με το νόημα των νημάτων χρήσης στη μαγνητική τομογραφία. Ας δούμε ένα άλλο παράδειγμα.
Δοκιμή Snooze
Εκτελεί μια μέθοδο ύπνου.
class SnoozeTest < Δοκιμή
def perform
sleep 1
end
end
Ακολουθούν τα αποτελέσματα
ΜΑΓΝΗΤΙΚΉ ΤΟΜΟΓΡΑΦΊΑ
JRuby
Rubinius
διαδοχική
4.004620
4.006000
4.003186
διχάλα
1.022066
–
1.028381
σπείρωμα
1.001548
1.004000
1.003642
Όπως μπορείτε να δείτε, κάθε υλοποίηση δίνει παρόμοια αποτελέσματα όχι μόνο στις διαδοχικές και διακλαδικές εκτελέσεις, αλλά και στις εκτελέσεις με νήματα. Επομένως, γιατί το MRI έχει το ίδιο κέρδος απόδοσης με το JRuby και το Rubinius; Η απάντηση βρίσκεται στην υλοποίηση της ύπνος.
Μαγνητικές τομογραφίες ύπνος η μέθοδος υλοποιείται με την rb_thread_wait_for C, η οποία χρησιμοποιεί μια άλλη συνάρτηση που ονομάζεται native_sleep. Ας ρίξουμε μια γρήγορη ματιά στην υλοποίησή του (ο κώδικας απλοποιήθηκε, η αρχική υλοποίηση μπορεί να βρεθεί εδώ):
static void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN(),
{
// do some stuff here
}
GVL_UNLOCK_END(),
thread_debug("native_sleep donen"),
}
Ο λόγος για τον οποίο αυτή η συνάρτηση είναι σημαντική είναι ότι εκτός από τη χρήση του αυστηρού πλαισίου Ruby, μεταβαίνει και στο πλαίσιο του συστήματος για να εκτελέσει ορισμένες λειτουργίες εκεί. Σε τέτοιες περιπτώσεις, η διεργασία Ruby δεν έχει τίποτα να κάνει... Μεγάλο παράδειγμα σπατάλης χρόνου; Όχι ακριβώς, γιατί υπάρχει ένα GIL που λέει: "Δεν υπάρχει τίποτα να κάνετε σε αυτό το νήμα; Ας αλλάξουμε σε ένα άλλο και ας επιστρέψουμε εδώ μετά από λίγο". Αυτό θα μπορούσε να γίνει με το ξεκλείδωμα και το κλείδωμα του GIL με GVL_UNLOCK_BEGIN() και GVL_UNLOCK_END() λειτουργίες.
Η κατάσταση γίνεται σαφής, αλλά ύπνος μέθοδος είναι σπάνια χρήσιμη. Χρειαζόμαστε περισσότερα παραδείγματα από την πραγματική ζωή.
Δοκιμή λήψης αρχείων
Εκτελεί μια διαδικασία που κατεβάζει και αποθηκεύει ένα αρχείο.
απαιτούν "net/http"
class DownloadFileTest < Test
def perform
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
end
end
Δεν χρειάζεται να σχολιαστούν τα ακόλουθα αποτελέσματα. Είναι αρκετά παρόμοια με εκείνα του παραπάνω παραδείγματος.
1.003642
JRuby
Rubinius
διαδοχική
0.327980
0.334000
0.329353
διχάλα
0.104766
–
0.121054
σπείρωμα
0.085789
0.094000
0.088490
Ένα άλλο καλό παράδειγμα θα μπορούσε να είναι η διαδικασία αντιγραφής αρχείων ή οποιαδήποτε άλλη λειτουργία εισόδου/εξόδου.
Συμπεράσματα
Rubinius υποστηρίζει πλήρως τόσο το forking όσο και το threading (από την έκδοση 2.X, όταν καταργήθηκε το GIL). Ο κώδικάς σας θα μπορούσε να είναι ταυτόχρονος και να εκτελείται παράλληλα.
JRuby κάνει καλή δουλειά με τα νήματα, αλλά δεν υποστηρίζει καθόλου το forking. Ο παραλληλισμός και ο συγχρονισμός θα μπορούσαν να επιτευχθούν με νήματα.
ΜΑΓΝΗΤΙΚΉ ΤΟΜΟΓΡΑΦΊΑ υποστηρίζει διακλάδωση, αλλά το threading περιορίζεται από την παρουσία του GIL. Ο συγχρονισμός θα μπορούσε να επιτευχθεί με νήματα, αλλά μόνο όταν ο εκτελούμενος κώδικας βγαίνει έξω από το πλαίσιο του διερμηνευτή Ruby (π.χ. λειτουργίες IO, λειτουργίες πυρήνα). Δεν υπάρχει τρόπος να επιτευχθεί παραλληλισμός.