CodeGym /Corsi /C# SELF /Problema delle risorse condivise

Problema delle risorse condivise

C# SELF
Livello 56 , Lezione 0
Disponibile

1. Introduzione

In un'applicazione multithread una risorsa condivisa è tutto ciò a cui possono accedere contemporaneamente due o più thread. Può essere:

  • Una variabile (per esempio, un contatore globale o una lista).
  • Un oggetto (per esempio, una collection di utenti).
  • Un file o una socket di rete.
  • Qualsiasi struttura dati modificata da thread diversi.

Nella nostra applicazione console ci imbatteremo più spesso in variabili e oggetti che vengono "share-ati" tra thread.

Analogia

Immagina due persone che cercano contemporaneamente di scrivere qualcosa nello stesso quaderno, senza mettersi d'accordo sul turno. Nel migliore dei casi otterrai una scrittura storta, nel peggiore qualcuno sovrascriverà i dati degli altri. In programmazione la situazione è esattamente la stessa, solo che questi “persone” sono i thread.

Breve panoramica sulle risorse tipiche con race

Nella tabella qui sotto — le risorse più comuni che sono pericolose per l'accesso concorrente da thread diversi:

Risorsa Gruppi di problemi Esempio
Variabili di tipo int Incremento/decremento non corretti Contatori, indici
Collection condivise Perdita/corruzione di elementi, eccezioni Lista condivisa di ordini
Oggetti Cambiamenti di stato non coerenti Flag, proprietà
File Danneggiamento dei dati, lettura/scrittura incorretta Log-file, configurazione

2. Race condition: come si manifesta?

Esempio: Contatore di visite

Supponiamo di voler contare quante volte un utente ha premuto un bottone (o, nel nostro esempio, quante volte thread diversi incrementano una variabile). Versione semplice del codice:


int counter = 0;

void Increment() {
    counter++;
}

Ora creiamo due thread, in ognuno dei quali viene chiamato Increment() 100.000 volte:


using System;
using System.Threading;

class Program
{
    static int counter = 0;

    static void Increment()
    {
        for (int i = 0; i < 100_000; i++)
        {
            counter++;
        }
    }

    static void Main()
    {
        Thread t1 = new Thread(Increment);
        Thread t2 = new Thread(Increment);

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();

        Console.WriteLine($"Si aspettava: 200000, ottenuto: {counter}");
    }
}

Quante volte logicamente dovrebbe essere incrementato counter? 200000! Ma se esegui questo codice più volte, molto probabilmente vedrai numeri diversi: 185000, 192500, 198765… Perché?

3. Perché counter++ non è un'operazione atomica?

Come funziona realmente counter++

In C# e in altri linguaggi di alto livello il programma viene tradotto in una serie di istruzioni macchina. L'operatore counter++, sfortunatamente, non si trasforma in un unico comando magico "aggiungi 1 alla variabile". Ecco cosa succede realmente:

  1. Il thread LEGGE il valore dalla memoria (counter).
  2. Lo incrementa di 1 (nel registro della CPU).
  3. Scrive il nuovo valore di nuovo in memoria (counter).

Se due thread fanno questo quasi contemporaneamente, entrambi possono leggere lo stesso valore vecchio, incrementarlo e poi entrambi scrivere il risultato, perdendo un incremento.

Scenario di race

Supponiamo che counter sia 1000. Entrambi i thread leggono questo valore (passo 1), entrambi lo incrementano a 1001 (passo 2), e poi entrambi scrivono indietro 1001 (passo 3). Che disastro: un incremento è semplicemente perso!

Visualizzazione della race

Momento nel tempo Thread 1 Thread 2 Valore di counter
1 Lettura 1000 1000
2 Lettura 1000 1000
3 Incremento a 1001 Incremento a 1001 1000 (non ancora scritto)
4 Scrittura 1001 1001
5 Scrittura 1001 1001

Alla fine, per due incrementi il valore è aumentato di solo 1!

4. Altri esempi: "bug invisibili"

E se la race non riguarda i numeri?

Immaginiamo ora che più thread aggiungano elementi alla stessa lista:


using System;
using System.Collections.Generic;
using System.Threading;

class Program
{
    static List<int> numbers = new List<int>();

    static void AddNumbers()
    {
        for (int i = 0; i < 10000; i++)
        {
            numbers.Add(i);
        }
    }

    static void Main()
    {
        Thread t1 = new Thread(AddNumbers);
        Thread t2 = new Thread(AddNumbers);

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();

        Console.WriteLine($"Si aspettava: 20000, ottenuto: {numbers.Count}");
    }
}

Anche questo codice può dare risultati diversi a ogni esecuzione: a volte l'app può terminare con un crash (eccezione), a volte vedrai meno elementi di quelli attesi.

Perché? Perché la collection List<T> non è thread-safe out-of-the-box. Quindi quando due thread chiamano contemporaneamente Add, la struttura interna della lista può corrompersi.

5. Atomicità delle operazioni

Cos'è un'operazione atomica?

Un'operazione atomica è un'operazione che viene eseguita tutta insieme, senza la possibilità di essere interrotta da un altro thread a metà strada. È come una "transazione": o tutto avviene, o niente.

  • Le assegnazioni di variabili di tipo int come myVar = 42; su molte piattaforme sono atomiche (a meno che non si tratti di un oggetto enorme).
  • Ma counter++ non è atomico — è costituito da tre azioni consecutive.

Operazioni atomiche speciali

In .NET ci sono classi speciali per operazioni atomiche: per esempio, Interlocked. Questo approccio lo vedremo nelle lezioni successive.

Esempio di incremento atomico con Interlocked.Increment:


using System.Threading;

int counter = 0;
Interlocked.Increment(ref counter); // operazione atomica!

6. Perché è difficile catturare una race condition?

La race condition è pericolosa perché:

  • Può manifestarsi solo sotto carico elevato.
  • Si presenta non nel 100% dei casi, ma nel 5% o anche nello 0.01% dei casi.
  • Crasha "a caso" e si verifica dove nessuno se lo aspetta.

Come riconoscere il problema?

Se ad ogni esecuzione del programma ottieni risultati diversi (e sbagliati), è il caso di sospettare una race condition.

Scherzi dei programmatori

"Se un bug appare raramente e si risolve aggiungendo Thread.Sleep(50) — hai problemi più seri di quanto sembri."

7. Consigli utili

Sincronizzazione

Per proteggere le sezioni critiche (porzioni di codice che lavorano con risorse condivise) è necessario sincronizzarle. Ma questo è argomento delle lezioni successive. Per ora l'importante è imparare a notare e spiegare il problema.

Errori tipici dei principianti

Molti programmatori alle prime armi pensano: “Ho counter++ — cosa potrebbe andare storto?” Purtroppo, appena hai più di un thread, tutto può andare storto! Anche cose apparentemente semplici: leggere e scrivere variabili, aggiungere elementi a una lista, modificare lo stato di un oggetto e molto altro.

Il ruolo delle race condition nello sviluppo reale

Nelle moderne applicazioni multithread (per esempio, API server, gestione di richieste web, giochi e app mobili) ci sono quasi sempre risorse condivise. Senza sincronizzazione le race condition portano a processamenti scorretti degli ordini, crash, memory leak e enormi difficoltà nel debugging.

Ai colloqui per posizioni middle/senior ti chiederanno sicuramente: “Cos'è una race condition? Come evitarla?” Se riesci a portare gli esempi sopra e spiegare la meccanica — i recruiter saranno contenti!

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION