Ασύγχρονη είσοδος/έξοδος, Task.Run και sync-over-async μέσα από την οπτική ενός Senior .NET Developer

Μία από τις συχνότερες παρανοήσεις στον προγραμματισμό με .NET είναι ότι η προσθήκη των async και await κάνει αυτομάτως ένα API πιο γρήγορο.

Αυτό δεν ισχύει.

Το βασικό πλεονέκτημα του ασύγχρονου προγραμματισμού στο ASP.NET Core είναι ότι επιτρέπει στην εφαρμογή να αξιοποιεί αποτελεσματικότερα τα διαθέσιμα threads, ιδιαίτερα όταν εξυπηρετεί λειτουργίες που εξαρτώνται από I/O, όπως ερωτήματα σε βάση δεδομένων, HTTP αιτήματα προς εξωτερικές υπηρεσίες και πρόσβαση σε αρχεία.

Για να κατανοήσουμε το γιατί, θα εξετάσουμε τρία συνηθισμένα μοτίβα υλοποίησης: το ασύγχρονο I/O, την περιττή χρήση του Task.Run και τον συγχρονισμένο αποκλεισμό κατά την αναμονή ασύγχρονων λειτουργιών μέσω του .Result.

1. Σωστή προσέγγιση: Ασύγχρονο I/O με async/await

Ας εξετάσουμε το παρακάτω παράδειγμα:

public async Task<User?> GetUser( int id, CancellationToken cancellationToken) { return await _db.Users .FirstOrDefaultAsync( x => x.Id == id, cancellationToken); } 

Αυτή είναι μια σωστή προσέγγιση, επειδή το Entity Framework Core παρέχει την ασύγχρονη μέθοδο FirstOrDefaultAsync. Έτσι, η εφαρμογή μπορεί να περιμένει το αποτέλεσμα της βάσης δεδομένων χωρίς να δεσμεύει ένα thread για ολόκληρη τη διάρκεια της αναμονής του I/O.

Ας υποθέσουμε ότι ένα API δέχεται εκατοντάδες ταυτόχρονα αιτήματα. Κάθε αίτημα πρέπει να ανακτήσει δεδομένα από τον SQL Server και κάθε ερώτημα χρειάζεται 200 ms.

Κατά τη διάρκεια αυτών των 200 ms, η βάση δεδομένων εκτελεί την εργασία της. Η εφαρμογή δεν χρειάζεται να κρατά ένα .NET thread απασχολημένο αποκλειστικά και μόνο επειδή περιμένει το αποτέλεσμα. Όταν η ασύγχρονη λειτουργία φτάσει σε ένα await του οποίου η εργασία δεν έχει ολοκληρωθεί, η μέθοδος μπορεί να αναστείλει την εκτέλεσή της και να επιστρέψει τον έλεγχο στον καλούντα. Το thread μπορεί στο μεταξύ να εξυπηρετήσει άλλη εργασία. Όταν ολοκληρωθεί η λειτουργία της βάσης, η υπόλοιπη μέθοδος προγραμματίζεται να συνεχίσει.

Αυτό είναι το ουσιαστικό πλεονέκτημα του ασύγχρονου I/O: το thread δεν χρειάζεται να παραμένει δεσμευμένο όσο ένας εξωτερικός πόρος εκτελεί την εργασία του.

Τι συμβαίνει εσωτερικά;

Η εκτέλεση ακολουθεί, σε γενικές γραμμές, τα παρακάτω βήματα:

  1. Το ASP.NET Core λαμβάνει το HTTP αίτημα και ξεκινά την επεξεργασία του σε ένα thread του Thread Pool.
  2. Η εφαρμογή ξεκινά την ασύγχρονη λειτουργία προς τη βάση δεδομένων.
  3. Εάν η λειτουργία δεν έχει ολοκληρωθεί, η μέθοδος αναστέλλεται στο await και επιστρέφει τον έλεγχο στο framework.
  4. Το thread μπορεί να χρησιμοποιηθεί για άλλη εργασία.
  5. Όταν ολοκληρωθεί η λειτουργία της βάσης δεδομένων, η συνέχεια της μεθόδου εκτελείται για να επεξεργαστεί το αποτέλεσμα.
  6. Το ASP.NET Core ολοκληρώνει την HTTP απόκριση.

Το async/await δεν δημιουργεί υποχρεωτικά νέο thread. Ο μηχανισμός ασύγχρονου I/O και ο οδηγός της βάσης δεδομένων διαχειρίζονται την ασύγχρονη λειτουργία, ενώ το .NET αναλαμβάνει τη συνέχιση της μεθόδου.

Κάνει τελικά το αίτημα πιο γρήγορο;

Όχι απαραίτητα.

Εάν το ερώτημα στη βάση δεδομένων χρειάζεται 200 ms, ο ασύγχρονος προγραμματισμός δεν το μετατρέπει αυτομάτως σε ερώτημα των 100 ms. Ο χρόνος εκτέλεσης της βάσης, η καθυστέρηση του δικτύου, τα indexes, τα query plans και ο φόρτος του SQL Server εξακολουθούν να επηρεάζουν την απόδοση.

Το όφελος γίνεται σημαντικότερο όταν αυξάνεται ο αριθμός των ταυτόχρονων αιτημάτων. Επειδή δεν υπάρχουν threads που παραμένουν αποκλεισμένα περιμένοντας I/O, η εφαρμογή μπορεί να εξυπηρετεί περισσότερα ταυτόχρονα αιτήματα με τους διαθέσιμους πόρους, πριν η πίεση στο Thread Pool γίνει περιοριστικός παράγοντας.

Βέβαια, οι ασύγχρονες λειτουργίες έχουν και ένα μικρό κόστος διαχείρισης. Επομένως, δεν πρέπει να περιμένουμε ότι κάθε μικρή λειτουργία θα γίνει ταχύτερη μόνο και μόνο επειδή χρησιμοποιεί await.

Γιατί είναι σημαντικό το CancellationToken;

Στο παράδειγμα περνάμε επίσης ένα CancellationToken:

await _db.Users.FirstOrDefaultAsync( x => x.Id == id, cancellationToken); 

Η ακύρωση επιτρέπει στη λειτουργία να ανταποκριθεί σε ένα αίτημα ακύρωσης, για παράδειγμα όταν ο client διακόψει το HTTP αίτημα. Έτσι, αποφεύγεται ενδεχομένως η συνέχιση περιττής εργασίας όταν ο καλών δεν χρειάζεται πλέον το αποτέλεσμα.

Αυτό προϋποθέτει ότι η υποκείμενη λειτουργία και ο provider υποστηρίζουν και λαμβάνουν υπόψη την ακύρωση.

Στο ASP.NET Core, το CancellationToken μπορεί να περάσει στην action ή στο endpoint και στη συνέχεια να προωθηθεί στα αντίστοιχα application και infrastructure layers.

Η αρχιτεκτονική διάσταση

Η ασύγχρονη συμπεριφορά πρέπει να διατηρείται σε ολόκληρη την αλυσίδα κλήσεων.

Για παράδειγμα, ένας controller πρέπει να κάνει await σε ένα application service, το οποίο με τη σειρά του πρέπει να περιμένει ασύγχρονα το repository ή τη βάση δεδομένων.

Εάν εισαγάγουμε ένα συγχρονισμένο σημείο αναμονής στη μέση μιας κατά τα άλλα ασύγχρονης ροής, μπορεί να χάσουμε μεγάλο μέρος του οφέλους.

Αυτό ισχύει εξίσου σε μια αρχιτεκτονική Clean Architecture ή CQRS. Είτε το αίτημα περνά από command handler, query handler, application service ή repository, κάθε επίπεδο πρέπει να διατηρεί την ασύγχρονη εκτέλεση όταν πραγματοποιεί I/O.

Δεν υπάρχει, όμως, λόγος να μετατρέπουμε κάθε μέθοδο σε ασύγχρονη. Μια καθαρά υπολογιστική λειτουργία ή ένας απλός in-memory μετασχηματισμός δεν χρειάζεται async απλώς για λόγους ομοιομορφίας.

Συμπέρασμα: Χρησιμοποιούμε πραγματικά ασύγχρονες λειτουργίες για I/O. Στόχος είναι η αποτελεσματικότερη αξιοποίηση των πόρων υπό φόρτο, όχι η αυτόματη μείωση του χρόνου εκτέλεσης κάθε μεμονωμένου αιτήματος.

2. Λανθασμένη επιλογή σε ένα τυπικό ASP.NET Core request: Task.Run για CPU-bound εργασία

Ας εξετάσουμε το δεύτερο παράδειγμα:

public async Task<IActionResult> GetReport() { var report = await Task.Run( () => BuildReport()); return Ok(report); } 

Με την πρώτη ματιά, η υλοποίηση μπορεί να φαίνεται λογική. Η δημιουργία της αναφοράς εκτελείται σε άλλο thread και το endpoint περιμένει ασύγχρονα την ολοκλήρωσή της.

Ωστόσο, σε μια τυπική εφαρμογή ASP.NET Core, αυτή η προσέγγιση δεν λύνει από μόνη της το πραγματικό πρόβλημα απόδοσης.

Τι κάνει πραγματικά το Task.Run;

Η Task.Run τοποθετεί τον delegate που της δίνουμε στην ουρά του .NET Thread Pool και επιστρέφει ένα Task που αντιπροσωπεύει την εργασία.

Στο παράδειγμά μας, η BuildReport() εκτελείται συγχρονισμένα σε ένα thread του Thread Pool. Το αρχικό αίτημα περιμένει μέσω await μέχρι να ολοκληρωθεί η εργασία.

Η κρίσιμη λεπτομέρεια είναι ότι η δημιουργία της αναφοράς δεν έγινε μη δεσμευτική ως προς το CPU. Η λειτουργία εξακολουθεί να χρειάζεται ένα thread για να εκτελέσει τον κώδικα. Επιπλέον, προστέθηκε κόστος προγραμματισμού της εργασίας και μεταφοράς της σε άλλο thread.

Το ASP.NET Core εκτελεί ήδη τον κώδικα επεξεργασίας των αιτημάτων σε threads του Thread Pool. Επομένως, η μεταφορά μιας συνηθισμένης CPU-bound εργασίας από ένα thread του Thread Pool σε ένα άλλο συνήθως δεν προσφέρει ουσιαστικό πλεονέκτημα κλιμάκωσης.

Γιατί μπορεί να επιβαρύνει την εφαρμογή;

Ας υποθέσουμε ότι η BuildReport() επεξεργάζεται μεγάλο όγκο δεδομένων, εκτελεί σύνθετους υπολογισμούς ή δημιουργεί ένα μεγάλο αντικείμενο αναφοράς στη μνήμη.

Εάν χρειάζεται 500 ms πραγματικού χρόνου CPU, εξακολουθεί να απαιτεί αντίστοιχο χρόνο επεξεργασίας, ανάλογα με τη διαθέσιμη υπολογιστική ισχύ και τον υπόλοιπο φόρτο.

Το Task.Run δεν μειώνει τον αριθμό των υπολογισμών και δεν κάνει τον επεξεργαστή να τους εκτελεί γρηγορότερα.

Όταν πολλά ταυτόχρονα αιτήματα προγραμματίζουν CPU-intensive εργασίες στο Thread Pool, αυξάνεται ο ανταγωνισμός για τους διαθέσιμους πόρους. Αυτό μπορεί να οδηγήσει σε μεγαλύτερες καθυστερήσεις, επιβάρυνση της CPU και αυξημένη κατανάλωση μνήμης.

Είναι επομένως σημαντικό να διακρίνουμε δύο διαφορετικές έννοιες:

  • Ασύγχρονο I/O: Η εφαρμογή μπορεί να αναστείλει την εκτέλεσή της όσο ένας εξωτερικός πόρος εκτελεί εργασία, χωρίς να κρατά δεσμευμένο ένα thread κατά τη διάρκεια της αναμονής.
  • CPU-bound εργασία: Ο επεξεργαστής πρέπει να εκτελέσει τις απαραίτητες εντολές. Η χρήση διαφορετικού thread αλλάζει το πού εκτελείται η εργασία, όχι το πόση υπολογιστική δουλειά απαιτείται.

Πρόκειται για διαφορετικά προβλήματα που απαιτούν διαφορετικές λύσεις.

Ποια είναι η σωστή προσέγγιση;

Η λύση εξαρτάται από το τι κάνει πραγματικά η BuildReport().

Περίπτωση Α: Η αναφορά ανακτά δεδομένα από βάση ή εξωτερική υπηρεσία.

Κάνουμε ασύγχρονες τις λειτουργίες I/O στο σημείο όπου πραγματοποιούνται:

public async Task<IActionResult> GetReport( CancellationToken cancellationToken) { var data = await _reportService.GetDataAsync( cancellationToken); var report = _reportService.BuildReport(data); return Ok(report); } 

Εδώ, η ανάκτηση δεδομένων είναι ασύγχρονη, ενώ η δημιουργία της αναφοράς παραμένει συγχρονισμένη. Αυτό είναι κατάλληλο όταν η δημιουργία της αναφοράς δεν απαιτεί σημαντικό χρόνο CPU.

Περίπτωση Β: Η αναφορά εκτελεί βαρείς υπολογισμούς.

Δεν πρέπει να θεωρούμε ότι το Task.Run αποτελεί την προεπιλεγμένη λύση για ένα API υψηλού φόρτου.

Αρχικά, μετράμε την πραγματική διάρκεια της λειτουργίας και εξετάζουμε αν μπορούμε να βελτιώσουμε τον αλγόριθμο, να μειώσουμε τα δεδομένα που επεξεργαζόμαστε ή να αξιοποιήσουμε caching.

Εάν η δημιουργία της αναφοράς απαιτεί αρκετά δευτερόλεπτα, μπορεί να είναι προτιμότερη μια αρχιτεκτονική background processing:

  1. Το API δέχεται και επικυρώνει το αίτημα.
  2. Το αίτημα δημιουργίας αναφοράς τοποθετείται σε μια ουρά εργασιών.
  3. Ένας background worker αναλαμβάνει την επεξεργασία.
  4. Το αποτέλεσμα αποθηκεύεται ή καθίσταται διαθέσιμο στον client.
  5. Ο client ανακτά το αποτέλεσμα ή ενημερώνεται όταν ολοκληρωθεί η εργασία.

Σε ένα περιβάλλον .NET, αυτό μπορεί να υλοποιηθεί με hosted background service, Hangfire ή message broker όπως το Azure Service Bus, ανάλογα με τις απαιτήσεις.

Έτσι, διαχωρίζουμε τις χρονοβόρες εργασίες από τον κύκλο ζωής του HTTP αιτήματος και μπορούμε να διαχειριστούμε καλύτερα retries, παρακολούθηση εργασιών και όρια ταυτόχρονης εκτέλεσης.

Το Task.Run έχει νόμιμες χρήσεις, για παράδειγμα σε desktop εφαρμογές όπου πρέπει να παραμείνει ελεύθερο το UI thread. Μπορεί επίσης να είναι κατάλληλο σε συγκεκριμένα, προσεκτικά σχεδιασμένα σενάρια. Το πρόβλημα δεν είναι η ίδια η μέθοδος, αλλά η χρήση της ως γενικής λύσης για τη μετατροπή συγχρονικού κώδικα σε «ασύγχρονο» μέσα σε ένα ASP.NET Core request.

Συμπέρασμα: Η μεταφορά μιας εργασίας σε άλλο thread δεν σημαίνει ότι η εργασία έγινε πιο αποδοτική ή περισσότερο κλιμακώσιμη. Βελτιστοποιούμε το CPU-bound έργο, ελέγχουμε τον παραλληλισμό ή μεταφέρουμε τις μεγάλες εργασίες στο κατάλληλο execution model.

3. Λανθασμένη προσέγγιση: Συγχρονισμένος αποκλεισμός με .Result

Ας δούμε τώρα το τρίτο παράδειγμα:

public IActionResult GetData() { var response = _client.GetAsync(url).Result; return Ok( response.Content.ReadAsStringAsync().Result); } 

Αυτό αποτελεί χαρακτηριστικό παράδειγμα του λεγόμενου sync-over-async: καλούμε μια ασύγχρονη μέθοδο και στη συνέχεια περιμένουμε συγχρονισμένα το αποτέλεσμά της.

Οι λειτουργίες HTTP είναι ασύγχρονες, αλλά η μέθοδος που τις καλεί δεσμεύει το thread μέχρι να ολοκληρωθούν.

Έτσι, χάνεται ένα από τα σημαντικότερα πλεονεκτήματα του ασύγχρονου προγραμματισμού.

Τι συμβαίνει κατά την εκτέλεση;

Η πρώτη γραμμή ξεκινά ένα HTTP αίτημα και επιστρέφει ένα Task<HttpResponseMessage>.

Η ιδιότητα .Result μπλοκάρει το τρέχον thread μέχρι να ολοκληρωθεί το task. Κατά τη διάρκεια που ο απομακρυσμένος server επεξεργάζεται το αίτημα και η απόκριση μεταφέρεται μέσω του δικτύου, το thread παραμένει δεσμευμένο.

Όταν φτάσει η απόκριση, ο κώδικας διαβάζει το σώμα της με την ασύγχρονη ReadAsStringAsync(). Ωστόσο, το δεύτερο .Result μπλοκάρει ξανά το thread μέχρι να ολοκληρωθεί και αυτή η λειτουργία.

Εάν ο απομακρυσμένος server χρειάζεται 300 ms για να απαντήσει και η ανάγνωση του περιεχομένου χρειάζεται άλλα 20 ms, το thread μπορεί να παραμείνει μπλοκαρισμένο περίπου για αυτό το διάστημα, χωρίς να υπολογίζουμε πρόσθετο χρόνο επεξεργασίας και overhead.

Η ακριβής διάρκεια εξαρτάται από την εκτέλεση, αλλά η αρχή παραμένει η ίδια: η εφαρμογή κρατά δεσμευμένο ένα thread ενώ περιμένει μια λειτουργία I/O για την οποία ήδη διαθέτει ασύγχρονο API.

Γιατί επηρεάζεται η κλιμάκωση;

Το ASP.NET Core βασίζεται στο Thread Pool για την αποτελεσματική επεξεργασία των αιτημάτων.

Εάν πολλά αιτήματα μπλοκάρουν τα threads τους περιμένοντας HTTP αποκρίσεις, ερωτήματα βάσεων δεδομένων ή άλλους εξωτερικούς πόρους, τα διαθέσιμα threads μπορεί να μην επαρκούν για τον εισερχόμενο φόρτο.

Αυτό μπορεί να οδηγήσει σε Thread Pool starvation.

Ο όρος περιγράφει μια κατάσταση κατά την οποία υπάρχει εργασία που χρειάζεται thread, αλλά δεν μπορεί να αποκτήσει διαθέσιμο Thread Pool worker εγκαίρως, επειδή οι υπάρχοντες workers είναι απασχολημένοι ή το Thread Pool δυσκολεύεται να ανταποκριθεί στη ζήτηση.

Τα πιθανά αποτελέσματα περιλαμβάνουν:

  • Αύξηση της καθυστέρησης των HTTP αιτημάτων.
  • Συσσώρευση εργασιών που περιμένουν να εκτελεστούν.
  • Μείωση του throughput.
  • Μεγαλύτερη πίεση στη διαχείριση threads και στους υπόλοιπους πόρους.

Το πρόβλημα μπορεί να γίνει εντονότερο όταν πολλά επίπεδα της εφαρμογής μπλοκάρουν συγχρονισμένα. Για παράδειγμα, ένας controller περιμένει ένα application service, το οποίο περιμένει έναν HTTP client, ο οποίος περιμένει μια εξωτερική υπηρεσία.

Σε αυτή την περίπτωση, η εφαρμογή χάνει σταδιακά το πλεονέκτημα που θα έπρεπε να προσφέρει το ασύγχρονο I/O.

Προκαλεί πάντα deadlock το .Result;

Όχι.

Σε ορισμένα περιβάλλοντα .NET, ο συγχρονισμένος αποκλεισμός κατά την αναμονή ασύγχρονων λειτουργιών μπορεί να οδηγήσει σε deadlock.

Ένα κλασικό παράδειγμα εμφανίζεται όταν η συνέχεια μιας ασύγχρονης λειτουργίας πρέπει να εκτελεστεί σε ένα synchronization context που έχει μπλοκαριστεί από τον καλούντα, ο οποίος περιμένει συγχρονισμένα τη συνέχεια.

Το ASP.NET Core δεν χρησιμοποιεί από προεπιλογή το ίδιο synchronization-context μοντέλο με τις παλαιότερες εφαρμογές ASP.NET. Επομένως, το συγκεκριμένο είδος deadlock είναι λιγότερο συνηθισμένο.

Αυτό, όμως, δεν κάνει το .Result ασφαλή επιλογή στο ASP.NET Core. Η δέσμευση threads και ο κίνδυνος Thread Pool starvation εξακολουθούν να αποτελούν σημαντικά προβλήματα.

Αυτή είναι μια ουσιαστική διάκριση για έναν Senior Developer: αποφεύγουμε το .Result όχι μόνο για να περιορίσουμε τον κίνδυνο deadlock, αλλά και για να διατηρήσουμε την κλιμάκωση της εφαρμογής.

Η σωστή υλοποίηση

Η μέθοδος πρέπει να είναι ασύγχρονη από το endpoint μέχρι τις λειτουργίες HTTP:

public async Task<IActionResult> GetData( CancellationToken cancellationToken) { using var response = await _client.GetAsync( url, cancellationToken); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync( cancellationToken); return Ok(content); } 

Με αυτή την υλοποίηση, το HTTP αίτημα και η ανάγνωση του σώματος της απόκρισης μπορούν να ανασταλούν ασύγχρονα, αντί να κρατούν μπλοκαρισμένο το thread κατά την αναμονή του I/O.

Το παράδειγμα ελέγχει επίσης τον κωδικό κατάστασης της HTTP απόκρισης και αποδεσμεύει το HttpResponseMessage με το using.

Σε μια παραγωγική εφαρμογή, το URL θα προερχόταν συνήθως από έγκυρη είσοδο ή από τις ρυθμίσεις της εφαρμογής. Το HttpClient θα διαχειριζόταν μέσω IHttpClientFactory ή μιας κατάλληλα διαχειριζόμενης μακρόβιας παρουσίας.

Ανάλογα με τις απαιτήσεις, μπορεί να χρειάζεται επιπλέον χειρισμός σφαλμάτων, αντιστοίχιση των HTTP status codes και διαχείριση των αποκρίσεων της εξωτερικής υπηρεσίας.

Όπως και στην πρώτη περίπτωση, το CancellationToken πρέπει να προωθείται στα επόμενα επίπεδα, όπου υποστηρίζεται.

Συμπέρασμα: Δεν περιμένουμε συγχρονισμένα ασύγχρονες λειτουργίες μέσα στον κύκλο επεξεργασίας των αιτημάτων. Χρησιμοποιούμε await και διατηρούμε την ασύγχρονη εκτέλεση σε ολόκληρη την αλυσίδα κλήσεων.

4. Σύγκριση των τριών προσεγγίσεων

Τα τρία παραδείγματα δείχνουν διαφορετικούς τρόπους διαχείρισης της εκτέλεσης και της αναμονής.

Προσέγγιση Τι συμβαίνει Βασικό όφελος ή πρόβλημα
await με ασύγχρονο I/O Το thread μπορεί να αποδεσμευτεί όσο εκκρεμεί το I/O Καλύτερη αξιοποίηση πόρων υπό ταυτόχρονο φόρτο
Task.Run γύρω από CPU-bound εργασία Η εργασία προγραμματίζεται σε worker του Thread Pool Πρόσθετο overhead χωρίς να μειώνεται η υπολογιστική εργασία
.Result σε ασύγχρονη λειτουργία Το καλούν thread μπλοκάρει μέχρι την ολοκλήρωση Σπατάλη χωρητικότητας threads και πιθανό starvation

Η σημαντική διάκριση είναι ότι ο ασύγχρονος προγραμματισμός, η παράλληλη εκτέλεση και η βελτιστοποίηση CPU δεν είναι εναλλάξιμες έννοιες.

Μια ασύγχρονη μέθοδος δεν εκτελείται αυτομάτως παράλληλα. Ένα Task δεν αντιπροσωπεύει υποχρεωτικά ένα αποκλειστικό thread. Και η μεταφορά μιας εργασίας σε άλλο thread δεν κάνει αυτομάτως ένα API πιο γρήγορο.

Αυτές οι λεπτομέρειες είναι απαραίτητες όταν αξιολογούμε προβλήματα απόδοσης σε πραγματικά συστήματα παραγωγής.

5. Πρακτικό παράδειγμα: API που καλεί SQL Server και εξωτερική υπηρεσία

Ας υποθέσουμε ότι ένα endpoint ανακτά μια παραγγελία από τον SQL Server, καλεί μια εξωτερική υπηρεσία πληρωμών και επιστρέφει μια απόκριση.

Μια ενδεικτική υλοποίηση θα μπορούσε να είναι η εξής:

public async Task<OrderResponse?> GetOrderAsync( int orderId, CancellationToken cancellationToken) { var order = await _db.Orders .AsNoTracking() .Where(x => x.Id == orderId) .Select(x => new OrderResponse( x.Id, x.Total)) .FirstOrDefaultAsync(cancellationToken); if (order is null) { return null; } var payment = await _paymentClient .GetPaymentAsync( order.Id, cancellationToken); return order with { PaymentStatus = payment.Status }; } 

Το παράδειγμα προϋποθέτει ότι το OrderResponse είναι record με τις αντίστοιχες ιδιότητες και ότι ο payment client παρέχει μια πραγματικά ασύγχρονη μέθοδο.

Τόσο το ερώτημα στη βάση δεδομένων όσο και η HTTP κλήση είναι λειτουργίες I/O, επομένως είναι κατάλληλες για ασύγχρονη εκτέλεση.

Οι δύο λειτουργίες, όμως, εκτελούνται διαδοχικά, επειδή η κλήση προς την υπηρεσία πληρωμών εξαρτάται από την παραγγελία που ανακτήθηκε προηγουμένως. Πρέπει πρώτα να ολοκληρωθεί η ανάκτηση της παραγγελίας και έπειτα να ξεκινήσει η κλήση πληρωμών.

Εάν χρειαζόταν να πραγματοποιηθούν δύο ανεξάρτητες εξωτερικές κλήσεις, θα μπορούσαμε ενδεχομένως να τις ξεκινήσουμε ταυτόχρονα και να περιμένουμε την ολοκλήρωσή τους με Task.WhenAll. Αυτό θα μπορούσε να μειώσει τον συνολικό χρόνο απόκρισης, εφόσον οι λειτουργίες είναι ανεξάρτητες και η πρόσθετη ταυτόχρονη εκτέλεση είναι κατάλληλη για το σύστημα.

Εδώ φαίνεται η σημασία της αρχιτεκτονικής κρίσης. Το async/await επιτρέπει την αποτελεσματική αναμονή, ενώ οι αποφάσεις για την ταυτόχρονη εκτέλεση καθορίζουν εάν ανεξάρτητες εργασίες μπορούν να εκτελεστούν επικαλυπτόμενα.

Ένας Senior Developer πρέπει επίσης να εξετάσει τα query projections, τα indexes, την επαναχρησιμοποίηση HTTP συνδέσεων, τα timeouts, την ακύρωση, τα retries και τη χωρητικότητα των εξωτερικών υπηρεσιών.

Ο ασύγχρονος κώδικας δεν διορθώνει ένα μη αποδοτικό SQL query, μια αργή εξωτερική υπηρεσία ή μια υπερφορτωμένη βάση δεδομένων.

6. Τι πρέπει να μετράμε στην παραγωγή;

Ο κώδικας που φαίνεται ασύγχρονος δεν είναι απαραίτητα και αποδοτικός. Πρέπει να επιβεβαιώνουμε ότι η εφαρμογή συμπεριφέρεται σωστά υπό ρεαλιστικό φόρτο.

Για ένα ASP.NET Core API, θα εξέταζα τα εξής:

  • Χρόνος απόκρισης: Μέση τιμή, διάμεσος και υψηλά percentiles, όπως p95 και p99.
  • Throughput: Πόσα αιτήματα εξυπηρετεί επιτυχώς η εφαρμογή ανά δευτερόλεπτο.
  • CPU utilization: Εάν η εφαρμογή περιορίζεται από την υπολογιστική ισχύ.
  • Συμπεριφορά Thread Pool: Εάν τα threads μπλοκάρονται συχνά, εάν δημιουργούνται νέοι workers υπό πίεση ή εάν συσσωρεύεται εργασία που δεν μπορεί να εκτελεστεί εγκαίρως.
  • Απόδοση βάσης δεδομένων: Διάρκεια ερωτημάτων, πίεση στο connection pool, execution plans και κορεσμός της βάσης.
  • Εξωτερικές εξαρτήσεις: HTTP latency, timeouts, αποτυχίες, retries και χρήση συνδέσεων.
  • Μνήμη και garbage collection: Εάν οι allocations, τα μεγάλα αντικείμενα αναφορών ή η υπερβολική ταυτόχρονη εκτέλεση επιβαρύνουν το σύστημα.

Εργαλεία όπως το Visual Studio Diagnostics, το dotnet-counters, το dotnet-trace, το PerfView και το Application Insights μπορούν να βοηθήσουν στην ανάλυση διαφορετικών πλευρών του προβλήματος.

Η σωστή βελτιστοποίηση πρέπει να βασίζεται στα δεδομένα. Εάν το bottleneck είναι η βάση δεδομένων, η προσθήκη threads δεν θα το διορθώσει. Εάν η CPU βρίσκεται ήδη σε κορεσμό, η αύξηση της ταυτόχρονης εκτέλεσης μπορεί να επιδεινώσει την καθυστέρηση. Εάν το Thread Pool starvation οφείλεται σε συγχρονισμένες αναμονές, η αφαίρεσή τους μπορεί να βελτιώσει σημαντικά το throughput.

Συμπέρασμα

Το πραγματικό δίδαγμα δεν είναι απλώς ότι το async/await είναι καλό και τα Task.Run ή .Result είναι κακά.

Το δίδαγμα είναι ότι πρέπει να κατανοούμε τι περιμένει η εφαρμογή, ποιοι πόροι παραμένουν δεσμευμένοι κατά τη διάρκεια της αναμονής και πού βρίσκεται το πραγματικό bottleneck.

Χρησιμοποιούμε ασύγχρονα APIs για I/O. Αποφεύγουμε να τυλίγουμε χωρίς λόγο συνηθισμένες εργασίες ενός ASP.NET Core request σε Task.Run. Δεν αντιμετωπίζουμε το .Result ως εύκολη εναλλακτική του await σε μια κατά τα άλλα ασύγχρονη αλυσίδα εκτέλεσης.

Κυρίως, μετράμε τη συμπεριφορά της εφαρμογής υπό φόρτο αντί να υποθέτουμε ότι ένα συγκεκριμένο μοτίβο κώδικα εγγυάται καλύτερη απόδοση.

Ο ασύγχρονος προγραμματισμός είναι πρωτίστως μια στρατηγική αποτελεσματικότερης αξιοποίησης πόρων. Η πραγματική απόδοση προκύπτει από τον συνδυασμό του με σωστή αρχιτεκτονική, αποδοτικούς αλγορίθμους, καλά σχεδιασμένη πρόσβαση στα δεδομένα και κατάλληλη διαχείριση του φόρτου.

Επίσημες πηγές

  1. Microsoft Learn — Ασύγχρονα σενάρια προγραμματισμού σε C#
  2. Microsoft Learn — Βέλτιστες πρακτικές ASP.NET Core
  3. Microsoft Learn — Μοντέλο ασύγχρονου προγραμματισμού βάσει Task
  4. Microsoft Learn — Η μέθοδος Task.Run

dotnet #csharp #aspnetcore #softwarearchitecture #performance #asyncawait