Locks e Semafori in Java: Controllo Avanzato della Concorrenza

Edoardo Midali
Edoardo Midali

I Locks e Semafori rappresentano l’evoluzione avanzata dei meccanismi di sincronizzazione in Java, offrendo controllo granulare e funzionalità sofisticate che superano le limitazioni delle primitive tradizionali come synchronized. Questi strumenti, introdotti nel package java.util.concurrent.locks, forniscono flessibilità senza precedenti nella gestione della concorrenza.

La necessità di questi meccanismi avanzati emerge dalle limitazioni intrinseche della sincronizzazione tradizionale: impossibilità di interrompere thread in attesa di lock, mancanza di timeout per acquisizione di lock, e rigidità nella gestione di pattern complessi come reader-writer. I Locks e Semafori risolvono questi problemi fornendo API esplicite e configurabili.

Evoluzione da Synchronized a Lock

I Lock espliciti rappresentano un’evoluzione concettuale significativa rispetto alla sincronizzazione intrinseca di Java. Mentre synchronized è implicito e automatico, i Lock richiedono gestione esplicita ma offrono controllo fine-grained su acquisizione, rilascio e comportamento in caso di contention.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ComparazioneSyncLock {
    private final Lock lock = new ReentrantLock();
    private int contatore = 0;

    // Approccio tradizionale con synchronized
    public synchronized void incrementaSynchronized() {
        contatore++;
    }

    // Approccio con Lock esplicito
    public void incrementaLock() {
        lock.lock();
        try {
            contatore++;
        } finally {
            lock.unlock(); // Sempre nel finally!
        }
    }

    // Vantaggi dei Lock: acquisizione con timeout
    public boolean incrementaConTimeout() throws InterruptedException {
        if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) {
            try {
                contatore++;
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false; // Timeout scaduto
    }
}

ReentrantLock: Lock Rientrante Avanzato

ReentrantLock è l’implementazione principale dell’interfaccia Lock, fornendo tutte le funzionalità di synchronized plus capacità avanzate. Il nome “reentrant” indica che un thread può acquisire lo stesso lock multiple volte, mantenendo un contatore delle acquisizioni.

Caratteristiche Principali

Timeout e Interruzioni: Permette acquisizione con timeout e gestione delle interruzioni, cruciali per applicazioni responsive.

Fairness: Può essere configurato come fair lock, garantendo che il thread che aspetta da più tempo acquisisce il lock per primo.

Condizioni Multiple: Supporta multiple condition variables per coordinamento complesso.

public class GestoreRisorseAvanzato {
    private final ReentrantLock lock = new ReentrantLock(true); // fair lock
    private final List<String> risorse = new ArrayList<>();

    public void aggiungiRisorsa(String risorsa) {
        lock.lock();
        try {
            risorse.add(risorsa);
            // Chiamata ricorsiva - lock rientrante
            if (risorse.size() % 10 == 0) {
                ottimizzaRisorse(); // Acquisirà lo stesso lock
            }
        } finally {
            lock.unlock();
        }
    }

    private void ottimizzaRisorse() {
        lock.lock(); // Stesso thread, stesso lock - OK
        try {
            System.out.println("Ottimizzazione risorse...");
        } finally {
            lock.unlock();
        }
    }
}

ReadWriteLock: Ottimizzazione Reader-Writer

ReadWriteLock ottimizza scenari dove le operazioni di lettura sono significativamente più frequenti delle scritture, permettendo accesso concorrente per lettori multipli mentre mantenendo esclusività per scrittori.

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CacheAvanzata<K, V> {
    private final Map<K, V> cache = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    // Operazioni di lettura - accesso concorrente
    public V get(K key) {
        readLock.lock();
        try {
            return cache.get(key);
        } finally {
            readLock.unlock();
        }
    }

    // Operazioni di scrittura - accesso esclusivo
    public V put(K key, V value) {
        writeLock.lock();
        try {
            return cache.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }
}

Vantaggi Performance

I ReadWriteLock offrono vantaggi significativi in scenari read-heavy, permettendo throughput molto superiore rispetto a synchronized standard quando le letture sono predominanti rispetto alle scritture.

Semafori: Controllo delle Risorse

I Semafori controllano l’accesso a risorse limitate attraverso un contatore di permit. Sono utilizzati per implementare pool di connessioni, limitazione di rate, e altri pattern di resource management.

import java.util.concurrent.Semaphore;

public class PoolConnessioni {
    private final Semaphore semaforo;
    private final Queue<Connessione> pool;

    public PoolConnessioni(int maxConnessioni) {
        this.semaforo = new Semaphore(maxConnessioni, true); // fair semaphore
        this.pool = new ConcurrentLinkedQueue<>();

        // Inizializza pool con connessioni
        for (int i = 0; i < maxConnessioni; i++) {
            pool.offer(new Connessione("Conn-" + i));
        }
    }

    public Connessione acquisisciConnessione() throws InterruptedException {
        semaforo.acquire(); // Acquisisce un permit

        Connessione conn = pool.poll();
        System.out.println("Connessione acquisita: " + conn.getId());
        return conn;
    }

    public void rilasciaConnessione(Connessione conn) {
        if (conn != null) {
            pool.offer(conn);
            semaforo.release(); // Rilascia un permit
        }
    }

    static class Connessione {
        private final String id;
        Connessione(String id) { this.id = id; }
        String getId() { return id; }
    }
}

Applicazioni Pratiche

Rate Limiting: Controllo del throughput per API e servizi.

Resource Pooling: Gestione di pool di connessioni database o thread.

Throttling: Limitazione di accesso a risorse costose.

public class RateLimiter {
    private final Semaphore semaforo;

    public RateLimiter(int permitsPerSecond) {
        this.semaforo = new Semaphore(permitsPerSecond);

        // Ripristina permit ogni secondo
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        scheduler.scheduleAtFixedRate(() -> {
            int permitsToAdd = permitsPerSecond - semaforo.availablePermits();
            if (permitsToAdd > 0) {
                semaforo.release(permitsToAdd);
            }
        }, 1, 1, TimeUnit.SECONDS);
    }

    public void acquire() throws InterruptedException {
        semaforo.acquire();
    }

    public boolean tryAcquire() {
        return semaforo.tryAcquire();
    }
}

Condition Variables: Coordinamento Avanzato

Le Condition variables forniscono un meccanismo più flessibile rispetto a wait/notify tradizionale, permettendo multiple condition per lo stesso lock.

public class BufferAvanzato<T> {
    private final Object[] buffer;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition nonPieno = lock.newCondition();
    private final Condition nonVuoto = lock.newCondition();
    private int count = 0;
    private int putIndex = 0;
    private int takeIndex = 0;

    public BufferAvanzato(int capacita) {
        buffer = new Object[capacita];
    }

    public void put(T elemento) throws InterruptedException {
        lock.lock();
        try {
            while (count == buffer.length) {
                nonPieno.await(); // Aspetta che il buffer non sia pieno
            }

            buffer[putIndex] = elemento;
            putIndex = (putIndex + 1) % buffer.length;
            count++;

            nonVuoto.signal(); // Notifica che il buffer non è vuoto
        } finally {
            lock.unlock();
        }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                nonVuoto.await(); // Aspetta che il buffer non sia vuoto
            }

            T elemento = (T) buffer[takeIndex];
            takeIndex = (takeIndex + 1) % buffer.length;
            count--;

            nonPieno.signal(); // Notifica che il buffer non è pieno
            return elemento;
        } finally {
            lock.unlock();
        }
    }
}

Confronto e Scelta degli Strumenti

Synchronized vs ReentrantLock

Synchronized: Più semplice, gestione automatica, performance adeguata per la maggior parte dei casi.

ReentrantLock: Maggiore flessibilità, timeout, interruzioni, fairness configurabile, ma richiede gestione esplicita.

ReadWriteLock vs Synchronized

ReadWriteLock: Superiore per scenari read-heavy con alta concorrenza.

Synchronized: Più semplice per scenari con mix bilanciato di letture e scritture.

Semafori: Quando Utilizzarli

I Semafori sono la scelta ideale quando si deve controllare l’accesso a un numero limitato di risorse identiche, implementare rate limiting, o gestire pool di oggetti costosi da creare.

Best Practices

Gestione Corretta dei Lock

Utilizzare sempre try-finally per garantire il rilascio dei lock, anche in caso di eccezioni. Evitare operazioni bloccanti all’interno di sezioni critiche.

Prevenzione dei Deadlock

Acquisire lock sempre nello stesso ordine, utilizzare timeout quando possibile, e mantenere sezioni critiche il più brevi possibile.

Performance Considerations

Scegliere il tipo di lock appropriato per il pattern d’uso specifico. Non utilizzare fair lock se non necessario per le performance.

Conclusione

Locks e Semafori forniscono strumenti potenti per gestire la concorrenza in Java, offrendo controllo granulare e funzionalità avanzate rispetto alle primitive tradizionali. La scelta dell’strumento appropriato dipende dalle caratteristiche specifiche dell’applicazione: pattern di accesso, requisiti di performance, e necessità di funzionalità avanzate.

La comprensione di questi meccanismi è essenziale per sviluppare applicazioni concorrenti robuste e performanti, permettendo di sfruttare appieno le capacità delle architetture multi-core moderne mantenendo correttezza e manutenibilità del codice.