DEV Community

Async vs Multithreading στο C# .NET

Σε αυτό το άρθρο θα καλύψουμε σε βάθος τη διαφορά ανάμεσα σε 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!
}

Enter fullscreen mode Exit fullscreen mode
  • Το 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
}
Enter fullscreen mode Exit fullscreen mode
  • Εδώ, το await δεν “αναγκάζει” continuation να τρέξει στο ίδιο thread.
  • Χρήση ConfigureAwait(false) είναι καλή πρακτική σε βιβλιοθήκες, γιατί:
  • - Δεν υπάρχει SynchronizationContext που χρειάζεται να επαναφέρεις
  • - Αποφεύγεται περιττό overhead.
await SomeLibraryMethodAsync().ConfigureAwait(false);

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  • Δεν χρειάζεται 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Τι πετυχαίνουμε εδώ:

  • 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Σημεία προσοχής:

  • Η βιβλιοθήκη χειρίζεται 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();
}
Enter fullscreen mode Exit fullscreen mode

Σημείο: εδώ δεν μπλοκάρει κανένα 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;
    });
}
Enter fullscreen mode Exit fullscreen mode

Χρήση: όταν καλείστε από 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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Χρήσιμο αν χρειάζεστε thread-affinity, real-time loops ή να αποφεύγετε thread pool behavior.

E. Synchronization & lock-free (Interlocked)

private int _counter = 0;
public void Increment()
{
    Interlocked.Increment(ref _counter);
}
Enter fullscreen mode Exit fullscreen mode

Προτιμήστε 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)