1. Giới thiệu
Hãy tưởng tượng bạn là nhạc trưởng của dàn nhạc (ứng dụng của bạn). Nếu mỗi lần nghệ sĩ violon chỉnh đàn (I/O lâu) bạn đứng chờ mới tiếp tục với các nhạc cụ khác thì cả dàn sẽ đứng im. Nhưng nếu nghệ sĩ nói: "Mình sẽ chỉnh xong rồi báo, các bạn cứ chơi tiếp đi" — đó chính là bất đồng bộ!
Trong thế giới C# và .NET 9 có những công cụ để làm "đa nhiệm mà không treo". Nhân vật chính hôm nay là các phiên bản bất đồng bộ của Read và Write, tức ReadAsync và WriteAsync.
Chúng cho phép bạn khởi động thao tác đọc hoặc ghi rồi ngay lập tức "thả" luồng thực thi hiện tại để nó làm chuyện khác. Khi I/O xong (ví dụ dữ liệu được đọc từ đĩa hoặc ghi vào đĩa), code của bạn "tỉnh lại" và tiếp tục từ chỗ đã dừng.
Để dùng mấy thứ này ta cần hai từ khóa thần thánh được đưa vào C# từ 2012 (phiên bản 5.0) (và giờ ở C# 14 thì đã quá quen):
- async: Modifier bạn thêm vào method để nói với compiler: "Trong method này có thể có thao tác bất đồng bộ và mình sẽ dùng await".
- await: Operator bạn đặt trước lời gọi bất đồng bộ (như ReadAsync hoặc WriteAsync). Nó có nghĩa: "Bắt đầu thao tác này, nhưng đừng chờ ở đây. Trả control về caller và quay lại khi xong".
Đừng lo nếu mấy khái niệm này vẫn hơi mơ hồ. Chúng ta sẽ có bài riêng về async và await (Level 58) để khám kỹ. Hiện tại quan trọng là hiểu chúng giúp không block main thread.
2. ReadAsync: đọc thảnh thơi
Phương thức ReadAsync cho phép đọc dữ liệu từ stream một cách bất đồng bộ. Thay vì chờ từng byte đọc từ đĩa, bạn khởi đọc rồi chuyển sang việc khác ngay.
Đây là chữ ký chính để đọc vào buffer:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Hoặc, thường thấy hơn trong C# hiện đại (và .NET 9), dùng Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Phân tích tham số:
- buffer: Mảng byte (hoặc Memory<byte>) để chứa dữ liệu đọc vào. Như đã nói, buffer giúp tối ưu.
- offset: Offset trong buffer bắt đầu ghi các byte đọc được.
- count: Số byte tối đa sẽ đọc.
- CancellationToken cancellationToken: Tham số hữu dụng để huỷ thao tác nếu không cần nữa (ví dụ người dùng đóng app hoặc bấm nút "Hủy").
- ValueTask<int>: "Lời hứa" khi xong sẽ trả về int — số byte đã đọc. ValueTask là phiên bản tối ưu hơn của Task khi kết quả đôi khi có thể có ngay lập tức (synchronously) hoặc bất đồng bộ.
Ví dụ 1: Đọc file bất đồng bộ
Giả sử có file text lớn, muốn đọc mà không block main thread. Ví dụ cơ bản:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Hàm bất đồng bộ đếm số dòng trong file
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Mở file bất đồng bộ
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"Trong file {filename} có số dòng: {count}");
}
}
Ghi chú code:
- Chú ý tham số useAsync: true trên FileStream. Điều này quan trọng để có bất đồng bộ thật.
- Ta dùng await với ReadLineAsync để không block thread khi đang đọc 1 dòng.
- Method Main giờ là bất đồng bộ (C# 7+ hỗ trợ).
Nếu đây là app GUI thì trong khi đọc file (khi ReadAsync đang đợi dữ liệu từ đĩa) user vẫn có thể click, scroll, làm việc khác vì UI thread không bị block. Ở console ít thấy khác biệt nhưng nguyên lý giống nhau.
3. WriteAsync: ghi không chần chừ
Tương tự ReadAsync, WriteAsync cho phép ghi vào stream bất đồng bộ. Rất hữu ích khi cần ghi khối lớn dữ liệu mà không bắt app chờ disk IO.
Chữ ký chính:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Và với ReadOnlyMemory<byte> (vì khi ghi ta không sửa buffer):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
Tham số giống ReadAsync:
- buffer: Mảng byte (hoặc ReadOnlyMemory<byte>) chứa dữ liệu để ghi.
- offset: Offset trong buffer nơi bắt đầu đọc dữ liệu để ghi.
- count: Số byte cần ghi.
- CancellationToken cancellationToken: Dùng để huỷ.
- ValueTask: Không trả giá trị vì số byte ghi đã biết từ tham số count.
Ví dụ 2: Ghi file bất đồng bộ
Bây giờ ghi vài thứ vào file — bất đồng bộ.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Dòng số {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"Ghi {filename} đã hoàn tất.");
}
}
Vòng lặp ghi 10000 dòng, main thread không bị block: nếu đây là GUI, giao diện sẽ không "treo".
Nhờ async và await, app console có thể vừa copy file vừa phản hồi input user (ví dụ bấm Enter để huỷ). Đây là nguyên tắc cơ bản để viết ứng dụng responsive và có hiệu năng trên C#.
4. Những điều hữu ích cần biết
Minh hoạ: cách đọc/ghi bất đồng bộ hoạt động
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Mã của bạn (UI/logic)│ ─────────────────────→ │ OS/E/S: thao tác bất đồng bộ │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(làm việc khác) │(đọc file, đợi đĩa)
│<────────────────────────────────────────→│
└─ Đợi Task, nhận kết quả ←───────────────┘
Sơ đồ tóm tắt: khi đĩa chậm, code có thể làm việc khác. Chỉ khi cần dữ liệu mới chờ Task.
Ứng dụng thực tế: cần nó ở đâu?
- Ứng dụng desktop: nếu app đọc/ghi nhiều (log, DB, video), bất đồng bộ là must-have. Ngay cả máy mạnh, người dùng mở file trên mạng có tốc độ chậm.
- Backend/web apps: hàng chục đến hàng ngàn user có thể gọi cùng lúc. Nếu mỗi thread bị block khi đọc file — sẽ thành bottleneck và có thể gặp 502 Bad Gateway.
- Ứng dụng mobile: mở hoặc ghi file lâu sẽ gây lag cho user. Dùng bất đồng bộ!
- Xử lý hàng loạt file: app làm việc với bộ file lớn (archivers, parsers, analyzers) đều hưởng lợi từ I/O bất đồng bộ.
Đọc/ghi đồng bộ vs bất đồng bộ
| Method | Block thread? | Dễ implement? | Hiệu năng tốt hơn? | Tiện cho UI/server |
|---|---|---|---|---|
| Đồng bộ (Read/Write) | Có | Có | Không | Không |
| Bất đồng bộ (ReadAsync) | Không | Gần như | Có | Có |
5. Những lưu ý và best practices
Buffer vẫn quan trọng: Dù dùng ReadAsync và WriteAsync, đọc/ghi từng byte vẫn rất kém hiệu quả. Bất đồng bộ loại bỏ block nhưng không thần kỳ tăng tốc từng byte. Kích thước buffer khởi điểm tốt là 4096-8192 byte; với file lớn thử 65536 hoặc 131072.
"Async All The Way Down": Nếu bắt đầu dùng async/await ở chỗ nào đó, thường nên kéo kiểu này dọc theo chuỗi gọi: C gọi async => C là async Task, B cũng là async Task, A tương tự. Nếu không có thể gặp block hoặc deadlock trên UI.
Xử lý ngoại lệ: Trong code bất đồng bộ dùng try-catch như bình thường. Thường gặp OperationCanceledException và IOException — xử lý rõ ràng.
Giải phóng resource (await using): Với stream và IDisposable, giải phóng đúng cách. Nếu type implement IAsyncDisposable thì await using sẽ gọi DisposeAsync(); nếu chỉ có IDisposable thì gọi Dispose().
Bên dưới nắp máy (tóm tắt): Khi gặp await, compiler biến method thành state machine: khởi thao tác, method "pause", control trả về caller. Khi kết quả sẵn sàng, SynchronizationContext (trong UI) hoặc ThreadPool (console/server) resume execution từ chỗ dừng. Điều này cho phép 1 thread phục vụ nhiều task "tạm dừng" mà không block.
Kết lại, lập trình bất đồng bộ với async và await là công cụ mạnh để tạo ứng dụng phản hồi nhanh và dễ scale. Nó giúp dùng tài nguyên hệ thống hiệu quả, không block UI hay các thread server. Ban đầu có thể hơi lạ, nhưng rất đáng để học! Ở các bài sau chúng ta sẽ đi sâu hơn về async và parallelism. Hẹn gặp lại!
GO TO FULL VERSION