Salta ai contenuti

Esercizi sui Task

In questa pagina vengono proposti alcuni esercizi svolti sulla programmazione con i Task in C#. L’obiettivo è quello di mettere in pratica i concetti appresi riguardo alla Task Parallel Library (TPL), la gestione asincrona, la sincronizzazione e i pattern avanzati di concorrenza.

Quattro ragazzi (Giovanni, Mattia, Alessandro, Roberto) giocano a volley. Stanno facendo un esercizio di riscaldamento nel quale si passano la palla a turno: Giovanni passa a Mattia, Mattia passa ad Alessandro, Alessandro la passa a Roberto, Roberto la passa nuovamente a Giovanni e il ciclo si ripete.

Ogni ragazzo quando riceve la palla dice la frase: “Sono {proprio nome} e passo la palla a {nome del ragazzo che riceverà la palla}”.

L’attività di palleggio (tempo in cui un ragazzo tiene la palla prima di passarla) varia da 0,1 a 0,3 secondi in maniera random. Simulare un ciclo indefinito di palleggio che viene terminato quando si riceve il fischio dell’allenatore.

Utilizzare i Task per gestire l’attività di ciascun ragazzo.

Questo esercizio introduce l’uso di Task per attività concorrenti coordinate, utilizzando semafori per il passaggio del controllo tra task.

Di seguito vengono presentate due versioni della soluzione: una versione base con gestione della terminazione tramite variabile booleana condivisa, e una versione avanzata con CancellationToken.

In questa prima versione, si utilizza una variabile bool condivisa protetta da lock per gestire la terminazione.

using System;
using System.Threading;
using System.Threading.Tasks;
namespace VolleyWarmUp
{
class GiocatoreData
{
public string? MyName { get; set; }
public string? ToName { get; set; }
public SemaphoreSlim? CanIPlay { get; set; }
public SemaphoreSlim? PassToNext { get; set; }
}
internal class Program
{
// Semafori per coordinare i turni di gioco
static SemaphoreSlim giocaGiovanni = new(1, 1); // Giovanni inizia il gioco
static SemaphoreSlim giocaMattia = new(0, 1);
static SemaphoreSlim giocaAlessandro = new(0, 1);
static SemaphoreSlim giocaRoberto = new(0, 1);
// Flag per terminare il gioco
static bool fischioAllenatore = false;
private static readonly Lock _lockFischioAllenatore = new();
static void Main(string[] args)
{
// Il main fa partire l'attività di palleggio per ogni giocatore
Task giovanni = Task.Factory.StartNew(WarmUp,
new GiocatoreData() {
MyName = "Giovanni",
ToName = "Mattia",
CanIPlay = giocaGiovanni,
PassToNext = giocaMattia
});
Task mattia = Task.Factory.StartNew(WarmUp,
new GiocatoreData() {
MyName = "Mattia",
ToName = "Alessandro",
CanIPlay = giocaMattia,
PassToNext = giocaAlessandro
});
Task alessandro = Task.Factory.StartNew(WarmUp,
new GiocatoreData() {
MyName = "Alessandro",
ToName = "Roberto",
CanIPlay = giocaAlessandro,
PassToNext = giocaRoberto
});
Task roberto = Task.Factory.StartNew(WarmUp,
new GiocatoreData() {
MyName = "Roberto",
ToName = "Giovanni",
CanIPlay = giocaRoberto,
PassToNext = giocaGiovanni
});
// Attendo 10 secondi per il riscaldamento
Task.Delay(10000).Wait();
Console.WriteLine("Riscaldamento terminato, fischio di fine gioco");
// Segnalo la fine del gioco
lock (_lockFischioAllenatore)
{
fischioAllenatore = true;
}
// Attendo che tutti i task terminino
Task.WaitAll(giovanni, mattia, alessandro, roberto);
}
private static void WarmUp(object? obj)
{
if (obj is GiocatoreData data)
{
Random gen = new();
bool haFischiato;
// Verifico se l'allenatore ha già fischiato
lock (_lockFischioAllenatore)
{
haFischiato = fischioAllenatore;
}
while (!haFischiato)
{
// Attendo il mio turno
data?.CanIPlay?.Wait();
// Simulo il tempo di palleggio
Task.Delay(gen.Next(100, 301)).Wait();
Console.WriteLine($"Sono {data?.MyName} e passo la palla a {data?.ToName}");
// Segnalo al prossimo giocatore che può giocare
data?.PassToNext?.Release();
// Verifico se l'allenatore ha fischiato
lock(_lockFischioAllenatore)
{
haFischiato = fischioAllenatore;
}
}
}
}
}
}

La versione avanzata utilizza CancellationToken per una cancellazione cooperativa più robusta e idiomatica in .NET.

using System;
using System.Threading;
using System.Threading.Tasks;
namespace VolleyWarmUpCancellationToken
{
class GiocatoreData
{
public string? MyName { get; set; }
public string? ToName { get; set; }
public SemaphoreSlim? CanIPlay { get; set; }
public SemaphoreSlim? PassToNext { get; set; }
}
internal class Program
{
static SemaphoreSlim giocaGiovanni = new(1, 1);
static SemaphoreSlim giocaMattia = new(0, 1);
static SemaphoreSlim giocaAlessandro = new(0, 1);
static SemaphoreSlim giocaRoberto = new(0, 1);
static void Main(string[] args)
{
// Creo il cancellation token a partire da un cancellation token source
CancellationTokenSource cts = new();
CancellationToken ct = cts.Token;
// Faccio partire le attività di palleggio
Task giovanni = Task.Factory.StartNew((object? obj) => WarmUp(obj, ct),
new GiocatoreData() {
MyName = "Giovanni",
ToName = "Mattia",
CanIPlay = giocaGiovanni,
PassToNext = giocaMattia
}, ct);
Task mattia = Task.Factory.StartNew((object? obj) => WarmUp(obj, ct),
new GiocatoreData() {
MyName = "Mattia",
ToName = "Alessandro",
CanIPlay = giocaMattia,
PassToNext = giocaAlessandro
}, ct);
Task alessandro = Task.Factory.StartNew((object? obj) => WarmUp(obj, ct),
new GiocatoreData() {
MyName = "Alessandro",
ToName = "Roberto",
CanIPlay = giocaAlessandro,
PassToNext = giocaRoberto
}, ct);
Task roberto = Task.Factory.StartNew((object? obj) => WarmUp(obj, ct),
new GiocatoreData() {
MyName = "Roberto",
ToName = "Giovanni",
CanIPlay = giocaRoberto,
PassToNext = giocaGiovanni
}, ct);
// Attendo 10 secondi
Task.Delay(10000).Wait();
Console.WriteLine("Riscaldamento terminato, fischio di fine gioco");
// Richiedo la cancellazione dei task
cts.Cancel();
try
{
Task allPlayers = Task.WhenAll(giovanni, mattia, alessandro, roberto);
allPlayers.Wait();
}
catch(AggregateException ae)
{
foreach(var e in ae.Flatten().InnerExceptions)
{
if(e is TaskCanceledException)
{
Console.WriteLine($"\n{nameof(TaskCanceledException)} catturato correttamente\n");
}
else
{
Console.WriteLine("Eccezione inaspettata");
throw;
}
}
}
finally
{
cts.Dispose();
}
}
private static void WarmUp(object? obj, CancellationToken ct)
{
if (obj is GiocatoreData data)
{
Random gen = new();
// Controllo se la cancellazione è già stata richiesta prima di iniziare
if(ct.IsCancellationRequested)
{
Console.WriteLine($"L'attività del giocatore {data?.MyName} è stata fermata prima di iniziare");
ct.ThrowIfCancellationRequested();
}
while (true)
{
// Attendo il turno (può lanciare OperationCanceledException se cancellato)
data?.CanIPlay?.Wait(ct);
// Passo la palla con delay cancellabile
Task.Delay(gen.Next(100, 301), ct).Wait(ct);
Console.WriteLine($"Sono {data?.MyName} e passo la palla a {data?.ToName}");
// Segnalo al prossimo che può giocare
data?.PassToNext?.Release();
// Verifico se l'allenatore ha fischiato
if (ct.IsCancellationRequested)
{
Console.WriteLine($"L'attività del giocatore {data?.MyName} è stata terminata");
ct.ThrowIfCancellationRequested();
}
}
}
}
}
}

Questo esercizio introduce concetti fondamentali della programmazione con Task:

  1. Task.Factory.StartNew: Permette di creare e avviare un task passando un delegato e opzionalmente uno stato (in questo caso GiocatoreData). È più flessibile di Task.Run quando serve passare parametri o configurare opzioni avanzate.

  2. Coordinazione con Semafori: I quattro semafori implementano un pattern di “token passing” dove solo un giocatore alla volta può “giocare” (avere la palla). Quando un giocatore termina, rilascia il semaforo del prossimo giocatore.

  3. Gestione della Terminazione:

    • Versione 1: Usa una variabile booleana protetta da lock. Semplice ma richiede controlli manuali periodici.
    • Versione 2: Usa CancellationToken, che è il metodo raccomandato in .NET. Il token può interrompere operazioni di attesa (Wait, Delay) immediatamente senza dover aspettare che il codice controlli manualmente.
  4. Exception Handling con AggregateException: Quando i task vengono cancellati lanciano TaskCanceledException. Poiché Task.WaitAll può ricevere eccezioni da più task, queste vengono incapsulate in un AggregateException. Si usa Flatten() per semplificare la gestione di eccezioni annidate.


Esercizio 2: Autolavaggio Concorrente (Singolo Tunnel)

Sezione intitolata “Esercizio 2: Autolavaggio Concorrente (Singolo Tunnel)”

Scrivere un programma multithreading console C# che simuli il funzionamento di un autolavaggio mediante Task.

L’autolavaggio è costituito da un tunnel nel quale possono entrare le macchine una alla volta. Davanti al tunnel c’è un parcheggio nel quale possono entrare al massimo 20 macchine.

  • Se una macchina arriva, c’è posto nel parcheggio ed è aperto, entra e aspetta di entrare nel tunnel
  • Se una macchina arriva e l’autolavaggio è chiuso, oppure è aperto ma non c’è posto nel parcheggio, va via
  • Le macchine arrivano con un intervallo di tempo variabile casualmente tra 0,1 e 0,3 secondi
  • Quando una macchina entra nel tunnel, il programma stampa: “la macchina con indice i su Task id = TaskId entra nel tunnel di lavaggio, ho atteso x ms da quando sono entrata”
  • Il tempo di permanenza nel tunnel è di 400 ms
  • Quando esce, il programma stampa: “la macchina i-ma esce dal tunnel pulita e va via”

Se l’autolavaggio viene chiuso ma ci sono ancora auto in attesa, il sistema lava tutte le auto nel parcheggio. Quando non ci sono più macchine in attesa e il parcheggio è chiuso, il sistema termina.

Il Main program fa partire il Task che simula il tunnel di lavaggio e 50 Task che simulano le auto che arrivano, quindi va in sleep per qualche secondo, poi chiude l’ autolavaggio e aspetta che il sistema finisca.

Questo esercizio introduce il pattern Sleeping Barber (Barbiere che dorme) con semafori e la gestione di stato condiviso.

Questa soluzione utilizza due semafori:

  • tunnelReady: semaforo “servente” (il tunnel disponibile)
  • carInParkingLotReady: semaforo “utente” (auto in attesa)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AutolavaggioTasks
{
internal class Program
{
const int NumberOfPlaces = 20;
const int NumberOfCars = 50;
const int NumberOfTunnels = 1; // Un solo tunnel
static int freePlaces = NumberOfPlaces;
static bool carWashOpen;
static int currentCarInTunnelIndex; // Indice della macchina nel tunnel
const int TunnelWashingDurationMs = 400;
const int MinCarArrivalIntervalMs = 100;
const int MaxCarArrivalIntervalMs = 300;
// Lock per proteggere variabili condivise
private static readonly Lock _lockCurrentCarInTunnelIndex = new();
private static readonly Lock _lockFreePlaces = new();
private static readonly Lock _lockCarWashOpen = new();
// Semafori per coordinare tunnel e auto
static readonly SemaphoreSlim tunnelReady = new(NumberOfTunnels, NumberOfTunnels); // Servente
static readonly SemaphoreSlim carInParkingLotReady = new(0, NumberOfPlaces); // Utente
static void Main(string[] args)
{
// Apro l'autolavaggio
lock (_lockCarWashOpen)
{
Console.WriteLine("L'autolavaggio è aperto");
carWashOpen = true;
}
// Faccio partire il task del tunnel
var tunnel = Task.Factory.StartNew(TunnelAction);
// Faccio partire i task delle auto che arrivano
Random rand = new();
for (int i = 0; i < NumberOfCars; i++)
{
Task.Factory.StartNew(CarAction, new CarData() { Id = i, CreationTime = DateTime.Now.Ticks });
int timeInterval = rand.Next(MinCarArrivalIntervalMs, MaxCarArrivalIntervalMs + 1);
Task.Delay(timeInterval).Wait();
}
Console.WriteLine("Non arrivano più auto");
// Aspetto 1 secondo
Task.Delay(1000).Wait();
// Chiudo l'autolavaggio
lock (_lockCarWashOpen)
{
var previousColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("L'autolavaggio è chiuso");
Console.ForegroundColor = previousColor;
carWashOpen = false;
}
// Attendo che il tunnel finisca il suo lavoro
tunnel.Wait();
}
private static void CarAction(object? obj)
{
CarData? carData = obj as CarData;
// Verifico se il parcheggio è aperto
bool isCarWashOpen;
lock (_lockCarWashOpen)
{
isCarWashOpen = carWashOpen;
}
if (isCarWashOpen)
{
// Provo ad occupare un posto nel parcheggio
bool carEntersParkingLot = false;
int freePlacesFound = 0;
lock (_lockFreePlaces)
{
if (freePlaces > 0)
{
freePlacesFound = freePlaces;
freePlaces--;
carEntersParkingLot = true;
}
}
if (carEntersParkingLot)
{
Console.WriteLine($"L'auto con indice = {carData?.Id} sul Task Id = {Task.CurrentId} trova posto e attende di essere lavata; posti liberi {freePlacesFound}");
// Segnalo che c'è un'auto in attesa
carInParkingLotReady.Release();
// Attendo che il tunnel sia disponibile
tunnelReady.Wait();
// Scrivo nella variabile condivisa l'indice della macchina che entra nel tunnel
lock (_lockCurrentCarInTunnelIndex)
{
currentCarInTunnelIndex = carData?.Id ?? -1;
}
// Calcolo il tempo di attesa
double tempoDiAttesa = 0;
if (carData != null)
{
tempoDiAttesa = new TimeSpan(DateTime.Now.Ticks - carData.CreationTime).TotalMilliseconds;
}
lock (_lockFreePlaces)
{
Console.WriteLine($"Sono l'auto con indice = {carData?.Id} su Task Id = {Task.CurrentId}. " +
$"sono entrata nel tunnel di lavaggio dopo {tempoDiAttesa:F2} ms di attesa. Posti disponibili = {freePlaces}");
}
}
else
{
Console.WriteLine($"L'auto con indice = {carData?.Id} sul Task Id = {Task.CurrentId} non ha trovato posto e se ne va");
}
}
}
private static void TunnelAction()
{
// Finché l'autolavaggio è aperto o ci sono auto in attesa
bool carWashIsStillOpen;
lock (_lockCarWashOpen)
{
carWashIsStillOpen = carWashOpen;
}
while (carWashIsStillOpen || carInParkingLotReady.CurrentCount > 0)
{
if (!carWashIsStillOpen && carInParkingLotReady.CurrentCount > 0)
{
var previousColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"L'autolavaggio è chiuso, ma ci sono ancora {carInParkingLotReady.CurrentCount} macchine da lavare");
Console.ForegroundColor = previousColor;
}
// Attendo che ci sia una macchina disponibile
carInParkingLotReady.Wait();
// La macchina entra nel tunnel e si libera un posto nel parcheggio
lock (_lockFreePlaces)
{
freePlaces++;
Console.WriteLine("Il tunnel sta lavando un'auto. Si libera un posto nel parcheggio");
}
// Tempo di lavaggio
Task.Delay(TunnelWashingDurationMs).Wait();
// Il tunnel ha finito ed è nuovamente pronto
tunnelReady.Release();
lock (_lockCurrentCarInTunnelIndex)
{
Console.WriteLine($"La macchina {currentCarInTunnelIndex}-ma esce dal tunnel pulita e va via");
}
// Controllo se l'autolavaggio è ancora aperto
lock (_lockCarWashOpen)
{
carWashIsStillOpen = carWashOpen;
}
}
var color = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Il tunnel si spegne");
Console.ForegroundColor = color;
}
class CarData
{
public int Id { get; set; }
public long CreationTime { get; set; }
}
}
}

Questo esercizio introduce il pattern Sleeping Barber (Barbiere che dorme) mediante semafori:

  1. Due Semafori Complementari:

    • tunnelReady: Rappresenta il Barbiere (tunnel disponibile). Inizializzato a 1.
    • carInParkingLotReady: Rappresenta i Clienti (auto in attesa). Inizializzato a 0.
  2. Flusso di Controllo:

    • Le auto (Clienti) entrano nel parcheggio e fanno Release() su carInParkingLotReady
    • Il tunnel (Barbiere) fa Wait() su carInParkingLotReady, processa l’auto, poi fa Release() su tunnelReady
    • Le auto fanno Wait() su tunnelReady per entrare nel tunnel
  3. Protezione con Lock: Tre lock separati proteggono:

    • _lockFreePlaces: posti liberi nel parcheggio
    • _lockCarWashOpen: stato aperto/chiuso
    • _lockCurrentCarInTunnelIndex: indice dell’auto corrente
  4. Terminazione Graduale: Anche dopo la chiusura, il tunnel continua a servire le auto già in attesa, garantendo che nessuna venga persa.


Esercizio 3: Autolavaggio Concorrente (Tunnel Multipli)

Sezione intitolata “Esercizio 3: Autolavaggio Concorrente (Tunnel Multipli)”

Modificare l’esercizio precedente in modo che ci siano due o più tunnel nello stesso autolavaggio, permettendo di lavare più auto contemporaneamente.

La modifica è minima: basta cambiare NumberOfTunnels e creare un array di task per i tunnel:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace AutolavaggioTasksMultiTunnel
{
internal class Program
{
const int NumberOfPlaces = 20;
const int NumberOfCars = 50;
const int NumberOfTunnels = 3; // 3 Tunnel (Barbieri)
static int freePlaces = NumberOfPlaces;
static bool carWashOpen;
const int TunnelWashingDurationMs = 400;
const int MinCarArrivalIntervalMs = 100;
const int MaxCarArrivalIntervalMs = 300;
// Lock per proteggere variabili condivise
private static readonly Lock _lockFreePlaces = new();
private static readonly Lock _lockCarWashOpen = new();
// Coda concorrente per passare i dati delle auto ai tunnel
static ConcurrentQueue<int> carsInParking = new();
// Semafori per coordinare tunnel e auto
static readonly SemaphoreSlim tunnelReady = new(NumberOfTunnels, NumberOfTunnels); // Servente (Barbiere)
static readonly SemaphoreSlim carInParkingLotReady = new(0, NumberOfPlaces); // Utente (Cliente)
static void Main(string[] args)
{
// Apro l'autolavaggio
lock (_lockCarWashOpen)
{
Console.WriteLine("L'autolavaggio è aperto");
carWashOpen = true;
}
// Faccio partire i task dei tunnel
Task[] tunnels = new Task[NumberOfTunnels];
for(int i = 0; i < NumberOfTunnels; i++)
{
int index = i;
tunnels[i] = Task.Factory.StartNew(() => TunnelAction(index));
}
// Faccio partire i task delle auto che arrivano
Random rand = new();
for (int i = 0; i < NumberOfCars; i++)
{
Task.Factory.StartNew(CarAction, new CarData() { Id = i, CreationTime = DateTime.Now.Ticks });
int timeInterval = rand.Next(MinCarArrivalIntervalMs, MaxCarArrivalIntervalMs + 1);
Task.Delay(timeInterval).Wait();
}
Console.WriteLine("Non arrivano più auto");
// Aspetto 1 secondo
Task.Delay(1000).Wait();
// Chiudo l'autolavaggio
lock (_lockCarWashOpen)
{
var previousColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("L'autolavaggio è chiuso");
Console.ForegroundColor = previousColor;
carWashOpen = false;
}
// Attendo che TUTTI i tunnel finiscano
Task.WaitAll(tunnels);
}
private static void CarAction(object? obj)
{
CarData? carData = obj as CarData;
// Verifico se il parcheggio è aperto
bool isCarWashOpen;
lock (_lockCarWashOpen)
{
isCarWashOpen = carWashOpen;
}
if (isCarWashOpen)
{
// Provo ad occupare un posto nel parcheggio
bool carEntersParkingLot = false;
int freePlacesFound = 0;
lock (_lockFreePlaces)
{
if (freePlaces > 0)
{
freePlacesFound = freePlaces;
freePlaces--;
carEntersParkingLot = true;
}
}
if (carEntersParkingLot)
{
Console.WriteLine($"L'auto con indice = {carData?.Id} sul Task Id = {Task.CurrentId} trova posto e attende di essere lavata; posti liberi {freePlacesFound}");
// Aggiungo l'auto alla coda e segnalo
if (carData != null)
{
carsInParking.Enqueue(carData.Id);
}
carInParkingLotReady.Release();
// Attendo che un tunnel sia disponibile
tunnelReady.Wait();
// Calcolo il tempo di attesa
double tempoDiAttesa = 0;
if (carData != null)
{
tempoDiAttesa = new TimeSpan(DateTime.Now.Ticks - carData.CreationTime).TotalMilliseconds;
}
lock (_lockFreePlaces)
{
Console.WriteLine($"Sono l'auto con indice = {carData?.Id} su Task Id = {Task.CurrentId}. " +
$"sono entrata nel tunnel di lavaggio dopo {tempoDiAttesa:F2} ms di attesa. Posti disponibili = {freePlaces}");
}
}
else
{
Console.WriteLine($"L'auto con indice = {carData?.Id} sul Task Id = {Task.CurrentId} non ha trovato posto e se ne va");
}
}
}
private static void TunnelAction(int tunnelIndex)
{
// Finché l'autolavaggio è aperto o ci sono auto in attesa
bool carWashIsStillOpen;
lock (_lockCarWashOpen)
{
carWashIsStillOpen = carWashOpen;
}
while (carWashIsStillOpen || carInParkingLotReady.CurrentCount > 0)
{
if (!carWashIsStillOpen && carInParkingLotReady.CurrentCount > 0)
{
var previousColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"L'autolavaggio è chiuso, ma ci sono ancora {carInParkingLotReady.CurrentCount} macchine da lavare");
Console.ForegroundColor = previousColor;
}
// Attendo che ci sia una macchina disponibile
carInParkingLotReady.Wait();
// Prelevo l'auto dalla coda
int carId = -1;
if (carsInParking.TryDequeue(out int id))
{
carId = id;
}
// La macchina entra nel tunnel e si libera un posto nel parcheggio
lock (_lockFreePlaces)
{
freePlaces++;
Console.WriteLine($"Il tunnel {tunnelIndex} sta lavando l'auto {carId}. Si libera un posto nel parcheggio");
}
// Tempo di lavaggio
Task.Delay(TunnelWashingDurationMs).Wait();
// Il tunnel ha finito ed è nuovamente pronto
tunnelReady.Release();
Console.WriteLine($"La macchina {carId} esce dal tunnel {tunnelIndex} pulita e va via");
// Controllo se l'autolavaggio è ancora aperto
lock (_lockCarWashOpen)
{
carWashIsStillOpen = carWashOpen;
}
}
var color = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Il tunnel {tunnelIndex} si spegne");
Console.ForegroundColor = color;
}
class CarData
{
public int Id { get; set; }
public long CreationTime { get; set; }
}
}
}

Il passaggio da un tunnel a più tunnel dimostra la scalabilità del pattern Sleeping Barber:

  1. Semaforo con Contatore > 1: tunnelReady (Barbiere) ora è inizializzato con NumberOfTunnels invece di 1. Questo permette a più auto (Clienti) di entrare simultaneamente se ci sono tunnel (Barbieri) disponibili.

  2. Array di Task: Creando un array di task tunnel, ogni tunnel opera indipendentemente ma accede alle stesse code (semafori). Questo è il vero potere della concor renza: possiamo scalare semplicemente aumentando il numero di “worker”.

  3. Closure e Capture di Variabili: È fondamentale catturare l’indice del loop in una variabile locale (int index = i) prima di passarlo alla lambda. Altrimenti, tutti i task potrebbero vedere lo stesso valore finale di i.

  4. Throughput Migliorato: Con 3 tunnel invece di 1, il throughput teorico triplica, riducendo drasticamente i tempi di attesa delle auto.


Riscrivere l’esercizio del Negozio Concorrente (visto nella sezione Thread) utilizzando i Task invece dei Thread.

Recall: Il negozio ha tre attività concurrent i:

  • EntraCliente: ogni 1,5s tenta di far entrare un cliente (max 5)
  • EsceCliente: ogni 5,5s fa uscire un cliente che paga 20 euro
  • ChiudiNegozio: dopo 15s chiude il negozio

Il programma stampa il ricavo giornaliero alla fine.

La conversione da Thread a Task richiede modifiche minime:

using System;
using System.Threading;
using System.Threading.Tasks;
namespace NegozioConcorrenteTask
{
internal class Program
{
// Variabili condivise
static decimal saldoNegozio = 0;
static int clientiInNegozio = 0;
static bool negozioAperto = true;
// Lock per proteggere le variabili condivise
static readonly Lock _lockClientiInNegozio = new();
static readonly Lock _lockSaldoNegozio = new();
static readonly Lock _lockNegozioAperto = new();
const int NumeroMassimoClientiNegozio = 5;
static void Main(string[] args)
{
// VERSIONE CON TASK - differenza principale rispetto alla versione Thread
Task entraCliente = Task.Factory.StartNew(EntraCliente);
Task esceCliente = Task.Factory.StartNew(EsceCliente);
Task chiudiNegozio = Task.Factory.StartNew(ChiudiNegozio);
// Attendo il completamento di tutti e tre i task
Task.WaitAll(entraCliente, esceCliente, chiudiNegozio);
// Il main accede alla variabile condivisa del saldo
lock (_lockSaldoNegozio)
{
Console.WriteLine($"Ricavo giornaliero: {saldoNegozio} Euro");
}
}
private static void ChiudiNegozio()
{
Console.WriteLine("Negozio aperto");
Thread.Sleep(15000);
lock (_lockNegozioAperto)
{
negozioAperto = false;
Console.WriteLine("Negozio chiuso");
}
}
private static void EsceCliente()
{
bool possoAncoraUscire = false;
int clientiLetti;
do
{
Thread.Sleep(5500);
lock (_lockClientiInNegozio)
{
if (clientiInNegozio > 0)
{
clientiInNegozio--;
possoAncoraUscire = true;
}
else
{
possoAncoraUscire = false;
}
clientiLetti = clientiInNegozio;
}
if (possoAncoraUscire)
{
lock (_lockSaldoNegozio)
{
saldoNegozio += 20.0m;
Console.WriteLine($"Un cliente esce e paga. Ora i clienti sono {clientiLetti} e il ricavo è di {saldoNegozio} Euro");
}
}
} while (possoAncoraUscire);
}
private static void EntraCliente()
{
bool hoTrovatoAperto;
do
{
Thread.Sleep(1500);
lock (_lockNegozioAperto)
{
hoTrovatoAperto = negozioAperto;
}
lock (_lockClientiInNegozio)
{
if (clientiInNegozio < NumeroMassimoClientiNegozio)
{
clientiInNegozio++;
Console.WriteLine($"Un nuovo cliente entra in negozio. Ora i clienti sono {clientiInNegozio}");
}
else if (hoTrovatoAperto)
{
Console.WriteLine($"Negozio affollato, impossibile entrare. Ora i clienti in negozio sono {clientiInNegozio}");
}
}
} while (hoTrovatoAperto);
}
}
}

La transizione da Thread a Task evidenzia i vantaggi dell’astrazione offerta da TPL:

  1. Sintassi Più Pulita:

    // Thread approach
    Thread t = new Thread(Method);
    t.Start();
    t.Join();
    // Task approach
    Task t = Task.Factory.StartNew(Method);
    t.Wait();
  2. Task.WaitAll vs Multiple Join: Task.WaitAll(t1, t2, t3) è più conciso di chiamare Join() su tre thread separati.

  3. Stesso Comportamento, Diversa Implementazione: La logica di business rimane identica. I Task vengono eseguiti sul ThreadPool invece che su thread dedicati, risparmiando risorse di sistema.

  4. Pronto per Async/Await: Questa versione con Task è facilmente convertibile a async/await se necessario, mentre la versione Thread richiederebbe una riscrittura completa.


Calcolare la somma dei numeri primi compresi tra 1 e 100.000 utilizzando:

  1. Il calcolo sequenziale
  2. Il calcolo parallelo con Parallel.ForEach e ConcurrentBag
  3. Il calcolo parallelo con Parallel.ForEach e thread-local data

Calcolare il fattore di speedup nei due casi di calcolo parallelo (con e senza thread local data) rispetto al calcolo sequenziale.

Di seguito le tre implementazioni con confronto delle prestazioni.

using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace SommaNumeriPrimi
{
internal class Program
{
const int MaxNumber = 100_000;
static void Main(string[] args)
{
Console.WriteLine($"Calcolo della somma dei numeri primi da 1 a {MaxNumber:N0}\n");
// 1. Calcolo Sequenziale
var (sommaSeq, tempoSeq) = CalcoloSequenziale();
// 2. Calcolo Parallelo con ConcurrentBag
var (sommaParConcurrent, tempoParConcurrent) = CalcoloParalleloConcurrentBag();
// 3. Calcolo Parallelo con Thread Local Data
var (sommaParLocal, tempoParLocal) = CalcoloParalleloThreadLocal();
// Calcolo speedup
double speedupConcurrent = (double)tempoSeq / tempoParConcurrent;
double speedupLocal = (double)tempoSeq / tempoParLocal;
Console.WriteLine("\n=== RIEPILOGO ===");
Console.WriteLine($"Somma (Sequenziale): {sommaSeq:N0}");
Console.WriteLine($"Somma (Parallel + ConcurrentBag): {sommaParConcurrent:N0}");
Console.WriteLine($"Somma (Parallel + Thread Local): {sommaParLocal:N0}");
Console.WriteLine("\n=== PRESTAZIONI ===");
Console.WriteLine($"Tempo Sequenziale: {tempoSeq:N0} ms");
Console.WriteLine($"Tempo Parallel (ConcurrentBag): {tempoParConcurrent:N0} ms - Speedup: {speedupConcurrent:F2}x");
Console.WriteLine($"Tempo Parallel (Thread Local): {tempoParLocal:N0} ms - Speedup: {speedupLocal:F2}x");
}
/// <summary>
/// Implementazione sequenziale - baseline per il confronto
/// </summary>
static (long somma, long tempoMs) CalcoloSequenziale()
{
Console.WriteLine("=== CALCOLO SEQUENZIALE ===");
Stopwatch sw = Stopwatch.StartNew();
long somma = 0;
for (int i = 1; i <= MaxNumber; i++)
{
if (IsPrime(i))
{
somma += i;
}
}
sw.Stop();
Console.WriteLine($"Tempo: {sw.ElapsedMilliseconds:N0} ms");
Console.WriteLine($"Somma: {somma:N0}\n");
return (somma, sw.ElapsedMilliseconds);
}
/// <summary>
/// Calcolo parallelo con ConcurrentBag (thread-safe collection)
/// </summary>
static (long somma, long tempoMs) CalcoloParalleloConcurrentBag()
{
Console.WriteLine("=== CALCOLO PARALLELO (ConcurrentBag) ===");
Stopwatch sw = Stopwatch.StartNew();
// ConcurrentBag è thread-safe, ma ogni Add() ha overhead di sincronizzazione
ConcurrentBag<int> primi = new();
Parallel.For(1, MaxNumber + 1, numero =>
{
if (IsPrime(numero))
{
primi.Add(numero);
}
});
long somma = primi.Sum(x => (long)x);
sw.Stop();
Console.WriteLine($"Tempo: {sw.ElapsedMilliseconds:N0} ms");
Console.WriteLine($"Somma: {somma:N0}\n");
return (somma, sw.ElapsedMilliseconds);
}
/// <summary>
/// Calcolo parallelo con Thread Local Data (approccio ottimizzato)
/// </summary>
static (long somma, long tempoMs) CalcoloParalleloThreadLocal()
{
Console.WriteLine("=== CALCOLO PARALLELO (Thread Local Data) ===");
Stopwatch sw = Stopwatch.StartNew();
// Ogni thread accumula in una variabile locale, riducendo la contesa
object lockObj = new();
long sommaGlobale = 0;
Parallel.For(
1,
MaxNumber + 1,
// Inizializzatore thread-local: crea una variabile locale per ogni thread
() => 0L,
// Body: opera sulla variabile locale del thread
(numero, loopState, sommaLocale) =>
{
if (IsPrime(numero))
{
sommaLocale += numero;
}
return sommaLocale;
},
// Finalizer: combina i risultati locali in quello globale
(sommaLocale) =>
{
lock (lockObj)
{
sommaGlobale += sommaLocale;
}
}
);
sw.Stop();
Console.WriteLine($"Tempo: {sw.ElapsedMilliseconds:N0} ms");
Console.WriteLine($"Somma: {sommaGlobale:N0}\n");
return (sommaGlobale, sw.ElapsedMilliseconds);
}
/// <summary>
/// Verifica se un numero è primo
/// </summary>
private static bool IsPrime(int number)
{
if (number <= 2)
{
return number == 2;
}
for (int divisor = 2; divisor <= Math.Sqrt(number); divisor++)
{
if (number % divisor == 0)
{
return false;
}
}
return true;
}
}
}

Questo esercizio dimostra concetti chiave del parallelismo dei dati:

  1. Speedup Atteso: Con un processore multi-core, ci si aspetta uno speedup significativo. Su un sistema quad-core, uno speedup di 3-3.5x è realistico (non 4x a causa dell’overhead di sincronizzazione e Amdahl’s Law).

  2. ConcurrentBag vs Thread Local Data:

    • ConcurrentBag: Più semplice da usare, ma ogni Add() richiede sincronizzazione interna, creando contesa tra thread.
    • Thread Local Data: Ogni thread accumula in una variabile locale privata, riducendo drasticamente la contesa. Solo il “finalizer” richiede un lock, che viene chiamato una volta per thread (non per ogni numero primo).
  3. Struttura di Parallel.ForEach con Thread-Local:

    Parallel.ForEach(
    source, // Sorgente dati da processare
    () => initialValue, // Inizializzatore: valore iniziale per ogni thread
    (item, state, local) => { /* processa item, aggiorna local */ return local; },
    (local) => { /* combina local in risultato globale */ }
    );
  4. Quando Usare il Parallelismo:

    • ✅ Buono per: Operazioni CPU-intensive su dati indipendenti (come questo calcolo)
    • ❌ Less effective per: Operazioni I/O-bound, dataset molto piccoli (overhead > beneficio)

Tabella di Confronto Prestazioni Tipiche (su quad-core):

MetodoTempo RelativoSpeedupNote
Sequenziale100%1.0xBaseline
Parallel + ConcurrentBag120-140%0.7-0.9xPiù lento! Overhead sincronizzazione eccessivo
Parallel + Thread Local15-25%4.0-6.0xOttimale - minima contesa

La moltiplicazione di matrici è un’operazione computazionalmente intensiva che si presta perfettamente alla parallelizzazione, poiché ogni cella della matrice risultante può essere calcolata indipendentemente dalle altre.

Scrivere un programma che:

  1. Generi due matrici quadrate e di dimensione (es. ) con valori casuali.
  2. Implementi la moltiplicazione in modo sequenziale.
  3. Implementi la moltiplicazione in modo parallelo utilizzando Parallel.For.
  4. Misuri e confronti i tempi di esecuzione per diverse dimensioni di (es. 500, 1000, 1500).
using System;
using System.Diagnostics;
using System.Threading.Tasks;
class MatrixMultiplication
{
static void Main()
{
int N = 1000; // Dimensione matrice
Console.WriteLine($"Generazione matrici {N}x{N}...");
double[,] A = GenerateMatrix(N);
double[,] B = GenerateMatrix(N);
double[,] C = new double[N, N];
// --- Sequenziale ---
Stopwatch sw = Stopwatch.StartNew();
MultiplySequential(A, B, C, N);
sw.Stop();
long tempoSeq = sw.ElapsedMilliseconds;
Console.WriteLine($"Sequenziale: {tempoSeq} ms");
// Reset matrice risultato
Array.Clear(C, 0, C.Length);
// --- Parallelo ---
sw.Restart();
MultiplyParallel(A, B, C, N);
sw.Stop();
long tempoPar = sw.ElapsedMilliseconds;
Console.WriteLine($"Parallelo: {tempoPar} ms");
double speedup = (double)tempoSeq / tempoPar;
Console.WriteLine($"Speedup: {speedup:F2}x");
}
static void MultiplySequential(double[,] A, double[,] B, double[,] C, int N)
{
for (int i = 0; i < N; i++)
{
for (int j = 0; j < N; j++)
{
double sum = 0;
for (int k = 0; k < N; k++)
{
sum += A[i, k] * B[k, j];
}
C[i, j] = sum;
}
}
}
static void MultiplyParallel(double[,] A, double[,] B, double[,] C, int N)
{
// Parallelizziamo il ciclo esterno (righe) con chunk size ottimizzato
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
Parallel.For(0, N, options, i =>
{
for (int j = 0; j < N; j++)
{
double sum = 0;
for (int k = 0; k < N; k++)
{
sum += A[i, k] * B[k, j];
}
C[i, j] = sum;
}
});
}
static double[,] GenerateMatrix(int N)
{
var matrix = new double[N, N];
var rand = new Random();
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
matrix[i, j] = rand.NextDouble();
return matrix;
}
}

Cache Performance

Attenzione: Con matrici di dimensioni medio-piccole (N < 2000), la versione parallela potrebbe essere più lenta della sequenziale!

Questo accade perché:

  1. Cache Thrashing: L’accesso alla matrice B non è cache-friendly. Ogni thread accede a colonne diverse di B, causando continui cache miss.
  2. Overhead del Parallelismo: Il costo di creare e sincronizzare i thread supera il beneficio della parallelizzazione.
  3. Competizione per la Cache: I core competono per gli stessi dati in cache L2/L3.

Soluzioni:

  • Usare matrici più grandi (N >= 2000-3000) per vedere benefici
  • Implementare loop tiling/blocking per migliorare la località della cache
  • Usare librerie ottimizzate come BLAS per operazioni su matrici in produzione

Risultati tipici su quad-core (con ottimizzazioni):

  • N=500: Speedup 2.0-3.0x
  • N=1000: Speedup 3.5-5.0x (o 6-8x su 8+ core!)
  • N=2000+: Speedup 4.0-7.0x (scala con i core disponibili)

Esercizio 7: Elaborazione Immagini Reale (Multi-Filtro)

Sezione intitolata “Esercizio 7: Elaborazione Immagini Reale (Multi-Filtro)”

Realizzare un’applicazione console che applichi un filtro grafico a scelta a un’immagine reale caricata da disco. L’elaborazione deve avvenire in parallelo per sfruttare tutti i core disponibili.

  1. L’utente fornisce il percorso di un’immagine.
  2. L’utente sceglie il filtro da applicare tra:
    • Negativo: Inverte i colori.
    • Scala di Grigi: Converte l’immagine in bianco e nero usando la formula della luminanza.
    • Aumento Contrasto: Aumenta il contrasto dei colori.
  3. Il programma elabora l’immagine in parallelo accedendo direttamente ai byte in memoria.
  4. Salvare l’immagine elaborata su disco.

Installazione Pacchetto NuGet

Da .NET 6 in poi, System.Drawing.Common non è più incluso di default. Occorre installare il pacchetto NuGet:

Finestra del terminale
dotnet add package System.Drawing.Common

Nota sul Platform Warning: Poiché System.Drawing.Common funziona solo su Windows, occorre modificare il file .csproj per indicare che il progetto è Windows-only:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
</PropertyGroup>
</Project>

Cambiare net9.0 in net9.0-windows (o la versione di sdk con -windows alla fine) per eliminare il warning.

using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
class ImageProcessingReal
{
// Enumerativo per le opzioni del menu
enum MenuOption
{
Exit = 0,
Negative = 1,
Grayscale = 2,
Contrast = 3
}
enum FilterType { Negative, Grayscale, Contrast }
static void Main(string[] args)
{
Console.WriteLine("Inserisci il path all'immagine");
string inputPath = Console.ReadLine() ?? string.Empty;
// Normalizza il path: rimuovi spazi, virgolette e converti a path assoluto
inputPath = inputPath.Trim().Trim('"', '\'');
if (!string.IsNullOrEmpty(inputPath))
{
try
{
// Tentiamo di convertire a path assoluto (risolve relativi, normalizza separatori, ecc.)
inputPath = Path.GetFullPath(inputPath);
}
catch (ArgumentException ex)
{
// Il path contiene caratteri non validi per il filesystem
Console.WriteLine($"Avvertimento: il path contiene caratteri non validi: {ex.Message}");
Console.WriteLine("Continuerò con il path originale (dopo trim).\n");
}
catch (PathTooLongException)
{
// Il path è troppo lungo (> 260 caratteri su Windows)
Console.WriteLine("Avvertimento: il path è troppo lungo (max 260 caratteri su Windows)");
Console.WriteLine("Continuerò con il path originale (dopo trim).\n");
}
catch (Exception ex)
{
// Altre eccezioni impreviste
Console.WriteLine($"Avvertimento: errore durante la normalizzazione del path: {ex.GetType().Name}");
Console.WriteLine($"Messaggio: {ex.Message}");
Console.WriteLine("Continuerò con il path originale (dopo trim).\n");
}
}
if (!File.Exists(inputPath))
{
Console.WriteLine($"File non trovato: {inputPath}");
return;
}
using (Bitmap originalImage = new Bitmap(inputPath))
{
Console.WriteLine($"Immagine caricata: {originalImage.Width}x{originalImage.Height}\n");
// Estrai la cartella del file di input per salvare gli output
string outputDirectory = Path.GetDirectoryName(inputPath) ?? Directory.GetCurrentDirectory();
// Ciclo principale del menu
bool continua = true;
while (continua)
{
// Otteniamo la scelta dell'utente in modo robusto
bool shouldExit = GetMenuChoice(out MenuOption scelta);
if (shouldExit)
{
Console.WriteLine("\nUscita dal programma...");
continua = false;
continue;
}
// Converti MenuOption a FilterType
FilterType filter = scelta switch
{
MenuOption.Negative => FilterType.Negative,
MenuOption.Grayscale => FilterType.Grayscale,
MenuOption.Contrast => FilterType.Contrast,
_ => throw new InvalidOperationException($"Opzione menu non valida: {scelta}")
};
// Elaborazione del filtro
ProcessAndDisplayFilter(originalImage, outputDirectory, filter);
}
}
}
/// <summary>
/// Visualizza il menu delle opzioni disponibili
/// </summary>
static void DisplayMenu()
{
Console.WriteLine("=== MENU FILTRI ===");
Console.WriteLine("1. Negativo");
Console.WriteLine("2. Scala di Grigi");
Console.WriteLine("3. Aumento Contrasto");
Console.WriteLine("0. Esci");
Console.Write("\nScelta: ");
}
/// <summary>
/// Ottiene la scelta dell'utente dal menu con validazione robusta
/// </summary>
/// <param name="scelta">Opzione del menu selezionata dall'utente</param>
/// <returns>true se l'utente ha scelto di uscire (Exit), false altrimenti</returns>
static bool GetMenuChoice(out MenuOption scelta)
{
scelta = MenuOption.Exit; // Valore di default
while (true)
{
DisplayMenu();
string input = Console.ReadLine() ?? string.Empty;
input = input.Trim(); // Rimuovi spazi iniziali/finali
// Verifica se l'input è vuoto
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("Errore: inserire un'opzione valida (0-3).\n");
continue;
}
// Tentiamo di convertire l'input a intero
if (!int.TryParse(input, out int sceltaNumero))
{
Console.WriteLine("Errore: inserire un numero intero (0-3).\n");
continue;
}
// Verifichiamo se il numero è un valore valido dell'enum
if (!Enum.IsDefined(typeof(MenuOption), sceltaNumero))
{
Console.WriteLine($"Errore: {sceltaNumero} non è un'opzione valida. Scegli tra 0-3.\n");
continue;
}
scelta = (MenuOption)sceltaNumero;
// Se è Exit, ritorna true; altrimenti false
return scelta == MenuOption.Exit;
}
}
/// <summary>
/// Elabora e visualizza i risultati del filtro con confronto di prestazioni
/// </summary>
static void ProcessAndDisplayFilter(Bitmap originalImage, string outputDirectory, FilterType filter)
{
string outputPathSeq = Path.Combine(outputDirectory, $"output_{filter}_seq.jpg");
string outputPathPar = Path.Combine(outputDirectory, $"output_{filter}_par.jpg");
Console.WriteLine($"\n=== Elaborazione con filtro {filter} ===\n");
// Versione Sequenziale
Console.WriteLine("=== ELABORAZIONE SEQUENZIALE ===");
Stopwatch sw = Stopwatch.StartNew();
Bitmap processedImageSeq = ApplyFilterSequential(originalImage, filter);
sw.Stop();
long tempoSeq = sw.ElapsedMilliseconds;
Console.WriteLine($"Completato in {tempoSeq} ms");
processedImageSeq.Save(outputPathSeq, ImageFormat.Jpeg);
Console.WriteLine($"Salvato in: {outputPathSeq}\n");
processedImageSeq.Dispose();
// Versione Parallela
Console.WriteLine("=== ELABORAZIONE PARALLELA ===");
sw.Restart();
Bitmap processedImagePar = ApplyFilterParallel(originalImage, filter);
sw.Stop();
long tempoPar = sw.ElapsedMilliseconds;
Console.WriteLine($"Completato in {tempoPar} ms");
processedImagePar.Save(outputPathPar, ImageFormat.Jpeg);
Console.WriteLine($"Salvato in: {outputPathPar}\n");
processedImagePar.Dispose();
// Calcolo Speedup
double speedup = tempoPar > 0 ? (double)tempoSeq / tempoPar : 0;
Console.WriteLine("=== CONFRONTO PRESTAZIONI ===");
Console.WriteLine($"Tempo Sequenziale: {tempoSeq} ms");
Console.WriteLine($"Tempo Parallelo: {tempoPar} ms");
Console.WriteLine($"Speedup: {speedup:F2}x\n");
Console.WriteLine("Premi un tasto per continuare...");
Console.ReadKey();
Console.Clear();
}
static Bitmap ApplyFilterSequential(Bitmap source, FilterType filterType)
{
// Otteniamo le dimensioni dell'immagine
int width = source.Width;
int height = source.Height;
// Creiamo una copia dell'immagine originale per non modificarla direttamente
Bitmap result = new Bitmap(source);
// Rectangle: definisce l'area dell'immagine da elaborare (tutta l'immagine in questo caso)
Rectangle rect = new Rectangle(0, 0, width, height);
// BitmapData: permette l'accesso diretto ai dati grezzi dei pixel in memoria
// LockBits blocca l'immagine in memoria per un accesso veloce e sicuro
// ImageLockMode.ReadWrite: possiamo leggere e modificare i pixel
BitmapData bmpData = result.LockBits(rect, ImageLockMode.ReadWrite, result.PixelFormat);
// Calcoliamo quanti byte occupa ogni pixel (solitamente 3 per RGB o 4 per RGBA)
int bytesPerPixel = Image.GetPixelFormatSize(result.PixelFormat) / 8;
// Stride: numero di byte per ogni riga (include eventuali byte di padding per allineamento)
// byteCount: totale byte dell'immagine
int byteCount = Math.Abs(bmpData.Stride) * height;
// Array che conterrà tutti i pixel dell'immagine in formato grezzo (byte)
byte[] pixels = new byte[byteCount];
// Marshal.Copy: copia i dati grezzi dalla memoria (Scan0) all'array pixels
// Scan0: puntatore al primo byte dell'immagine in memoria
Marshal.Copy(bmpData.Scan0, pixels, 0, byteCount);
// ===== ELABORAZIONE SEQUENZIALE =====
// Rappresentazione della struttura immagine in memoria (esempio: width=4, height=3):
//
// Immagine: [width=4 pixel]
// ↓
// Riga 0 (y=0): [P0_B][P0_G][P0_R] [P1_B][P1_G][P1_R] [P2_B][P2_G][P2_R] [P3_B][P3_G][P3_R]
// Riga 1 (y=1): [P4_B][P4_G][P4_R] [P5_B][P5_G][P5_R] [P6_B][P6_G][P6_R] [P7_B][P7_G][P7_R]
// Riga 2 (y=2): [P8_B][P8_G][P8_R] [P9_B][P9_G][P9_R] [P10_B][P10_G][P10_R] [P11_B][P11_G][P11_R]
// ↑
// [height=3 righe]
//
// Legenda: P#_B = Byte del componente blu del pixel #
// P#_G = Byte del componente verde del pixel #
// P#_R = Byte del componente rosso del pixel #
// width = 4 pixel per riga
// height = 3 righe totali
// Totale pixel = width * height = 12 pixel
//
// La sequenza avviene riga per riga (raster scan), da sinistra a destra, dall'alto al basso.
// Ogni riga inizia all'indice: rowStart = y * bmpData.Stride
// Ogni pixel inizia all'indice: i = rowStart + x * bytesPerPixel
//
// Processiamo ogni pixel uno alla volta, riga per riga (ciclo esterno)
// e pixel per pixel all'interno di ogni riga (ciclo interno)
for (int y = 0; y < height; y++)
{
// Calcoliamo l'indice di inizio della riga corrente nell'array pixels
int rowStart = y * bmpData.Stride;
// Iteriamo su ogni pixel della riga
for (int x = 0; x < width; x++)
{
// Calcoliamo l'indice del pixel corrente nell'array
// Ogni pixel occupa bytesPerPixel byte consecutivi
int i = rowStart + x * bytesPerPixel;
// Leggiamo i tre componenti di colore del pixel
// IMPORTANTE: l'ordine in memoria è BGR (Blue, Green, Red), non RGB
byte b = pixels[i]; // Blue (componente blu)
byte g = pixels[i + 1]; // Green (componente verde)
byte r = pixels[i + 2]; // Red (componente rosso)
switch (filterType)
{
case FilterType.Negative:
pixels[i] = (byte)(255 - b);
pixels[i + 1] = (byte)(255 - g);
pixels[i + 2] = (byte)(255 - r);
break;
case FilterType.Grayscale:
byte gray = (byte)(0.299 * r + 0.587 * g + 0.114 * b);
pixels[i] = gray;
pixels[i + 1] = gray;
pixels[i + 2] = gray;
break;
case FilterType.Contrast:
double threshold = 1.5;
pixels[i] = AdjustContrast(b, threshold);
pixels[i + 1] = AdjustContrast(g, threshold);
pixels[i + 2] = AdjustContrast(r, threshold);
break;
}
}
}
Marshal.Copy(pixels, 0, bmpData.Scan0, byteCount);
result.UnlockBits(bmpData);
return result;
}
static Bitmap ApplyFilterParallel(Bitmap source, FilterType filterType)
{
// Otteniamo le dimensioni dell'immagine
int width = source.Width;
int height = source.Height;
// Creiamo una copia dell'immagine originale
Bitmap result = new Bitmap(source);
// Rectangle: definisce l'area dell'immagine da elaborare
Rectangle rect = new Rectangle(0, 0, width, height);
// BitmapData: permette l'accesso diretto ai dati grezzi dei pixel in memoria
// LockBits blocca l'immagine per un accesso veloce e thread-safe
BitmapData bmpData = result.LockBits(rect, ImageLockMode.ReadWrite, result.PixelFormat);
// Calcoliamo quanti byte occupa ogni pixel
int bytesPerPixel = Image.GetPixelFormatSize(result.PixelFormat) / 8;
// Stride: byte per riga (include padding), byteCount: totale byte immagine
int byteCount = Math.Abs(bmpData.Stride) * height;
// Array condiviso che contiene tutti i pixel dell'immagine
byte[] pixels = new byte[byteCount];
// Copiamo i dati dalla memoria all'array
Marshal.Copy(bmpData.Scan0, pixels, 0, byteCount);
// ===== ELABORAZIONE PARALLELA =====
// Rappresentazione della struttura immagine in memoria (esempio: width=4, height=3):
//
// Immagine: [width=4 pixel]
// ↓
// Riga 0 (y=0): [P0_B][P0_G][P0_R] [P1_B][P1_G][P1_R] [P2_B][P2_G][P2_R] [P3_B][P3_G][P3_R]
// Riga 1 (y=1): [P4_B][P4_G][P4_R] [P5_B][P5_G][P5_R] [P6_B][P6_G][P6_R] [P7_B][P7_G][P7_R]
// Riga 2 (y=2): [P8_B][P8_G][P8_R] [P9_B][P9_G][P9_R] [P10_B][P10_G][P10_R] [P11_B][P11_G][P11_R]
// ↑
// [height=3 righe]
//
// Con parallelizzazione:
// Thread 0 elabora: Riga 0
// Thread 1 elabora: Riga 1
// Thread 2 elabora: Riga 2
// (La divisione dipende dal numero di core disponibili)
//
// Totale pixel da processare = width * height = 4 * 3 = 12 pixel
//
// Parallel.For: divide automaticamente il lavoro tra più thread
// STRATEGIA DI PARALLELIZZAZIONE:
// - L'immagine viene divisa per RIGHE (non per pixel singoli)
// - Ogni thread elabora un gruppo di righe in modo indipendente
// - Questo evita race condition perché ogni riga è indipendente dalle altre
// - Il Task Parallel Library (TPL) gestisce automaticamente:
// * La suddivisione del lavoro tra i core CPU disponibili
// * Il bilanciamento del carico dinamico
// * La sincronizzazione finale
Parallel.For(0, height, y =>
{
// Ogni iterazione di questo ciclo può essere eseguita da un thread diverso
// La variabile 'y' rappresenta l'indice della riga assegnata a questo thread
// Calcoliamo l'inizio della riga nell'array pixels
int rowStart = y * bmpData.Stride;
// Ogni thread processa tutti i pixel della sua riga assegnata
for (int x = 0; x < width; x++)
{
// Calcoliamo l'indice del pixel nell'array
int i = rowStart + x * bytesPerPixel;
// Leggiamo i componenti di colore (ordine BGR in memoria)
byte b = pixels[i]; // Blue
byte g = pixels[i + 1]; // Green
byte r = pixels[i + 2]; // Red
// Applichiamo il filtro scelto
switch (filterType)
{
case FilterType.Negative:
pixels[i] = (byte)(255 - b);
pixels[i + 1] = (byte)(255 - g);
pixels[i + 2] = (byte)(255 - r);
break;
case FilterType.Grayscale:
// Formula luminanza: 0.299R + 0.587G + 0.114B
byte gray = (byte)(0.299 * r + 0.587 * g + 0.114 * b);
pixels[i] = gray; // Blue
pixels[i + 1] = gray; // Green
pixels[i + 2] = gray; // Red
break;
case FilterType.Contrast:
// Fattore contrasto (es. 1.5 aumenta il contrasto)
double threshold = 1.5;
pixels[i] = AdjustContrast(b, threshold);
pixels[i + 1] = AdjustContrast(g, threshold);
pixels[i + 2] = AdjustContrast(r, threshold);
break;
}
}
});
Marshal.Copy(pixels, 0, bmpData.Scan0, byteCount);
result.UnlockBits(bmpData);
return result;
}
/// <summary>
/// Regola il contrasto di una singola componente di colore (R, G o B)
/// </summary>
/// <param name="colorComponent">Valore originale del componente (0-255)</param>
/// <param name="factor">Fattore di contrasto (>1 aumenta, <1 diminuisce, 1 = nessun cambiamento)</param>
/// <returns>Valore del componente con contrasto regolato (0-255)</returns>
static byte AdjustContrast(byte colorComponent, double factor)
{
// ALGORITMO DI REGOLAZIONE DEL CONTRASTO:
// 1. Normalizzazione: convertiamo il valore da range [0, 255] a [0.0, 1.0]
// Questo facilita i calcoli matematici
double pixel = colorComponent / 255.0;
// 2. Centratura: spostiamo il valore in modo che 0.5 diventi il punto centrale (grigio medio)
// Ora il range è [-0.5, 0.5] con 0 come punto neutro
pixel -= 0.5;
// 3. Applicazione del contrasto: moltiplichiamo per il fattore
// - Se factor > 1: i valori si allontanano dal centro (aumenta contrasto)
// - Se factor < 1: i valori si avvicinano al centro (diminuisce contrasto)
// - Se factor = 1: nessun cambiamento
// Esempio con factor=1.5: un valore 0.2 diventa 0.3, un valore -0.2 diventa -0.3
pixel *= factor;
// 4. Decentratura: riportiamo il punto centrale a 0.5
// Torniamo al range [0.0, 1.0] (circa)
pixel += 0.5;
// 5. Denormalizzazione: convertiamo da [0.0, 1.0] a [0, 255]
pixel *= 255;
// 6. Clamp: assicuriamo che il valore finale sia nel range valido [0, 255]
// Questo è necessario perché con factor > 1 potremmo superare i limiti
// Math.Clamp limita il valore tra 0 e 255
return (byte)Math.Clamp(pixel, 0, 255);
}
}

Esercizio 8: Processo Produttivo con Pressa e Verniciatrice

Sezione intitolata “Esercizio 8: Processo Produttivo con Pressa e Verniciatrice”

Si vuole simulare un processo produttivo di pezzi meccanici per automobili con due macchine collegate tra loro da un nastro trasportatore:

  • La prima macchina è una pressa, che produce pezzi di lamiera e li mette su un nastro trasportatore
  • La seconda macchina è una verniciatrice che preleva i pezzi dal nastro, li vernicia e li passa al processo di montaggio
  • Il nastro trasportatore può essere simulato con una coda FIFO con un numero massimo di pezzi trasportati pari a 10

Scrivere un programma .NET Core Console che simuli il processo produttivo costituito da pressa, verniciatrice e nastro trasportatore, mediante Task.

Specifiche:

  • La pressa impiega 0,5 secondi per pressare la lamiera
  • La verniciatrice impiega 0,7 secondi per verniciare un pezzo
  • Simulare la produzione di 25 pezzi
  • Quando una macchina lavora il pezzo, stampa a console:
    • Pressa: "Pressa: prodotto il pezzo i-mo"
    • Verniciatrice: "Verniciatrice: verniciato il pezzo i-mo"

Questo è un classico problema Producer-Consumer con buffer limitato (nastro trasportatore). Richiede:

La soluzione utilizza due semafori complementari e una coda FIFO per implementare il buffer del nastro trasportatore:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ProcessoProduttivo
{
internal class Program
{
const int CapacitaNastro = 10; // Capacità massima del nastro trasportatore
const int NumeroPezzi = 25; // Numero totale di pezzi da produrre
const int TempoPressa = 500; // Tempo pressa in millisecondi
const int TempoVerniciatrice = 700; // Tempo verniciatrice in millisecondi
// Semafori per gestire il nastro trasportatore
static readonly SemaphoreSlim postiLiberi = new(CapacitaNastro, CapacitaNastro); // Posti disponibili
static readonly SemaphoreSlim pezziPronti = new(0, CapacitaNastro); // Pezzi sul nastro
// Coda FIFO per il nastro trasportatore
static readonly Queue<int> nastroTrasportatore = new();
static readonly object lockNastro = new(); // Lock per proteggere l'accesso alla coda
static void Main(string[] args)
{
Console.WriteLine("=== AVVIO PROCESSO PRODUTTIVO ===\n");
// Creo i task per pressa e verniciatrice
Task pressa = Task.Factory.StartNew(Pressa);
Task verniciatrice = Task.Factory.StartNew(Verniciatrice);
// Attendo il completamento di entrambi i task
Task.WaitAll(pressa, verniciatrice);
Console.WriteLine("\n=== PROCESSO PRODUTTIVO COMPLETATO ===");
Console.WriteLine($"Totale pezzi prodotti e verniciati: {NumeroPezzi}");
}
/// <summary>
/// Task che simula la pressa (produttore)
/// </summary>
static void Pressa()
{
for (int i = 1; i <= NumeroPezzi; i++)
{
// Attendo che ci sia spazio disponibile sul nastro
postiLiberi.Wait();
// Simulo il tempo di pressatura
Task.Delay(TempoPressa).Wait();
// Metto il pezzo sul nastro trasportatore (sezione critica)
lock (lockNastro)
{
nastroTrasportatore.Enqueue(i);
Console.WriteLine($"Pressa: prodotto il pezzo {i}-mo (sul nastro: {nastroTrasportatore.Count})");
}
// Segnalo che c'è un nuovo pezzo pronto sul nastro
pezziPronti.Release();
}
Console.WriteLine("\n[Pressa] Produzione completata");
}
/// <summary>
/// Task che simula la verniciatrice (consumatore)
/// </summary>
static void Verniciatrice()
{
for (int i = 1; i <= NumeroPezzi; i++)
{
// Attendo che ci sia un pezzo disponibile sul nastro
pezziPronti.Wait();
// Prelevo il pezzo dal nastro trasportatore (sezione critica)
int numeroPezzo;
lock (lockNastro)
{
numeroPezzo = nastroTrasportatore.Dequeue();
}
// Simulo il tempo di verniciatura
Task.Delay(TempoVerniciatrice).Wait();
Console.WriteLine($"Verniciatrice: verniciato il pezzo {numeroPezzo}-mo");
// Segnalo che si è liberato un posto sul nastro
postiLiberi.Release();
}
Console.WriteLine("\n[Verniciatrice] Verniciatura completata");
}
}
}

ConcurrentQueue è la versione thread-safe di Queue ma non ha funzionalità di blocco automatico, quindi bisogna implementare la logica di sincronizzazione manuale per gestire:

  • Il blocco quando la coda è piena (produttore)
  • Il blocco quando la coda è vuota (consumatore)
  • La segnalazione di completamento

In questo esercizio si hanno le seguenti caratteristiche:

  • 1 solo produttore (Pressa)
  • 1 solo consumatore (Verniciatrice)
  • Semafori che già sincronizzano l’accesso

Una Queue<int> normale funziona correttamente perché:

  1. Non c’è accesso concorrente alla coda stessa:

    • Solo la Pressa fa Enqueue() (dopo aver acquisito semaforoCapacita)
    • Solo la Verniciatrice fa Dequeue() (dopo aver acquisito semaforoDisponibilita)
    • Non c’è mai sovrapposizione tra le due operazioni
  2. I semafori garantiscono la mutua esclusione:

    • semaforoCapacita impedisce alla Pressa di aggiungere quando è piena
    • semaforoDisponibilita impedisce alla Verniciatrice di prelevare quando è vuota

Quando serve davvero la ConcurrentQueue:

  • Più produttori che fanno Enqueue() contemporaneamente
  • Più consumatori che fanno Dequeue() contemporaneamente
  • Accesso concorrente senza semafori/lock
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace ProcessoProduttivoBlockingCollection
{
internal class Program
{
const int CapacitaNastro = 10;
const int NumeroPezzi = 25;
const int TempoPressa = 500;
const int TempoVerniciatrice = 700;
// ConcurrentQueue per il nastro trasportatore
static readonly ConcurrentQueue<int> nastroTrasportatore = new();
// Semaforo per limitare la capacità massima della coda
static readonly SemaphoreSlim semaforoCapacita = new(CapacitaNastro, CapacitaNastro);
// Semaforo per segnalare la disponibilità di elementi
static readonly SemaphoreSlim semaforoDisponibilita = new(0);
// Flag per segnalare il completamento della produzione
static volatile bool produzioneCompletata = false;
static void Main(string[] args)
{
Console.WriteLine("=== AVVIO PROCESSO PRODUTTIVO (BlockingCollection) ===\n");
// Creo i task per pressa e verniciatrice
Task pressa = Task.Factory.StartNew(Pressa);
Task verniciatrice = Task.Factory.StartNew(Verniciatrice);
// Attendo il completamento di entrambi i task
Task.WaitAll(pressa, verniciatrice);
Console.WriteLine("\n=== PROCESSO PRODUTTIVO COMPLETATO ===");
Console.WriteLine($"Totale pezzi prodotti e verniciati: {NumeroPezzi}");
// Verifica che la produzione sia stata completata
if (produzioneCompletata && nastroTrasportatore.IsEmpty)
{
Console.WriteLine("✓ Verifica: Produzione completata correttamente e nastro vuoto");
}
else
{
Console.WriteLine("✗ Attenzione: Possibile anomalia nel processo produttivo");
Console.WriteLine($" - Produzione completata: {produzioneCompletata}");
Console.WriteLine($" - Nastro vuoto: {nastroTrasportatore.IsEmpty}");
Console.WriteLine($" - Elementi rimasti sul nastro: {nastroTrasportatore.Count}");
}
}
/// <summary>
/// Task che simula la pressa (produttore)
/// </summary>
static void Pressa()
{
for (int i = 1; i <= NumeroPezzi; i++)
{
// Simulo il tempo di pressatura
Task.Delay(TempoPressa).Wait();
// Attendo che ci sia spazio disponibile nel nastro
semaforoCapacita.Wait();
// Aggiungo il pezzo alla coda
nastroTrasportatore.Enqueue(i);
// Segnalo che c'è un nuovo elemento disponibile
semaforoDisponibilita.Release();
Console.WriteLine($"Pressa: prodotto il pezzo {i}-mo (sul nastro: {nastroTrasportatore.Count})");
}
// Segnalo che la produzione è completata
produzioneCompletata = true;
Console.WriteLine("\n[Pressa] Produzione completata");
}
/// <summary>
/// Task che simula la verniciatrice (consumatore)
/// </summary>
static void Verniciatrice()
{
int pezziVerniciati = 0;
while (pezziVerniciati < NumeroPezzi)
{
// Attendo che ci sia un elemento disponibile
semaforoDisponibilita.Wait();
// Provo a prelevare un pezzo dalla coda
if (nastroTrasportatore.TryDequeue(out int numeroPezzo))
{
// Segnalo che c'è spazio disponibile nel nastro
semaforoCapacita.Release();
// Simulo il tempo di verniciatura
Task.Delay(TempoVerniciatrice).Wait();
Console.WriteLine($"Verniciatrice: verniciato il pezzo {numeroPezzo}-mo");
pezziVerniciati++;
}
}
Console.WriteLine("\n[Verniciatrice] Verniciatura completata");
}
}
}

Versione 3: uso di BlockingCollection (Thread-Safe)

Sezione intitolata “Versione 3: uso di BlockingCollection (Thread-Safe)”

.NET fornisce BlockingCollection<T>, una collezione thread-safe specifica per scenari Producer-Consumer che semplifica enormemente il codice eliminando la necessità di gestire manualmente semafori e lock.

BlockingCollection<T> non è una coda, ma è un wrapper thread-safe che può contenere diverse strutture dati sottostanti.

Caratteristiche di BlockingCollection:

È un contenitore generico che per default usa internamente una ConcurrentQueue<T>, ma può anche usare:

  • ConcurrentStack<T> (LIFO)

  • ConcurrentBag<T> (non ordinato)

  • Qualsiasi classe che implementa IProducerConsumerCollection<T>

  • Fornisce funzionalità di blocco:

    • Add()/TryAdd() - blocca se la capacità massima è raggiunta
    • Take()/TryTake() - blocca se la collezione è vuota
    • CompleteAdding() - segnala che non verranno aggiunti altri elementi

Esempio di utilizzo:

// Default: usa ConcurrentQueue internamente
var bc = new BlockingCollection<int>(boundedCapacity10);
// Oppure specifica la struttura dati
var bcStack = new BlockingCollection<int>(new ConcurrentStack<int>(), 10);

Esercizio svolto con BlockingCollection:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace ProcessoProduttivoBlockingCollection
{
internal class Program
{
const int CapacitaNastro = 10;
const int NumeroPezzi = 25;
const int TempoPressa = 500;
const int TempoVerniciatrice = 700;
// BlockingCollection gestisce automaticamente la sincronizzazione
// Il parametro boundedCapacity limita la capacità massima
static readonly BlockingCollection<int> nastroTrasportatore = new(boundedCapacity: CapacitaNastro);
static void Main(string[] args)
{
Console.WriteLine("=== AVVIO PROCESSO PRODUTTIVO (BlockingCollection) ===\n");
// Creo i task per pressa e verniciatrice
Task pressa = Task.Factory.StartNew(Pressa);
Task verniciatrice = Task.Factory.StartNew(Verniciatrice);
// Attendo il completamento di entrambi i task
Task.WaitAll(pressa, verniciatrice);
Console.WriteLine("\n=== PROCESSO PRODUTTIVO COMPLETATO ===");
Console.WriteLine($"Totale pezzi prodotti e verniciati: {NumeroPezzi}");
}
/// <summary>
/// Task che simula la pressa (produttore)
/// </summary>
static void Pressa()
{
for (int i = 1; i <= NumeroPezzi; i++)
{
// Simulo il tempo di pressatura
Task.Delay(TempoPressa).Wait();
// Add() blocca automaticamente se la collezione è piena
nastroTrasportatore.Add(i);
Console.WriteLine($"Pressa: prodotto il pezzo {i}-mo (sul nastro: {nastroTrasportatore.Count})");
}
// Segnalo che non verranno aggiunti altri elementi
nastroTrasportatore.CompleteAdding();
Console.WriteLine("\n[Pressa] Produzione completata");
}
/// <summary>
/// Task che simula la verniciatrice (consumatore)
/// </summary>
static void Verniciatrice()
{
// GetConsumingEnumerable() blocca finché ci sono elementi o finché CompleteAdding() viene chiamato
foreach (int numeroPezzo in nastroTrasportatore.GetConsumingEnumerable())
{
// Simulo il tempo di verniciatura
Task.Delay(TempoVerniciatrice).Wait();
Console.WriteLine($"Verniciatrice: verniciato il pezzo {numeroPezzo}-mo");
}
Console.WriteLine("\n[Verniciatrice] Verniciatura completata");
}
}
}

Questo esercizio presenta due approcci per implementare il pattern Producer-Consumer con buffer limitato:

Questo esercizio dimostra un’implementazione completa del pattern Producer-Consumer con buffer limitato:

Confronto Pressa (Produttore):

// Versione 1: Manuale
postiLiberi.Wait(); // Attendo spazio
lock(lockNastro) {
nastroTrasportatore.Enqueue(i);
}
pezziPronti.Release(); // Segnalo disponibilità
// Versione 2: BlockingCollection
nastroTrasportatore.Add(i); // Tutto in una chiamata!

Differenze rispetto all’Esercizio Fast Food:

  • Con Queue/BlockingCollection: Mantiene l’ordine FIFO dei pezzi (importante per tracciabilità)
  • Senza Queue (Fast Food): Non serviva tracciare quale hamburger specifico veniva consumato

Tempo di esecuzione:

  • Pressa più veloce (0,5s) della verniciatrice (0,7s)
  • Il nastro inizialmente si riempie perché la pressa produce più velocemente
  • Verso la fine, il nastro si svuota aspettando che la verniciatrice finisca