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.
Esercizio 1: Palleggio tra Quattro Ragazzi
Sezione intitolata “Esercizio 1: Palleggio tra Quattro Ragazzi”1.1 Traccia
Sezione intitolata “1.1 Traccia”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.
1.2 Riferimenti
Sezione intitolata “1.2 Riferimenti”Questo esercizio introduce l’uso di Task per attività concorrenti coordinate, utilizzando semafori per il passaggio del controllo tra task.
- Task Parallel Library (TPL)
- Task.Factory.StartNew (Microsoft Docs)
- SemaphoreSlim (Microsoft Docs)
- Cancellazione Task (Microsoft Docs)
1.3 Svolgimento
Sezione intitolata “1.3 Svolgimento”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.
Versione 1: Gestione con Flag Booleano
Sezione intitolata “Versione 1: Gestione con Flag Booleano”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; } } } } }}Versione 2: Gestione con CancellationToken
Sezione intitolata “Versione 2: Gestione con CancellationToken”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(); } } } } }}1.4 Riflessioni
Sezione intitolata “1.4 Riflessioni”Questo esercizio introduce concetti fondamentali della programmazione con Task:
-
Task.Factory.StartNew: Permette di creare e avviare un task passando un delegato e opzionalmente uno stato (in questo caso
GiocatoreData). È più flessibile diTask.Runquando serve passare parametri o configurare opzioni avanzate. -
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.
-
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.
-
Exception Handling con AggregateException: Quando i task vengono cancellati lanciano
TaskCanceledException. PoichéTask.WaitAllpuò ricevere eccezioni da più task, queste vengono incapsulate in unAggregateException. Si usaFlatten()per semplificare la gestione di eccezioni annidate.
Esercizio 2: Autolavaggio Concorrente (Singolo Tunnel)
Sezione intitolata “Esercizio 2: Autolavaggio Concorrente (Singolo Tunnel)”2.1 Traccia
Sezione intitolata “2.1 Traccia”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.
2.2 Riferimenti
Sezione intitolata “2.2 Riferimenti”Questo esercizio introduce il pattern Sleeping Barber (Barbiere che dorme) con semafori e la gestione di stato condiviso.
2.3 Svolgimento
Sezione intitolata “2.3 Svolgimento”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; } } }}2.4 Riflessioni
Sezione intitolata “2.4 Riflessioni”Questo esercizio introduce il pattern Sleeping Barber (Barbiere che dorme) mediante semafori:
-
Due Semafori Complementari:
tunnelReady: Rappresenta il Barbiere (tunnel disponibile). Inizializzato a 1.carInParkingLotReady: Rappresenta i Clienti (auto in attesa). Inizializzato a 0.
-
Flusso di Controllo:
- Le auto (Clienti) entrano nel parcheggio e fanno
Release()sucarInParkingLotReady - Il tunnel (Barbiere) fa
Wait()sucarInParkingLotReady, processa l’auto, poi faRelease()sutunnelReady - Le auto fanno
Wait()sutunnelReadyper entrare nel tunnel
- Le auto (Clienti) entrano nel parcheggio e fanno
-
Protezione con Lock: Tre lock separati proteggono:
_lockFreePlaces: posti liberi nel parcheggio_lockCarWashOpen: stato aperto/chiuso_lockCurrentCarInTunnelIndex: indice dell’auto corrente
-
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)”3.1 Traccia
Sezione intitolata “3.1 Traccia”Modificare l’esercizio precedente in modo che ci siano due o più tunnel nello stesso autolavaggio, permettendo di lavare più auto contemporaneamente.
3.2 Svolgimento
Sezione intitolata “3.2 Svolgimento”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; } } }}3.3 Riflessioni
Sezione intitolata “3.3 Riflessioni”Il passaggio da un tunnel a più tunnel dimostra la scalabilità del pattern Sleeping Barber:
-
Semaforo con Contatore > 1:
tunnelReady(Barbiere) ora è inizializzato conNumberOfTunnelsinvece di 1. Questo permette a più auto (Clienti) di entrare simultaneamente se ci sono tunnel (Barbieri) disponibili. -
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”.
-
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 dii. -
Throughput Migliorato: Con 3 tunnel invece di 1, il throughput teorico triplica, riducendo drasticamente i tempi di attesa delle auto.
Esercizio 4: Negozio Concorrente (Task Version)
Sezione intitolata “Esercizio 4: Negozio Concorrente (Task Version)”4.1 Traccia
Sezione intitolata “4.1 Traccia”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.
4.2 Riferimenti
Sezione intitolata “4.2 Riferimenti”4.3 Svolgimento
Sezione intitolata “4.3 Svolgimento”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); } }}4.4 Riflessioni
Sezione intitolata “4.4 Riflessioni”La transizione da Thread a Task evidenzia i vantaggi dell’astrazione offerta da TPL:
-
Sintassi Più Pulita:
// Thread approachThread t = new Thread(Method);t.Start();t.Join();// Task approachTask t = Task.Factory.StartNew(Method);t.Wait(); -
Task.WaitAll vs Multiple Join:
Task.WaitAll(t1, t2, t3)è più conciso di chiamareJoin()su tre thread separati. -
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.
-
Pronto per Async/Await: Questa versione con Task è facilmente convertibile a
async/awaitse necessario, mentre la versione Thread richiederebbe una riscrittura completa.
Esercizio 5: Somma dei Numeri Primi
Sezione intitolata “Esercizio 5: Somma dei Numeri Primi”5.1 Traccia
Sezione intitolata “5.1 Traccia”Calcolare la somma dei numeri primi compresi tra 1 e 100.000 utilizzando:
- Il calcolo sequenziale
- Il calcolo parallelo con
Parallel.ForEacheConcurrentBag - Il calcolo parallelo con
Parallel.ForEache thread-local data
Calcolare il fattore di speedup nei due casi di calcolo parallelo (con e senza thread local data) rispetto al calcolo sequenziale.
5.2 Riferimenti
Sezione intitolata “5.2 Riferimenti”5.3 Svolgimento
Sezione intitolata “5.3 Svolgimento”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; } }}5.4 Riflessioni
Sezione intitolata “5.4 Riflessioni”Questo esercizio dimostra concetti chiave del parallelismo dei dati:
-
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).
-
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).
- ConcurrentBag: Più semplice da usare, ma ogni
-
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 */ }); -
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):
| Metodo | Tempo Relativo | Speedup | Note |
|---|---|---|---|
| Sequenziale | 100% | 1.0x | Baseline |
| Parallel + ConcurrentBag | 120-140% | 0.7-0.9x | Più lento! Overhead sincronizzazione eccessivo |
| Parallel + Thread Local | 15-25% | 4.0-6.0x | Ottimale - minima contesa |
Esercizio 6: Moltiplicazione di Matrici
Sezione intitolata “Esercizio 6: Moltiplicazione di Matrici”6.1 Traccia
Sezione intitolata “6.1 Traccia”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:
- Generi due matrici quadrate e di dimensione (es. ) con valori casuali.
- Implementi la moltiplicazione in modo sequenziale.
- Implementi la moltiplicazione in modo parallelo utilizzando
Parallel.For. - Misuri e confronti i tempi di esecuzione per diverse dimensioni di (es. 500, 1000, 1500).
6.2 Riferimenti
Sezione intitolata “6.2 Riferimenti”6.3 Svolgimento
Sezione intitolata “6.3 Svolgimento”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; }}6.4 Riflessioni
Sezione intitolata “6.4 Riflessioni”Cache Performance
Attenzione: Con matrici di dimensioni medio-piccole (N < 2000), la versione parallela potrebbe essere più lenta della sequenziale!
Questo accade perché:
- Cache Thrashing: L’accesso alla matrice B non è cache-friendly. Ogni thread accede a colonne diverse di B, causando continui cache miss.
- Overhead del Parallelismo: Il costo di creare e sincronizzare i thread supera il beneficio della parallelizzazione.
- 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)”7.1 Traccia
Sezione intitolata “7.1 Traccia”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.
- L’utente fornisce il percorso di un’immagine.
- 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.
- Il programma elabora l’immagine in parallelo accedendo direttamente ai byte in memoria.
- Salvare l’immagine elaborata su disco.
7.2 Riferimenti
Sezione intitolata “7.2 Riferimenti”7.3 Prerequisiti
Sezione intitolata “7.3 Prerequisiti”Installazione Pacchetto NuGet
Da .NET 6 in poi, System.Drawing.Common non è più incluso di default. Occorre installare il pacchetto NuGet:
dotnet add package System.Drawing.CommonNota 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.
7.4 Svolgimento
Sezione intitolata “7.4 Svolgimento”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); }}7.5 Riflessioni
Sezione intitolata “7.5 Riflessioni”Esercizio 8: Processo Produttivo con Pressa e Verniciatrice
Sezione intitolata “Esercizio 8: Processo Produttivo con Pressa e Verniciatrice”8.1 Traccia
Sezione intitolata “8.1 Traccia”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"
- Pressa:
8.2 Riferimenti
Sezione intitolata “8.2 Riferimenti”Questo è un classico problema Producer-Consumer con buffer limitato (nastro trasportatore). Richiede:
-
Un buffer FIFO (coda) per il nastro trasportatore
-
Sincronizzazione tra produttore (pressa) e consumatore (verniciatrice)
-
Gestione della capacità massima del nastro
8.3 Svolgimento
Sezione intitolata “8.3 Svolgimento”Versione 1: due semafori e coda FIFO
Sezione intitolata “Versione 1: due semafori e coda FIFO”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"); } }}Versione 2: uso di ConcurrentQueue
Sezione intitolata “Versione 2: uso di ConcurrentQueue”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é:
-
Non c’è accesso concorrente alla coda stessa:
- Solo la Pressa fa
Enqueue()(dopo aver acquisitosemaforoCapacita) - Solo la Verniciatrice fa
Dequeue()(dopo aver acquisitosemaforoDisponibilita) - Non c’è mai sovrapposizione tra le due operazioni
- Solo la Pressa fa
-
I semafori garantiscono la mutua esclusione:
semaforoCapacitaimpedisce alla Pressa di aggiungere quando è pienasemaforoDisponibilitaimpedisce 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 è raggiuntaTake()/TryTake()- blocca se la collezione è vuotaCompleteAdding()- segnala che non verranno aggiunti altri elementi
Esempio di utilizzo:
// Default: usa ConcurrentQueue internamentevar bc = new BlockingCollection<int>(boundedCapacity: 10);// Oppure specifica la struttura dativar 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"); } }}8.4 Riflessioni
Sezione intitolata “8.4 Riflessioni”Questo esercizio presenta due approcci per implementare il pattern Producer-Consumer con buffer limitato:
Versione 1: Queue<T> + Semafori Manuali
Sezione intitolata “Versione 1: Queue<T> + Semafori Manuali”Questo esercizio dimostra un’implementazione completa del pattern Producer-Consumer con buffer limitato:
Versione 2: BlockingCollection<T>
Sezione intitolata “Versione 2: BlockingCollection<T>”Confronto Pressa (Produttore):
// Versione 1: ManualepostiLiberi.Wait(); // Attendo spaziolock(lockNastro) { nastroTrasportatore.Enqueue(i);}pezziPronti.Release(); // Segnalo disponibilità
// Versione 2: BlockingCollectionnastroTrasportatore.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