Vai al contenuto

Mutex, lock e sezioni critiche

Padroneggia le primitive di sincronizzazione: mutex, lock_guard, unique_lock, scoped_lock, shared_mutex. Impara a proteggere i dati condivisi, a prevenire i deadlock e a capire la differenza tra data race e race condition.

Perché la sincronizzazione è importante

Quando più thread accedono alla stessa memoria e almeno uno di essi scrive, hai un data race — una forma di comportamento indefinito in C++. Lo standard non si limita a dire "il risultato è imprevedibile". Dice che *può succedere qualsiasi cosa*: dati corrotti, crash, vulnerabilità di sicurezza, oppure un comportamento apparentemente corretto che si rompe quando cambi i flag del compilatore.

Un mutex (mutual exclusion, mutua esclusione) è la primitiva di sincronizzazione fondamentale. Un solo thread alla volta può detenere il lock di un mutex. Qualunque altro thread provi ad acquisirlo si blocca finché il mutex non viene rilasciato. Proteggendo i dati condivisi con un mutex, serializzi gli accessi ed elimini i data race.

std::mutex e lock_guard

Il pattern più semplice e sicuro è std::lock_guard — un wrapper RAII che acquisisce il lock sul mutex alla costruzione e lo rilascia alla distruzione, anche se viene lanciata un'eccezione.

lock_guard_demo.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int shared_counter = 0;

void increment(int times) {
    for (int i = 0; i < times; ++i) {
        std::lock_guard<std::mutex> lock(mtx);  // RAII lock
        ++shared_counter;  // safe: only one thread at a time
    }  // lock released here automatically
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment, 10000);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Counter: " << shared_counter << "\n";
    // Always prints 100000 — no data race
    return 0;
}

unique_lock — lock flessibile

std::unique_lock è più flessibile di lock_guard. Supporta il lock differito, il lock temporizzato, lock/unlock manuali ed è richiesto da std::condition_variable.

unique_lock.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtx;

void try_lock_example() {
    // Deferred locking — don't lock yet
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);

    // Try to lock with a timeout
    if (lock.try_lock_for(std::chrono::milliseconds(100))) {
        std::cout << "Acquired lock\n";
        // ... do work ...
    } else {
        std::cout << "Could not acquire lock within 100ms\n";
    }
    // Automatically unlocks when lock goes out of scope
}

void manual_control() {
    std::unique_lock<std::mutex> lock(mtx);
    // ... critical section ...
    lock.unlock();  // release early if needed
    // ... non-critical work ...
    lock.lock();    // re-acquire
    // ... another critical section ...
}

scoped_lock — lock multipli senza deadlock (C++17)

Quando devi acquisire il lock su più mutex contemporaneamente, std::scoped_lock (C++17) usa internamente un algoritmo che evita i deadlock. Sostituisce l'uso manuale di std::lock, facile da sbagliare.

scoped_lock_bank.cpp
#include <mutex>
#include <thread>
#include <iostream>

struct BankAccount {
    std::mutex mtx;
    double balance = 1000.0;
};

void transfer(BankAccount& from, BankAccount& to, double amount) {
    // Lock both mutexes without risk of deadlock
    std::scoped_lock lock(from.mtx, to.mtx);

    if (from.balance >= amount) {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "Transferred " << amount << "\n";
    }
}  // both mutexes released

int main() {
    BankAccount alice, bob;

    // These two transfers lock accounts in different order,
    // but scoped_lock prevents deadlock.
    std::thread t1(transfer, std::ref(alice), std::ref(bob), 100.0);
    std::thread t2(transfer, std::ref(bob), std::ref(alice), 50.0);

    t1.join();
    t2.join();

    std::cout << "Alice: " << alice.balance
              << ", Bob: " << bob.balance << "\n";
    return 0;
}

shared_mutex — lock lettori-scrittori (C++17)

Quando le letture sono molto più numerose delle scritture, std::shared_mutex consente più lettori concorrenti ma accesso esclusivo in scrittura. Usa std::shared_lock per i lettori e std::unique_lock per gli scrittori.

shared_mutex_cache.cpp
#include <shared_mutex>
#include <mutex>
#include <thread>
#include <iostream>
#include <map>
#include <string>

class ThreadSafeCache {
    mutable std::shared_mutex smtx_;
    std::map<std::string, int> data_;
public:
    int read(const std::string& key) const {
        std::shared_lock lock(smtx_);  // multiple readers OK
        auto it = data_.find(key);
        return it != data_.end() ? it->second : -1;
    }

    void write(const std::string& key, int value) {
        std::unique_lock lock(smtx_);  // exclusive access
        data_[key] = value;
    }
};
Insidia

Un data race si verifica quando due thread accedono concorrentemente alla stessa locazione di memoria e almeno uno dei due scrive, senza alcuna sincronizzazione. In C++ questo è comportamento indefinito — non semplicemente "output non deterministico", ma UB in cui può succedere qualsiasi cosa.

Una race condition è invece un bug logico di livello più alto, in cui la correttezza del programma dipende dalla tempistica. Anche codice privo di data race può avere race condition (per esempio un check-then-act su una mappa concorrente). I mutex eliminano i data race; eliminare le race condition richiede una progettazione attenta dei tuoi algoritmi concorrenti.

Buona pratica

Usa sempre i wrapper RAII (lock_guard, scoped_lock, unique_lock) invece delle chiamate grezze a mutex::lock()/unlock(). Mantieni le sezioni critiche il più brevi possibile — se puoi evitarlo, non eseguire mai I/O, allocazioni o chiamate bloccanti mentre detieni un lock. Quando ti servono più mutex, usa std::scoped_lock per evitare i deadlock. Se devi acquisire i lock manualmente, fallo sempre seguendo un ordine globale coerente.

Concetti chiave
  • Un data race è comportamento indefinito — usa un mutex per proteggere ogni variabile condivisa e modificabile
  • std::lock_guard è il lock RAII più semplice; preferiscilo per le sezioni critiche lineari
  • std::unique_lock aggiunge flessibilità: lock differito, lock temporizzato e lock/unlock manuali
  • std::scoped_lock (C++17) acquisisce più mutex contemporaneamente senza deadlock
  • std::shared_mutex (C++17) consente più lettori concorrenti ma scrittori esclusivi
  • Mantieni brevi le sezioni critiche e non tenere mai un lock mentre aspetti un altro thread

Quiz — Metti alla prova le tue conoscenze

(20 XP)

1. Qual è il vantaggio principale di `std::scoped_lock` rispetto alla chiamata manuale di `lock()` su più mutex?

2. Quale tipo di lock devi usare con `std::condition_variable`?

3. In C++, qual è la conseguenza di un data race?