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úc | Cô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úc | Công việc phụ trợ có thể bị bỏ dở |
// ThreadPool — managed pool of worker threads
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine("Running on ThreadPool thread");
});
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)
Task và Task<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) |
|---|---|
Created | Task đã đượ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 |
RanToCompletion | Hoàn thành thành công |
Faulted | Hoàn thành với ngoại lệ |
Canceled | Đã bị hủy qua CancellationToken |
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 Async | Trường hợp sử dụng (Use Case) |
|---|---|---|---|
lock | Đơn tiến trình | Không | Loại trừ lẫn nhau đơn giản (Simple Mutual Exclusion) |
Monitor | Đơn tiến trình | Không | Lock có thời gian chờ, Pulse/Wait |
Mutex | Liên tiến trình (Cross-process) | Không | Loại trừ liên tiến trình |
SemaphoreSlim | Đơn tiến trình | Có (WaitAsync) | Giới hạn đồng thời (Concurrency Limiting) |
ReaderWriterLockSlim | Đơn tiến trình | Không | Nhiều người đọc, một người ghi |
AutoResetEvent | Đơn tiến trình | Không | Báo hiệu một luồng đang chờ |
ManualResetEventSlim | Đơn tiến trình | Không | Bá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
});
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.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):
- 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
- Thời gian chờ khóa (Lock Timeout) — Sử dụng
Monitor.TryEntervới thời gian chờ thay vìlock - Khóa đơn (Single Lock) — Sử dụng một khóa khi có thể
- 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
}
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-consumer | BlockingCollection<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ập | Task.WhenAll |
| Giới hạn truy cập đồng thời | SemaphoreSlim |
| Đồ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)
-
Sử dụng
Threadtrự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ênTask.RunhoặcThreadPool. -
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ụngprivate readonly object _lock = new(). -
Khóa chết từ sync-over-async — Gọi
.Resulthoặ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ụngawait. -
Quên hủy — Không truyền
CancellationTokencho 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. -
Đ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ụngTryAddhoặcAddOrUpdate. -
Song song quá mức — Đặt
MaxDegreeOfParallelismquá 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)
- Ưu tiên
TaskvàTask<T>hơnThreadthô cho hầu hết các tình huống. - Sử dụng
lockcho các phần găng krit đơn giản;SemaphoreSlimcho các tình huống tương thích async và giới hạn đồng thời. - 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.
- Luôn hỗ trợ
CancellationTokentrong các phương thức chạy dài. Parallel.For/ForEachvà PLINQ dành cho công việc CPU-bound;Task.WhenAlldành cho I/O-bound.- 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ụngMonitor.TryEntervới thời gian chờ. - 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 Thread và Task là gì?
Threadlà 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).Tasklà 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ênTaskhơn tạoThreadthủ công.
Q: Sự khác biệt giữa lock và Monitor là gì?
locklà đường cú pháp (Syntactic Sugar) biên dịch thànhMonitor.Enter/Monitor.Exittrong try/finally.Monitorcung cấp thêm tính năng:TryEntervới thời gian chờ,Pulse/Waitđể báo hiệu. Sử dụnglockcho trường hợp đơn giản;Monitorkhi 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
CancellationTokentừCancellationTokenSourcecho tác vụ. Tác vụ kiểm tratoken.ThrowIfCancellationRequested()định kỳ. Người gọi gọicts.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ọiawait.
Q: Khi nào nên sử dụng ConcurrentDictionary thay vì Dictionary với lock?
Sử dụng
ConcurrentDictionarykhi 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ộtlockđơn quanhDictionarythông thường. Cho các tình huống đọc nhiều hoặc một người ghi,Dictionarythông thường vớiReaderWriterLockSlimcó thể đủ.