Tìm hiểu Task Cancellation trong C#

Thực thi các tác vụ bất đồng bộ rất dễ dàng với C# và .NET. Nhưng đôi khi chúng ta cần hủy các tác vụ bất đồng bộ đang chạy, trong bài viết này chúng ta sẽ tìm hiểu cách hủy các tác vụ sử dụng CancellationToken ngay cả các tác vụ không thể cancel.

Đôi chút về ngày xưa

Quay trở lại .NET ngày trước, chúng ta sử dụng một đối tượng BackgroundWorker để chạy bất đồng bộ và các hành động chạy ngầm lâu dài.

Chúng ta có khả năng hủy các hành động này bằng cách gọi CancelAsync và cài đặt cờ CancellationPending là true

private void BackgroundLongRunningTask(object sender, DoWorkEventArgs e)
{
    BackgroundWorker worker = (BackgroundWorker)sender;

    for (int i = 1; i <= 10000; i++)
    {
        if (worker.CancellationPending == true)
        {
            e.Cancel = true;
            break;
        }
        
        // Do something
    }
}

Đây không phải là cách khuyến nghị để gọi các hành động bất đồng bộ và long-running. Nhưng hầu hết các khía niệm đó đều được sử dụng cho đến khi có sự xuất hiện của Tasks CancellationTokens

Sử dụng Tasks

Task class được đại điện cho một hành động bất đồng bộ, class này không trả về bất cứ giá trị nào trừ khi dùng Generic.

Bạn có thể dùng Task trực tiếp hoặc gọi pattern async await để đơn giản hóa code. Bài viết này sẽ có ví dụ sau:

Ví dụ:

Hãy tạo một long-running operation:

/// <summary>
/// Compute a value for a long time.
/// </summary>
/// <returns>The value computed.</returns>
/// <param name="loop">Number of iterations to do.</param>
private static Task<decimal> LongRunningOperation(int loop)
{
    // Start a task and return it
    return Task.Run(() =>
    {
        decimal result = 0;

        // Loop for a defined number of iterations
        for (int i = 0; i < loop; i++)
        {
            // Do something that takes times like a Thread.Sleep in .NET Core 2.
            Thread.Sleep(10);
            result += i;
        }

        return result;
    });
}

Chúng ta sử dụng Thread.Sleep để giả lập một hành động chạy dài hạn (long running) nhưng tất nhiên nếu thực tế bạn sử dụng một Thread.Sleep thì có lẽ là một vấn đề.

Gọi phương thức trên

Cách đơn giảm để gọi phương thức LongRunningOperation và lấy giá trị trả về là đợi nó (awaiting). Để có thể đợi nó chúng ta cần gọi phương thức với từ khóa async.

public static async Task ExecuteTaskAsync()
{
    Console.WriteLine(nameof(ExecuteTaskAsync));
    Console.WriteLine("Result {0}", await LongRunningOperation(100));
    Console.WriteLine("Press enter to continue");
    Console.ReadLine();
}

 

Giúp code của bạn có thể cancel (cancellable)

Vì bất cứ lý do nào, hành động chạy dài hạn (long running operation) mất quá lâu để thực thi hoặc chúng ta không cần kết quả nữa chúng ta có thể hủy nó.

Trong trường hợp này chúng ta cần truyền CancellationToken vào phương thức đó.

/// <summary>
/// Compute a value for a long time.
/// </summary>
/// <returns>The value computed.</returns>
/// <param name="loop">Number of iterations to do.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private static Task<decimal> LongRunningCancellableOperation(int loop, CancellationToken cancellationToken)
{
    Task<decimal> task = null;

    // Start a task and return it
    task = Task.Run(() =>
    {
        decimal result = 0;

        // Loop for a defined number of iterations
        for (int i = 0; i < loop; i++)
        {
            // Check if a cancellation is requested, if yes,
            // throw a TaskCanceledException.

            if (cancellationToken.IsCancellationRequested)
                throw new TaskCanceledException(task);

            // Do something that takes times like a Thread.Sleep in .NET Core 2.
            Thread.Sleep(10);
            result += i;
        }

        return result;
    });

    return task;
}

Kiểm tra nếu phương thức đã được dừng lại hay chưa bằng cách kiểm tra thuộc tính IsCancellationRequested. Cũng có thể dùng phương thức ThrowIfCancellationRequested sẽ ném ra một OperationCanceledException. Tôi nghiêng về phía ném exception vì tôi có thể truyền một task được cancel trong constructor của TaskCanceledException, nó sẽ cho mình thêm lựa chọn để xử lý trong lời gọi phương thức.

Mối quan hệ với cách sử dụng BackgroundWorker rõ ràng là rất rõ ràng, sự khác nhau chính là chúng ta giờ đây có thể ném một Exception thay vì chỉ là thoát khỏi phương thức.

Cancel sử dụng timeout

Thao tác một trạng thái CancellationToken được hoàn thành thông qua đối tượng CancellationTokenSource được tạo. Nó có thể xử lý một timeout bằng cách chỉ ra giá trị của nó ở thời điểm khởi tạo. Vì thế gọi phương thức LongRunningCancellableOperation sẽ có một timeout:

public static async Task ExecuteTaskWithTimeoutAsync(TimeSpan timeSpan)
{
    Console.WriteLine(nameof(ExecuteTaskWithTimeoutAsync));

    using (var cancellationTokenSource = new CancellationTokenSource(timeSpan))
    {
        try
        {
            var result = await LongRunningCancellableOperation(500, cancellationTokenSource.Token);
            Console.WriteLine("Result {0}", result);
        }
        catch (TaskCanceledException)
        {
            Console.WriteLine("Task was cancelled");
        }
    }
    Console.WriteLine("Press enter to continue");
    Console.ReadLine();
}

Cancel một task bằng tay

Thường chúng ta cần cancel một task bằng tay. Nó được giải quyết bằng cách sử dụng phương thức CancellationTokenSource.Cancel

public static async Task ExecuteManuallyCancellableTaskAsync()
{
    Console.WriteLine(nameof(ExecuteManuallyCancellableTaskAsync));

    using (var cancellationTokenSource = new CancellationTokenSource())
    {
        // Creating a task to listen to keyboard key press
        var keyBoardTask = Task.Run(() =>
        {
            Console.WriteLine("Press enter to cancel");
            Console.ReadKey();

            // Cancel the task
            cancellationTokenSource.Cancel();
        });

        try
        {
            var longRunningTask = LongRunningCancellableOperation(500, cancellationTokenSource.Token);

            var result = await longRunningTask;
            Console.WriteLine("Result {0}", result);
            Console.WriteLine("Press enter to continue");
        }
        catch (TaskCanceledException)
        {
            Console.WriteLine("Task was cancelled");
        }

        await keyBoardTask;
    }
}

Bởi vì ví dụ này là một ứng dụng console, chúng ta cần khởi tạo một task để cancel một long running operation khi bàn phím được ấn. Trong ứng dụng có giao diện ví dụ Xamarin Native hay Xamarin Form thì các nút hoặc các sự kiện điều hướng sẽ trigger sự kiện này.

Cancel một tác vụ không thể cancel

Đôi khi chúng ta sử dụng code không có CancellationToken, vậy không có cách nào để cancel nó. Vẫn được! Trong các trường hợp đó khi chúng ta muốn phương thức ddos trả về ngay lập tức, thì CancellationToken.Register  chính là giải pháp. Hãy làm cho phương thức LongRunningOperation có thể cancel được bằng cách gói nó lại (wrapper)

private static async Task<decimal> LongRunningOperationWithCancellationTokenAsync(int loop, CancellationToken cancellationToken)
{
    // We create a TaskCompletionSource of decimal
    var taskCompletionSource = new TaskCompletionSource<decimal>();

    // Registering a lambda into the cancellationToken
    cancellationToken.Register(() =>
    {
        // We received a cancellation message, cancel the TaskCompletionSource.Task
        taskCompletionSource.TrySetCanceled();
    });

    var task = LongRunningOperation(loop);

    // Wait for the first task to finish among the two
    var completedTask = await Task.WhenAny(task, taskCompletionSource.Task);

    // If the completed task is our long running operation we set its result.
    if (completedTask == task)
    {
        // Extract the result, the task is finished and the await will return immediately
        var result = await task;

        // Set the taskCompletionSource result
        taskCompletionSource.TrySetResult(result);
    }

    // Return the result of the TaskCompletionSource.Task
    return await taskCompletionSource.Task;
}

Hãy nán lại đọc comment trong code.

Đây là chúng ta sử dụng TaskCompletionSource để wrap LongRunningOperation. Chúng hỗ trợ việc hủy và chính xác cái nào mà chúng ta cần chuyển từ trạng thái không thể hủy sang có thể hủy. Tất nhiên là các cách khác cũng tương tự nhưng tôi thích cách này nó dễ đọc và thân thiện với async await.

Hãy note lại chúng ta không thực sự hủy phương thức đó. Chúng ta chỉ kích hoạt để nó trả về sớm hơn mà không đợi tiếp. Như gợi ý trong comment bên dưới, chúng ta có thể điều chỉnh đoạn code trên và bỏ dòng 12 và nó sẽ trở thành:

private static async Task<decimal> LongRunningOperationWithCancellationTokenAsync(int loop, CancellationToken cancellationToken)
{
    // We create a TaskCompletionSource of decimal
    var taskCompletionSource = new TaskCompletionSource<decimal>();

    // Registering a lambda into the cancellationToken
    cancellationToken.Register(() =>
    {
        // We received a cancellation message, cancel the TaskCompletionSource.Task
        taskCompletionSource.TrySetCanceled();
    });

    var task = LongRunningOperation(loop);

    // Wait for the first task to finish among the two
    var completedTask = await Task.WhenAny(task, taskCompletionSource.Task);

    return await completedTask;
}

Gọi phương thức LongRunningOperationWithCancellationTokenAsync:

public static async Task CancelANonCancellableTaskAsync()
{
    Console.WriteLine(nameof(CancelANonCancellableTaskAsync));

    using (var cancellationTokenSource = new CancellationTokenSource())
    {
        // Listening to key press to cancel
        var keyBoardTask = Task.Run(() =>
        {
            Console.WriteLine("Press enter to cancel");
            Console.ReadKey();

            // Sending the cancellation message
            cancellationTokenSource.Cancel();
        });

        try
        {
            // Running the long running task
            var longRunningTask = LongRunningOperationWithCancellationTokenAsync(100, cancellationTokenSource.Token);
            var result = await longRunningTask;

            Console.WriteLine("Result {0}", result);
            Console.WriteLine("Press enter to continue");
        }
        catch (TaskCanceledException)
        {
            Console.WriteLine("Task was cancelled");
        }

        await keyBoardTask;
    }
}

Tổng kết

Hầu hết thời gian chúng ta không cần tự viết các task có thể hủy, chúng ta chỉ cần dùng các API có sẵn. Nhưng luôn luôn tốt khi chúng ta hiểu nó làm việc ra sao. Hoặc nếu bạn gặp một đoạn code không thể cancel được thì bạn sẽ biết phải làm gì.


Tác giả: Bạch Ngọc Toàn

Chú ý: Tất cả các bài viết trên TEDU.COM.VN đều thuộc bản quyền TEDU, yêu cầu dẫn nguồn khi trích lại trên website khác.

Lên trên