Implementazione di strutture dati senza blocchi in sistemi ad alta concorrenza

Strutture dati senza blocchi: guida completa con esempi in C++, Java e .NET

Un mutex è semplice. Lo si blocca, si modifica lo stato condiviso, lo si sblocca. Ogni thread che desidera accedervi attende il proprio turno. Il problema è proprio questo "attendere il proprio turno": con un elevato numero di core e un throughput elevato, la serializzazione diventa il limite massimo. Un thread che detiene un blocco può essere interrotto, causando l'inattività di tutti gli altri thread. L'inversione di priorità può far sì che i thread a bassa priorità blocchino quelli ad alta priorità. Nei sistemi che elaborano milioni di operazioni al secondo su decine di core, la contesa sui mutex non si limita a rallentare le cose, ma fa crollare il throughput.

Analizzare la logica di concorrenza

SMART TS XL Traccia i percorsi delle operazioni atomiche, i modelli di accesso alla memoria condivisa e i dati tra thread.

Esplora ora

Le strutture dati senza lock sostituiscono l'esclusione reciproca con operazioni atomiche. Invece di prevenire i conflitti, li tollerano e si riprendono da essi. Un thread che fallisce un'operazione di confronto e scambio riprova con uno stato aggiornato. Nessun thread blocca mai un altro. Almeno un thread fa sempre progressi. Il risultato è un throughput notevolmente migliore in condizioni di elevata contesa, una latenza prevedibile senza effetti di convoglio e l'eliminazione, per impostazione predefinita, di deadlock e inversione di priorità. Il prezzo da pagare è la complessità di implementazione: il problema ABA, i rischi di recupero della memoria, la falsa condivisione e i sottili requisiti di ordinamento della memoria sono tutte insidie ​​che non esistono nel codice basato sui lock. Questo articolo le affronta tutte con esempi di codice concreti.

Lock-Free vs Wait-Free vs Mutex: quale utilizzare?

Prima di scegliere un approccio di implementazione, la domanda giusta è quale garanzia di progresso sia effettivamente necessaria al sistema. I tre modelli differiscono in ciò che promettono quando i thread competono.

ModelloGaranzia di progressoLatenza tipicaComplessitàIdeale per
Mutex / basato su bloccoBlocco, i thread attendonoImprevedibile sotto contesaBassoStato condiviso a bassa contesa, requisiti di correttezza semplici
Senza bloccoA livello di sistema, almeno un thread avanzaBasso in competizioneAltoCode, pile e contatori ad alta velocità
Senza attesePer ogni thread, ogni thread termina in passaggi limitatiCaso peggiore limitatoMolto altoSistemi in tempo reale, critici per la sicurezza, SLA di latenza rigorosi
Senza ostacoliProgresso solitario, solo quando non si incontrano ostacoliBasso senza contestazioniMedioPrototipi di memoria transazionale, contesti di ricerca

L'approccio senza blocchi (lock-free) è la soluzione predefinita per i sistemi di produzione ad alta concorrenza. La coda Michael-Scott, lo stack Treiber e la maggior parte dei buffer ad anello MPMC utilizzati in produzione sono senza blocchi. In caso di forte contesa, i singoli thread possono bloccarsi, ma il sistema nel suo complesso continua a funzionare.

Gli algoritmi wait-free garantiscono un avanzamento limitato per ogni thread, ma richiedono algoritmi significativamente più complessi. Esistono costruzioni universali, ma comportano un elevato overhead. Gli algoritmi wait-free sono appropriati per contesti hard real-time in cui la latenza di coda è più importante della velocità di trasmissione media.

L'approccio "obstruction-free" viene raramente utilizzato direttamente in produzione. Compare in alcune implementazioni di memoria transazionale e funge da passaggio intermedio nella dimostrazione della correttezza degli algoritmi.

Per la maggior parte dei sistemi ad alta concorrenza: utilizzare un mutex quando la contesa è bassa e la correttezza è semplice, utilizzare un meccanismo senza blocchi quando la velocità di elaborazione in condizioni di contesa è importante, utilizzare un meccanismo senza attesa solo quando la latenza per thread nel caso peggiore è un requisito imprescindibile.

Che cos'è una struttura dati senza blocchi (lock-free)?

Una struttura dati è senza blocchi (lock-free) se garantisce che almeno un thread tra tutti i thread che operano su di essa completerà la propria operazione in un numero finito di passaggi, indipendentemente da ciò che stanno facendo gli altri thread o da come sono pianificati. Questa definizione formale ha una precisa implicazione: nessun algoritmo senza blocchi può andare in deadlock. Se un thread viene arrestato, sospeso o procede lentamente, gli altri thread continuano a progredire.

Il meccanismo si basa su operazioni atomiche, istruzioni della CPU che vengono eseguite come un'unica unità indivisibile. La primitiva universale è Compare-and-Swap (CAS) :

CAS(location, expected, new_value):
  if *location == expected:
    *location = new_value
    return true
  else:
    return false  // someone else changed it first

CAS è atomico a livello hardware. Su x86 è il CMPXCHG istruzione. Su ARM è implementato tramite LDXR/STXR (load-exclusive/store-exclusive), che è la variante LL/SC (Load-Linked/Store-Conditional). Un thread legge un valore, calcola un nuovo valore e utilizza CAS per installarlo solo se nessun altro lo ha modificato nel frattempo. Se CAS fallisce, il thread riprova con il nuovo valore.

In C++11 e versioni successive, questo viene esposto tramite std::atomic:

cpp

#include <atomic>

// Atomic increment using CAS loop
void atomic_add(std::atomic<int>& counter, int delta) {
    int expected = counter.load(std::memory_order_relaxed);
    while (!counter.compare_exchange_weak(
        expected,
        expected + delta,
        std::memory_order_release,
        std::memory_order_relaxed))
    {
        // expected is updated on failure -- retry with fresh value
    }
}

compare_exchange_weak è la primitiva CAS. In caso di errore si aggiorna expected al valore corrente automaticamente, rendendo il ciclo di ripetizione idiomatico.

Il problema dell'ABA: la trappola più pericolosa di Lock-Free

Il problema ABA è il rischio di correttezza più controintuitivo nella programmazione senza blocchi. Il CAS verifica se una posizione di memoria contiene ancora il valore previsto prima di installarne uno nuovo. Non è in grado di rilevare se tale valore è stato modificato e poi ripristinato tra la lettura e il CAS. La posizione sembra ancora contenere il valore originale, ma lo stato sottostante del sistema è cambiato in modi che il CAS non può rilevare.

Lo scenario passo dopo passo

Consideriamo uno stack senza blocchi che utilizza un singolo puntatore top:

  1. Il thread A recita top = Node1Il puntatore successivo di Node1 è Node2.
  2. Il thread A viene interrotto prima che la sua operazione di pop venga completata.
  3. Il thread B rimuove Node1 (top diventa Node2), quindi rimuove Node2 (top diventa null).
  4. Il thread B inserisce un nuovo nodo, che risulta essere allocato allo stesso indirizzo di memoria di Node1 (comune con gli allocatori a lista libera). Top = Node1 (stesso puntatore, contenuto diverso).
  5. Il thread A riprende. Il suo CAS vede top == Node1 (valore atteso), ha successo e imposta top = Node2, ma Node2 era già stato liberato. Un disastro.

Il problema ABA si manifesta in modo diverso a seconda della struttura dati:

  • pila: CAS ha successo ma installa un puntatore next obsoleto, causando un errore di utilizzo dopo la sua liberazione
  • Fare la coda: L'indicatore della testa o della coda appare invariato, ma la struttura è cambiata
  • Lista collegata: Un nodo sembra essere ancora in posizione ma è stato rimosso e riassegnato

Il metodo LL/SC evita il problema dell'ABA?

Sì, LL/SC (Load-Linked/Store-Conditional) offre una semantica più forte rispetto a CAS ed evita naturalmente ABA. LL contrassegna l'indirizzo di memoria caricato come "collegato". SC sullo stesso indirizzo fallisce se è stata effettuata una qualsiasi operazione di scrittura su quell'indirizzo dopo l'LL, anche se il valore è stato ripristinato al suo stato originale. La cronologia delle modifiche viene tracciata a livello hardware, non solo il valore corrente.

Tuttavia, le implementazioni LL/SC su hardware reale presentano limitazioni pratiche. Su ARM e POWER, possono verificarsi falsi errori LL/SC anche in assenza di contesa (cambi di contesto, svuotamento della cache). Il codice deve tenere conto dei cicli di tentativi anche in assenza di conflitti reali. Su x86 non esiste un LL/SC nativo; CAS è la primitiva hardware. Per il codice x86, l'ABA deve essere impedito via software.

Riformare l'ABA: tre approcci

1. Contatori di versione (puntatori con tag)

Inserisci un contatore di versione nella stessa parola atomica del puntatore. Ogni operazione CAS riuscita incrementa il contatore. Anche se un puntatore ritorna al suo valore originale, il contatore risulterà diverso:

cpp

#include <atomic>
#include <cstdint>

struct TaggedPtr {
    uintptr_t ptr : 48;  // pointer (48-bit virtual address space)
    uintptr_t tag : 16;  // version counter
};

std::atomic<TaggedPtr> top;

bool cas_with_tag(std::atomic<TaggedPtr>& loc,
                  TaggedPtr expected,
                  void* new_ptr) {
    TaggedPtr desired = { (uintptr_t)new_ptr, expected.tag + 1 };
    return loc.compare_exchange_strong(expected, desired);
}

Questo è l'approccio standard nella pratica. Il tag a 16 bit si riavvia dopo 65,536 operazioni, il che è teoricamente non sicuro ma praticamente adeguato per la maggior parte dei sistemi; è estremamente improbabile che un thread venga interrotto per esattamente 65,536 cicli CAS nella stessa posizione.

2. Indicatori di pericolo

Ogni thread mantiene un piccolo insieme di "puntatori di pericolo", ovvero puntatori ai nodi a cui sta attualmente accedendo. Prima di liberare un nodo, un thread controlla tutti i registri dei puntatori di pericolo per confermare che nessun altro thread vi stia accedendo. Un nodo che compare in un puntatore di pericolo viene rimandato fino a quando tale puntatore non viene liberato.

cpp

// Simplified hazard pointer pattern
thread_local void* hazard_ptr = nullptr;

void* safe_load(std::atomic<void*>& head) {
    void* ptr;
    do {
        ptr = head.load(std::memory_order_acquire);
        hazard_ptr = ptr;                          // announce we are using this
        // memory fence ensures announcement is visible before validation
        std::atomic_thread_fence(std::memory_order_seq_cst);
    } while (ptr != head.load(std::memory_order_acquire)); // validate still valid
    return ptr;
}

void safe_release() {
    hazard_ptr = nullptr;
}

I puntatori di rischio impediscono l'ABA a livello di recupero della memoria: un nodo non può essere riutilizzato finché un thread detiene un puntatore di rischio ad esso. Ciò richiede la scansione di tutti i puntatori di rischio dei thread prima di liberare la memoria, il che aggiunge un overhead proporzionale al numero di thread.

3. Bonifica basata su epoche (EBR)

I thread operano in "epoche" tracciate a livello globale. I nodi ritirati vengono memorizzati in un buffer per ogni epoca e liberati solo quando tutti i thread hanno superato l'epoca in cui il nodo è stato ritirato. L'EBR è più semplice da usare rispetto ai puntatori di rischio e presenta un overhead per operazione inferiore, a costo di una crescita della memoria limitata ma imprevedibile durante i periodi di inattività.

A Giava, AtomicStampedReference Affronta direttamente il problema ABA associando un riferimento a un valore intero:

Giava

import java.util.concurrent.atomic.AtomicStampedReference;

AtomicStampedReference<Node> top =
    new AtomicStampedReference<>(null, 0);

void push(Node newNode) {
    int[] stamp = new int[1];
    Node current;
    do {
        current = top.get(stamp);
        newNode.next = current;
    } while (!top.compareAndSet(current, newNode, stamp[0], stamp[0] + 1));
}

Implementazioni di code senza blocco

Le code sono la struttura senza blocchi più comunemente utilizzata. La coda senza blocchi canonica è la coda Michael-Scott, che utilizza due puntatori (testa e coda) e un nodo sentinella per consentire operazioni di inserimento ed estrazione concorrenti.

Coda SPSC: massime prestazioni con sincronizzazione minima

Una coda a produttore singolo e consumatore singolo elimina ogni contesa tra scritture e tra letture. Un thread scrive in coda, un altro legge dalla testa. Solo il puntatore alla testa è condiviso tra produttore e consumatore.

cpp

#include <atomic>
#include <array>

template<typename T, size_t Capacity>
class SPSCQueue {
    std::array<T, Capacity> buffer;
    alignas(64) std::atomic<size_t> head{0}; // consumer reads head
    alignas(64) std::atomic<size_t> tail{0}; // producer writes tail
    // alignas(64) puts each on a separate cache line -- prevents false sharing

public:
    bool push(const T& item) {
        size_t t = tail.load(std::memory_order_relaxed);
        size_t next = (t + 1) % Capacity;
        if (next == head.load(std::memory_order_acquire))
            return false; // full
        buffer[t] = item;
        tail.store(next, std::memory_order_release);
        return true;
    }

    bool pop(T& item) {
        size_t h = head.load(std::memory_order_relaxed);
        if (h == tail.load(std::memory_order_acquire))
            return false; // empty
        item = buffer[h];
        head.store((h + 1) % Capacity, std::memory_order_release);
        return true;
    }
};

Migliori alignas(64) La presenza di scrittura sia in testa che in coda è fondamentale. Senza di essa, entrambe le operazioni si adattano alla stessa linea di cache. Ogni scrittura in coda innesca un'invalidazione della linea di cache visibile alla testina di lettura del core, e viceversa, causando una falsa condivisione che serializza operazioni che dovrebbero essere indipendenti.

Coda MPMC: multi-produttore e multi-consumatore

Una coda MPMC completamente concorrente è significativamente più complessa. Le implementazioni di produzione utilizzano un numero di sequenza per slot per coordinare produttori e consumatori senza un blocco:

cpp

#include <atomic>
#include <array>

template<typename T, size_t Capacity>
class MPMCQueue {
    struct Slot {
        std::atomic<size_t> sequence;
        T data;
    };

    alignas(64) std::array<Slot, Capacity> slots;
    alignas(64) std::atomic<size_t> enqueue_pos{0};
    alignas(64) std::atomic<size_t> dequeue_pos{0};

public:
    MPMCQueue() {
        for (size_t i = 0; i < Capacity; ++i)
            slots[i].sequence.store(i, std::memory_order_relaxed);
    }

    bool push(const T& item) {
        size_t pos = enqueue_pos.fetch_add(1, std::memory_order_relaxed);
        Slot& slot = slots[pos % Capacity];
        size_t seq = slot.sequence.load(std::memory_order_acquire);
        // wait for the slot to be ready for writing
        while (seq != pos) {
            seq = slot.sequence.load(std::memory_order_acquire);
        }
        slot.data = item;
        slot.sequence.store(pos + 1, std::memory_order_release);
        return true;
    }

    bool pop(T& item) {
        size_t pos = dequeue_pos.fetch_add(1, std::memory_order_relaxed);
        Slot& slot = slots[pos % Capacity];
        size_t seq = slot.sequence.load(std::memory_order_acquire);
        while (seq != pos + 1) {
            seq = slot.sequence.load(std::memory_order_acquire);
        }
        item = slot.data;
        slot.sequence.store(pos + Capacity, std::memory_order_release);
        return true;
    }
};

.NET ConcurrentQueue: come funziona internamente

.NET ConcurrentQueue<T> in .NET 5+ utilizza una struttura di array segmentato. Ogni segmento è un array di dimensioni fisse con un indice di testa e di coda gestito con Interlocked operazioni (che mappano su CAS sull'hardware sottostante). I segmenti sono collegati tramite un volatile _nextSegment puntatore. Enqueue aggiunge al segmento di coda corrente, espandendo la catena di segmenti quando è piena. Dequeue legge dal segmento di testa e avanza atomicamente l'indice di testa.

La decisione chiave nella progettazione è evitare un blocco globale. I produttori competono solo sull'indice di coda del segmento corrente. I consumatori competono solo sull'indice di testa. Nessun produttore compete mai con un consumatore. Questo rende ConcurrentQueue<T> altamente efficiente per le filiere produttore-consumatore:

nitido

using System.Collections.Concurrent;
using System.Threading;

// .NET ConcurrentQueue -- lock-free, thread-safe, FIFO
var queue = new ConcurrentQueue<int>();

// Producer thread
var producer = Task.Run(() => {
    for (int i = 0; i < 1_000_000; i++)
        queue.Enqueue(i);
});

// Consumer thread
var consumer = Task.Run(() => {
    long sum = 0;
    while (!queue.IsEmpty || !producer.IsCompleted) {
        if (queue.TryDequeue(out int item))
            sum += item;
        else
            Thread.SpinWait(1);  // brief spin before retry
    }
    return sum;
});

await Task.WhenAll(producer, consumer);

Coerenza della cache e falsa condivisione nel codice senza blocchi

La coerenza della cache è la variabile invisibile che incide sulle prestazioni nelle implementazioni senza blocchi. Quando un core scrive in una posizione di memoria, l'intera linea di cache da 64 byte contenente tale posizione viene invalidata nelle cache di tutti gli altri core. Questi ultimi devono recuperare la linea aggiornata prima di poter leggere. Nel codice senza blocchi con frequenti aggiornamenti atomici, questa invalidazione della linea di cache può diventare il costo dominante.

La falsa condivisione si verifica quando due thread modificano variabili diverse che condividono una linea di cache. Nessuno dei due thread accede agli stessi dati, ma il protocollo di coerenza della cache considera l'intera linea di cache come contesa:

cpp

// BAD: head and tail on the same cache line
struct BadQueue {
    std::atomic<size_t> head;   // bytes 0-7
    std::atomic<size_t> tail;   // bytes 8-15
    // both fit in the same 64-byte cache line
    // producer writing tail invalidates head in consumer's cache
};

// GOOD: head and tail on separate cache lines
struct GoodQueue {
    alignas(64) std::atomic<size_t> head;
    alignas(64) std::atomic<size_t> tail;
    // each on its own cache line -- no false sharing
};

Il protocollo MESI (Modificato, Esclusivo, Condiviso, Non valido) descrive come le CPU x86 coordinano la proprietà delle linee di cache. Quando un core desidera scrivere in una posizione condivisa con altri core (stato Condiviso), deve trasmettere una richiesta di scrittura, attendere la conferma da tutti i core che condividono la cache e passare allo stato Modificato. Questo ciclo di andata e ritorno al protocollo di coerenza richiede dai 50 ai 300 cicli su un sistema multi-socket. La falsa condivisione trasforma operazioni indipendenti a livello di thread in traffico di coerenza della cache, compromettendo le prestazioni che dovrebbero scalare linearmente con il numero di core.

Rilevamento della condivisione fraudolenta: uso perf stat -e cache-misses,L1-dcache-load-misses su Linux o tramite il profiler delle prestazioni della CPU di Visual Studio su Windows. Un'elevata frequenza di errori nella cache L1 in un programma multi-thread che si adatta alla cache è un forte indicatore di false sharing.

Ordinamento della memoria: perché memory_order_relaxed Non è sempre sufficiente

C++ std::atomic Le operazioni espongono l'intera gamma di ordinamenti di memoria del modello di memoria C++11. La scelta di un ordinamento errato è il secondo bug più comune nel codice senza blocchi, dopo il problema ABA.

cpp

// The five memory orderings and when each applies:

// relaxed: no synchronization, only atomicity
// Use for: counters where only the final value matters
counter.fetch_add(1, std::memory_order_relaxed);

// acquire: reads see all writes by threads that released this location
// Use for: reading shared state protected by a flag
if (ready.load(std::memory_order_acquire)) {
    use(data); // guaranteed to see all writes made before ready was set
}

// release: writes are visible to threads that acquire this location
// Use for: publishing shared state
data = compute();
ready.store(true, std::memory_order_release);

// acq_rel: acquire + release in one operation
// Use for: read-modify-write operations like CAS in the middle of a chain
node.compare_exchange_strong(expected, desired,
    std::memory_order_acq_rel,   // success
    std::memory_order_acquire);  // failure

// seq_cst: total order across all seq_cst operations, all cores
// Use for: when you need a global consistent view (but slowest)
flag.store(true, std::memory_order_seq_cst);

Un errore comune: usare relaxed per un flag di rilascio. Un thread che imposta ready = true con relaxed l'ordinamento non garantisce che le scritture precedenti data sono visibili ad altri thread che leggono readyLa coppia acquisizione/rilascio crea la relazione preesistente che collega scrittore e lettore.

La pila Treiber: pila senza blocchi in C++

Lo stack Treiber è la struttura dati senza blocco non banale più semplice. Utilizza un ciclo CAS su un singolo top puntatore:

cpp

#include <atomic>

template<typename T>
class TreiberStack {
    struct Node {
        T data;
        Node* next;
        explicit Node(T d) : data(std::move(d)), next(nullptr) {}
    };

    std::atomic<Node*> top{nullptr};

public:
    void push(T data) {
        Node* node = new Node(std::move(data));
        node->next = top.load(std::memory_order_relaxed);
        while (!top.compare_exchange_weak(
            node->next,          // expected -- updated on failure
            node,                // desired
            std::memory_order_release,
            std::memory_order_relaxed))
        { /* retry */ }
    }

    bool pop(T& result) {
        Node* node = top.load(std::memory_order_acquire);
        while (node) {
            if (top.compare_exchange_weak(
                node,
                node->next,      // new top
                std::memory_order_acquire,
                std::memory_order_relaxed))
            {
                result = std::move(node->data);
                // WARNING: cannot free node here safely without hazard pointers or EBR
                // delete node; -- ABA hazard if another thread reads node->next
                return true;
            }
        }
        return false; // empty
    }
};

Il commento su delete node è fondamentale: la cancellazione ingenua è il rischio ABA descritto in precedenza. In uno stack Treiber di produzione, il recupero dei nodi richiede puntatori di rischio, recupero basato su epoche o garbage collection (Java/C# gestisce questo aspetto automaticamente).

Strutture dati Java senza blocchi

La libreria standard di Java fornisce implementazioni senza blocchi di qualità di produzione in java.util.concurrent:

ClasseStructureProgressiNote
AtomicReference<T>Valore singoloSenza bloccoCAS sul riferimento all'oggetto
AtomicStampedReference<T>Valore + bolloSenza bloccoPrevenzione ABA tramite contatore di versione
ConcurrentLinkedQueue<T>coda Michael-ScottSenza bloccoFIFO, illimitato
ConcurrentLinkedDeque<T>Deque senza bloccoSenza bloccoEntrambe le estremità
LongAddercontatoreSenza bloccoConteggio a strisce ad alta produttività

LongAdder Vale la pena notare in particolare. Invece di un singolo contatore atomico che compete su tutti i thread, mantiene un array a strisce di contatori, ciascuno accessibile da un sottoinsieme di thread. La contesa è distribuita sulle strisce invece di essere concentrata in un'unica posizione. Il totale viene sommato in modo pigro su sum(). Per operazioni di incremento ad alta frequenza su più thread, LongAdder supera nettamente le prestazioni AtomicLong.incrementAndGet():

Giava

import java.util.concurrent.atomic.LongAdder;

// BAD for high-concurrency counting: all threads contend on one location
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet(); // single CAS -- all threads collide

// GOOD for high-concurrency counting: contention distributed across stripes
LongAdder adder = new LongAdder();
adder.increment();  // updates thread-local stripe -- minimal contention
long total = adder.sum(); // sum all stripes lazily

Come SMART TS XL Supporta lo sviluppo di sistemi senza blocchi

Il codice senza blocchi è tra i più difficili da realizzare correttamente. I bug non sono deterministici, si manifestano solo in presenza di specifiche interlacciamenti e spesso solo in produzione su larga scala. L'analisi statica affronta questo problema esaminando le proprietà strutturali del codice prima dell'esecuzione, anziché attendere che si manifesti una condizione di gara.

SMART TS XL Analizza i percorsi di esecuzione completi del codice concorrente, tracciando la relazione tra le operazioni atomiche e le posizioni di memoria che proteggono. Per i sistemi che combinano codice senza blocchi con componenti legacy o architetture multi-linguaggio, fornisce la visibilità transfrontaliera che gli strumenti mono-linguaggio non possono offrire: ad esempio, come una regione di memoria condivisa a cui accede un componente C++ senza blocchi si relaziona al servizio Java che la legge, o come una coda concorrente alimenta una pipeline di elaborazione basata su COBOL.

La funzionalità di analisi statica del codice identifica i modelli associati ai rischi di concorrenza: caricamenti atomici senza semantica di acquisizione corrispondente, cicli CAS che non aggiornano il valore atteso in caso di errore, strutture dati condivise in cui i campi a cui accedono thread diversi sono collocati nello stesso luogo senza padding di allineamento. La funzionalità di analisi dell'impatto traccia quali componenti a valle dipendono da una struttura dati concorrente condivisa, in modo che le modifiche all'interfaccia della struttura o alla strategia di recupero possano essere correttamente definite prima di essere apportate.

Per i sistemi aziendali in cui i componenti senza blocco devono coesistere con l'elaborazione batch legacy, il middleware di accodamento dei messaggi e i moderni livelli di servizio, SMART TS XL'S mappatura delle dipendenze Fornisce una visione architetturale di come i percorsi dati concorrenti collegano i componenti scritti in linguaggi diversi nell'intera architettura di sistema.

Quando la scelta di Lock-Free è sbagliata

Non sempre un sistema senza blocchi è migliore di un mutex. La convenienza delle strutture senza blocchi dipende dal profilo di contesa effettivo del carico di lavoro. Con un numero ridotto di thread o una bassa contesa, un mutex ben implementato è più veloce perché evita il sovraccarico dei cicli di ritentativo atomici e dell'invalidazione delle linee di cache tra i core.

Utilizzare un mutex quando:

  • Il numero di thread è basso (inferiore a 4-8) o la contesa è infrequente
  • La sezione critica è lunga e complessa (i cicli senza blocco diventano costosi rispetto alla sezione).
  • La correttezza è più importante della velocità di elaborazione e l'algoritmo è più semplice con un blocco
  • La piattaforma dispone di un efficiente sistema di bloccaggio basato su futex (Linux pthread_mutex, Windows SRWLOCK)

Utilizzare senza blocco quando:

  • Il numero di thread è elevato e la contesa è sostenuta
  • Le operazioni sono brevi (l'overhead del ciclo CAS è ridotto rispetto alla durata dell'operazione).
  • Il blocco è inaccettabile (thread in tempo reale, gestori di interrupt, gestori di segnali).
  • Composizione con altre strutture senza blocco dove un blocco introdurrebbe dipendenze dall'ordine di blocco

Utilizzare la modalità senza attesa quando:

  • Ogni thread deve essere completato entro un tempo limitato, indipendentemente dagli altri thread.
  • Requisiti rigorosi in tempo reale o critici per la sicurezza, dove la fame è inaccettabile
  • L'algoritmo può essere strutturato per supportare il completamento a passi limitati per thread

La disciplina della programmazione senza blocchi non consiste nell'adottarla ovunque, ma precisamente laddove le sue proprietà, ovvero l'avanzamento non bloccante, l'eliminazione dei deadlock e la tolleranza alla prelazione, corrispondono ai requisiti effettivi del sistema.