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

  1. Asincronia davant paral·lelisme: dos problemes diferents
  2. Thread: el fil bàsic de .NET
  3. Task.Run: paral·lelisme amb l'API de tasques
  4. Parallel.For i Parallel.ForEach: paral·lelitzar un bucle
  5. Condicions de cursa: el problema de compartir estat entre fils
  6. lock: protegir una secció crítica
  7. Col·leccions concurrents: menció a ConcurrentDictionary
  8. Exemple: validar en paral·lel el catàleg de BiblioTech
  9. Tancament del Mòdul 6 i enllaç amb el Mòdul 7

  1. 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.

  1. Thread: el fil bàsic de .NET

Un 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.

  1. Task.Run: paral·lelisme amb l'API de tasques

Task.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.

  1. Parallel.For i Parallel.ForEach: paral·lelitzar un bucle

Quan 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)

  1. 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.

  1. lock: protegir una secció crítica

lock (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

  1. Col·leccions concurrents: menció a ConcurrentDictionary

Com 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.

  1. 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"]

  1. 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 servir Task.Run per "fer asíncrona" una operació que ja té una versió async nativa (com HttpClient.GetAsync) malbarata un fil del thread pool sense necessitat.
  • Modificar una col·lecció compartida des de Parallel.ForEach sense protecció: List<T>.Add i 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 lock més codi del necessari: incloure el treball de CPU (VerificarChecksumIsbn) dins del lock, 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 altre lock sobre 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 un foreach seqüencial simple.

Exercicis

  1. Escriu un mètode long SumarSequencial(int quantitat) que sumi els números de 0 a quantitat - 1 en un bucle for normal, i compara'l (amb Stopwatch, ja fet servir al curs per mesurar temps) davant repartir la suma amb Parallel.For protegint l'acumulador compartit amb lock. Comenta, en un comentari, quina versió esperaries que fos més ràpida i per què.

  2. Retoma ValidarCatalegEnParalel d'aquesta lliçó, però substitueix la List<ResultatValidacio> protegida amb lock per un ConcurrentBag<ResultatValidacio> (de System.Collections.Concurrent), sense necessitat de cap lock explícit.

  3. Provoca deliberadament una condició de cursa: fes servir Parallel.ForEach sobre una llista de 1000 números per incrementar una variable compartida int comptador amb comptador++ (sense lock ni Interlocked), i executa el programa diverses vegades comprovant que el resultat final no sempre és 1000. Després, corregeix-ho amb lock.

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#

Mòdul 2: Estructures de Control

Mòdul 3: Programació Orientada a Objectes

Mòdul 4: Conceptes Avançats de C#

Mòdul 5: Treballant amb Dades

Mòdul 6: Temes Avançats

Mòdul 7: Construcció d'Aplicacions

Mòdul 8: Bones Pràctiques i Patrons de Disseny

Mòdul 9: Projecte Final

© Copyright 2026. Tots els drets reservats