Chord
Chord σε ~400 γραμμές C: Ένας μίνι κατανεμημένος hash table σε ένα μηχάνημα
Ένα μικρό project για να καταλάβει κανείς, μέσα σε ένα απόγευμα, γιατί το Chord έγινε διάσημο. Αν δεν το έχεις ακουστά, μην ανησυχείς — ξεκινάμε από το μηδέν.
Το πρόβλημα: «Ποιος έχει το αρχείο kittens.png;»
Ας πούμε ότι έχεις 10.000 μηχανήματα και ένα μάτσο αρχεία. Θέλεις, δοθέντος ενός κλειδιού (πες, του ονόματος του αρχείου), να βρίσκεις γρήγορα ποιο μηχάνημα έχει την τιμή. Οι «προφανείς» λύσεις σπάνε:
- Κεντρικός index server: εύκολο, αλλά αν πέσει αυτός, πέφτει το πάντα. Κλασικό single point of failure (το πρόβλημα του παλιού Napster).
- Broadcast σε όλους: «Έχει κανείς το αρχείο X;». Κλιμακώνει σαν αλεξίπτωτο φτιαγμένο από κουβέρτες (το πρόβλημα του Gnutella).
- Hash table στο τοπικό μηχάνημα: τέλειο για ένα μηχάνημα, αλλά πώς το «μοιράζεις» σε 10.000;
Το Chord, που πρότειναν οι Stoica et al. το 2001, λύνει αυτό το τελευταίο: είναι ένας κατανεμημένος hash table (DHT). Δίνεις κλειδί, σε γυρνάει στον κόμβο που είναι υπεύθυνος για αυτό. Καμία κεντρική αρχή. Καμία πλημμύρα μηνυμάτων. Κάθε κόμβος θυμάται μόνο O(log N) άλλους και κάθε αναζήτηση κοστίζει O(log N) hops.
Αυτό το repo είναι μια παιχνιδιάρικη υλοποίηση σε C: όλο το «δίκτυο» τρέχει σε ένα μηχάνημα, σε ένα process, χωρίς sockets — αλλά ο πυρήνας του αλγορίθμου είναι αυτούσιος από το paper.
Η μεγάλη ιδέα: ένας κύκλος, και κάθε κλειδί στον επόμενο γείτονα
Φαντάσου ότι παίρνεις όλους τους πιθανούς αριθμούς από 0 μέχρι 2^m − 1 και
τους τυλίγεις σε έναν κύκλο. Στον κώδικά μας m = 6, οπότε ο κύκλος έχει 64 θέσεις.
Δύο πράγματα ζουν πάνω σε αυτόν τον κύκλο:
- Κόμβοι (μηχανήματα). Κάθε κόμβος παίρνει μια ID παίρνοντας το hash της IP του. Έτσι σκορπίζονται «τυχαία» στον κύκλο.
- Κλειδιά. Κάθε κλειδί επίσης hash-άρεται και πέφτει σε μια θέση του κύκλου.
Ο κανόνας ανάθεσης είναι, με μία γραμμή, εξής:
Το κλειδί
kτο έχει ο πρώτος κόμβος που συναντάς αν περπατήσεις δεξιόστροφα από τοk. Αυτόν τον κόμβο τον λέμεsuccessor(k).
Αυτό λέγεται consistent hashing και έχει μια θαυμάσια ιδιότητα: όταν μπαίνει/βγαίνει κόμβος, μόνο τα κλειδιά της γειτονιάς του μετακινούνται. Όχι όλα. Σε σύστημα με 1.000.000 κλειδιά και 1.000 κόμβους, η εισαγωγή ενός νέου κόμβου μετακινεί κατά μέσο όρο 1.000 κλειδιά, όχι 500.000. Αυτό είναι καθαρή χρυσή ιδιότητα.
Η αφελής αναζήτηση: «πέρνα τον τόπο σου στον γείτονα»
Πόσα χρειάζεται να ξέρει ένας κόμβος για να δουλέψει αυτό; Τυπικά, έναν:
τον αμέσως επόμενο κόμβο στον κύκλο, τον λεγόμενο successor.
Με αυτό μόνο, μπορείς ήδη να ψάξεις:
n.find_successor(id):
if (id ∈ (n, successor]) return successor;
else return successor.find_successor(id);
Δηλαδή: «αν το κλειδί μένει μεταξύ εμού και του γείτονά μου, αυτός είναι ο υπεύθυνος. Αλλιώς, ρώτα τον γείτονά μου». Δουλεύει. Αλλά είναι O(N) — στη χειρότερη περίπτωση η ερώτηση κάνει ολόκληρο τον γύρο. Σε δίκτυο 10.000 κόμβων, αυτό είναι αργό.
Αυτό υλοποιεί η συνάρτηση lookup() στον κώδικα.
Η έξυπνη αναζήτηση: το finger table
Εδώ έρχεται η μαγική ιδέα. Αντί κάθε κόμβος να ξέρει μόνο τον αμέσως επόμενο, ας ξέρει και κάποιους «μακρινούς», αλλά με στρατηγική:
Ο κόμβος
nκρατάειmδείκτες (ένα ανά bit). Οi-ος δείκτης δείχνει στον επόμενο κόμβο σε απόσταση τουλάχιστον2^(i−1)δεξιόστροφα από τονn.
Αυτό λέγεται finger table. Σκέψου το σαν διπολικά γυαλιά: ο κόμβος βλέπει τους κοντινούς του με ακρίβεια, και τους μακρινούς με όλο και πιο αραιό δείγμα. Σε ένα δίκτυο 64 θέσεων, ο N8 ξέρει «κάποιον κοντά στο 9», «κάποιον κοντά στο 10», «κάποιον κοντά στο 12», «κάποιον κοντά στο 16», «κάποιον κοντά στο 24», «κάποιον κοντά στο 40». Έξι δείκτες, εκθετική κάλυψη.
Με αυτό, ο αλγόριθμος αναζήτησης μετατρέπεται σε binary-search-style:
n.find_successor(id):
if (id ∈ (n, successor]) return successor;
n' = ο μακρινότερος finger που είναι ακόμη πριν το id
return n'.find_successor(id);
Η ένταση: σε κάθε βήμα κόβουμε στη μέση την υπολειπόμενη απόσταση μέχρι τον στόχο. Αυτό είναι O(log N) — ίδιο με binary search σε ταξινομημένο πίνακα, μόνο που εδώ ο πίνακας είναι κατανεμημένος σε χιλιάδες μηχανήματα.
Αυτό υλοποιεί η smartLookup(). Στην έξοδο που ακολουθεί παρακάτω, βλέπεις τα
ίδια queries να τρέχουν με τους δύο αλγορίθμους και τη διαφορά να είναι σαφής:
lookup(N2, 42) successor-only, 10 hops
smartLookup(N2, 42) finger-table, 4 hops
Σε δίκτυο 10 κόμβων η διαφορά είναι μερικά hops. Σε δίκτυο εκατομμυρίων, είναι η διαφορά μεταξύ «δουλεύει» και «δεν δουλεύει».
Τι ΔΕΝ υλοποιήσαμε (και γιατί)
Το paper περιγράφει αρκετά παραπάνω από αυτά που υλοποιήσαμε:
- Δυναμικά joins/leaves με τον
stabilize()αλγόριθμο. - Αντοχή σε αποτυχίες μέσω successor list μεγέθους
r. - Replication των ίδιων δεδομένων σε πολλούς κόμβους.
Όλα αυτά είναι σπουδαία, αλλά η εκφώνηση ζητούσε τον πυρήνα: το consistent
hashing και τα δύο είδη αναζήτησης. Επιπλέον, αφού όλο τρέχει σε ένα μηχάνημα
και ένα process, η έννοια του «αποτυχημένου κόμβου» δεν είναι πραγματικά
ελκυστική. Στήνουμε όμως τα finger tables απευθείας με την τελική τους τιμή
κατά την initialize(), οπότε η συμπεριφορά είναι ισοδύναμη με ένα Chord
δίκτυο που έχει ήδη συγκλίνει.
Δομή του κώδικα
chord/
├── chord.h # Αφαιρετικός τύπος DHT — types και public interface
├── chord.c # Υλοποίηση: ring state, hashing, lookups
├── main.c # Demo που δείχνει τη χρήση και συγκρίνει lookup vs smartLookup
└── Makefile
Διεπαφή (όπως ζητούσε η εκφώνηση):
typedef int nodeType; // αναγνωριστικό κόμβου στον κύκλο
typedef int keyType; // αρχικό κλειδί (κατακερματίζεται)
typedef char * valueType; // τιμή ως string
void initialize(void);
void insert (nodeType n, keyType key, valueType value);
valueType lookup (nodeType n, keyType key);
valueType smartLookup(nodeType n, keyType key);
Βοηθητικά: getNodeID(int i) για να πάρεις τον i-οστό ενεργό κόμβο,
printRing() και printNode(n) για debugging.
Σχεδιαστικές επιλογές
m = 6, RING_SIZE = 64, MAXNODENUMBER = 10. Αυθαίρετα μικρά νούμερα
ώστε να βλέπεις τον κύκλο τυπωμένο. Με m = 6 και 10 κόμβους, ο κύκλος είναι
αραιά γεμάτος (10 από 64 θέσεις πιασμένες), όπως και στα σχήματα του paper.
Σε πραγματικό σύστημα θα έβαζες m = 160 (όσο και το SHA-1).
Σκόρπιος πίνακας ring[RING_SIZE]. Κάθε θέση του κύκλου είτε φιλοξενεί
έναν κόμβο, είτε είναι NULL. Έτσι το lookup-by-ID γίνεται σε O(1). Σπατάλη
χώρου, αλλά με RING_SIZE = 64 αυτό είναι 512 bytes — οριακά. Σε m = 160
δεν θα δούλευε καν, εκεί θες hash-set ή ταξινομημένο πίνακα.
Απλή multiplicative hash αντί για SHA-1. Το paper χρησιμοποιεί SHA-1
γιατί χρειάζεται cryptographic uniformity (ώστε αντίπαλος να μην μπορεί να
χτίσει κλειδιά που πέφτουν όλα στον ίδιο κόμβο). Εμείς θέλουμε απλώς «αρκετά
ομοιόμορφη» κατανομή για επίδειξη — μια μίξη με τη σταθερά 0x45d9f3b κάνει
τη δουλειά σε 6 γραμμές. Η αντικατάσταση από SHA-1 είναι αλλαγή μιας συνάρτησης.
Επαναληπτικά αντί για αναδρομικά. Το paper περιγράφει τις
find_successor αναδρομικά. Επαναληπτική υλοποίηση είναι ισοδύναμη και
απαλλάσσει από κίνδυνο stack overflow όταν αργότερα κάποιος βάλει m = 160.
Δύο range checks (in_range_oc, in_range_oo). Όλη η αριθμητική στο
Chord είναι modulo 2^m, οπότε τα διαστήματα μπορεί να «τυλίγονται» γύρω
από το 0. Δύο μικρές βοηθητικές με τη σημειογραφία του paper ((a, b] και
(a, b) αντίστοιχα) κρατάνε τον κύριο κώδικα ευανάγνωστο.
Deterministic seed (srand(42)). Το ίδιο τρέξιμο, το ίδιο δίκτυο, την
ίδια έξοδο — απαραίτητο για debugging και αναπαραγωγιμότητα.
Καμία δομή pre-computed «επόμενος ενεργός κόμβος». Κατά την
initialize(), για κάθε finger entry διασχίζω την ταξινομημένη λίστα IDs.
Αυτό είναι O(N · m) για όλο το στήσιμο, που είναι αμελητέο για N = 10.
Σε μεγαλύτερη κλίμακα θα έβαζα binary search.
Linked list για τα κλειδιά κάθε κόμβου. Είναι το πιο βαρετό κομμάτι του κώδικα και επίτηδες — η εστίαση είναι στο ring routing, όχι στη local αποθήκευση. Σε πραγματικό σύστημα ο κόμβος θα είχε δικό του local hash table.
Πώς το τρέχεις
make # μεταγλώττιση
make run # τρέχει το demo
make clean # καθαρίζει object files
Το demo:
- Κατασκευάζει το δίκτυο και τυπώνει τον κύκλο μαζί με τα finger tables όλων.
- Εισάγει 7 ζεύγη
(key, value)ξεκινώντας από διαφορετικούς κόμβους. - Ξανατυπώνει τον κύκλο για να δεις πού πήγε κάθε κλειδί.
- Τρέχει τα ίδια queries με
lookupκαιsmartLookupκαι τυπώνει τα hops. - Κάνει μια συστηματική σύγκριση: από 3 διαφορετικούς αρχικούς κόμβους ψάχνει όλα τα κλειδιά και με τους δύο τρόπους.
Δείγμα εξόδου
=== Δαχτυλίδι μετά τις εισαγωγές ===
N2 pred=N49 succ=N6
finger: [k=1 s=3→N6] [k=2 s=4→N6] [k=3 s=6→N6]
[k=4 s=10→N23] [k=5 s=18→N23] [k=6 s=34→N36]
keys : (k=999 id=50 "σχεδόν χίλια")
(k=7 id=51 "τυχερό")
(k=42 id=53 "η απάντηση")
N6 pred=N2 succ=N7
...
N36 pred=N31 succ=N41
keys : (k=10 id=36 "δέκα")
N41 pred=N36 succ=N45
keys : (k=1 id=39 "ένα")
...
=== Σύγκριση hops από N2 ===
lookup(N2, 42) successor-only, 10 hops -> "η απάντηση"
smartLookup(N2, 42) finger-table, 4 hops -> "η απάντηση"
lookup(N2, 1) successor-only, 7 hops -> "ένα"
smartLookup(N2, 1) finger-table, 2 hops -> "ένα"
Σταθερά παίρνεις 1–4 hops με fingers vs μέχρι N hops χωρίς. Αυτό είναι ολόκληρο το insight του paper σε δύο γραμμές.
Αν θες να σκάψεις παραπάνω
- Το paper: Chord: A Scalable Peer-to-peer Lookup Protocol for Internet Applications (Stoica, Morris, Liben-Nowell, Karger, Kaashoek, Dabek, Balakrishnan, 2003). Διαβάζεται μια Κυριακή απόγευμα.
- Δοκίμασε να επεκτείνεις τον κώδικα ώστε ένας κόμβος να γίνεται «νεκρός»
και δες τι σπάει στο
lookupπριν προσθέσεις successor list. - Άλλαξε το
mσε 16 και βάλε 1.000 κόμβους. Η μέσηsmartLookupπαραμένει στους ~5 hops — αυτό είναι το½ log₂ Nπου μετράει το paper.
Καλό κυκλικό περπάτημα.