Chuyển tới nội dung chính

Multithreading and Parallelism

Định nghĩa (Definition)

Đa luồng (Multithreading) cho phép thực thi đồng thời (Concurrent Execution) mã trong một tiến trình đơn. C# và .NET cung cấp nhiều tầng trừu tượng: luồng thô (Raw Threads) (System.Threading.Thread), ThreadPool, Mẫu đồng bộ dựa trên Task (Task-based Asynchronous Pattern - TAP), và các API lập trình song song (Parallel Programming APIs).

Phân biệt chính: Đồng thời (Concurrency) là xử lý nhiều việc cùng lúc (chuyển đổi giữa các tác vụ); Song song (Parallelism) là thực hiện nhiều việc cùng lúc (thực thi đồng thời trên nhiều nhân).

// Sequential — one at a time
var stopwatch = Stopwatch.StartNew();
Transform(data1);
Transform(data2);
Transform(data3);
Console.WriteLine($"Sequential: {stopwatch.ElapsedMilliseconds}ms");

// Parallel — all at once
stopwatch.Restart();
Parallel.Invoke(
() => Transform(data1),
() => Transform(data2),
() => Transform(data3)
);
Console.WriteLine($"Parallel: {stopwatch.ElapsedMilliseconds}ms");

Khái niệm cốt lõi (Core Concepts)

Kiểu nền tảng về Luồng (Thread Fundamentals)

Lớp System.Threading.Thread bao bọc một luồng hệ điều hành (OS Thread). Mỗi luồng có ngăn xếp (Stack) riêng (~1MB), mức ưu tiên (Priority) và có thể là tiền cảnh (Foreground) hoặc hậu cảnh (Background).

// Creating and starting a thread
var thread = new Thread(() =>
{
Console.WriteLine($"Running on thread: {Thread.CurrentThread.ManagedThreadId}");
});
thread.Name = "Worker";
thread.IsBackground = true;
thread.Start();
thread.Join(); // Wait for completion

Luồng tiền cảnh (Foreground) vs hậu cảnh (Background):

Kiểu (Type)Hành vi (Behavior)Trường hợp sử dụng (Use Case)
Tiền cảnh (Foreground)Giữ tiến trình hoạt động cho đến khi luồng kết thúcCông việc quan trọng phải hoàn thành
Hậu cảnh (Background)Chấm dứt khi tất cả luồng tiền cảnh kết thúcCông việc phụ trợ có thể bị bỏ dở
// ThreadPool — managed pool of worker threads
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("Running on ThreadPool thread");
});
Ưu tiên ThreadPool thay vì tạo Luồng thủ công

Tạo luồng thủ công phân bổ ~1MB ngăn xếp cho mỗi luồng và không tái sử dụng luồng. ThreadPool quản lý một nhóm luồng, tái sử dụng chúng và điều tiết việc tạo mới. Cho hầu hết các tình huống, sử dụng Task.Run (sử dụng ThreadPool) thay vì new Thread().

Mẫu Đồng bộ dựa trên Task (Task-Based Asynchronous Pattern - TAP)

TaskTask<T> là đơn vị công việc không đồng bộ (Asynchronous Work) hiện đại. Chúng biểu diễn một thao tác có thể hoàn thành trong tương lai.

// CPU-bound work offloaded to ThreadPool
Task<int> task = Task.Run(() =>
{
int result = 0;
for (int i = 0; i < 100_000_000; i++) result += i;
return result;
});

// Task<T> — get the result
int sum = await task;
Console.WriteLine($"Sum: {sum}");

// Fan-out — run multiple tasks concurrently
var tasks = urls.Select(url => FetchDataAsync(url)).ToArray();
string[] results = await Task.WhenAll(tasks);

// Task.WhenAny — process as each completes
while (tasks.Length > 0)
{
Task<string> completed = await Task.WhenAny(tasks);
tasks = tasks.Where(t => t != completed).ToArray();
Console.WriteLine(await completed);
}

Các trạng thái vòng đời Task (Task Lifecycle States):

Trạng thái (State)Ý nghĩa (Meaning)
CreatedTask đã được khởi tạo nhưng chưa được lập lịch
WaitingToRunĐã lập lịch, đang chờ luồng ThreadPool
RunningĐang thực thi
RanToCompletionHoàn thành thành công
FaultedHoàn thành với ngoại lệ
CanceledĐã bị hủy qua CancellationToken
Task.Run vs async/await

Task.Run dành cho công việc CPU-bound nên chạy trên luồng nền. Cho công việc I/O-bound (mạng, tệp, cơ sở dữ liệu), sử dụng async/await trực tiếp — xem Async/Await để biết hướng dẫn đầy đủ.

Nguyên thủy Đồng bộ hóa (Synchronization Primitives)

Khi nhiều luồng truy cập trạng thái có thể thay đổi dùng chung (Shared Mutable State), đồng bộ hóa ngăn ngừa điều kiện tranh chấp (Race Conditions).

// lock statement — simplest synchronization
private readonly object _lock = new();
private int _counter = 0;

public void Increment()
{
lock (_lock)
{
_counter++;
}
}

So sánh các nguyên thủy đồng bộ hóa (Synchronization Primitives):

Nguyên thủy (Primitive)Phạm vi (Scope)Tương thích AsyncTrường hợp sử dụng (Use Case)
lockĐơn tiến trìnhKhôngLoại trừ lẫn nhau đơn giản (Simple Mutual Exclusion)
MonitorĐơn tiến trìnhKhôngLock có thời gian chờ, Pulse/Wait
MutexLiên tiến trình (Cross-process)KhôngLoại trừ liên tiến trình
SemaphoreSlimĐơn tiến trìnhCó (WaitAsync)Giới hạn đồng thời (Concurrency Limiting)
ReaderWriterLockSlimĐơn tiến trìnhKhôngNhiều người đọc, một người ghi
AutoResetEventĐơn tiến trìnhKhôngBáo hiệu một luồng đang chờ
ManualResetEventSlimĐơn tiến trìnhKhôngBáo hiệu tất cả luồng đang chờ
// SemaphoreSlim — limit concurrent access (async-friendly)
private readonly SemaphoreSlim _semaphore = new(3, 3); // Max 3 concurrent

public async Task ProcessAsync(string url)
{
await _semaphore.WaitAsync();
try
{
await FetchDataAsync(url);
}
finally
{
_semaphore.Release();
}
}
// ReaderWriterLockSlim — multiple readers OR single writer
private readonly ReaderWriterLockSlim _rwLock = new();
private readonly Dictionary<string, string> _cache = new();

public string? Get(string key)
{
_rwLock.EnterReadLock();
try { return _cache.GetValueOrDefault(key); }
finally { _rwLock.ExitReadLock(); }
}

public void Set(string key, string value)
{
_rwLock.EnterWriteLock();
try { _cache[key] = value; }
finally { _rwLock.ExitWriteLock(); }
}

Bộ sưu tập Đồng thời (Concurrent Collections)

System.Collections.Concurrent cung cấp các bộ sưu tập an toàn luồng (Thread-safe Collections):

Bộ sưu tập (Collection)Mô tảPhương thức chính
ConcurrentDictionary<TKey, TValue>Từ điển an toàn luồng (Thread-safe Dictionary)TryAdd, AddOrUpdate, GetOrAdd
ConcurrentQueue<T>Hàng đợi FIFO không khóa (Lock-free FIFO Queue)Enqueue, TryDequeue
ConcurrentStack<T>Ngăn xếp LIFO không khóa (Lock-free LIFO Stack)Push, TryPop
ConcurrentBag<T>Bộ lưu trữ không thứ tự, theo luồng cục bộ (Thread-local Storage)Add, TryTake
BlockingCollection<T>Trình bao bọc giới hạn và chặn (Bounding and Blocking Wrapper)Add, Take, CompleteAdding
// ConcurrentDictionary — atomic operations
var counts = new ConcurrentDictionary<string, int>();
counts.TryAdd("apple", 0);

// Thread-safe increment
counts.AddOrUpdate("apple", 1, (_, old) => old + 1);

// Get or add
int count = counts.GetOrAdd("banana", _ => ExpensiveLookup("banana"));
// BlockingCollection — producer-consumer pattern
var collection = new BlockingCollection<string>(boundedCapacity: 10);

// Producer
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
collection.Add($"Item {i}"); // Blocks if at capacity

collection.CompleteAdding(); // Signal no more items
});

// Consumer
Task.Run(() =>
{
foreach (var item in collection.GetConsumingEnumerable())
Process(item); // Blocks until item available or CompleteAdding
});
Các thao tác phức hợp vẫn cần cẩn thận

Bộ sưu tập đồng thời (Concurrent Collections) bảo vệ từng thao tác riêng lẻ (TryAdd, TryGetValue), nhưng các thao tác phức hợp (Compound Operations - đọc rồi ghi qua nhiều lệnh gọi) không phải là nguyên tử (Atomic). Sử dụng AddOrUpdate hoặc GetOrAdd cho các thao tác phức hợp nguyên tử.

Lớp Parallel và PLINQ (Parallel Class and PLINQ)

Lớp System.Threading.Tasks.Parallel và PLINQ cung cấp các API cấp cao cho tính toán song song CPU-bound.

// Parallel.For — parallelize a counted loop
Parallel.For(0, 1000, i =>
{
results[i] = Compute(i);
});

// Parallel.ForEach — parallelize iteration
Parallel.ForEach(items, item =>
{
Process(item);
});

// With options
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
CancellationToken = cts.Token
};
Parallel.ForEach(data, options, item => Transform(item));
// PLINQ — parallel LINQ queries
var results = data.AsParallel()
.Where(x => x.IsValid)
.OrderBy(x => x.Priority)
.Select(x => x.Value)
.ToArray();

// Control parallelism
var ordered = data.AsParallel()
.AsOrdered() // Preserve original order
.WithDegreeOfParallelism(4)
.WithCancellation(cts.Token)
.Select(ExpensiveTransform)
.ToList();
Parallel dành cho công việc CPU-bound

Parallel.For/ForEach và PLINQ được thiết kế cho công việc CPU-bound. Cho các thao tác I/O-bound, sử dụng Task.WhenAll — các API song song sẽ lãng phí luồng ThreadPool chờ I/O.

Mã thông báo Hủy (CancellationToken)

Mẫu hủy hợp tác (Cooperative Cancellation Pattern) để dừng các thao tác chạy dài một cáchGraceful.

// Creating and using a CancellationToken
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
CancellationToken token = cts.Token;

// Pass to Task.Run
var task = Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
token.ThrowIfCancellationRequested(); // Throws OperationCanceledException
Process(i);
}
}, token);

// Cancel from another context
cts.Cancel(); // Triggers cancellation

// Checking without throwing
if (token.IsCancellationRequested) return;

// Combining multiple tokens
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token1, token2);

Mẫu An toàn Luồng (Thread Safety Patterns)

// 1. Immutable state — the safest approach
public record Config(string Host, int Port, string Database); // Immutable by default

// 2. Interlocked — atomic operations without locks
private int _counter = 0;
Interlocked.Increment(ref _counter); // Thread-safe increment
int result = Interlocked.CompareExchange(ref _counter, 10, 5); // Set to 10 if currently 5

// 3. Thread-local storage
var threadLocal = new ThreadLocal<int>(() => 0);
threadLocal.Value++; // Each thread has its own copy
int sum = threadLocal.Values.Sum();

// 4. AsyncLocal — flows with async context
static readonly AsyncLocal<string> _correlationId = new();
_correlationId.Value = Guid.NewGuid().ToString(); // Available throughout async call chain

Khóa chết (Deadlocks)

Khóa chết (Deadlock) xảy ra khi hai hoặc nhiều luồng mỗi luồng giữ một khóa và chờ khóa mà luồng kia đang giữ, tạo ra phụ thuộc vòng (Circular Dependency).

Tình huống khóa chết kinh điển:

private readonly object _lockA = new();
private readonly object _lockB = new();

// Thread 1: locks A then B
lock (_lockA)
{
Thread.Sleep(100);
lock (_lockB) { /* Deadlock if Thread 2 holds _lockB */ }
}

// Thread 2: locks B then A
lock (_lockB)
{
Thread.Sleep(100);
lock (_lockA) { /* Deadlock if Thread 1 holds _lockA */ }
}

Chiến lược phòng ngừa (Prevention Strategies):

  1. Thứ tự khóa (Lock Ordering) — Luôn thu nhận khóa theo cùng thứ tự trên tất cả luồng
  2. Thời gian chờ khóa (Lock Timeout) — Sử dụng Monitor.TryEnter với thời gian chờ thay vì lock
  3. Khóa đơn (Single Lock) — Sử dụng một khóa khi có thể
  4. Giữ phần găng krit nhỏ (Keep Critical Sections Small) — Giảm thiểu thời gian giữ khóa
// Lock ordering — always acquire _lockA before _lockB
lock (_lockA)
{
lock (_lockB)
{
// Safe — all code follows the same order
}
}

// Lock timeout
if (Monitor.TryEnter(_lock, TimeSpan.FromSeconds(5)))
{
try { /* work */ }
finally { Monitor.Exit(_lock); }
}
else
{
// Handle timeout — log, retry, or fail
}
Không bao giờ giữ lock qua await

Câu lệnh lock không hỗ trợ await bên trong thân của nó (lỗi biên dịch), nhưng Monitor.Enter + await là có thể và cực kỳ nguy hiểm. Khóa có thể được giải phóng trên một luồng khác, gây ra khóa chết. Sử dụng SemaphoreSlim với WaitAsync thay thế:

// WRONG — compiler prevents this, but Monitor + await is possible
lock (_lock)
{
await DoSomethingAsync(); // Compile error
}

// CORRECT — use SemaphoreSlim for async
await _semaphore.WaitAsync();
try
{
await DoSomethingAsync();
}
finally
{
_semaphore.Release();
}

Khi nào sử dụng (When to Use)

Tình huống (Scenario)Phương pháp được khuyến nghị
Công việc CPU-bound (tính toán)Task.Run hoặc Parallel.For
Công việc I/O-bound (mạng, tệp, DB)async/await — xem Async/Await
Đường ống Producer-consumerBlockingCollection<T> hoặc Channel<T>
Trạng thái có thể thay đổi dùng chung (Shared Mutable State)lock, SemaphoreSlim, hoặc bộ sưu tập đồng thời
Fan-out nhiều tác vụ độc lậpTask.WhenAll
Giới hạn truy cập đồng thờiSemaphoreSlim
Đồng bộ hóa liên tiến trình (Cross-process)Mutex hoặc EventWaitHandle có tên

Lỗi thường gặp (Common Pitfalls)

  1. Sử dụng Thread trực tiếp thay vì Task — Luồng thủ công đắt tiền (~1MB ngăn xếp mỗi luồng) và không được tái sử dụng. Ưu tiên Task.Run hoặc ThreadPool.

  2. Khóa trên this, typeof(...), hoặc chuỗi ký tự — Những thứ này có thể truy cập công khai, khiến mã bên ngoài có rủi ro khóa chết. Sử dụng private readonly object _lock = new().

  3. Khóa chết từ sync-over-async — Gọi .Result hoặc .Wait() trên mã async có thể gây khóa chết trong ngữ cảnh UI/ASP.NET. Luôn sử dụng await.

  4. Quên hủy — Không truyền CancellationToken cho các thao tác chạy dài dẫn đến các tác vụ không kiểm soát. Luôn hỗ trợ hủy.

  5. Điều kiện tranh chấp với thao tác phức hợp — Kiểm tra rồi hành động (ví dụ: if (!dict.ContainsKey(key)) dict[key] = value) không phải là nguyên tử. Sử dụng TryAdd hoặc AddOrUpdate.

  6. Song song quá mức — Đặt MaxDegreeOfParallelism quá cao gây cạn kiệt ThreadPool. Giá trị mặc định thường là tối ưu.

Điểm chính (Key Takeaways)

  1. Ưu tiên TaskTask<T> hơn Thread thô cho hầu hết các tình huống.
  2. Sử dụng lock cho các phần găng krit đơn giản; SemaphoreSlim cho các tình huống tương thích async và giới hạn đồng thời.
  3. Bộ sưu tập đồng thời (Concurrent Collections) an toàn luồng cho từng thao tác, nhưng thao tác phức hợp vẫn cần cẩn thận.
  4. Luôn hỗ trợ CancellationToken trong các phương thức chạy dài.
  5. Parallel.For/ForEach và PLINQ dành cho công việc CPU-bound; Task.WhenAll dành cho I/O-bound.
  6. Tránh khóa chết bằng cách thiết lập thứ tự khóa, không bao giờ giữ lock qua await, và sử dụng Monitor.TryEnter với thời gian chờ.
  7. Cho mô hình lập trình async/await, xem hướng dẫn chuyên về Async/Await.

Câu hỏi Phỏng vấn (Interview Questions)

Q: Sự khác biệt giữa ThreadTask là gì?

Thread là trình bao bọc luồng hệ điều hành (OS Thread Wrapper) cấp thấp phân bổ ngăn xếp riêng (~1MB). Task là trừu tượng cấp cao hơn chạy trên ThreadPool, hỗ trợ chuỗi tiếp nối (Continuation Chaining), tổng hợp ngoại lệ (Exception Aggregation) và hủy. Luôn ưu tiên Task hơn tạo Thread thủ công.

Q: Sự khác biệt giữa lockMonitor là gì?

lock là đường cú pháp (Syntactic Sugar) biên dịch thành Monitor.Enter/Monitor.Exit trong try/finally. Monitor cung cấp thêm tính năng: TryEnter với thời gian chờ, Pulse/Wait để báo hiệu. Sử dụng lock cho trường hợp đơn giản; Monitor khi cần thời gian chờ hoặc báo hiệu.

Q: Làm thế nào để xử lý hủy trong các tác vụ chạy dài?

Truyền CancellationToken từ CancellationTokenSource cho tác vụ. Tác vụ kiểm tra token.ThrowIfCancellationRequested() định kỳ. Người gọi gọi cts.Cancel() để báo hiệu hủy một cách hợp tác.

Q: Khóa chết (Deadlock) là gì và làm thế nào để phòng ngừa?

Khóa chết xảy ra khi hai hoặc nhiều luồng mỗi luồng giữ một khóa và chờ khóa mà luồng kia đang giữ. Phòng ngừa bằng cách luôn thu nhận khóa theo thứ tự nhất quán, sử dụng thời gian chờ với Monitor.TryEnter, giữ phần găng krit nhỏ và không bao giờ giữ lock khi gọi await.

Q: Khi nào nên sử dụng ConcurrentDictionary thay vì Dictionary với lock?

Sử dụng ConcurrentDictionary khi nhiều luồng thường xuyên đọc và ghi vào cùng một từ điển. Khóa chi tiết (Fine-grained Locking) và các phương thức nguyên tử (TryAdd, AddOrUpdate) của nó hoạt động tốt hơn một lock đơn quanh Dictionary thông thường. Cho các tình huống đọc nhiều hoặc một người ghi, Dictionary thông thường với ReaderWriterLockSlim có thể đủ.

Tài liệu tham khảo (References)