Σε αυτό το άρθρο θα καλύψουμε σε βάθος τη διαφορά ανάμεσα σε async/await (ασύγχρονο προγραμματισμό) και multithreading (πολλαπλά νήματα), τον τρόπο που επηρεάζουν το υλικό (hardware), πότε να προτιμάτε το ένα ή το άλλο και πρακτικά παραδείγματα σε C#.
1. Βασική διάκριση
Async/await (ασύγχρονο I/O-first μοντέλο).
Είναι ένα γλωσσικό/συγχρονιστικό abstraction γύρω από Task/ValueTask. Σκοπός: μη-εμπόδιση (non-blocking) του καλούντος νήματος όταν περιμένουμε λειτουργίες I/O (δίκτυο, δίσκος, βάσεις δεδομένων, timers). Συνήθως δεν δημιουργεί νέο OS thread — αντίθετα, επιτρέπει στο runtime να «απελευθερώσει» το τρέχον νήμα και να το χρησιμοποιήσει για άλλες εργασίες μέχρι να ολοκληρωθεί το I/O.
Multithreading (νήματα)
Δημιουργία/χρήση πολλών στοίβων εκτέλεσης (OS threads ή thread pool threads) που εκτελούν κώδικα παράλληλα (concurrently). Χρήσιμο όταν έχουμε CPU-bound έργο (βαριά υπολογιστική εργασία) ή όταν απαιτείται παραλληλοποίηση εργασιών.
Συνοπτικά:
async = concurrency χωρίς blocking (συνήθως για I/O)
threads = parallelism / πολλαπλοί εκτελεστές (συνήθως για CPU-bound).
2. Πώς λειτουργεί το async/await
- async μέθοδος που επιστρέφει Task/ValueTask επιστρέφει γρήγορα στον καλούντα μόλις φτάσει σε await σε κάτι που δεν είναι ακόμη έτοιμο.
- Το await «σπάζει» τη μέθοδο σε δύο μέρη: πριν και μετά. Το μέρος μετά το await θα εγγραφεί σαν continuation, και θα εκτελεστεί όταν ο awaited Task ολοκληρωθεί.
- Στο .NET, όταν το await συναντά Task που δεν έχει ολοκληρωθεί, ο current thread δεν μπλοκάρει — το runtime αποδεσμεύει τον thread.
- Το continuation προγραμματίζεται συνήθως στον SynchronizationContext (π.χ. UI thread σε WPF/WinForms). Σε ASP.NET Core ο SynchronizationContext είναι απλό και τα continuations συχνά εκτελούνται σε thread pool threads — γι’ αυτό συχνά χρησιμοποιούμε ConfigureAwait(false) σε βιβλιοθηκές.
Σημείο-κλειδί: async/await βελτιστοποιεί την εκμετάλλευση των περιορισμένων threads όταν υπάρχουν πολλές I/O-εξαρτώμενες διεργασίες
🔹 Τι είναι το SynchronizationContext
Το SynchronizationContext είναι ένα abstraction που λέει στο .NET runtime “πού να εκτελεστεί ο continuation ενός async task”.
- Continuation: Το κομμάτι του κώδικα που εκτελείται μετά από ένα await.
- Το runtime πρέπει να αποφασίσει σε ποιο thread θα τρέξει αυτό το continuation.
Παράδειγμα UI:
private async void Button_Click(object sender, RoutedEventArgs e)
{
await Task.Delay(1000); // async
label.Content = "Done"; // continuation πρέπει να τρέξει στο UI thread!
}
- Το UI έχει ένα thread που χειρίζεται όλα τα controls.
- Το SynchronizationContext του UI thread λέει: “όλα τα continuations πρέπει να τρέχουν εδώ”.
- Χωρίς αυτό, το label.Content = "Done" θα έκανε exception αν εκτελείτο σε άλλο thread.
🔹 Τι γίνεται σε ASP.NET Core
- Σε παλιό ASP.NET (Framework), κάθε request είχε SynchronizationContext που αντιστοιχούσε σε ένα thread.
- Σε ASP.NET Core, δεν υπάρχει ειδικός SynchronizationContext — το continuation εκτελείται σε οποιοδήποτε διαθέσιμο ThreadPool thread.
- Αυτό σημαίνει ότι δεν έχει νόημα να επιστρέψουμε σε συγκεκριμένο thread, εκτός αν το κάνουμε εμείς χειροκίνητα.
Παράδειγμα ASP.NET Core
public async Task<IActionResult> Get()
{
await Task.Delay(1000); // async I/O
return Ok("Hello"); // continuation τρέχει σε thread pool thread
}
- Εδώ, το await δεν “αναγκάζει” continuation να τρέξει στο ίδιο thread.
- Χρήση ConfigureAwait(false) είναι καλή πρακτική σε βιβλιοθήκες, γιατί:
- - Δεν υπάρχει SynchronizationContext που χρειάζεται να επαναφέρεις
- - Αποφεύγεται περιττό overhead.
await SomeLibraryMethodAsync().ConfigureAwait(false);
1️⃣ Τι κάνει το ConfigureAwait(false)
- Λέει στο runtime: “Δεν χρειάζομαι να επιστρέψω στο SynchronizationContext όταν ολοκληρωθεί το await”.
- Χρήσιμο κυρίως σε βιβλιοθήκες ή κώδικα που δεν εξαρτάται από το thread που ξεκίνησε το await.
2️⃣ Όπως προαναφέρθηκε στην ASP.NET Core
- Σε ASP.NET Core δεν υπάρχει ειδικός SynchronizationContext.
- Αυτό σημαίνει ότι το continuation ήδη μπορεί να τρέξει σε οποιοδήποτε ThreadPool thread.
- Συνεπώς, το ConfigureAwait(false) δεν αλλάζει τίποτα λειτουργικά, μόνο “τυπικά” παραλείπει το capture του (performance gain, μικρό).
3️⃣ Πότε να χρησιμοποιείς ConfigureAwait(false)
1. Βιβλιοθήκες / class libraries
- Δεν θέλουν να δεσμεύονται σε UI thread ή ASP.NET context.
- Αποφεύγεις περιττό capture και μικρό overhead.
2. UI apps (WPF/WinForms)
- Μην χρησιμοποιείς ConfigureAwait(false) αν χρειάζεσαι πρόσβαση σε UI elements μετά το await.
- Χρήση: μόνο σε κομμάτια κώδικα που είναι pure background work (π.χ. logging, network, parsing).
3. ASP.NET Core apps
- Προαιρετικό. Μπορείς να το χρησιμοποιήσεις σε βιβλιοθήκες που καλεί το controller, αλλά στο controller κώδικα δεν χρειάζεται.
✅ Συμπέρασμα για το παράδειγμά σου
public async Task<IActionResult> Get()
{
await Task.Delay(1000); // async
return Ok("Hello"); // continuation ήδη σε ThreadPool thread
}
- Δεν χρειάζεται ConfigureAwait(false) εδώ.
- Αν το Task.Delay ή άλλες awaitable calls βρίσκονται σε βιβλιοθήκη, τότε εκεί είναι καλή πρακτική να βάλεις ConfigureAwait(false).
Ας δούμε ένα πλήρες παράδειγμα βιβλιοθήκης που χρησιμοποιεί async/await σωστά, με ConfigureAwait(false) για καθαρότητα και απόδοση, χωρίς να εξαρτάται από το calling thread.
1️⃣ Δημιουργούμε βιβλιοθήκη
// Library: MyLibrary.csproj
public class NetworkHelper
{
private readonly HttpClient _httpClient;
public NetworkHelper(HttpClient httpClient)
{
_httpClient = httpClient;
}
// Async method in library
public async Task<string> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
// Προσοχή: χρησιμοποιούμε ConfigureAwait(false) γιατί η βιβλιοθήκη
// δεν εξαρτάται από το caller thread (UI ή ASP.NET context)
using var response = await _httpClient.GetAsync(url, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync()
.ConfigureAwait(false);
}
}
✅ Τι πετυχαίνουμε εδώ:
- Non-blocking I/O — δεν καίμε threads περιμένοντας HTTP response ή stream.
- Ανεξαρτησία από context — μπορεί να καλέσει οποιοδήποτε thread, δεν χρειάζεται UI ή ASP.NET SynchronizationContext.
- Αποδοτικότητα — μικρότερο overhead, ιδανικό για βιβλιοθήκες που μπορεί να χρησιμοποιηθούν σε UI apps, ASP.NET Core apps ή console apps.
- Το “κομμάτι μετά το await” (continuation) θα εκτελεστεί.
- Το runtime διαλέγει thread από το pool ή οποιοδήποτε διαθέσιμο.
- Δεν σου δίνει έλεγχο να διαλέξεις εσύ χειροκίνητα το thread — απλώς το runtime αποφασίζει για εσένα, επειδή δεν χρειάζεται συγκεκριμένο context.
Δηλαδή ναι, ουσιαστικά λες στον runtime:
“Όταν ολοκληρωθεί η I/O, εκτέλεσε το continuation σε οποιοδήποτε διαθέσιμο thread, δεν χρειάζεται να επιστρέψεις σε συγκεκριμένο context.”
2️⃣ Χρήση της βιβλιοθήκης σε ASP.NET Core
[ApiController]
[Route("[controller]")]
public class SampleController : ControllerBase
{
private readonly NetworkHelper _networkHelper;
public SampleController(NetworkHelper networkHelper)
{
_networkHelper = networkHelper;
}
[HttpGet]
public async Task<IActionResult> Get()
{
// Δεν χρειάζεται ConfigureAwait εδώ — το continuation εκτελείται ήδη σε ThreadPool thread
string json = await _networkHelper.GetJsonAsync("https://api.example.com/data");
return Ok(json);
}
}
✅ Σημεία προσοχής:
- Η βιβλιοθήκη χειρίζεται ConfigureAwait(false) → safe για κάθε περιβάλλον.
- Το controller δεν χρειάζεται ConfigureAwait(false) γιατί στο ASP.NET Core το continuation μπορεί να τρέξει σε οποιοδήποτε ThreadPool thread.
- Αν η ίδια βιβλιοθήκη χρησιμοποιηθεί σε UI app, το ConfigureAwait(false) προστατεύει το UI thread από περιττά captures.
3. Πώς λειτουργεί το multithreading (ThreadPool, Tasks, raw Threads)
Thread (νέο OS thread)
Κάθε thread είναι σαν ένας ξεχωριστός υπάλληλος που μπορεί να κάνει δουλειά μόνος του.
Δημιουργία thread = “προσλαμβάνεις νέο υπάλληλο” → αργό και καταναλώνει μνήμη (στοίβα).
Χρήσιμο για:
μακροχρόνιες εργασίες,
real-time loops,
όταν χρειάζεσαι να δουλεύει πάντα στο ίδιο thread.ThreadPool / Task
Το .NET έχει ένα “συνεργείο υπαλλήλων” έτοιμο, που ονομάζεται ThreadPool.
Όταν θες δουλειά, βάζεις εργασία (Task) στο ThreadPool → ένας υπάλληλος παίρνει την εργασία.
Πλεονέκτημα: δεν χρειάζεται να προσλάβεις νέο υπάλληλο κάθε φορά → πιο γρήγορο και οικονομικό.
Parallel.For / PLINQ
Εύκολοι τρόποι να κάνεις παράλληλη δουλειά για CPU-heavy tasks.
Το .NET φροντίζει να μοιράζει τη δουλειά στους διαθέσιμους πυρήνες του επεξεργαστή.
Παράδειγμα: επεξεργασία ενός μεγάλου πίνακα αριθμών ταυτόχρονα.
Context switch
Όταν ο επεξεργαστής αλλάζει από τον έναν υπάλληλο (thread) στον άλλο, χάνει λίγο χρόνο για να “θυμηθεί” τι έκανε ο προηγούμενος.
Αυτό το “λίγο χρόνο” λέγεται κόστος context switch.
Πολλά threads που αλλάζουν συνέχεια → χάνουμε χρόνο και αποδοτικότητα.
4. Πώς επηρεάζει το hardware (CPU, πυρήνες, cache, μνήμη, I/O devices)
CPU cores & parallelism
Εάν έχετε ένα CPU-bound task και 1 πυρήνα, πολλαπλά threads δεν αυξάνουν throughput — προκαλούν context switches.
Για N πυρήνες, θεωρητικά μπορείτε να επιτύχετε ~N φορές speedup (Amdahl’s law περιορίζει το καθαρό speedup). Η παραλληλοποίηση έχει overhead (συγχρονισμός, κατανομή εργασιών).
Hyper-threading / SMT
Hyperthreading δίνει logical cores — δεν ισοδυναμεί με διπλασιασμό throughput για CPU-bound heavy workloads. Συχνά ωφελείται από workloads που περιλαμβάνουν stalls (I/O, memory latency).
Cache & memory bandwidth
Πολλά threads που δουλεύουν σε κοινά δεδομένα μπορεί να προκαλέσουν cache thrashing και false sharing — μεγάλο performance killer.
Σχεδιάστε ώστε threads να έχουν locality σε δεδομένα (partitioning), και αποφύγετε μικρές κοινές μεταβλητές που μοιράζονται.
I/O subsystems
Για I/O-bound εργασίες (δικτύo, δίσκοι), bottleneck γίνεται το I/O υποσύστημα — εκεί το async/await παρέχει scalability με μικρό αριθμό threads.
NUMA systems
Σε μεγάλα μηχανήματα NUMA, το pinning ή η συμφωνία μεταξύ μνήμης και CPU έχει σημασία. Dedicated threads με affinity μπορεί να βοηθήσουν.
5. Πότε να χρησιμοποιήσετε ποιο μοντέλο — πρακτικός οδηγός
Χρήση async/await όταν:
- Εργασία είναι I/O-bound (HTTP calls, DB queries, disk I/O, sockets).
- Θέλετε scalability (π.χ. ASP.NET Core web servers που χειρίζονται πολλά concurrent αιτήματα).
- Θέλετε να μην μπλοκάρετε UI thread σε desktop/mobile apps.
- Θέλετε να ελαχιστοποιήσετε threads που «κάθονται» περιμένοντας I/O.
Μη-κάντε: Μην χρησιμοποιείτε Task.Run για να «κάνετε async» πραγματικό I/O — απλά καίτε ένα thread περιμένοντας. Αν η βιβλιοθήκη σας είναι sync-only και τρέχετε σε ASP.NET, ίσως χρειαστείς background thread, αλλά προτίμησε async APIs.
Χρήση multithreading / parallelism όταν:
- Εργασία είναι CPU-bound (ληψη / αποκωδικοποίηση εικόνας, μαθηματικοί υπολογισμοί, επεξεργασία βίντεο).
- Θέλετε παράλληλη εκτέλεση για να εκμεταλλευτείτε πολλαπλούς πυρήνες.
- Έχετε long-lived worker threads με affinity/real-time constraints.
6. Παραδείγματα κώδικα (C# .NET Core)
A. Async I/O — HTTP call (καλός τρόπος)
public async Task<string> GetJsonAsync(HttpClient httpClient, string url, CancellationToken ct = default)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var resp = await httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
// ReadAsStreamAsync είναι non-blocking IO
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var sr = new StreamReader(stream);
return await sr.ReadToEndAsync();
}
Σημείο: εδώ δεν μπλοκάρει κανένα thread περιμένοντας το δίκτυο ή το disk.
B. Πρέπει να τρέξω CPU-bound δουλειά στο background — χρήση Task.Run
public Task<long> ComputePrimesAsync(int n)
{
// Offload heavy CPU work to thread pool
return Task.Run(() => {
long count = 0;
for (int i = 2; i <= n; i++)
if (IsPrime(i)) count++;
return count;
});
}
Χρήση: όταν καλείστε από UI thread και δεν θέλετε να μπλοκάρετε. Σε server-side, προσοχή — Task.Run σε ASP.NET Core μπορεί να αυξήσει το συνολικό load χωρίς να προσφέρει καλύτερη κατανομή.
C. Parallel.For για CPU-bound
public int[] SquareAll(int[] input)
{
var output = new int[input.Length];
Parallel.For(0, input.Length, i => {
output[i] = input[i] * input[i];
});
return output;
}
Parallel χρησιμοποιεί partitioning και thread pool για να εκμεταλλευτεί πολλαπλούς πυρήνες.
D. Raw Thread για dedicated worker affinity
public void StartDedicatedWorker()
{
var thread = new Thread(() => {
Thread.CurrentThread.IsBackground = true;
WorkerLoop();
})
{
Name = "DedicatedWorker1"
};
thread.Start();
}
Χρήσιμο αν χρειάζεστε thread-affinity, real-time loops ή να αποφεύγετε thread pool behavior.
E. Synchronization & lock-free (Interlocked)
private int _counter = 0;
public void Increment()
{
Interlocked.Increment(ref _counter);
}
Προτιμήστε Interlocked για απλές atomic ops αντί lock όταν είναι κατάλληλο.
Χρήσημες έννοιες
1 Deadlocks
Συμβαίνει όταν μια async μέθοδος περιμένει blocking (.Result/.Wait()) και το thread που χρειάζεται είναι μπλοκαρισμένο. Λύση: πάντα await, όχι .Result.
2 ConfigureAwait
Λέει στο runtime αν η continuation πρέπει να επιστρέψει στο αρχικό thread. ConfigureAwait(false) χρησιμοποιείται σε βιβλιοθήκες για αποφυγή deadlocks και καλύτερη απόδοση.
3 Async void
Δεν επιστρέφει Task, άρα δεν μπορείς να περιμένεις ή να πιάσεις exceptions. Χρησιμοποιούμε μόνο σε event handlers, αλλιώς πάντα async Task.
4 Overhead
Είναι ο “επιπλέον χρόνος ή πόροι” που ξοδεύει το σύστημα για να διαχειριστεί κάτι, πέρα από την ίδια τη δουλειά.
- Παράδειγμα: δημιουργία thread, context switch, allocation αντικειμένων.
- Λιγότερο overhead → πιο γρήγορη και αποδοτική εφαρμογή.
Πρέπει να ξέρεις
async/await
- Χρησιμοποιείται για I/O-bound εργασία (π.χ. HTTP request, ανάγνωση αρχείου).
- Δεν καίει thread ενώ περιμένει → non-blocking.
Παράδειγμα:
var data = await httpClient.GetStringAsync("https://example.com");
Task.Run
Χρησιμοποιείται για CPU-bound εργασία (π.χ. επεξεργασία μεγάλου πίνακα).
Βάζει τη δουλειά σε ThreadPool thread → παράλληλη εκτέλεση σε άλλο thread.
Παράδειγμα:
var result = await Task.Run(() => HeavyCompute());
💡 Σύντομο κανόνας:
Async/await → I/O (περιμένεις κάτι από εξωτερικό).
Task.Run → CPU (θέλεις να τρέξει σε άλλο thread).
Top comments (0)