1. ¿Por qué el lock habitual puede ser insuficiente?
Pensemos: tenemos un recurso compartido —por ejemplo, una lista de clientes que guardamos en nuestra aplicación multihilo. La mayor parte del tiempo sólo leemos esa lista —por ejemplo, mostrando datos en la UI, generando informes, filtrando y ordenando información. Sólo de vez en cuando alguien añade un cliente nuevo o elimina uno viejo.
Si usamos el habitual lock, cualquier hilo que quiera leer datos tiene que esperar a que otro hilo termine de leer o, peor, de escribir. ¿Y si las lecturas son alrededor del 99% de las operaciones? Entonces estamos ralentizando a todos los lectores innecesariamente, cuando podrían ejecutarse en paralelo.
Aquí entra en juego un tipo especial de bloqueo —el Reader-Writer Lock (candado para lectores y escritores). La idea clave: muchos hilos pueden leer simultáneamente, pero si alguien quiere modificar algo, adquiere acceso exclusivo, bloqueando a los lectores.
2. Teoría: ¿qué es ReaderWriterLockSlim?
Breve sobre las clases
- ReaderWriterLock — más antiguo, lento, puede provocar deadlocks. Usar sólo si necesitas soportar .NET Framework 2.0.
- ReaderWriterLockSlim — alternativa moderna y rápida (la palabra Slim: «delgado», «ligero»). Esta clase está optimizada para operaciones frecuentes concurrentes de lectura/escritura y se recomienda cuando hay muchos hilos y se lee con mucha más frecuencia que se escribe.
Principio de funcionamiento
ReaderWriterLockSlim implementa tres modos de bloqueo:
- Read Lock (bloqueo de lectura): muchos hilos pueden leer al mismo tiempo.
- Write Lock (bloqueo de escritura): sólo un hilo puede modificar los datos, y ningún otro hilo lee.
- Upgradeable Read Lock (bloqueo de lectura escalable): sólo un hilo puede tener este candado a la vez; lee y, si hace falta, puede «elevarse» a write lock.
Esquema de bloqueo
| Hilo-Lector | Hilo-Escritor | Hilo con Upgradeable Read Lock | |
|---|---|---|---|
| Lector | ✅ | ⛔ | ✅/⛔ |
| Escritor | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — permitido
⛔ — bloqueado
* — sólo si el upgradeable read lock no se ha «elevado» a write
Cuándo (y cuándo no) usar ReaderWriterLockSlim
Recomendado:
- Muchos hilos concurrentes que principalmente leen datos compartidos (catálogos, caché, configuraciones).
- Las escrituras son raras (por ejemplo, una vez por segundo/minuto/hora).
- Importa maximizar el rendimiento de lectura sin bloquear a los hilos.
No recomendado:
- Las lecturas/escrituras ocurren en proporciones similares (mejor usar el lock habitual).
- Los datos cambian con frecuencia (la ganancia es pequeña).
3. Uso de ReaderWriterLockSlim en la práctica
Vamos a ampliar nuestra aplicación de ejemplo. Antes construimos un directorio simple de clientes y probamos acceso multihilo a la colección. Ahora complicamos la tarea: supongamos que tenemos una lista de clientes a la que acceden varios hilos (por ejemplo, un servicio notifica a todos los clientes sobre una promoción, mientras otro hilo añade nuevos clientes).
Primero describamos nuestra colección de clientes:
// Clase Client — un personaje conocido de las clases anteriores
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
Ahora creamos un wrapper para acceso thread-safe usando ReaderWriterLockSlim:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// Slim-lock para lectura/escritura
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// Añadir cliente (write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // Sólo un hilo puede añadir cliente
try
{
_clients.Add(client);
Console.WriteLine($"[Hilo {Thread.CurrentThread.ManagedThreadId}] Cliente añadido {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// Obtener copia de la lista de clientes (read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // Muchos hilos pueden leer simultáneamente
try
{
// Devolvemos una copia para que nadie intente modificar el original por accidente
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. Lectura multihilo y escritura periódica
Supongamos que hay 5 hilos que periódicamente leen todos los clientes ("generan informes" o simplemente miran quién está registrado), y aparte un hilo que a veces añade un nuevo cliente.
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// Hilo para añadir clientes
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"Cliente {i}" });
Thread.Sleep(700); // Simulación de operaciones largas
}
});
// Varios lectores
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[Lector {readerId}][Hilo {Thread.CurrentThread.ManagedThreadId}] Total de clientes: {clients.Count}");
Thread.Sleep(200); // Leen más seguido que se escribe
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// Dejamos a los lectores terminar su ronda:
Thread.Sleep(3000);
}
}
Casi siempre varios hilos pueden leer la lista de clientes simultáneamente (sin esperar entre ellos), pero cuando un hilo añade un cliente —los demás hilos deben esperar hasta que la operación de escritura termine. Esto permite atender a muchos lectores sin bloquearlos innecesariamente.
5. Bloqueo escalable: UpgradeableReadLock
A veces ocurre que un hilo lee los datos y sólo ocasionalmente, si se cumple cierta condición, quiere modificarlos. Podría parecer suficiente tomar un read lock, comprobar la condición, luego salir del read lock y adquirir un write lock. Pero en el intervalo "entre" otro hilo podría haber cambiado los datos. Aquí entra la bloqueo escalable.
Usarlo es sencillo:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // Sólo un hilo puede tener este bloqueo
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // Elevamos a write lock
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
Primero el hilo lee, y si necesita cambiar algo —eleva el nivel de bloqueo sin soltar la colección a otros hilos.
6. Matices útiles
Comparación: ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| Lectura concurrente | No | Sí |
| Escritura concurrente | No | No |
| Soporte para lectura escalable | No | Sí (EnterUpgradeableReadLock) |
| Velocidad | Muy rápido | Más rápido cuando hay muchas lecturas |
| Memoria | Mínima | Un poco más |
| Simplicidad | Fácil | Más complejo, con matices |
Cómo se ve en proyectos reales
En aplicaciones grandes, donde hay tablas enormes o cachés que son leídos por cientos de hilos pero modificados raramente, usar ReaderWriterLockSlim ayuda a evitar cuellos de botella en lectura. Por ejemplo:
- Caché de configuración que usan microservicios.
- Almacenamiento de catálogos para cálculos financieros.
- Caché interno para enrutamiento de mensajes.
Subrayo —en la mayoría de los casos, especialmente si estás empezando con la concurrencia, el lock habitual es más simple y seguro. ReaderWriterLockSlim es para escenarios donde realmente hay lecturas concurrentes frecuentes.
Visualización: esquema de acceso
flowchart TD
A[Hilo-Lector 1] --Read--> D[ReaderWriterLockSlim]
B[Hilo-Lector 2] --Read--> D
C[Hilo-Escritor] --Write--> D
D --Read permitido--> E[Lectura de la colección compartida]
D --Write bloquea lectura/escritura--> F[Escritura en la colección compartida]
Mientras sólo hay lecturas —el acceso se abre para todos; al intentar escribir —todos esperan hasta que la escritura termine.
7. ReaderWriterLockSlim: matices y errores típicos
Lock Recursion (recursión de bloqueo): Por defecto ReaderWriterLockSlim no permite que el mismo hilo adquiera repetidamente el mismo tipo de bloqueo. Es decir, si un hilo ya tiene un read lock y intenta tomarlo de nuevo —se lanzará una excepción. Igual con el write lock. Sin embargo, el UpgradeableReadLock puede elevarse a write lock desde dentro.
No confundas el orden: Adquiere EnterUpgradeableReadLock() → dentro de él EnterWriteLock(). ¡Pero no al revés!
Excepciones: Si no liberas el lock (por ejemplo, no llamas a ExitWriteLock()), otros hilos esperarán para siempre. Por eso siempre usa la estructura try { ... } finally { ... }.
No lo mantengas mucho tiempo: No hagas operaciones IO largas dentro del candado. Cuanto más tiempo un hilo mantenga la bloqueo, más latencia habrá para los demás. Procura salir del lock lo antes posible.
Evalúa la complejidad: No uses ReaderWriterLockSlim para proteger operaciones pequeñas donde el lock habitual es más simple y rápido.
GO TO FULL VERSION