Identificación del Problema de Concurrencia
Al intentar optimizar la inserción masiva de registros en una base de datos, se optó por utilizar Parallel.ForEach para aprovechar el procesamiento multinúcleo. La implementación inicial con Entity Framework utilizaba DbSet.Add() dentro del bucle paralelo:
using (var context = new ApplicationDbContext())
{
Parallel.ForEach(Enumerable.Range(1, 1000), index =>
{
var newAccount = new UserAccount
{
AccountId = $"ACC_{index}",
Balance = 0.0m,
CreatedAt = DateTime.UtcNow,
Username = $"user_{index}",
IsActive = true
};
context.UserAccounts.Add(newAccount);
});
context.SaveChanges();
}
Esta aproximación generó excepciones intermitentes y difíciles de reproducir de manera consistente. Los errores más frecuentes incluían:
NullReferenceException: Referencia a objeto no establecida como instancia de un objeto.ArgumentException: Ya se ha agregado un elemento con la misma clave.InvalidOperationException: Colección modificada; puede que no se ejecute la operación de enumeración.MappingException: Un tipo EdmType no se puede asignar varias veces a una clase CLR.
Estos fallos ocurrían principalmente durante la invocación al método Add, indicando una corrupción del estado interno del contexto.
Refactorización Inicial y Fallos Ocultos
Para evitar la manipulación directa del DbContext en múltiples hilos, se modificó la lógica para acumular las entidades en una lista genérica List<T> y posteriormente utilizar AddRange:
var accountsToInsert = new List<UserAccount>();
using (var context = new ApplicationDbContext())
{
Parallel.ForEach(Enumerable.Range(1, 1000), index =>
{
var newAccount = new UserAccount
{
AccountId = $"ACC_{index}",
Balance = 0.0m,
CreatedAt = DateTime.UtcNow,
Username = $"user_{index}",
IsActive = true
};
accountsToInsert.Add(newAccount);
});
context.UserAccounts.AddRange(accountsToInsert);
context.SaveChanges();
}
Aunque las pruebas con volúmenes de datos pequeños parecían exitosas, al escalar a 1000 iteraciones, la lista resultante presentaba inconsistencias: el conteo final de elementos era inferior al esperado y algunos índices contenían valores null. Este comportamiento aleatorio indicaba claramente un problema de condición de carrera.
Análisis de la Causa Raíz: Seguridad de Subprocesos
La documentación oficial de .NET especifica que List<T> no es segura para subprocesos (thread-safe) cuando se realizan operaciones de escritura simultáneas. Para corroborar esta hipótesis, se implementó un mecanismo de bloqueo (lock) alrededor de la operación de adición:
var lockObject = new object();
Parallel.ForEach(Enumerable.Range(1, 1000), index =>
{
var newAccount = new UserAccount
{
AccountId = $"ACC_{index}",
Balance = 0.0m,
CreatedAt = DateTime.UtcNow,
Username = $"user_{index}",
IsActive = true
};
lock (lockObject)
{
accountsToInsert.Add(newAccount);
}
});
Con la sincronización aplicada, la lista mantuvo la integridad de los datos sin elementos nulos ni pérdidas de registros.
Este hallazgo llevó a revisar la documentación de DbContext y DbSet en Entity Framework. Ambos componentes están diseñados para ser utilizados en un único hilo de ejecución. Las excepciones iniciales eran el resultado directo de múltiples hilos intentando mutar el estado interno del contexto de base de datos simultáneamente.
Evaluación de Rendimiento de Colecciones Concurrentes
Como alternativa a los bloqueos manuales, se evaluó el uso de colecciones thread-safe proporcionadas por el framework, como ConcurrentBag<T> y ConcurrentQueue<T>, las cuales utilizan operaciones atómicas en lugar de bloqueos tradicionales. Se realizó una comparativa de rendimiento entre:
- Bucle de un solo hilo con
List<T>. - Bucle paralelo con
List<T>y bloqueos (lock). - Bucle paralelo con
ConcurrentBag<T>. - Bucle paralelo con
ConcurrentQueue<T>.
Los resultados de las pruebas de estrés revelaron que, para un número elevado de iteraciones, las colecciones concurrentes introducen una sobrecarga significativa debido a la sincronización interna, resultando en un rendimiento inferior al de un bucle secuencial simple. Para volúmenes pequeños, la diferencia de rendimiento es marginal y no justifica la complejidad adicional.
Resolución Definitiva
Dado que List<T> y DbSet no son seguros para subprocesos, y considerando que la sobrecarga de sincronización en colecciones concurrentes anula los beneficios del paralelismo en operaciones de construcción de objetos en memoria, la solución óptima para este escenario específico es abandonar el paralelismo en la capa de construcción de entidades. La implemenatción final utiliza un bucle secuencial estándar para poblar la lista y una única llamada a AddRange para la inserción en el contexto, garantizando la integridad de los datos y un rendimiento predecible sin la complejidad de la gestión de concurrencia.