CodeGym /Các khóa học /C# SELF /Bộ sinh đồng bộ và bất đồng bộ trong C# ( yield)

Bộ sinh đồng bộ và bất đồng bộ trong C# ( yield)

C# SELF
Mức độ, Bài học
Có sẵn

1. Giới thiệu

Hãy tưởng tượng bạn cần xử lý một file log khổng lồ có hàng triệu dòng, hoặc sinh một dãy số cực dài. Bạn sẽ làm sao nếu không có bộ sinh?

Cách truyền thống sẽ trông như sau:


// Vấn đề: tạo toàn bộ collection trong bộ nhớ cùng lúc
List<int> GenerateAllNumbersSync(int count)
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i);
    }
    return numbers; // Trả về khi mọi thứ đã xong
}

// Sử dụng:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 triệu số trong bộ nhớ ngay lập tức!
foreach (var num in myNumbers) { /* Xử lý */ }

Có vấn đề gì ở đây?

  1. Tiêu thụ bộ nhớ: Nếu count rất lớn, toàn bộ collection được tạo trong bộ nhớ, có thể dẫn đến OutOfMemoryException.
  2. Độ trễ: Người dùng hoặc phần tiếp theo của chương trình phải chờ cho đến khi toàn bộ dữ liệu được sinh và nạp vào bộ nhớ.
  3. Dãy vô hạn: Cách này không hoạt động nếu dãy có khả năng vô hạn.

Bộ sinh (generators) xuất hiện để giải quyết! Chúng triển khai khái niệm tính toán lazy (Lazy Evaluation) và xử lý streaming. Thay vì sinh tất cả dữ liệu cùng lúc, generator tạo các phần tử từng cái một, chỉ khi nào cần thực sự.

2. Cơ bản về bộ sinh

Trong C# bộ sinh được tạo bằng từ khoá đặc biệt yield.

Bộ sinh là gì?

Là một method, khối get của property hoặc operator chứa một hoặc nhiều biểu thức yield return.

yield return

Đây là trái tim của generator. Khi compiler gặp yield return:

  1. Phần tử đứng sau yield return được trả cho code gọi.
  2. Thực thi của method-generator bị tạm dừng, và trạng thái hiện tại của nó (vị trí trong vòng lặp, giá trị biến cục bộ) được lưu lại.
  3. Khi có yêu cầu phần tử tiếp theo (ví dụ lần lặp tiếp theo của foreach), thực thi method được tiếp tục từ chỗ đã dừng.

Kiểu trả về: Method-generator phải trả về IEnumerable<T> hoặc IEnumerator<T>. Compiler sẽ tự sinh toàn bộ "ma thuật" cần thiết.


// Ví dụ 2.1: Generator số đơn giản
IEnumerable<int> GenerateNumbers(int count)
{
    Console.WriteLine("Bắt đầu sinh...");
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"Đang sinh: {i}");
        yield return i; // Tạm dừng và trả phần tử
    }
    Console.WriteLine("Sinh hoàn tất.");
}

// Sử dụng:
// Chú ý rằng "Bắt đầu sinh..." chỉ xuất hiện ở lần lặp đầu tiên!
// Còn "Đang sinh: X" — xuất hiện ở mỗi lần lặp mới.
foreach (var num in GenerateNumbers(3))
{
    Console.WriteLine($"Nhận trong foreach: {num}");
}

yield break

Dùng để kết thúc sớm việc lặp. Sau yield break sẽ không trả thêm phần tử nào. Nếu thực thi đến cuối method thì không cần yield break riêng.


// Ví dụ 2.2: Generator với điều kiện thoát
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
    int count = 0;
    foreach (var item in source)
    {
        if (count >= n)
        {
            yield break; // Thoát khỏi generator
        }
        yield return item;
        count++;
    }
}

// Sử dụng:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
//     Console.WriteLine(fruit); // In "Apple", "Banana"
// }

3. Máy trạng thái

Nó hoạt động "bên dưới nắp cap" thế nào? Không có phép màu — chỉ là compiler làm việc thông minh.

Khi bạn viết method có yield, compiler C# chuyển nó thành một class hiện thực IEnumerator<T> và IEnumerable<T>. Class được sinh này là máy trạng thái.

  • Lưu trạng thái: máy lưu số trạng thái (chỗ đã dừng) và giá trị các biến cục bộ khi tạm dừng.
  • Lặp: khi duyệt bằng foreach gọi các method MoveNext() và đọc thuộc tính Current. MoveNext() tiếp tục thực thi đến yield return/yield break, còn Current trả phần tử hiện tại.

Thực chất compiler giúp bạn hiện thực mẫu thiết kế Iterator Pattern.

4. Ứng dụng của generator

Ví dụ: Xử lý dữ liệu lớn

Đọc file theo dòng mà không nạp toàn bộ file vào bộ nhớ.


// Giả lập đọc file lớn
IEnumerable<string> ReadBigFileLines(string filePath)
{
    Console.WriteLine($"Mở file: {filePath}");
    // Trong ứng dụng thật sẽ có StreamReader ở đây
    yield return "Dòng dữ liệu 1";
    yield return "Dòng dữ liệu 2";
    yield return "Dòng dữ liệu 3";
    Console.WriteLine("Kết thúc giả lập đọc file.");
}

// Sử dụng:
Console.WriteLine("Bắt đầu xử lý.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
    Console.WriteLine($"Dòng đã xử lý: {line}");
    if (line.Contains("2")) break; // Ta có thể dừng khi muốn
}
Console.WriteLine("Xử lý hoàn tất.");

Lưu ý rằng thông báo kết thúc chỉ xuất hiện sau khi vòng lặp kết thúc.

Ví dụ: Dãy vô hạn


IEnumerable<long> FibonacciSequence()
{
    long a = 0;
    long b = 1;
    while (true) // Dãy có khả năng vô hạn
    {
        yield return a;
        long temp = a;
        a = b;
        b = temp + b;
    }
}

// Sử dụng:
int count = 0;
foreach (var num in FibonacciSequence())
{
    Console.WriteLine(num);
    count++;
    if (count >= 10) break; // Cần giới hạn để khỏi treo
}

Ví dụ: Pipeline xử lý dữ liệu

Tạo chuỗi các phương thức, mỗi bước xử lý dữ liệu "on the fly".


IEnumerable<int> GetNumbers()
{
    yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}

IEnumerable<int> FilterEven(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        if (num % 2 == 0) yield return num;
    }
}

IEnumerable<int> Square(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        yield return num * num;
    }
}

// Sử dụng:
foreach (var result in Square(FilterEven(GetNumbers())))
{
    Console.WriteLine(result); // 4, 16
}

Rất giống cách nhiều operator của LINQ hoạt động (ví dụ Where, Select, Take, Skip).

5. Bộ sinh bất đồng bộ

Bộ sinh đồng bộ rất hay, nhưng nếu mỗi phần tử yêu cầu một operation bất đồng bộ (ví dụ request mạng) thì sao? Trước C# 8.0 điều này khó thực hiện.

Vấn đề: luồng dữ liệu bất đồng bộ

Không thể dùng await trong method-generator đồng bộ.


// Điều này SẼ KHÔNG BIÊN DỊCH!
IEnumerable<string> GetStringsAsyncProblem()
{
    await Task.Delay(100); // Lỗi: await chỉ dùng trong method async
    yield return "Hello";
}

Giải pháp: IAsyncEnumerable<T> và await foreach

  • IAsyncEnumerable<T> — phiên bản bất đồng bộ của IEnumerable<T>.
  • await foreach — cú pháp tiện để duyệt dãy bất đồng bộ (bên trong gọi MoveNextAsync() và xử lý trạng thái bất đồng bộ).

async yield return

Bây giờ có thể dùng yield return trong method async trả về IAsyncEnumerable<T>. Compiler sẽ tạo máy trạng thái bất đồng bộ.


// Ví dụ 5.1: Generator số bất đồng bộ
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    Console.WriteLine("Bắt đầu sinh bất đồng bộ...");
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100); // Giả lập công việc bất đồng bộ (ví dụ request mạng)
        Console.WriteLine($"Đang sinh bất đồng bộ: {i}");
        yield return i; // Trả phần tử
    }
    Console.WriteLine("Sinh bất đồng bộ hoàn tất.");
}

// Sử dụng:
async Task ConsumeAsyncNumbers()
{
    Console.WriteLine("Bắt đầu xử lý bất đồng bộ...");
    await foreach (var number in GenerateNumbersAsync())
    {
        Console.WriteLine($"Nhận bất đồng bộ: {number}");
    }
    Console.WriteLine("Xử lý bất đồng bộ hoàn tất.");
}

// Chạy:
await ConsumeAsyncNumbers(); // Gọi cái này từ async Main hoặc ngữ cảnh tương đương

IAsyncDisposable và await using (trong ngữ cảnh generator)

Nếu generator mở một resource cần giải phóng bất đồng bộ (DisposeAsync()), dùng await using. Khi kết thúc await foreach sẽ tự gọi DisposeAsync() trên iterator nội bộ nếu nó hiện thực IAsyncDisposable.


// Ví dụ 5.2: Đọc file bất đồng bộ với await using
// StreamReader thực tế hiện thực IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[Generator] Mở file bất đồng bộ: {filePath}");
    // await using đảm bảo gọi DisposeAsync() sau khi thoát block
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // Đọc dòng bất đồng bộ
    {
        yield return line;
    }
    Console.WriteLine($"[Generator] Đã đọc xong file: {filePath}");
}

// Sử dụng:
async Task ProcessFileAsync()
{
    Console.WriteLine("[Handler] Bắt đầu xử lý file.");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // thay bằng đường dẫn thật
    {
        Console.WriteLine($"[Handler] Nhận dòng: {line}");
        // Ở đây có thể xử lý bất đồng bộ từng dòng
        await Task.Delay(50); 
    }
    Console.WriteLine("[Handler] Xử lý file hoàn tất.");
}

// Chạy:
await ProcessFileAsync(); // Gọi từ async Main

Huỷ generator bất đồng bộ: CancellationToken

Thêm CancellationToken vào generator bất đồng bộ để code gọi có thể huỷ việc sinh.


// Ví dụ 5.3: Generator bất đồng bộ có huỷ
async IAsyncEnumerable<int> GenerateCancelableSequence(
    int start, int count, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < count; i++)
    {
        token.ThrowIfCancellationRequested(); // Kiểm tra token huỷ
        await Task.Delay(100, token); // Task.Delay cũng hỗ trợ huỷ
        yield return start + i;
    }
}

Sử dụng


var cts = new CancellationTokenSource();
Task.Run(async () =>
{
    await Task.Delay(300); // Cho generator chạy một lát
    cts.Cancel(); // Huỷ!
});

try
{
    await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
    {
        Console.WriteLine($"Nhận: {num}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("Việc sinh đã bị huỷ.");
}

6. Hạn chế và khi nào nên cẩn trọng

Hạn chế của yield:

  • Không thể dùng yield return trong try-block mà catch hoặc finally cũng chứa yield.
  • Method có yield không thể là unsafe.
  • Không dùng được yield trong async void (dùng async Task hoặc IAsyncEnumerable<T> thay thế).

Hiệu năng: Với các collection rất nhỏ hoặc cố định, overhead của máy trạng thái có thể hơi cao hơn so với trả thẳng List<T>. Nhưng với dữ liệu lớn, lợi ích từ lazy và streaming thường quan trọng hơn nhiều.

Xử lý lỗi: Exceptions ném bên trong generator sẽ bay về code gọi và có thể được catch ở đó giống như trong method bình thường.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Bộ sinh số bất đồng bộ trong một khoảng
Bộ sinh số bất đồng bộ trong một khoảng
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION