Un mutex es sencillo. Se bloquea, se modifica el estado compartido y se desbloquea. Cada hilo que desea acceder espera su turno. El problema radica en esa espera, ya que con un gran número de núcleos y un alto rendimiento, la serialización se convierte en un obstáculo. Un hilo que mantiene un bloqueo puede ser interrumpido, lo que provoca que todos los demás hilos permanezcan inactivos. La inversión de prioridad puede causar que los hilos de baja prioridad bloqueen a los de alta prioridad. En sistemas que procesan millones de operaciones por segundo en decenas de núcleos, la contención de mutex no solo ralentiza el sistema, sino que colapsa el rendimiento.
Analizar la lógica de concurrencia
SMART TS XL Analiza las rutas de las operaciones atómicas, los patrones de acceso a la memoria compartida y los datos entre hilos.
Explora ahoraLas estructuras de datos sin bloqueo reemplazan la exclusión mutua con operaciones atómicas. En lugar de prevenir conflictos, los toleran y se recuperan de ellos. Un hilo que falla en una operación de comparación e intercambio reintenta con un estado actualizado. Ningún hilo bloquea a otro. Al menos un hilo siempre avanza. El resultado es un rendimiento notablemente superior bajo alta contención, una latencia predecible sin efectos de convoy y la eliminación de interbloqueos e inversión de prioridad por diseño. El precio es la complejidad de la implementación: el problema ABA, los riesgos de recuperación de memoria, el falso compartimiento y los requisitos sutiles de ordenación de memoria son trampas que no existen en el código basado en bloqueos. Este artículo aborda todas ellas con código concreto.
Sin bloqueo vs. sin espera vs. mutex: ¿Cuál usar?
Antes de elegir un enfoque de implementación, la pregunta clave es qué garantía de progreso necesita realmente el sistema. Los tres modelos difieren en lo que prometen cuando los hilos compiten entre sí.
| Modelo | Garantía de progreso | Latencia típica | Complejidad: | Uso recomendado |
|---|---|---|---|---|
| Mutex / basado en bloqueo | Bloqueo, los hilos esperan | Impredecible en disputa | Bajo | Estado compartido de baja contención, requisitos de corrección sencillos |
| Sin cerradura | En todo el sistema, al menos un hilo avanza. | Bajo bajo contención | Alto | Colas, pilas y contadores de alto rendimiento |
| Sin esperas | Por hilo, cada hilo termina en pasos delimitados. | peor caso limitado | Muy alto | Sistemas en tiempo real, de seguridad crítica, con estrictos acuerdos de nivel de servicio (SLA) de latencia. |
| Sin obstrucciones | Progreso en solitario, solo cuando no haya oposición. | Bajo sin contienda | Media | Prototipos de memoria transaccional, contextos de investigación |
Sin cerradura Es la opción predeterminada práctica para sistemas de producción de alta concurrencia. La cola de Michael-Scott, la pila de Treiber y la mayoría de los búferes circulares MPMC de producción no utilizan bloqueos. Los hilos individuales pueden quedarse sin recursos en situaciones de contención extrema, pero el sistema en su conjunto sigue funcionando.
Sin esperas Garantiza un progreso limitado por hilo, pero requiere algoritmos significativamente más complejos. Existen construcciones universales, pero conllevan una sobrecarga considerable. Los algoritmos sin espera son apropiados para contextos de tiempo real estricto, donde la latencia de cola es más importante que el rendimiento promedio.
Sin obstrucciones Rara vez se utiliza directamente en producción. Aparece en algunas implementaciones de memoria transaccional y sirve como punto de partida para demostrar la corrección de los algoritmos.
Para la mayoría de los sistemas de alta concurrencia: utilice un mutex cuando la contención sea baja y la corrección sea simple, utilice un protocolo sin bloqueo cuando el rendimiento bajo contención sea importante, utilice un protocolo sin espera solo cuando la latencia por hilo en el peor de los casos sea un requisito estricto.
¿Qué es una estructura de datos sin bloqueo?
Una estructura de datos es sin candado Si garantiza que al menos un hilo entre todos los que operan sobre él completará su operación en un número finito de pasos, independientemente de lo que hagan los demás hilos o de cómo estén programados. Esta definición formal tiene una implicación precisa: ningún algoritmo sin bloqueo puede sufrir un interbloqueo. Si un hilo se detiene, se suspende o se ejecuta lentamente, los demás hilos continúan avanzando.
El mecanismo son operaciones atómicas, instrucciones de CPU que se ejecutan como una sola unidad indivisible. La primitiva universal es Comparación e Intercambio (CAS):
CAS(location, expected, new_value):
if *location == expected:
*location = new_value
return true
else:
return false // someone else changed it first
CAS es atómico a nivel de hardware. En x86 es el CMPXCHG instrucción. En ARM se implementa mediante LDXR/STXR (carga exclusiva/almacenamiento exclusivo), que es la variante LL/SC (carga vinculada/almacenamiento condicional). Un hilo lee un valor, calcula un nuevo valor y utiliza CAS para instalarlo solo si nadie más lo ha modificado. Si CAS falla, el hilo vuelve a intentarlo con el nuevo valor.
En C++11 y versiones posteriores, esto se expone a través de 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 es la primitiva CAS. En caso de fallo, se actualiza expected al valor actual automáticamente, lo que hace que el bucle de reintento sea idiomático.
El problema del ABA: el escollo más peligroso del sistema Lock-Free.
El problema ABA es el riesgo de corrección más contraintuitivo en la programación sin bloqueo. CAS comprueba si una ubicación de memoria aún contiene el valor esperado antes de instalar uno nuevo. No puede detectar si ese valor se modificó y se volvió a cambiar entre la lectura y la comprobación de CAS. La ubicación aún parece tener el valor original, pero el estado subyacente del sistema ha cambiado de maneras que CAS no puede percibir.
El escenario paso a paso
Consideremos una pila sin bloqueo que utilice un único puntero. top:
- El hilo A dice
top = Node1El siguiente puntero del Nodo1 es el Nodo2. - El hilo A es interrumpido antes de completar su pop.
- El hilo B elimina el Nodo1 (top se convierte en Nodo2), luego elimina el Nodo2 (top se convierte en nulo).
- El hilo B inserta un nuevo nodo, que casualmente se asigna en la misma dirección de memoria que Node1 (algo común en los asignadores de lista libre). Top = Node1 (mismo puntero, contenido diferente).
- El hilo A se reanuda. Su CAS ve
top == Node1(valor esperado), tiene éxito y establecetop = Node2, pero Node2 ya estaba liberado. Desastre.
El problema ABA se manifiesta de manera diferente en cada estructura de datos:
- Apilar: CAS tiene éxito pero instala un puntero siguiente obsoleto, lo que provoca un error de uso de memoria liberada.
- Cola: El puntero de cabeza o cola aparece sin cambios, pero la estructura ha cambiado.
- Lista enlazada: Un nodo parece seguir en su posición, pero ha sido eliminado y reasignado.
¿Evita LL/SC el problema del ABA?
Sí, LL/SC (conexiones de carga/almacenamiento condicional) proporciona RESULTADOS La semántica es diferente a la de CAS y, naturalmente, evita ABA. LL marca la dirección de memoria cargada como "vinculada". SC en la misma dirección falla si se produjo algún almacenamiento en esa dirección desde LL, incluso si el valor se restauró a su estado original. El historial de modificaciones se registra a nivel de hardware, no solo el valor actual.
Sin embargo, las implementaciones de LL/SC en hardware real presentan limitaciones prácticas. En ARM y POWER, pueden producirse fallos espurios de LL/SC incluso sin contención (cambios de contexto, desalojo de caché). El código debe contemplar los bucles de reintento incluso sin conflictos reales. En x86, no existe LL/SC nativo; CAS es la primitiva de hardware. Para el código x86, debe evitarse ABA mediante software.
Cómo mejorar el ABA: Tres enfoques
1. Contadores de versión (punteros etiquetados)
Empaqueta un contador de versiones en la misma palabra atómica que el puntero. Cada operación CAS exitosa incrementa el contador. Incluso si un puntero regresa a su valor original, el contador difiere:
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);
}
Este es el enfoque estándar en la práctica. La etiqueta de 16 bits se reinicia después de 65,536 operaciones, lo cual es teóricamente inseguro pero prácticamente adecuado para la mayoría de los sistemas; es extremadamente improbable que un hilo sea interrumpido durante exactamente 65,536 ciclos CAS en la misma ubicación.
2. Indicadores de peligro
Cada hilo mantiene un pequeño conjunto de "punteros de riesgo", que son punteros a los nodos a los que está accediendo actualmente. Antes de liberar cualquier nodo, un hilo comprueba todos los registros de punteros de riesgo para confirmar que ningún otro hilo esté accediendo a él. Un nodo que aparece en un puntero de riesgo se pospone hasta que se borre dicho puntero.
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;
}
Los punteros de riesgo impiden la optimización de acceso aleatorio (ABA) a nivel de recuperación de memoria: un nodo no puede reutilizarse mientras algún hilo mantenga un puntero de riesgo hacia él. Esto requiere examinar todos los punteros de riesgo de los hilos antes de liberarlos, lo que añade una sobrecarga proporcional al número de hilos.
3. Recuperación basada en épocas (EBR)
Los hilos operan en “épocas” que se rastrean globalmente. Los nodos retirados se almacenan en búfer por época y solo se liberan cuando todos los hilos han superado la época en la que se retiró el nodo. EBR es más sencillo de usar que los punteros de riesgo y tiene una menor sobrecarga por operación, a costa de un crecimiento de memoria limitado pero impredecible durante los períodos de inactividad.
En Java, AtomicStampedReference Aborda directamente el problema ABA emparejando una referencia con una marca de tiempo entera:
Java
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));
}
Implementaciones de colas sin bloqueo
Las colas son la estructura sin bloqueo más utilizada. La cola sin bloqueo canónica es la cola de Michael-Scott, que utiliza dos punteros (cabeza y cola) y un nodo centinela para permitir operaciones concurrentes de inserción y extracción de elementos.
Cola SPSC: Máximo rendimiento con mínima sincronización
Una cola de un solo productor y un solo consumidor elimina toda contención de escritura-escritura y lectura-lectura. Un hilo escribe en el final; otro hilo lee desde el principio. Solo el puntero al principio se comparte entre el productor y el consumidor.
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;
}
};
El alignas(64) La compartición de la caché en la cabeza y la cola es fundamental. Sin ella, ambas se almacenan en la misma línea de caché. Cada escritura en la cola provoca una invalidación de la línea de caché visible para el núcleo que lee la cabeza, y viceversa, lo que genera una compartición falsa que serializa operaciones que deberían ser independientes.
Cola MPMC: multiproductor, multiconsumidor
Una cola MPMC totalmente concurrente es significativamente más compleja. Las implementaciones de producción utilizan un número de secuencia por ranura para coordinar productores y consumidores sin bloqueo:
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;
}
};
Cola concurrente de .NET: cómo funciona internamente
.NET ConcurrentQueue<T> En .NET 5+ se utiliza una estructura de matriz segmentada. Cada segmento es una matriz de tamaño fijo con un índice de cabeza y cola gestionado con Interlocked operaciones (que se asignan a CAS en el hardware subyacente). Los segmentos están vinculados a través de un valor volátil _nextSegment puntero. Enqueue agrega al segmento final actual, haciendo crecer la cadena de segmentos cuando está llena. Dequeue lee del segmento inicial y avanza el índice del segmento inicial de forma atómica.
La decisión clave de diseño es evitar un bloqueo global. Los productores compiten solo en el índice de cola del segmento actual. Los consumidores compiten solo en el índice de cabeza. Ningún productor compite jamás con un consumidor. Esto hace que ConcurrentQueue<T> altamente eficiente para oleoductos productor-consumidor:
CSharp
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);
Coherencia de caché y compartición falsa en código sin bloqueo
La coherencia de caché es la variable de rendimiento invisible en las implementaciones sin bloqueo. Cuando un núcleo escribe en una ubicación de memoria, la línea de caché completa de 64 bytes que contiene esa ubicación se invalida en las cachés de todos los demás núcleos. Estos deben recuperar la línea actualizada antes de poder leerla. En código sin bloqueo con actualizaciones atómicas frecuentes, esta invalidación de la línea de caché puede convertirse en el principal coste.
Compartir información falsa Esto ocurre cuando dos hilos modifican variables diferentes que comparten una línea de caché. Ninguno de los hilos accede a los mismos datos, pero el protocolo de coherencia de caché trata toda la línea de caché como si estuviera en conflicto:
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
};
El protocolo MESI (Modificado, Exclusivo, Compartido, Inválido) describe cómo las CPU x86 coordinan la propiedad de las líneas de caché. Cuando un núcleo desea escribir en una ubicación que comparte con otros núcleos (estado Compartido), debe enviar una solicitud de escritura, esperar la confirmación de todos los que comparten la caché y pasar al estado Modificado. Este ciclo completo con el protocolo de coherencia tarda entre 50 y 300 ciclos o más en un sistema con múltiples sockets. El falso uso compartido convierte las operaciones locales de subprocesos independientes en tráfico de coherencia de caché, lo que reduce el rendimiento, que debería escalar linealmente con el número de núcleos.
Detección de compartición falsa: utilizar perf stat -e cache-misses,L1-dcache-load-misses en Linux o el analizador de rendimiento de CPU de Visual Studio en Windows. Una alta tasa de fallos de caché L1 en un programa multihilo que cabe en la caché es un fuerte indicador de compartición falsa.
Ordenación de la memoria: ¿Por qué? memory_order_relaxed No siempre es suficiente
C + + std::atomic Las operaciones exponen todo el rango de ordenamiento de memoria del modelo de memoria de C++11. Elegir un ordenamiento incorrecto es el segundo error más común en el código sin bloqueo, después del 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 error común: usar relaxed para una bandera de lanzamiento. Un hilo que establece ready = true con relaxed El pedido no garantiza que los escritos anteriores a data son visibles para otros hilos que leen readyEl par adquisición/liberación crea la relación previa que conecta al escritor y al lector.
La pila Treiber: pila sin bloqueo en C++
La pila de Treiber es la estructura de datos sin bloqueo no trivial más simple. Utiliza un bucle CAS en un único top puntero:
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
}
};
El comentario sobre delete node Es fundamental: la eliminación ingenua es el riesgo ABA descrito anteriormente. En una pila Treiber de producción, la recuperación de nodos requiere punteros de riesgo, recuperación basada en épocas o recolección de basura (Java/C# lo gestiona automáticamente).
Estructuras de datos sin bloqueo en Java
La biblioteca estándar de Java proporciona implementaciones sin bloqueo de calidad de producción en java.util.concurrent:
| Clase | Estructura | Progreso | Notas |
|---|---|---|---|
AtomicReference<T> | Valor único | Sin cerradura | CAS en referencia de objeto |
AtomicStampedReference<T> | Valor + sello | Sin cerradura | Prevención de ABA mediante contador de versiones |
ConcurrentLinkedQueue<T> | Cola de Michael Scott | Sin cerradura | FIFO, sin límites |
ConcurrentLinkedDeque<T> | deque sin cerradura | Sin cerradura | ambos extremos |
LongAdder | Para contrarrestar | Sin cerradura | Conteo de alto rendimiento con franjas |
LongAdder Vale la pena destacar específicamente. En lugar de un único contador atómico que compite en todos los hilos, mantiene una matriz de contadores segmentada, cada uno accedido por un subconjunto de hilos. La contención se distribuye entre las franjas en lugar de concentrarse en una sola ubicación. El total se suma de forma diferida en sum(). Para operaciones de incremento de alta frecuencia en muchos hilos, LongAdder supera drásticamente AtomicLong.incrementAndGet():
Java
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
Cómo SMART TS XL Admite el desarrollo de sistemas sin bloqueo
El código sin bloqueo es uno de los más difíciles de desarrollar correctamente. Los errores no son deterministas, aparecen solo bajo ciertas intercalaciones y, a menudo, solo en entornos de producción a gran escala. El análisis estático aborda este problema examinando las propiedades estructurales del código antes de su ejecución, en lugar de esperar a que se manifieste una condición de carrera.
SMART TS XL Analiza las rutas de ejecución completas del código concurrente, rastreando cómo las operaciones atómicas se relacionan con las ubicaciones de memoria que protegen. Para sistemas que combinan código sin bloqueo con componentes heredados o arquitecturas multilingües, proporciona la visibilidad entre límites que las herramientas de un solo lenguaje no pueden ofrecer: cómo una región de memoria compartida a la que accede un componente C++ sin bloqueo se relaciona con el servicio Java que lee de ella, o cómo una cola concurrente alimenta una canalización de procesamiento basada en COBOL.
El análisis de código estático La capacidad identifica patrones asociados con riesgos de concurrencia: cargas atómicas sin semántica de adquisición correspondiente, bucles CAS que no actualizan el valor esperado en caso de fallo, estructuras de datos compartidas donde los campos a los que acceden diferentes hilos están ubicados conjuntamente sin relleno de alineación. análisis de impacto Esta capacidad permite rastrear qué componentes posteriores dependen de una estructura de datos concurrente compartida, de modo que los cambios en la interfaz o la estrategia de recuperación de la estructura puedan definirse correctamente antes de que se realice el cambio.
Para sistemas empresariales donde los componentes sin bloqueo deben coexistir con el procesamiento por lotes heredado, el middleware de colas de mensajes y las capas de servicio modernas, SMART TS XL, mapeo de dependencia Proporciona una visión arquitectónica de cómo las rutas de datos concurrentes conectan componentes escritos en diferentes lenguajes a lo largo de toda la pila del sistema.
Cuando la opción sin cerradura no es la elección correcta
Las estructuras sin bloqueo no siempre son mejores que un mutex. La conveniencia de usar estructuras sin bloqueo depende del perfil de contención de la carga de trabajo. Con pocos hilos o baja contención, un mutex bien implementado es más rápido porque evita la sobrecarga de los bucles de reintento atómicos y la invalidación de líneas de caché en todos los núcleos.
Utilice un mutex cuando:
- El número de subprocesos es bajo (inferior a 4-8) o la contención es poco frecuente.
- La sección crítica es larga y compleja (los bucles sin bloqueo resultan costosos en relación con la sección).
- La corrección es más importante que el rendimiento y el algoritmo es más simple con un bloqueo.
- La plataforma cuenta con un sistema de bloqueo eficiente basado en futex (Linux).
pthread_mutex, Windows SRWLOCK)
Utilice el sistema sin bloqueo cuando:
- El número de subprocesos es alto y la contención es constante.
- Las operaciones son cortas (la sobrecarga del bucle CAS es pequeña en relación con la operación).
- El bloqueo es inaceptable (hilos en tiempo real, manejadores de interrupciones, manejadores de señales).
- Composición con otras estructuras sin bloqueo donde un bloqueo introduciría dependencias de orden de bloqueo.
Utilice wait-free cuando:
- Cada hilo debe completarse en un tiempo límite, independientemente de los demás hilos.
- Requisitos estrictos en tiempo real o de seguridad crítica donde la inanición es inaceptable.
- El algoritmo puede estructurarse para admitir la finalización de pasos limitados por hilo.
La disciplina de la programación sin bloqueos no consiste en elegirla en todas partes, sino en elegirla precisamente donde sus propiedades (progreso sin bloqueos, eliminación de interbloqueos, tolerancia a la interrupción) coinciden con los requisitos reales del sistema.