Luôn Nhớ Về Async-Await: Khi “Async” Thực Ra Là “Sync” trong .NET

Async-await trong .NET rất thông minh. Nếu bạn gọi một phương thức async trả về một Task đã hoàn thành ngay từ đầu, luồng gọi sẽ không lên lịch phần còn lại của việc thực thi nó như một continuation mà tiếp tục đồng bộ. Điều này có nghĩa là những gì bạn nghĩ là một tác vụ chạy dài được lên lịch cho thread pool có thể thực tế là một cuộc gọi đồng bộ chạy dài trên thread hiện tại của bạn!

Nếu bạn dựa vào các tác vụ được lên lịch, hãy đảm bảo chúng được lên lịch bằng cách bọc chúng trong Task.Run, điều này sẽ xếp hàng tác vụ một cách rõ ràng trên thread pool.

Bối Cảnh

Gần đây, tôi đang làm việc trên một thư viện bao gồm BackgroundService để giữ cho dữ liệu quan trọng (trong trường hợp của tôi là access token) luôn được cập nhật. Vì tôi đang cố gắng cải thiện TDD, tôi đã viết các bài kiểm tra để đảm bảo rằng nó sẽ hoạt động đúng cách, ví dụ: thực hiện các cuộc gọi chính xác đến nhà cung cấp identity bằng cách gọi BackgroundService.StartAsync() theo cách thủ công.

Đoạn code đại loại như thế này:

var fakeHttpMessageHandler = new FakeMessageHandler(); // My HttpMessageHandler fake that logs every HttpMessageRequest and returns canned responses.
var httpClient = new HttpClient(fakeHttpMessageHandler);
var tcs = new TaskCompletionSource(); // Since the tokenManager is supposed to run in the background, it can be hard to tell when it's done. Triggering a TaskCompletionSource from an event handler allows us to await it as usual.
var tokenManager = new TokenManager(httpClient);
tokenManager.OnTokenChanged += tcs.SetResult();
await tokenManager.StartAsync(default);
await tcs.Task;
// Run assertions on fakeHttpMessageHandler.Requests.Should().SatisfyRespectively( request => request... );

TokenManager sẽ chạy trong một vòng lặp vô hạn để giữ cho access token được cập nhật, vì vậy nó sẽ trông tương tự như thế này:

public async Task ExecuteAsync()
{
    while (true)
    {
        var token = await tokenClient.FetchToken();
        // Omitted: Do something with the token
        OnTokenChanged?.Invoke(this, e); // Notify subscribers - I used a slightly different pattern inspired by Reactive Extensions with subscribers getting an IDisposable that they would attach their event handlers to and which would automatically deregister when disposed, but we're keeping it simple here.
    }
}

Bây giờ, rõ ràng tôi sẽ không tấn công một nhà cung cấp identity thực tế cho các unit test của mình. Như tôi đã đề cập trong comment code ở trên, tôi sử dụng một HttpMessageHandler giả trả về các response đóng hộp. Nó có một SendAsync trông giống như thế này:

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    return Task.FromResult(cannedResponse);
}

Nếu bạn thực sự hiểu rõ về async-await trong .NET, bạn có thể hơi lắc đầu ở điểm này. Tuy nhiên, tôi đang vui vẻ chạy thử nghiệm của mình, mong đợi nó chuyển sang màu xanh lá cây (sau khi nhìn thấy một màu đỏ, tất nhiên).

Than ôi, điều đó đã không xảy ra.

Nó Thực Sự Không Nên Làm Như Vậy…

Bài kiểm tra của tôi đã hết thời gian chờ. Hay đúng hơn, nó sẽ tiếp tục chạy cho đến khi tôi giết nó. Gỡ lỗi cho thấy nó đang chạy bên trong vòng lặp vô hạn của TokenManager.ExecuteAsync của tôi, điều đó là ổn – nó phải làm điều đó! Nhưng vì một số lý do, phần còn lại của bài kiểm tra đã không tiến tới câu lệnh await tcs.Task;. Chuyện gì đang xảy ra vậy?

Sau khi xem xét nó trước đây, tôi khá tự tin rằng BackgroundService.StartAsync sẽ không chờ TokenManager.ExecuteAsync và chắc chắn, xem xét source code đã xác nhận điều đó:

public virtual Task StartAsync(CancellationToken cancellationToken)
{
    // Create linked token to allow cancelling executing task from provided token
    _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    // Store the task we're executing
    _executeTask = ExecuteAsync(_stoppingCts.Token);

    // If the task is completed then return it, this will bubble cancellation and failure to the caller
    if (_executeTask.IsCompleted)
    {
        return _executeTask;
    }

    // Otherwise it's running
    return Task.CompletedTask;
}

(https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs)

ExecuteAsync được gọi nhưng không được chờ! Tại sao code của tôi không tiếp tục sau nó?!

Cảm Giác Thần Kinh

Bây giờ, có một cái gì đó ngứa ngáy trong đầu tôi. Một cái gì đó về state machine mà compiler thiết lập để quản lý continuations và cách nó xử lý task completion…

Tôi đã thực hiện một thay đổi nhỏ đối với FakeHttpMessageHandler của mình:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await Task.Yield();
    return Task.FromResult(cannedResponse);
}

Với thay đổi nhỏ đó, bài kiểm tra của tôi đã chạy chính xác như mong đợi. BackgroundService đã được kích hoạt, chạy trong vòng lặp vô hạn của nó, trong khi phần còn lại của bài kiểm tra ngay lập tức tiếp tục chờ tín hiệu từ TaskCompletionSource.

Điều gì khiến tôi thử thay đổi đó? Nói một cách đơn giản: Stephen Toub.

Hơn một năm trước, Stephen Toub đã xuất bản một bài viết chuyên sâu về async-await, một tour de force thực sự về kiến thức .NET huyền bí: How Async/Await Really Works in C#. Nếu bạn phát triển C# để kiếm sống, đây là một bài đọc bắt buộc.

Đó là một bài viết tuyệt vời và kỹ lưỡng. Rất kỹ lưỡng. Mặc dù tôi hết lòng khuyên bạn nên đọc tất cả, nhưng tôi sẽ làm nổi bật điểm chính ở đây:

Nếu tác vụ của bạn đã hoàn thành khi được trả về, continuation sẽ chạy đồng bộ.

Trước khi thêm await Task.Yield() nhỏ bé vô tội, phương thức SendAsync sẽ trả về canned response ngay lập tức và đồng bộ – không cần lên lịch bất cứ điều gì. Bất cứ điều gì đã gọi nó (có lẽ là một cái gì đó bên trong HttpClient) sẽ nhìn vào task và nói “hey, cái đó đã xong rồi. Không cần thiết lập một state machine và theo dõi completion. Chúng ta sẽ chỉ tiếp tục xử lý nó.”. Điều đó đến lượt nó có nghĩa là phương thức TokenClient.FetchToken được gọi trong vòng lặp vô hạn của TokenManager sẽ trả về ngay lập tức và đồng bộ.

Vì vậy, mọi thứ bên trong BackgroundService.ExecuteAsync() của tôi đều đang chạy đồng bộ. Nhớ lại phần thân của phương thức BackgroundService.StartAsync():

public virtual Task StartAsync(CancellationToken cancellationToken)
{
    // Create linked token to allow cancelling executing task from provided token
    _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    // Store the task we're executing
    _executeTask = ExecuteAsync(_stoppingCts.Token);

    // If the task is completed then return it, this will bubble cancellation and failure to the caller
    if (_executeTask.IsCompleted)
    {
        return _executeTask;
    }

    // Otherwise it's running
    return Task.CompletedTask;
}

Tôi nghĩ rằng tôi chỉ đang lưu trữ một Task sẽ chạy trong nền – được lên lịch cho thread pool – nhưng không có gì được lên lịch cả. Những gì đáng lẽ phải là “lấy một tham chiếu và tiếp tục” đã trở thành “chạy nó để hoàn thành”, điều này với một vòng lặp vô hạn đòi hỏi một chút kiên nhẫn.

Chi Tiết Cụ Thể

Nếu bạn thực sự muốn đi sâu vào chi tiết, hãy lấy cho mình một công cụ để xem một số IL. Tôi đã thử cả ildasm, ILSpy và dotPeek – tôi thấy cái sau dễ phân tích cú pháp nhất.

Những gì bạn sẽ thấy – và những gì bạn có thể đọc trong bài viết xuất sắc của Stephen Toub trong khối code thứ ba dưới tiêu đề “MoveNext” – là các khối như thế này (snippet để rõ ràng):

if (!awaiter.IsCompleted) { num = (1__state = 1); u__2 = awaiter; t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; }

Rõ ràng có rất nhiều điều đang xảy ra, nhưng điểm mấu chốt là nó kiểm tra xem task đã hoàn thành hay chưa và chỉ khi nó chưa hoàn thành, nó mới thực sự lên lịch một continuation trên thread pool.

Vậy bạn có thể làm gì nếu bạn phụ thuộc vào việc nó được lên lịch? Bạn có thể bọc nó trong Task.Run hoặc – nếu bạn có quyền truy cập – bạn có thể định cấu hình phương thức gọi thực thi đồng bộ với .ConfigureAwait(ConfigureAwaitOptions.ForceYielding) mà, như tên gọi, buộc nó phải được lên lịch trên thread pool.

Vì vậy, bạn đã có nó. Cách nói dài dòng đáng kinh ngạc của tôi về việc “nó chỉ là async nếu nó phải như vậy, vì vậy hãy cẩn thận cách bạn giả mạo nó”.

Tôi hy vọng nó sẽ giúp bạn tiết kiệm được một số nghiến răng.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *