La lliçó de Programació Asíncrona (Mòdul 4) va presentar async/await per esperar operacions
d'entrada/sortida —una consulta HTTP, una lectura de fitxer— sense bloquejar el fil mentre
s'espera. Això resol un problema molt concret (esperar), però no és el mateix que
paral·lelisme real: diversos fils de CPU executant feina genuïnament alhora, en nuclis
diferents del processador. Aquesta lliçó, l'última del Mòdul 6, distingeix totes dues idees,
presenta Thread, Task.Run i la llibreria Parallel per a treball intensiu en CPU, i exposa
el problema central de compartir dades entre diversos fils: les condicions de cursa, resoltes
amb lock. Tanca així els Temes Avançats abans que el Mòdul 7 connecti, per fi, tota la lògica
de BiblioTech a una interfície real.
Contingut
- Asincronia davant paral·lelisme: dos problemes diferents
Thread: el fil bàsic de .NETTask.Run: paral·lelisme amb l'API de tasquesParallel.ForiParallel.ForEach: paral·lelitzar un bucle- Condicions de cursa: el problema de compartir estat entre fils
lock: protegir una secció crítica- Col·leccions concurrents: menció a
ConcurrentDictionary - Exemple: validar en paral·lel el catàleg de BiblioTech
- Tancament del Mòdul 6 i enllaç amb el Mòdul 7
- Asincronia davant paral·lelisme: dos problemes diferents
És habitual confondre async/await amb "fer diverses coses alhora", però resolen problemes
diferents, encara que relacionats:
Asincronia (async/await, Mòdul 4) |
Paral·lelisme (aquesta lliçó) | |
|---|---|---|
| Problema que resol | Esperar una operació d'E/S sense bloquejar el fil mentre s'espera | Executar treball de CPU genuïnament alhora, en diversos nuclis |
| Què passa mentre s'espera | El fil queda lliure per fer una altra feina; no hi ha cap fil "ocupat esperant" | Cada fil paral·lel consumeix activament un nucli de CPU |
| Exemple ja vist al curs | await ClientHttp.GetAsync(...) (Mòdul 5): esperar la xarxa |
Parallel.ForEach sobre milers de Llibre (apartat 8): calcular, no esperar |
| Nombre de fils del sistema operatiu usats | Normalment un (o molt pocs), reutilitzats eficientment | Diversos, potencialment tants com nuclis de CPU disponibles |
PrestarLlibreAsync (Mòdul 4) fa servir await Task.Delay(...) per simular una espera —cap
nucli de CPU està "treballant" durant aquesta espera, només aguardant—; l'exemple de l'apartat 8
d'aquesta lliçó, en canvi, reparteix càlcul real (verificar checksums d'ISBN sobre milers de
llibres) entre diversos nuclis de CPU treballant simultàniament. Totes dues tècniques es poden
combinar en una aplicació real, però convé tenir clar quina resol cada problema abans de triar-ne
una o l'altra.
Thread: el fil bàsic de .NET
Thread: el fil bàsic de .NETUn Thread (de System.Threading) representa un fil d'execució del sistema operatiu, gestionat
directament:
using System.Threading;
Thread fil = new Thread(() =>
{
Console.WriteLine($"Treballant al fil {Thread.CurrentThread.ManagedThreadId}");
});
fil.Start();
fil.Join(); // espera que el fil acabi abans de continuar
Console.WriteLine("Fil acabat.");fil.Start() llança el fil, que s'executa de forma independent al fil que el va crear;
fil.Join() bloqueja el fil actual fins que fil acabi —útil quan la resta del programa
necessita esperar aquest resultat abans de continuar. Thread és l'API de més baix nivell per
treballar amb fils a .NET; a la pràctica, el codi d'aplicació modern rarament crea Thread
directament, i prefereix les abstraccions de més alt nivell dels apartats següents (Task.Run,
Parallel), que gestionen per sota un grup de fils compartit (thread pool) de forma molt
més eficient que crear un Thread nou per a cada tasca.
Task.Run: paral·lelisme amb l'API de tasques
Task.Run: paral·lelisme amb l'API de tasquesTask.Run (del mateix Task ja conegut des d'async/await, Mòdul 4) programa una peça de
treball perquè s'executi en un fil del thread pool, sense necessitat de gestionar un Thread
manualment:
using System.Threading.Tasks;
Task<int> tasca = Task.Run(() =>
{
// treball intensiu en CPU, no una espera d'E/S
int resultat = 0;
for (int i = 0; i < 100_000_000; i++)
{
resultat += i % 7;
}
return resultat;
});
int valor = await tasca; // espera el resultat sense bloquejar el fil que fa l'await
Console.WriteLine(valor);Task.Run retorna un Task<T>, el mateix tipus que ja coneixes d'async/await; la diferència
d'intenció és important: await Task.Delay(...) (Mòdul 4) allibera el fil mentre s'espera
alguna cosa externa (no hi ha treball de CPU a fer), mentre que Task.Run(...) reserva
activament un fil del thread pool per executar càlcul real. Fer servir Task.Run per
"embolicar" una espera d'E/S (com una crida a HttpClient) seria un error de disseny: ja
existeixen versions asíncrones natives (GetAsync, GetFromJsonAsync) que no consumeixen un fil
sencer només per esperar.
Parallel.For i Parallel.ForEach: paral·lelitzar un bucle
Parallel.For i Parallel.ForEach: paral·lelitzar un bucleQuan el treball consisteix a repetir la mateixa operació, independent entre si, sobre molts
elements d'una col·lecció, Parallel.ForEach (i el seu equivalent Parallel.For per a rangs
numèrics) reparteix automàticament les iteracions entre diversos fils del thread pool, sense
que el programador hagi de crear ni coordinar els fils manualment:
using System.Threading.Tasks;
List<Llibre> cataleg = ObtenirCatalegComplet(); // milers de Llibre
Parallel.ForEach(cataleg, llibre =>
{
bool esValid = VerificarChecksumIsbn(llibre.Isbn); // treball de CPU per cada llibre
Console.WriteLine($"{llibre.Isbn}: {(esValid ? "valid" : "invalid")}");
});Parallel.ForEach decideix internament quants fils fer servir (normalment, aproximadament un
per nucli de CPU disponible) i en quin ordre repartir els elements —l'ordre d'execució no
està garantit, a diferència d'un foreach normal—. És l'eina correcta quan cada iteració és
independent de les altres (no depèn del resultat de la iteració anterior); si les iteracions
depenen entre si, paral·lelitzar-les amb Parallel.ForEach produiria resultats incorrectes o
inconsistents.
foreach (seqüencial) |
Parallel.ForEach |
|
|---|---|---|
| Ordre d'execució | Garantit, un darrere l'altre | No garantit |
| Nuclis de CPU usats | Un | Diversos, en paral·lel |
| Quan convé | Poques iteracions, o iteracions dependents entre si | Moltes iteracions independents, amb treball de CPU significatiu cadascuna |
| Risc si es comparteix estat mutable | Cap (una sola execució alhora) | Condicions de cursa si no es protegeix (apartat 5) |
- Condicions de cursa: el problema de compartir estat entre fils
Una condició de cursa (race condition) passa quan diversos fils llegeixen i escriuen la mateixa variable compartida al mateix temps, sense coordinació, i el resultat final depèn de l'ordre —no determinista— en què el sistema operatiu intercala les seves operacions:
List<string> resultatsCompartits = new List<string>();
Parallel.ForEach(cataleg, llibre =>
{
bool esValid = VerificarChecksumIsbn(llibre.Isbn);
resultatsCompartits.Add($"{llibre.Isbn}: {esValid}"); // PERILL: List<T> no es segura per a diversos fils alhora
});List<T>.Add(...) no està dissenyat perquè diversos fils l'invoquin simultàniament: internament
gestiona un array i un comptador d'elements, i si dos fils executen Add exactament al mateix
temps, es poden trepitjar l'un a l'altre —perdent un element, corrompent l'array intern, o
llançant una excepció en temps d'execució de forma intermitent i difícil de reproduir—. Aquesta
mena d'error és especialment traïdora perquè no sempre es manifesta: pot funcionar
correctament en la majoria d'execucions i fallar només ocasionalment, quan la sincronització
exacta del sistema operatiu fa coincidir dos fils en el moment just.
lock: protegir una secció crítica
lock: protegir una secció críticalock (de C#, sobre un objecte fet servir com a "cadenat") garanteix que només un fil alhora
pot executar el bloc de codi protegit, obligant la resta a esperar el seu torn:
List<string> resultatsCompartits = new List<string>();
object cadenat = new object();
Parallel.ForEach(cataleg, llibre =>
{
bool esValid = VerificarChecksumIsbn(llibre.Isbn); // treball de CPU, fora del lock: no necessita proteccio
lock (cadenat)
{
resultatsCompartits.Add($"{llibre.Isbn}: {esValid}"); // seccio critica: un fil alhora
}
});L'objecte cadenat (una instància qualsevol, dedicada únicament a aquest propòsit, sense cap
altra funció) actua com a "testimoni": mentre un fil és dins del bloc lock (cadenat), qualsevol
altre fil que intenti entrar en un bloc lock (cadenat) —el mateix objecte cadenat— queda
bloquejat, esperant el seu torn, fins que el primer acabi. Només cal protegir amb lock la part
que modifica estat compartit (resultatsCompartits.Add); el càlcul (VerificarChecksumIsbn)
pot continuar executant-se en paral·lel sense restricció, ja que no toca cap dada compartida
—protegir més codi del necessari dins del lock malbarata el paral·lelisme, obligant els fils a
esperar-se entre si sense motiu real.
Consell sobre lock |
Per què |
|---|---|
Fer servir un objecte dedicat exclusivament a ser cadenat (private readonly object _cadenat = new object();) |
Evita bloquejos inesperats si un altre codi extern també fes lock sobre el mateix objecte per error |
| Protegir només la secció que de veritat modifica estat compartit | Maximitza el paral·lelisme real; un lock massa ampli anul·la bona part del benefici de paral·lelitzar |
Evitar treball lent (E/S, esperes) dins d'un lock |
Mentre un fil espera dins del lock, tots els altres queden bloquejats sense motiu |
- Col·leccions concurrents: menció a
ConcurrentDictionary
ConcurrentDictionaryCom a alternativa a protegir manualment una col·lecció normal amb lock,
System.Collections.Concurrent ofereix col·leccions ja preparades internament perquè diversos
fils les facin servir alhora sense condicions de cursa, com ConcurrentDictionary<TKey, TValue>
o ConcurrentBag<T>:
using System.Collections.Concurrent;
ConcurrentDictionary<string, bool> resultatsConcurrents = new ConcurrentDictionary<string, bool>();
Parallel.ForEach(cataleg, llibre =>
{
bool esValid = VerificarChecksumIsbn(llibre.Isbn);
resultatsConcurrents[llibre.Isbn] = esValid; // segur sense lock explicit: ja gestionat internament
});ConcurrentDictionary gestiona la seva pròpia sincronització interna, de forma més eficient que
un Dictionary<TKey, TValue> normal protegit amb lock (permet, en molts casos, que diversos
fils operin sobre parts diferents de la col·lecció simultàniament en lloc de bloquejar-se tots
entre si). És una alternativa a tenir present quan l'estat compartit és, precisament, una
col·lecció; per a la resta de casos (una variable simple, una llista amb lògica d'acumulació més
complexa com a l'apartat 8), lock continua sent l'eina més directa i explícita.
- Exemple: validar en paral·lel el catàleg de BiblioTech
Unint tot l'anterior, es pot paral·lelitzar una operació de validació costosa sobre tot el
catàleg de Biblioteca —verificar que l'ISBN de cada Llibre té un format i checksum vàlids—,
acumulant els resultats de forma segura en una llista compartida:
using System.Threading.Tasks;
static bool VerificarChecksumIsbn(string isbn)
{
// Simplificacio amb finalitats didactiques: suma els digits de l'ISBN (ignorant guions)
// i comprova que el resultat sigui multiple de 10; una verificacio real d'ISBN-13
// segueix un algorisme de pesos alterns (1 i 3) fora de l'abast d'aquesta lliço.
int suma = 0;
foreach (char caracter in isbn)
{
if (char.IsDigit(caracter))
{
suma += caracter - '0';
}
}
return suma % 10 == 0;
}class ResultatValidacio
{
public string Isbn { get; }
public bool EsValid { get; }
public ResultatValidacio(string isbn, bool esValid)
{
Isbn = isbn;
EsValid = esValid;
}
}static List<ResultatValidacio> ValidarCatalegEnParalel(Biblioteca biblioteca)
{
List<ResultatValidacio> resultats = new List<ResultatValidacio>();
object cadenat = new object();
List<Llibre> llibres = biblioteca.Cataleg.OfType<Llibre>().ToList(); // OfType, de LINQ (Modul 4)
Parallel.ForEach(llibres, llibre =>
{
bool esValid = VerificarChecksumIsbn(llibre.Isbn); // treball de CPU, fora del lock
lock (cadenat)
{
resultats.Add(new ResultatValidacio(llibre.Isbn, esValid)); // seccio critica
}
});
return resultats;
}List<ResultatValidacio> resultats = ValidarCatalegEnParalel(biblioteca);
foreach (ResultatValidacio resultat in resultats)
{
string estat = resultat.EsValid ? "valid" : "INVALID";
Console.WriteLine($"{resultat.Isbn}: {estat}");
}ValidarCatalegEnParalel reparteix VerificarChecksumIsbn —el treball de CPU, independent per
a cada Llibre— entre diversos fils amb Parallel.ForEach, i protegeix únicament el moment
d'afegir cada resultat a la llista compartida resultats amb lock (cadenat). Amb un catàleg
de milers de llibres i un càlcul de verificació més costós que aquesta simplificació didàctica,
aquest repartiment entre nuclis de CPU pot reduir notablement el temps total davant validar el
catàleg d'un en un amb un foreach seqüencial.
flowchart TD
A["ValidarCatalegEnParalel(biblioteca)"] --> B["Parallel.ForEach sobre cada Llibre"]
B --> C1["Fil 1: VerificarChecksumIsbn"]
B --> C2["Fil 2: VerificarChecksumIsbn"]
B --> C3["Fil N: VerificarChecksumIsbn"]
C1 --> D["lock (cadenat): resultats.Add(...)"]
C2 --> D
C3 --> D
D --> E["List de ResultatValidacio completa"]
- Tancament del Mòdul 6 i enllaç amb el Mòdul 7
Amb aquesta lliçó es tanca el Mòdul 6 (Temes Avançats): reflexió, atributs, dynamic, gestió de
memòria i multifil són eines d'infraestructura i rendiment que actuen per sota de la lògica
de negoci de BiblioTech, sense canviar el que Llibre, Soci o Prestec representen com a
domini. El Mòdul 7 (Construcció d'Aplicacions) fa el pas següent: connectar, per fi, tot aquest
domini —inclosa la validació en paral·lel d'aquest apartat, o les operacions asíncrones dels
Mòduls 4 i 5— a una interfície real, ja sigui d'escriptori (Windows Forms, WPF), web (ASP.NET
Core, Blazor) o mòbil (Xamarin, .NET MAUI). Allà, patrons com Parallel.ForEach s'hauran de
combinar amb cura amb les restriccions pròpies de cada tipus d'interfície (per exemple,
actualitzar un control visual únicament des del seu fil d'interfície, mai directament des d'un
fil paral·lel), un matís que es retomarà al seu moment.
Errors Comuns i Consells
- Confondre asincronia amb paral·lelisme:
async/await(Mòdul 4) allibera un fil mentre s'espera E/S; no reparteix treball entre diversos nuclis de CPU. Fer servirTask.Runper "fer asíncrona" una operació que ja té una versióasyncnativa (comHttpClient.GetAsync) malbarata un fil del thread pool sense necessitat. - Modificar una col·lecció compartida des de
Parallel.ForEachsense protecció:List<T>.Addi estructures similars no són segures per a crides concurrents des de diversos fils; el resultat és, en el millor cas, una excepció intermitent i, en el pitjor, dades corrompudes en silenci. - Protegir amb
lockmés codi del necessari: incloure el treball de CPU (VerificarChecksumIsbn) dins dellock, en lloc de només l'escriptura a la llista compartida, serialitza de fet tota l'operació i anul·la bona part del benefici de paral·lelitzar. - Fer servir objectes diferents com a cadenat en parts diferents del codi que protegeixen la
mateixa dada:
lock (cadenat)només bloqueja davant un altrelocksobre el mateix objecte; si dos blocs de codi fan servir cadenats diferents per protegir la mateixa llista, no es protegeixen entre si en absolut. - Consell: abans de paral·lelitzar un bucle amb
Parallel.ForEach, comprova que el treball de cada iteració és realment costós en CPU i veritablement independent de la resta; per a col·leccions petites o treball trivial, la sobrecàrrega de coordinar diversos fils pot fer que la versió paral·lela sigui, de fet, més lenta que unforeachseqüencial simple.
Exercicis
-
Escriu un mètode
long SumarSequencial(int quantitat)que sumi els números de0aquantitat - 1en un buclefornormal, i compara'l (ambStopwatch, ja fet servir al curs per mesurar temps) davant repartir la suma ambParallel.Forprotegint l'acumulador compartit amblock. Comenta, en un comentari, quina versió esperaries que fos més ràpida i per què. -
Retoma
ValidarCatalegEnParaleld'aquesta lliçó, però substitueix laList<ResultatValidacio>protegida amblockper unConcurrentBag<ResultatValidacio>(deSystem.Collections.Concurrent), sense necessitat de caplockexplícit. -
Provoca deliberadament una condició de cursa: fes servir
Parallel.ForEachsobre una llista de 1000 números per incrementar una variable compartidaint comptadorambcomptador++(senselockniInterlocked), i executa el programa diverses vegades comprovant que el resultat final no sempre és1000. Després, corregeix-ho amblock.
Solucions
using System.Diagnostics;
static long SumarSequencial(int quantitat)
{
long suma = 0;
for (int i = 0; i < quantitat; i++)
{
suma += i;
}
return suma;
}
static long SumarParalel(int quantitat)
{
long suma = 0;
object cadenat = new object();
Parallel.For(0, quantitat, i =>
{
lock (cadenat)
{
suma += i;
}
});
return suma;
}
Stopwatch cronometre = Stopwatch.StartNew();
long resultatSequencial = SumarSequencial(50_000_000);
Console.WriteLine($"Sequencial: {cronometre.ElapsedMilliseconds} ms");
cronometre.Restart();
long resultatParalel = SumarParalel(50_000_000);
Console.WriteLine($"Paralel amb lock: {cronometre.ElapsedMilliseconds} ms");
// En aquest cas concret, la versio paralela probablement NO es mes rapida: el treball
// dins de cada iteracio (una simple suma) es massa petit comparat amb el cost
// de sincronitzar el lock a cada iteracio; el lock converteix de fet la suma en sequencial.
using System.Collections.Concurrent;
static ConcurrentBag<ResultatValidacio> ValidarCatalegEnParalelConcurrentBag(Biblioteca biblioteca)
{
ConcurrentBag<ResultatValidacio> resultats = new ConcurrentBag<ResultatValidacio>();
List<Llibre> llibres = biblioteca.Cataleg.OfType<Llibre>().ToList();
Parallel.ForEach(llibres, llibre =>
{
bool esValid = VerificarChecksumIsbn(llibre.Isbn);
resultats.Add(new ResultatValidacio(llibre.Isbn, esValid)); // segur sense lock explicit
});
return resultats;
}
List<int> nombres = Enumerable.Range(0, 1000).ToList(); // Enumerable.Range, de LINQ (Modul 4)
int comptador = 0;
Parallel.ForEach(nombres, _ =>
{
comptador++; // SENSE proteccio: condicio de cursa
});
Console.WriteLine(comptador); // sovint, diferent de 1000 en execucions diferents
// Correccio amb lock:
int comptadorCorregit = 0;
object cadenat = new object();
Parallel.ForEach(nombres, _ =>
{
lock (cadenat)
{
comptadorCorregit++;
}
});
Console.WriteLine(comptadorCorregit); // sempre 1000
Conclusió
En aquesta lliçó has distingit asincronia (esperar E/S sense bloquejar, Mòdul 4) de paral·lelisme
real (diversos fils de CPU treballant alhora), i has fet servir Thread, Task.Run i
Parallel.For/Parallel.ForEach per repartir treball de CPU entre diversos nuclis. També has
vist el problema de les condicions de cursa en compartir estat mutable entre fils, com lock
protegeix una secció crítica sense sacrificar el paral·lelisme de la resta del treball, i
l'alternativa de les col·leccions concurrents com ConcurrentDictionary. L'exemple de validació
paral·lela del catàleg de BiblioTech reuneix totes aquestes peces sobre dades reals del domini
del curs.
Amb això es tanca el Mòdul 6 (Temes Avançats) al complet: reflexió, atributs, dynamic, gestió
de memòria i multifil. El Mòdul 7 (Construcció d'Aplicacions) retoma ara tot el que s'ha
construït en els sis mòduls anteriors —el domini de BiblioTech amb MaterialBibliotecari,
Llibre, Revista, Soci, Prestec i Biblioteca, la seva persistència en text, JSON, SQLite
i Entity Framework, la seva comunicació amb serveis externs, i les tècniques avançades d'aquest
mòdul— i el connecta, per primera vegada al curs, a una interfície d'usuari real: aplicacions
d'escriptori amb Windows Forms i WPF, aplicacions web amb ASP.NET Core i Blazor, i aplicacions
mòbils amb Xamarin i .NET MAUI.
Curs de Programació en C#
Mòdul 1: Introducció al C#
- Introducció al C#
- Configuració de l'Entorn de Desenvolupament
- Programa Hola Món
- Sintaxi i Estructura Bàsica
- Variables i Tipus de Dades
- Arrays i Cadenes de Text
Mòdul 2: Estructures de Control
Mòdul 3: Programació Orientada a Objectes
- Classes i Objectes
- Mètodes
- Constructors i Destructors
- Herència
- Polimorfisme
- Encapsulació
- Abstracció
- Structs i Records: Tipus per Valor i per Referència
Mòdul 4: Conceptes Avançats de C#
- Interfícies
- Delegats i Esdeveniments
- Pattern Matching i Característiques Modernes de C#
- Genèrics
- Col·leccions
- LINQ (Consulta Integrada en el Llenguatge)
- Programació Asíncrona
Mòdul 5: Treballant amb Dades
- Entrada/Sortida de Fitxers
- Serialització
- Connectivitat amb Bases de Dades
- Entity Framework
- Treball amb JSON i Consum d'APIs REST
Mòdul 6: Temes Avançats
- Reflexió
- Atributs
- Programació Dinàmica
- Gestió de Memòria i Recollida d'Escombraries
- Multifil i Programació Paral·lela
Mòdul 7: Construcció d'Aplicacions
Mòdul 8: Bones Pràctiques i Patrons de Disseny
- Estàndards de Codificació i Bones Pràctiques
- Patrons de Disseny
- Injecció de Dependències i Inversió de Control
- Proves Unitàries
- Revisió i Refactorització de Codi
