1. Giới thiệu
Đa luồng — giống như có vài người làm việc song song trong văn phòng: người thì in tài liệu, người gọi điện cho khách hàng, người kia pha cà phê (và, tất nhiên, tất cả đều là lập trình viên). Nếu chỉ có một người thì mọi thứ phải làm lần lượt, và văn phòng sẽ ngập trong hàng đợi và buồn tẻ. Trong lập trình cũng vậy: chương trình đơn luồng chỉ làm được một việc tại một thời điểm.
Giả sử ứng dụng của bạn đang thực hiện một công việc dài, ví dụ tải file từ internet hoặc tính một bảng lớn. Mọi thứ khác trong lúc đó bị "đóng băng" — nút bấm không phản hồi, animation đứng im, thông báo "không phản hồi" xuất hiện.
Đa luồng cho phép ứng dụng làm nhiều việc cùng lúc: giao diện vẫn mượt, các thao tác chạy song song, và mình không phải hét lên kiểu "Máy tính, sao lại treo nữa thế!?".
Các khái niệm cơ bản và thuật ngữ
Trước khi lao vào, hiểu nhanh thế nào là luồng (thread) và khác với tiến trình ra sao.
- Tiến trình (Process): Một chương trình độc lập với không gian địa chỉ riêng, biến và tài nguyên riêng. Ví dụ, mỗi ứng dụng chạy trên Windows là một process riêng.
- Luồng (Thread): Đơn vị thực thi bên trong tiến trình. Một process có thể chứa một hoặc nhiều thread, và các thread thường chia sẻ cùng tài nguyên (bộ nhớ, biến).
Tại sao đa luồng lại gây nhiều câu hỏi?
Vì các luồng rất "ngẫu hứng": chúng có thể bắt đầu chạy bất kỳ lúc nào, trộn lẫn dữ liệu, tạm dừng lẫn nhau và gây ra lỗi trong bộ nhớ nếu không kiểm soát thứ tự. Nếu bạn cảm thấy giống như một lớp mẫu giáo mà không có cô giáo — đúng vậy! Kỷ luật và chú ý đến luồng là nền tảng để viết chương trình đa luồng đáng tin cậy.
2. Lịch sử và vai trò của đa luồng trong C# và .NET
Ngày xưa C# còn đơn giản, chương trình chạy chủ yếu đơn luồng. Khi nhu cầu về hiệu năng tăng, CPU đa nhân xuất hiện và cần làm ứng dụng phản hồi mà không bị treo giao diện, .NET bổ sung công cụ cho đa luồng. Ban đầu có System.Threading.Thread, sau đó xuất hiện Task, phương thức bất đồng bộ async/await, xử lý song song dữ liệu (PLINQ) và các primitive đồng bộ hóa cấp cao.
C# đã phát triển thành nền tảng mạnh mẽ, nơi đa luồng là chuyện bình thường chứ không còn là điều lạ.
Hình dung: tiến trình và các luồng
Đây là sơ đồ đơn giản:
+--------------------------------------------------+
| Tiến trình (chương trình của bạn)|
| +-------------+ +-------------+ |
| | Luồng 1 | | Luồng 2 | |
| +-------------+ +-------------+ |
| ... |
| +-------------+ |
| | Luồng N | |
| +-------------+ |
+--------------------------------------------------+
Tất cả các luồng trong cùng một tiến trình nhìn thấy biến và tài nguyên chung.
3. Cách tạo luồng trong C#?
Bắt đầu với cách cơ bản nhất: lớp Thread từ namespace System.Threading.
Ví dụ: Khởi chạy luồng thứ hai
Giả sử ta có một công việc dài — ví dụ tính tổng các số từ 1 đến 10_000_000. Trong khi đang tính, luồng chính sẽ in lời chào tới người dùng.
using System;
using System.Threading;
class Program
{
// Phương thức cho nhiệm vụ thứ hai
static void CalculateSum()
{
long sum = 0;
for (int i = 1; i <= 10_000_000; i++)
sum += i;
Console.WriteLine($"[Luồng 2] Tổng: {sum}");
}
static void Main()
{
// Tạo luồng, truyền delegate tới phương thức
Thread thread = new Thread(CalculateSum);
thread.Start(); // Khởi chạy luồng thứ hai
// Luồng chính tiếp tục làm việc
Console.WriteLine("[Luồng 1] Chào! Chúng ta chạy song song...");
// Chờ luồng thứ hai kết thúc trước khi thoát
thread.Join();
Console.WriteLine("[Luồng 1] Mọi thứ đã xong!");
}
}
Chuyện gì sẽ xảy ra?
Màn hình sẽ in dòng "[Luồng 1] Chào! Chúng ta chạy song song...", và sau đó khi luồng tính toán xong sẽ in ra tổng.
Vấn đề thường gặp: Ai in trước thì thắng
Thử chạy code vài lần — thứ tự in ra có thể khác nhau! Đôi khi tổng xuất hiện trước, đôi khi lời chào xuất hiện trước. Đó chính là đặc trưng của đa luồng — chương trình kém dự đoán hơn, giống tâm trạng con mèo vào thứ Hai vậy.
4. Vùng nhớ: các luồng nhìn thấy gì?
Tất cả luồng trong cùng một tiến trình có quyền truy cập tới cùng biến (trừ biến cục bộ trong phương thức). Nếu thay đổi biến ở một luồng — các luồng khác sẽ thấy thay đổi đó!
Ví dụ: Biến chung
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 1000; i++)
counter++;
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"counter = {counter}");
}
}
Mong đợi thấy giá trị của counter là bao nhiêu? Lý trí bảo là 2000, vì mỗi luồng tăng 1000 lần.
Nhưng không! Chạy vài lần — bạn sẽ thấy các giá trị khác nhau: 1782, 1935, 1999…
Tại sao? Đây là vấn đề kinh điển Race Condition — các luồng "đụng nhau" giữa thao tác đọc -> tăng -> ghi, và một số phép tăng bị mất.
5. Những chút lưu ý hữu ích
Luồng tương tác thế nào với UI?
Trong các ứng dụng desktop hiện đại (WinForms/WPF/MAUI) luồng chính chịu trách nhiệm cho giao diện người dùng. Tất cả hành động người dùng (click, nhập liệu) chạy trên luồng này. Công việc nền nên chạy trên luồng khác, và theo quy tắc thì không được trực tiếp "chạm" vào UI từ luồng background. Điều này nhằm tránh hỗn loạn.
Trong console không có giới hạn đó, có thể gọi Console.WriteLine từ bất kỳ luồng nào. Tuy nhiên trong ứng dụng thực tế nếu không đồng bộ đúng cách thì UI có thể "hỏng" hoặc in nhầm thứ tự.
Tuần tự và song song
Bảng để dễ phân biệt.
| Mã đơn luồng | Mã đa luồng |
|---|---|
| Thực hiện các tác vụ theo thứ tự | Các tác vụ có thể chạy đồng thời |
| UI "treo" khi có tác vụ dài | UI vẫn phản hồi |
| Dễ đọc/ghi biến | Cần kiểm soát truy cập dữ liệu |
| Dễ gỡ lỗi | Khó gỡ lỗi hơn |
Những điểm quan trọng khi làm việc với luồng
- Biến chung = rủi ro chung. Như ví dụ phía trên, nếu nhiều luồng dùng chung biến mà không đồng bộ thì sẽ có lỗi! (Chi tiết hơn ở các bài sau.)
- Có thể "chờ" luồng bằng Join(). Phương thức Join() cho phép tạm dừng luồng hiện tại cho tới khi luồng khác kết thúc. Dùng khi cần chờ kết quả.
- Luồng không thể Start hai lần. Sau khi luồng chạy xong không thể start lại — phải tạo đối tượng Thread mới.
- Kết thúc luồng. Luồng kết thúc khi phương thức của nó hoàn tất. Ép buộc "giết" luồng — là ý tưởng tồi (ví dụ Abort() đã bị coi là hỏng và lỗi thời).
Đa luồng dùng để làm gì trong thực tế?
- Ứng dụng UI: không để giao diện treo khi tải dữ liệu hay tính toán nền.
- Server và dịch vụ: xử lý đồng thời nhiều request từ client.
- Tính toán hiệu năng cao: chia bài toán lớn (ví dụ xử lý hàng triệu bản ghi) thành các phần và chạy song song.
- Game, mô phỏng, xử lý dữ liệu: mô phỏng hệ thống phức tạp mà không mất hiệu năng.
Các vấn đề của đa luồng
Đa luồng mang lại sức mạnh nhưng cũng tăng độ phức tạp:
- Race Condition (tình trạng đua): khi nhiều luồng cùng sửa dữ liệu, kết quả phụ thuộc vào thứ tự thực thi, điều này không dự đoán được.
- Deadlock (vòng chờ): các luồng chờ nhau và không ai tiếp tục được.
- Starvation (bị bỏ đói): một luồng bị từ chối quyền truy cập tới tài nguyên liên tục.
Trong bài này mình chỉ nêu các vấn đề chính, ở các bài sau sẽ học cách phát hiện và tránh chúng. Hiện tại — nhớ rằng nếu mọi thứ "treo", không chỉ bug mà đôi khi các luồng đang "tổ chức tiệc" cũng là thủ phạm.
GO TO FULL VERSION