Xử lý cancel và timeout cho async/await

Tìm hiểu cách xử lý hiệu quả cho quá trình cancel hoặc timeout của async/await

Giới thiệu

Một trong những điều quan trọng khi sử dụng async/await đó là xử lý cancellation phù hợp. Tạo ra CancellationTokenSource -> sử dụng CancellationToken -> xử lý OperationCanceledException. Tuy nhiên điều này gặp phải những khó khăn nhất định bài viết này sẽ chỉ ra những khó khăn đó và đưa ra cách xử lý tốt nhất

CreateLinkedTokenSource Pattern

Đầu tiên ta hãy xem xét cách đơn giản nhất để xử lý cancellation đó là cung cấp một CancellationToken trực tiếp hoặc là tham số truyền vào sau đó nó được xử lý để ném ngoại lệ OperationCanceledException khi phát hiện có yêu cầu hủy

xem đoạn mã sau

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public async Task CallAsync(CancellationToken cancellationToken = default)
{
    await HeavyTaskAsync(cancellationToken);
}

private static async Task HeavyTaskAsync(CancellationToken cancellationToken)
{
    await Task.Run(() =>
        {
            for (int i = 0; i < int.MaxValue; i++)
            {
                float p = Mathf.Pow(2, 9);
            }
        },
        cancellationToken);
}

Chúng ta cũng cần xử lý vấn đề timeout, điều này đặc biệt quan trọng trong các hệ thống mạng. Ý tưởng cơ bản đằng sau quá trình xử lý timeout đó là timeout cũng là một quá trình cancellation

Bên trong CancellationTokenSource đã có sẵn phương thức CancelAfter. Khi phương thức này được gọi nó sẽ kích hoạt lệnh cancel sau một khoảng thời gian nhất định. Vì vậy việc sử dụng phương thức này sẽ dẫn đến timeout cho các CancellcationToken của nó

1
2
3
4
5
6
private async void Start()
{
    CancellationTokenSource tokenSource = new CancellationTokenSource();
    tokenSource.CancelAfter(TimeSpan.FromSeconds(10));
    await CallAsync(tokenSource.Token);
}

Bên trong UniTask cung cấp một phương thức gọi là CancelAfterSlim, nó là một triển khai dành cho Unity. Khác với CancelAfter của Task sử dụng thread pool CancelAfterSlim của UniTask sử dụng PlayerLoop

Việc phải gọi CancelAfter mỗi lần khá là bất tiện vì vậy CancelAfter sẽ được gọi bên trong. Như ví dụ ở trên thì CancelAfter bây giờ sẽ được gọi ở trong phương thức CallAsync. Để đồng nhất giữa timeout của CancellationToken truyền vào và timeout của CancellationToken mới tạo ra bên trong CallAsync là một chúng ta sử dụng CancellationTokenSource.CreateLinkedTokenSource

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private async void Start()
{
    CancellationTokenSource tokenSource = new CancellationTokenSource();
    await CallAsync(10, tokenSource.Token);
}

public async Task CallAsync(int timeout, CancellationToken cancellationToken = default)
{
    using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    linkedTokenSource.CancelAfter(timeout);
    await HeavyTaskAsync(linkedTokenSource.Token);
}

CancellationTokenSource được tạo bởi CreateLinkedTokenSource sẽ bị hủy khi bất kỳ CancellationTokenSource nào được liên kết bị hủy bỏ. Để xác định tác nhân gây ra quá trình hủy OperationCanceledException cung cấp một thuộc tính là CancellationToken. Chúng ta có thể bắt một OperationCanceledException và sau đó thêm điều kiện để phân nhánh các trường hợp như mong muốn.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public async Task CallAsync(int timeout, CancellationToken cancellationToken = default)
{
    using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    linkedTokenSource.CancelAfter(timeout);
    try
    {
        await HeavyTaskAsync(cancellationToken);
    }
    catch (OperationCanceledException exception) when(exception.CancellationToken == linkedTokenSource.Token)
    {
        // TO_DO
    }
}

Trong một số trường hợp ta cần phân biệt được ngoại lệ được ném hoặc timeout vì ngoại lệ do lỗi hoặc timeout đều ném ra OperationCanceledException Vì vậy chúng ta cần kiểm tra điều này thông qua thuộc tính IsCancellationRequested bên trong cancellationToken

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public async Task CallAsync(int timeout, CancellationToken cancellationToken = default)
{
    using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    linkedTokenSource.CancelAfter(timeout);
    try
    {
        await HeavyTaskAsync(linkedTokenSource.Token);
    }
    catch (OperationCanceledException exception) when(exception.CancellationToken == linkedTokenSource.Token)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            // Error reason is argument CancellationToken
            throw new OperationCanceledException(exception.Message, exception, cancellationToken);
        }
        else
        {
            // Error reason is timeout
            throw new TimeoutException($"The request was canceled due to the configured Timeout of {timeout} seconds elapsing.", exception);
        }
    }
}

Zero Allocation

Việc tạo mới một CancellationTokenSource bằng CreateLinkedTokenSource sẽ tạo ra rác, dù bằng cách nào nếu phương thức async được thực thi không đồng bộ thì nó đã được cấp phát bộ nhớ cho state machine. Ta có thể có một triển khai tránh phân bổ (avoid allocation) sử dụng IValueTaskSource hoặc PoolingAsyncValueTaskMethodBuilder

Xem xét trường hợp không có cancellation từ bên ngoài, chỉ bị hủy bởi timeout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private ObjectPool<CancellationTokenSource> _timeoutTookenSource;
private void Start()
{
    _timeoutTookenSource = new ObjectPool<CancellationTokenSource>(() => new CancellationTokenSource());
}

public async Task CallAsync(int timeout)
{
    var tokenSource = _timeoutTookenSource.Get();
    tokenSource.CancelAfter(timeout);
    try
    {
        await HeavyTaskAsync(tokenSource.Token);
    }
    finally
    {
        // Not raised timeout, you can reuse by Reset
        if (tokenSource.TryReset()) // .NET 6
        {
            _timeoutTookenSource.Release(tokenSource);
        }
    }
}

Nếu bạn truyền cancellation bên ngoài vào đừng tạo LinkedToken mà thay vào đấy là cancel CancellationTokenSource với CancellationToken.UnsafeRegister

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private ObjectPool<CancellationTokenSource> _timeoutTookenSource;
private void Start()
{
    _timeoutTookenSource = new ObjectPool<CancellationTokenSource>(() => new CancellationTokenSource());
}

public async Task CallAsync(int timeout, CancellationToken cancellationToken = default)
{
    var tokenSource = _timeoutTookenSource.Get();
    CancellationTokenRegistration externalCancellation = default;
    if (cancellationToken.CanBeCanceled)
    {
        // When raised argument CancellationToken, raise Timeout's CancellationToken
        externalCancellation = cancellationToken.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, tokenSource);
    }
    tokenSource.CancelAfter(timeout);
    try
    {
        await HeavyTaskAsync(tokenSource.Token);
    }
    finally
    {
        // Unregister(must before `TryReset`),
        // CancellationTokenRegistration.Dispose block and wait reliably until the release is completed (or the execution is finished if a callback is being executed)
        externalCancellation.Dispose();
        if (tokenSource.TryReset())
        {
            _timeoutTookenSource.Release(tokenSource);
        }
    }
}

UnsafeRegister là một phương thức từ .NET 6 hiệu quả hơn vì nó không Capture ExceptionContext Nếu một callback tạo một CancellationToken có khả năng rảy ra tình trạng phân hóa. Trong trường hợp này nếu một CancellationTokenSource cho timeout được trả lại pool và sau đó một cuộc gọi Cancel được gọi đó sẽ là một thảm họa. Để tránh điều này hãy luôn gọi CancelTokenRegistration.Dispose trước TryReset. Cái hay của CancellationTokenRegistration.Dispose là nếu lệnh callback đang chạy nó sẽ chặn và đợi cho đến khi trình thực thi kết thúc để đảm bảo rằng nó được thực hiện Điều này ngăn ngừa các vấn đề liên quan đến đa luồng

CancellationTokenRegistration cũng có phương thức DisposeAsync nhưng sẽ hiệu quả hơn nếu gọi Dispose vì hầu hết thời gian nó giống như lock. Ngoài ra còn có một phương thức Unregister trong CancellationTokenRegistration hữu ích cho việc hủy đăng ký theo dạng fire-and-forget.

Phương thức UnsafeRegister được sử dụng để đăng ký một callback đến CancellationToken, phương thức này tạo ra một vị trí cho callback registration lần đầu Tuy nhiên lần thứ hai và lần tiếp theo nữa vị trí này được tái sử dụng. Đây là một điểm cộng so với vệc tạo mới một CancellationTokenSource linked

Dưới đây là một ví dụ thêm CancellationToken vào xuyên xuốt vòng đời.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private ObjectPool<CancellationTokenSource> _timeoutTookenSource;
private CancellationTokenSource _lifetimeTokenSource;
private void Start()
{
    _timeoutTookenSource = new ObjectPool<CancellationTokenSource>(() => new CancellationTokenSource());
    _lifetimeTokenSource = new CancellationTokenSource();
}

public async Task CallAsync(int timeout, CancellationToken cancellationToken = default)
{
    var tokenSource = _timeoutTookenSource.Get();
    CancellationTokenRegistration externalCancellation = default;
    if (cancellationToken.CanBeCanceled)
    {
        // When raised argument CancellationToken, raise Timeout's CancellationToken
        externalCancellation = cancellationToken.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, tokenSource);
    }
    
    var lifetimeCancellation = _lifetimeTokenSource.Token.UnsafeRegister(static state =>
    {
        ((CancellationTokenSource)state!).Cancel();
    }, tokenSource);
    
    tokenSource.CancelAfter(timeout);
    try
    {
        await HeavyTaskAsync(tokenSource.Token);
    }
    finally
    {
        // unregister
        externalCancellation.Dispose();
        lifetimeCancellation.Dispose();
        if (tokenSource.TryReset())
        {
            _timeoutTookenSource.Release(tokenSource);
        }
    }
}

public void Dispose()
{
    _lifetimeTokenSource.Cancel();
    _lifetimeTokenSource.Dispose();
}

Bắt ngoại lệ cũng sẽ tương tự như ví dụ trước đó bằng cách xử lý với LinkedToken

Lượt nghé thăm