ECS Multi-Threading¶
Learn how to safely use multi-threading and parallel processing with Brine2D's Entity Component System.
Overview¶
Multi-threading can significantly improve performance when processing large numbers of entities:
- Parallel Systems - Process entities across multiple threads
- Job System - Queue and execute work asynchronously
- Thread Safety - Avoid race conditions and data corruption
- Synchronization - Coordinate work between threads
- Performance - When to use (and when not to use) threading
Important: Multi-threading adds complexity. Only use it when single-threaded performance is insufficient.
Threading Model¶
graph TB
A[Main Thread] --> B[Update Systems]
B --> C{Parallel Safe?}
C -->|Yes| D[Parallel Processing]
C -->|No| E[Single-Threaded]
D --> F[Worker Thread 1]
D --> G[Worker Thread 2]
D --> H[Worker Thread N]
F --> I[Process Entities]
G --> I
H --> I
I --> J[Synchronization Point]
J --> K[Continue Main Thread]
E --> K
style A fill:#2d5016,stroke:#4ec9b0,stroke-width:2px,color:#fff
style D fill:#4a2d4a,stroke:#c586c0,stroke-width:2px,color:#fff
style J fill:#1e3a5f,stroke:#569cd6,stroke-width:2px,color:#fff
Key concepts:
- Main Thread - Game loop, rendering, input
- Worker Threads - Entity processing, physics
- Synchronization - Ensure all work completes before continuing
- Thread Safety - Prevent concurrent access to shared data
Thread Safety Basics¶
Read vs Write Access¶
public class ThreadSafetyExample
{
// ✅ SAFE: Multiple threads reading same data
public void ReadOnlyOperation()
{
var entities = world.QueryEntities()
.With<TransformComponent>();
Parallel.ForEach(entities, entity =>
{
var transform = entity.GetComponent<TransformComponent>();
var position = transform.Position; // Reading is safe
ProcessPosition(position);
});
}
// ❌ UNSAFE: Multiple threads writing same data
public void WriteOperation()
{
var entities = world.QueryEntities()
.With<TransformComponent>();
Parallel.ForEach(entities, entity =>
{
var transform = entity.GetComponent<TransformComponent>();
transform.Position += Vector2.One; // Writing is NOT SAFE!
});
}
// ✅ SAFE: Each thread writes to different data
public void IsolatedWriteOperation()
{
var entities = world.QueryEntities()
.With<TransformComponent>();
Parallel.ForEach(entities, entity =>
{
var transform = entity.GetComponent<TransformComponent>();
// Each entity is separate, so this is safe
transform.Position += Vector2.One;
});
}
}
Rules:
| Access Pattern | Thread Safety | Example |
|---|---|---|
| Multiple reads | ✅ Safe | Reading positions |
| Single write | ✅ Safe | One thread updates |
| Multiple writes to different data | ✅ Safe | Each entity separate |
| Multiple writes to same data | ❌ Unsafe | Shared counter |
Parallel Entity Processing¶
Basic Parallel ForEach¶
Process entities in parallel:
using System.Threading.Tasks;
public class ParallelMovementSystem : IUpdateSystem
{
private readonly World _world;
public string Name => "ParallelMovementSystem";
public int UpdateOrder => 100;
public ParallelMovementSystem(World world)
{
_world = world;
}
public void Update(GameTime gameTime)
{
var deltaTime = (float)gameTime.DeltaTime;
// Get entities
var entities = _world.QueryEntities()
.With<TransformComponent>()
.With<VelocityComponent>()
.ToList(); // Important: materialize query
// Process in parallel
Parallel.ForEach(entities, entity =>
{
var transform = entity.GetComponent<TransformComponent>();
var velocity = entity.GetComponent<VelocityComponent>();
if (transform != null && velocity != null)
{
// Safe: Each entity's data is independent
transform.Position += velocity.Velocity * deltaTime;
}
});
}
}
Important: Always materialize the query (.ToList()) before parallel processing!
Parallel Options¶
Control parallelism degree:
public class ConfigurableParallelSystem : IUpdateSystem
{
private readonly World _world;
private readonly ParallelOptions _parallelOptions;
public ConfigurableParallelSystem(World world)
{
_world = world;
// Configure parallel options
_parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
// Or limit threads: MaxDegreeOfParallelism = 4
};
}
public void Update(GameTime gameTime)
{
var entities = _world.QueryEntities()
.With<TransformComponent>()
.ToList();
// Use configured options
Parallel.ForEach(entities, _parallelOptions, entity =>
{
ProcessEntity(entity);
});
}
}
Synchronization¶
Thread-Safe Collections¶
Use concurrent collections for shared data:
using System.Collections.Concurrent;
public class CollisionDetectionSystem : IUpdateSystem
{
private readonly World _world;
private readonly ConcurrentBag<Collision> _collisions = new();
public string Name => "CollisionDetectionSystem";
public int UpdateOrder => 150;
public void Update(GameTime gameTime)
{
// Clear previous collisions
_collisions.Clear();
var entities = _world.QueryEntities()
.With<TransformComponent>()
.With<ColliderComponent>()
.ToList();
// Check collisions in parallel
Parallel.ForEach(entities, entity1 =>
{
foreach (var entity2 in entities)
{
if (entity1 == entity2) continue;
if (CheckCollision(entity1, entity2))
{
// Thread-safe add
_collisions.Add(new Collision(entity1, entity2));
}
}
});
// Process collisions on main thread
ProcessCollisions();
}
private void ProcessCollisions()
{
foreach (var collision in _collisions)
{
HandleCollision(collision);
}
}
}
Concurrent collections:
| Collection | Use Case | Performance |
|---|---|---|
ConcurrentBag<T> |
Unordered additions | Fast |
ConcurrentQueue<T> |
FIFO order | Fast |
ConcurrentDictionary<K,V> |
Key-value lookup | Good |
ConcurrentStack<T> |
LIFO order | Fast |
Lock-Based Synchronization¶
Use locks for complex shared state:
public class ScoreSystem : IUpdateSystem
{
private readonly World _world;
private readonly object _scoreLock = new();
private int _totalScore = 0;
public string Name => "ScoreSystem";
public int UpdateOrder => 200;
public void Update(GameTime gameTime)
{
var enemies = _world.QueryEntities()
.With<EnemyComponent>()
.With<HealthComponent>()
.ToList();
// Process enemies in parallel
Parallel.ForEach(enemies, enemy =>
{
var health = enemy.GetComponent<HealthComponent>();
if (health != null && health.IsDead)
{
// Lock when modifying shared state
lock (_scoreLock)
{
_totalScore += 10;
}
}
});
}
public int GetScore()
{
lock (_scoreLock)
{
return _totalScore;
}
}
}
Warning: Locks can reduce parallelism. Use sparingly!
Interlocked Operations¶
Atomic operations without locks:
using System.Threading;
public class CounterSystem : IUpdateSystem
{
private readonly World _world;
private int _entityCount = 0;
public void Update(GameTime gameTime)
{
var entities = _world.QueryEntities()
.With<TransformComponent>()
.ToList();
// Reset counter
_entityCount = 0;
// Count in parallel (atomic)
Parallel.ForEach(entities, entity =>
{
if (IsActive(entity))
{
Interlocked.Increment(ref _entityCount);
}
});
Logger.LogDebug("Active entities: {Count}", _entityCount);
}
}
Interlocked operations:
| Operation | Method | Use Case |
|---|---|---|
| Increment | Interlocked.Increment(ref x) |
Counters |
| Decrement | Interlocked.Decrement(ref x) |
Counters |
| Add | Interlocked.Add(ref x, value) |
Accumulators |
| Exchange | Interlocked.Exchange(ref x, value) |
Swap values |
| CompareExchange | Interlocked.CompareExchange(...) |
Conditional update |
Common Patterns¶
Parallel Physics Simulation¶
public class ParallelPhysicsSystem : IUpdateSystem
{
private readonly World _world;
private const float Gravity = 980f;
public string Name => "ParallelPhysicsSystem";
public int UpdateOrder => 50;
public void Update(GameTime gameTime)
{
var deltaTime = (float)gameTime.DeltaTime;
var entities = _world.QueryEntities()
.With<RigidbodyComponent>()
.ToList();
// Apply forces in parallel (each entity independent)
Parallel.ForEach(entities, entity =>
{
var rigidbody = entity.GetComponent<RigidbodyComponent>();
if (rigidbody != null)
{
// Apply gravity
if (rigidbody.UseGravity)
{
rigidbody.Velocity.Y += Gravity * deltaTime;
}
// Apply drag
rigidbody.Velocity *= (1.0f - rigidbody.Drag * deltaTime);
}
});
}
}
Parallel Pathfinding¶
public class ParallelPathfindingSystem : IUpdateSystem
{
private readonly World _world;
public string Name => "ParallelPathfindingSystem";
public int UpdateOrder => 30;
public void Update(GameTime gameTime)
{
var entities = _world.QueryEntities()
.With<AIComponent>()
.With<TransformComponent>()
.ToList();
// Calculate paths in parallel
Parallel.ForEach(entities, entity =>
{
var ai = entity.GetComponent<AIComponent>();
var transform = entity.GetComponent<TransformComponent>();
if (ai != null && transform != null && ai.NeedsPath)
{
// Each pathfinding operation is independent
var path = CalculatePath(
transform.Position,
ai.TargetPosition);
ai.CurrentPath = path;
ai.NeedsPath = false;
}
});
}
private List<Vector2> CalculatePath(Vector2 start, Vector2 end)
{
// A* or other pathfinding algorithm
// This can be expensive, so parallel processing helps
return new List<Vector2>();
}
}
Parallel Particle Updates¶
public class ParallelParticleSystem : IUpdateSystem
{
private readonly World _world;
public string Name => "ParallelParticleSystem";
public int UpdateOrder => 170;
public void Update(GameTime gameTime)
{
var deltaTime = (float)gameTime.DeltaTime;
var particles = _world.QueryEntities()
.With<ParticleComponent>()
.ToList();
if (particles.Count < 100)
{
// Not worth parallelizing for small counts
UpdateParticlesSingleThreaded(particles, deltaTime);
return;
}
// Parallel for large particle counts
Parallel.ForEach(particles, particle =>
{
var particleComp = particle.GetComponent<ParticleComponent>();
if (particleComp != null)
{
// Update lifetime
particleComp.Lifetime -= deltaTime;
// Update physics
var transform = particle.GetComponent<TransformComponent>();
if (transform != null)
{
transform.Position += particleComp.Velocity * deltaTime;
particleComp.Velocity += particleComp.Acceleration * deltaTime;
}
}
});
}
}
Performance Considerations¶
When to Use Parallelism¶
public class SmartParallelSystem : IUpdateSystem
{
private readonly World _world;
private const int ParallelThreshold = 1000;
public void Update(GameTime gameTime)
{
var entities = _world.QueryEntities()
.With<TransformComponent>()
.ToList();
// Only parallelize if enough work
if (entities.Count < ParallelThreshold)
{
// Single-threaded for small counts
foreach (var entity in entities)
{
ProcessEntity(entity);
}
}
else
{
// Parallel for large counts
Parallel.ForEach(entities, entity =>
{
ProcessEntity(entity);
});
}
}
}
Guidelines:
| Entity Count | Recommendation | Reason |
|---|---|---|
| < 100 | Single-threaded | Overhead > benefit |
| 100-1000 | Test both | Depends on work per entity |
| > 1000 | Parallel | Significant benefit |
Measuring Parallelism Benefit¶
using System.Diagnostics;
public class ParallelismBenchmark
{
public void BenchmarkSystem(World world, int iterations = 100)
{
var entities = world.QueryEntities()
.With<TransformComponent>()
.ToList();
// Benchmark single-threaded
var singleThreaded = Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
foreach (var entity in entities)
{
ProcessEntity(entity);
}
}
singleThreaded.Stop();
// Benchmark parallel
var parallel = Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
Parallel.ForEach(entities, entity =>
{
ProcessEntity(entity);
});
}
parallel.Stop();
// Compare
var speedup = (double)singleThreaded.ElapsedMilliseconds /
parallel.ElapsedMilliseconds;
Logger.LogInformation(
"Single: {Single}ms, Parallel: {Parallel}ms, Speedup: {Speedup:F2}x",
singleThreaded.ElapsedMilliseconds,
parallel.ElapsedMilliseconds,
speedup);
}
}
Unsafe Patterns¶
Race Conditions¶
public class RaceConditionExample
{
private int _counter = 0;
// ❌ BAD: Race condition!
public void UnsafeIncrement()
{
var entities = world.QueryEntities()
.With<EnemyComponent>()
.ToList();
Parallel.ForEach(entities, entity =>
{
// Multiple threads read-modify-write _counter
// Lost updates! Final value will be incorrect
_counter++;
});
}
// ✅ GOOD: Use Interlocked
public void SafeIncrement()
{
var entities = world.QueryEntities()
.With<EnemyComponent>()
.ToList();
Parallel.ForEach(entities, entity =>
{
Interlocked.Increment(ref _counter);
});
}
// ✅ GOOD: Use lock
public void SafeIncrementWithLock()
{
var lockObj = new object();
var entities = world.QueryEntities()
.With<EnemyComponent>()
.ToList();
Parallel.ForEach(entities, entity =>
{
lock (lockObj)
{
_counter++;
}
});
}
}
Shared Component Access¶
public class SharedComponentExample
{
// ❌ BAD: Multiple threads modifying same component
public void UnsafeSharedAccess()
{
var playerEntity = GetPlayer();
var playerTransform = playerEntity.GetComponent<TransformComponent>();
var enemies = world.QueryEntities()
.With<EnemyComponent>()
.ToList();
Parallel.ForEach(enemies, enemy =>
{
// Multiple threads writing to playerTransform!
playerTransform.Position += Vector2.One; // UNSAFE!
});
}
// ✅ GOOD: Accumulate changes, apply on main thread
public void SafeSharedAccess()
{
var playerEntity = GetPlayer();
var forces = new ConcurrentBag<Vector2>();
var enemies = world.QueryEntities()
.With<EnemyComponent>()
.ToList();
// Parallel: Calculate forces
Parallel.ForEach(enemies, enemy =>
{
var force = CalculateForce(enemy, playerEntity);
forces.Add(force); // Thread-safe collection
});
// Main thread: Apply forces
var playerTransform = playerEntity.GetComponent<TransformComponent>();
foreach (var force in forces)
{
playerTransform.Position += force;
}
}
}
Best Practices¶
DO¶
-
Materialize queries before parallel processing
// ✅ Good - materialize first var entities = world.QueryEntities() .With<TransformComponent>() .ToList(); // Important! Parallel.ForEach(entities, entity => { ... }); -
Use concurrent collections for shared data
// ✅ Good - thread-safe collection var results = new ConcurrentBag<Result>(); Parallel.ForEach(entities, entity => { results.Add(ProcessEntity(entity)); }); -
Benchmark before and after parallelization
// ✅ Good - measure actual improvement var stopwatch = Stopwatch.StartNew(); // ... parallel code stopwatch.Stop(); Logger.LogDebug("Parallel time: {Ms}ms", stopwatch.ElapsedMilliseconds); -
Use threshold checks
// ✅ Good - only parallelize when beneficial if (entities.Count > 1000) { Parallel.ForEach(entities, ProcessEntity); } else { foreach (var entity in entities) ProcessEntity(entity); } -
Keep work per entity substantial
// ✅ Good - expensive operation per entity Parallel.ForEach(entities, entity => { var path = CalculateExpensivePath(entity); // Worth parallelizing });
DON'T¶
-
Don't query during parallel processing
// ❌ Bad - query during parallel processing Parallel.ForEach(entities, entity => { var nearby = world.QueryEntities() // UNSAFE! .With<EnemyComponent>() .ToList(); }); // ✅ Good - query before parallel processing var entities = world.QueryEntities().With<Component>().ToList(); var nearby = world.QueryEntities().With<EnemyComponent>().ToList(); Parallel.ForEach(entities, entity => { ... }); -
Don't modify shared state without synchronization
// ❌ Bad - race condition int counter = 0; Parallel.ForEach(entities, entity => { counter++; // UNSAFE! }); // ✅ Good - use Interlocked int counter = 0; Parallel.ForEach(entities, entity => { Interlocked.Increment(ref counter); }); -
Don't parallelize small workloads
// ❌ Bad - too few entities var entities = world.QueryEntities().Take(10).ToList(); Parallel.ForEach(entities, ProcessEntity); // Overhead > benefit // ✅ Good - use single-threaded foreach (var entity in entities) { ProcessEntity(entity); } -
Don't create/destroy entities in parallel
// ❌ Bad - modifying world structure Parallel.ForEach(entities, entity => { world.CreateEntity(); // UNSAFE! world.DestroyEntity(entity); // UNSAFE! }); // ✅ Good - collect entities to destroy, process on main thread var toDestroy = new ConcurrentBag<Entity>(); Parallel.ForEach(entities, entity => { if (ShouldDestroy(entity)) { toDestroy.Add(entity); } }); // Main thread foreach (var entity in toDestroy) { world.DestroyEntity(entity); } -
Don't use excessive locking
// ❌ Bad - lock every iteration var lockObj = new object(); Parallel.ForEach(entities, entity => { lock (lockObj) // Serializes all work! { ProcessEntity(entity); } }); // ✅ Good - minimize locked sections Parallel.ForEach(entities, entity => { var result = ProcessEntity(entity); // Parallel lock (lockObj) // Only lock when necessary { AddResult(result); } });
Troubleshooting¶
Problem: No performance improvement¶
Symptom: Parallel version no faster than single-threaded.
Solutions:
-
Check entity count:
Logger.LogDebug("Entity count: {Count}", entities.Count); // Need at least 100-1000 entities for benefit -
Increase work per entity:
// Too little work entity.GetComponent<TransformComponent>().Position += Vector2.One; // More substantial work CalculateComplexPathfinding(entity); -
Check CPU cores:
Logger.LogInformation("CPU cores: {Count}", Environment.ProcessorCount); // Need multiple cores for parallelism
Problem: Race condition / data corruption¶
Symptom: Random crashes, incorrect values, inconsistent state.
Solutions:
-
Use thread-safe collections:
// Replace List<T> with ConcurrentBag<T> var results = new ConcurrentBag<Result>(); -
Add synchronization:
lock (_syncObject) { // Protect shared state } -
Remove shared state:
// Best: Ensure each thread works on independent data
Problem: Deadlock¶
Symptom: Application hangs, threads waiting forever.
Solutions:
-
Avoid nested locks:
// ❌ Bad - potential deadlock lock (lockA) { lock (lockB) { ... } } // ✅ Good - consistent lock ordering // Always acquire locks in same order -
Use timeouts:
if (Monitor.TryEnter(lockObj, TimeSpan.FromSeconds(5))) { try { // Critical section } finally { Monitor.Exit(lockObj); } }
Problem: Poor CPU utilization¶
Symptom: CPU cores not fully utilized.
Solutions:
-
Increase MaxDegreeOfParallelism:
var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; Parallel.ForEach(entities, options, entity => { ... }); -
Check work distribution:
// Ensure entities have similar processing time // Avoid some entities taking much longer than others
Summary¶
When to parallelize:
| Entity Count | Work Per Entity | Recommendation |
|---|---|---|
| < 100 | Any | Single-threaded |
| 100-1000 | Light | Single-threaded |
| 100-1000 | Heavy | Parallel |
| > 1000 | Any | Parallel |
Thread safety:
| Pattern | Safe? | Notes |
|---|---|---|
| Multiple reads | ✅ Yes | No synchronization needed |
| Isolated writes | ✅ Yes | Each entity independent |
| Shared writes | ❌ No | Need synchronization |
| World modifications | ❌ No | Main thread only |
Synchronization tools:
| Tool | Use Case | Performance |
|---|---|---|
ConcurrentBag<T> |
Collect results | Fast |
lock statement |
Protect shared state | Moderate |
Interlocked |
Atomic operations | Very Fast |
Monitor |
Complex synchronization | Moderate |
Best practices:
- ✅ Materialize queries before parallelization
- ✅ Use concurrent collections
- ✅ Benchmark performance
- ✅ Keep work substantial per entity
- ✅ Minimize shared state
- ❌ Don't modify world structure in parallel
- ❌ Don't query during parallel processing
- ❌ Don't parallelize small workloads
- ❌ Don't use excessive locking
Next Steps¶
- Systems Guide - Learn about ECS systems
- Performance Optimization - Optimize game performance
- Queries - Entity query patterns
- Components - Component design
Quick Reference¶
// Basic parallel processing
var entities = world.QueryEntities()
.With<TransformComponent>()
.ToList(); // Important: materialize first!
Parallel.ForEach(entities, entity =>
{
var transform = entity.GetComponent<TransformComponent>();
// Process entity (safe: independent data)
});
// Thread-safe collection
var results = new ConcurrentBag<Result>();
Parallel.ForEach(entities, entity =>
{
var result = ProcessEntity(entity);
results.Add(result); // Thread-safe
});
// Atomic counter
int counter = 0;
Parallel.ForEach(entities, entity =>
{
if (IsActive(entity))
{
Interlocked.Increment(ref counter);
}
});
// Lock for shared state
var lockObj = new object();
Parallel.ForEach(entities, entity =>
{
var result = ProcessEntity(entity);
lock (lockObj)
{
AddToSharedState(result);
}
});
// Conditional parallelization
if (entities.Count > 1000)
{
// Parallel for large counts
Parallel.ForEach(entities, ProcessEntity);
}
else
{
// Single-threaded for small counts
foreach (var entity in entities)
{
ProcessEntity(entity);
}
}
// Configure parallel options
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
Parallel.ForEach(entities, options, ProcessEntity);
Remember: Only parallelize when single-threaded performance is insufficient. Measure first!