Đỏ, Xanh dương và Tím – Szymon Kulec @Scooletz

Chúng ta đều yêu thích lập trình bất đồng bộ (async). Cho đến khi chúng ta không còn yêu thích nó nữa. Hãy thử hỏi những kỹ sư đồng nghiệp được giao nhiệm vụ chuyển đổi một đoạn mã sang async. Họ có thể sẽ đề cập đến thứ gì đó về màu sắc, như màu đỏ hay xanh dương, và vung tay nói về vài quy tắc mà bạn cần tuân theo. Rốt cuộc họ đang nói về cái quái gì vậy?

Đội đỏ vs Đội xanh dương

Function coloring (tô màu hàm) là một mô hình tư duy đơn giản mà bạn có thể sử dụng để xử lý các ngôn ngữ hỗ trợ bất đồng bộ. Dù là C#, JS hay TS, tất cả đều hỗ trợ các từ khóa tuyệt vời asyncawait. Nếu bạn đang sử dụng các phiên bản hiện đại của chúng, có lẽ bạn sử dụng chúng rất nhiều. Trong bài viết này, chúng ta sẽ tập trung vào C# hiện đại và những gì chúng ta có thể tận dụng từ nó. Hãy bắt đầu với các hàm sau:

async Task<int> ProcessWithAzureAsync()
{
    object payload = await SomeSlowAzureFunc();
    int result = Process(payload);
    return result;
}

int ProcessLocally()
{
    object payload = Build();
    int result = Process(payload);
    return result;
}

int Process(object payload)
{
    // Triển khai tối mật
    return 42 + payload.GetHashCode();
}

Cả hai phương thức, ProcessWithAzureAsyncProcessLocally, đều thực hiện gần như cùng một thao tác. Cuối cùng, cả hai đều gọi Process. Sự khác biệt chính là cách chúng lấy payload cần thiết cho quá trình xử lý tiếp theo. Phiên bản Azure lấy nó từ một tài nguyên từ xa, do đó nó sử dụng async-await để xử lý I/O đúng cách. Phiên bản cục bộ không yêu cầu xử lý như vậy vì nó xây dựng payload ngay tại chỗ, không cần gọi các phụ thuộc từ xa bên thứ ba. Hãy xem xét thêm một mức trừu tượng nữa, một hàm sẽ gọi một trong các phương thức Process*.

int Combine()
{
    int result = Process();
    return result + _previous;
}

Chúng ta có thể thay thế lời gọi Process bằng bất kỳ phương thức nào đã giới thiệu ở trên không? Chắc chắn chúng ta có thể làm điều đó với ProcessLocally. Còn ProcessWithAzure thì sao? Điều đó sẽ yêu cầu thay đổi phương thức Combine thành:

Task<int> CombineAsync()
{
    int result = await SomeSlowAzureFunc();
    return result + _previous;
}

Bây giờ chúng ta có thể thấy rằng các lời gọi bất đồng bộ đang lan truyền lên trên, buộc người gọi phải kết hợp cùng hành vi bất đồng bộ đó. Không có gì ngăn cản chúng ta gọi các phương thức đồng bộ từ bất đồng bộ cả! Nếu bạn muốn, chúng ta có thể thêm một vài lời gọi phương thức thông thường vào CombineAsync.

Bây giờ, vì màu đỏ thường được cho là đau đớn hơn màu xanh dương, chúng ta có thể đồng ý tô màu các hàm này tương ứng. Hãy tô màu đỏ cho các phương thức trả về Task (bất đồng bộ) và màu xanh dương cho tất cả các phương thức đồng bộ. Điều này sẽ làm ProcessWithAzure có màu đỏ nhưng ProcessProcessLocally vẫn là màu xanh dương.

Nói như vậy, hãy liệt kê tất cả các quy tắc bạn cần tuân theo:

  1. Xanh dương có thể gọi xanh dương
  2. Đỏ có thể gọi xanh dương
  3. Đỏ có thể gọi đỏ
  4. Xanh dương không bao giờ được gọi đỏ

Quy tắc thứ 4 khiến mọi thứ trở nên bi kịch (trừ khi bạn flex với những thứ như sync-over-async). Một khi bạn tận dụng tính bất đồng bộ, bạn PHẢI (theo phong cách RFC) lan truyền nó lên trên. Người gọi của một phương thức bất đồng bộ cũng phải là bất đồng bộ, và cứ thế.

Cỗ máy trạng thái ẩn

Khi chúng ta nhìn vào đoạn mã đẹp đẽ mang tính bất đồng bộ, được rắc thêm async-await, có một thứ bị ẩn khỏi tầm mắt chúng ta. Đó là async state machine (cỗ máy trạng thái bất đồng bộ). Tại sao nó lại cần thiết?

Khi bạn nhìn vào các lời gọi bất đồng bộ, mã của chúng có thể được chia thành hai phần:

  1. Trước await đầu tiên
  2. Sau await đầu tiên

Phần mã sau ranh giới await có thể cần "chờ" thực thi cho đến khi thao tác bất đồng bộ cơ bản hoàn thành. Bạn có thể hỏi async-await có nên loại bỏ việc chờ đợi không? Bạn đúng! Nó làm điều đó bằng cách giới thiệu khái niệm về các continuation và biến phương thức của bạn thành một cỗ máy trạng thái tiến lên phía trước với mỗi await. Việc continuation yêu cầu capture trạng thái của nó và đó là những gì cỗ máy trạng thái làm. Bên cạnh việc xử lý việc tiến lên phía trước, nó cũng capture mọi thứ cần thiết để thực hiện việc di chuyển đó.

Nếu mô tả này hơi phức tạp, hãy nghĩ xem các cỗ máy trạng thái có thể trở nên phức tạp đến mức nào để xử lý tất cả các chuyển đổi này! Nếu luồng rẽ nhánh nhiều, chúng có thể thực sự khổng lồ. Hãy xem xét một ví dụ. Ví dụ này được trích xuất từ RavenDB. Bạn sẽ không thấy điều này trong mã C# của nó, vì trình biên dịch C# phát ra nó. Bức ảnh cho thấy Intermediate Language (IL) cơ bản, sau đó được chuyển đổi thành assembly.

Hình ảnh cỗ máy trạng thái

Chúng ta có thể thấy hai trường được đánh dấu màu đỏ. Đó là trạng thái của cỗ máy trạng thái. Có rất nhiều trường khác ở đây được đánh dấu màu vàng. Đây là tất cả các biến đáng thương cần được capture giữa các lần chuyển đổi. Và cách duy nhất để capture chúng là cấp phát một thể hiện của một lớp (như hình trên) và gán tất cả chúng. Một lần nữa, thông thường điều này nên được xử lý tốt, nhưng trong một số trường hợp, nó có thể phình to. Hãy đếm các trường. Có rất nhiều. Có cách nào để tối ưu hóa nó không?

Elide như không có ngày mai

Một cách để làm điều đó là đi theo con đường elision (lược bỏ). Hãy xem xét hai phương thức sau:

async Task<int> ProcessWithAzure()
{
    object payload = await SomeSlowAzureFunc();
    int result = Process(payload);
    return result;
}

async Task<int> ProcessWrapper()
{
    return await ProcessWithAzure();
}

Hãy giả sử vì một lý do nào đó, chúng ta cần bọc ProcessWithAzure thực tế bằng một wrapper bổ sung. Không có gì khác được thực hiện, ngoài một wrapper đơn giản. Trong trường hợp này, chúng ta có thể lược bỏ lời gọi, nghĩa là loại bỏ khía cạnh chờ đợi:

async Task<int> ProcessWithAzure()
{
    object payload = await SomeSlowAzureFunc();
    int result = Process(payload);
    return result;
}

Task<int> ProcessWrapper()
{
    return ProcessWithAzure();
}

Mặc dù điều này có thể được thực hiện cho các trường hợp đơn giản, nhưng nó có thể nhanh chóng phức tạp khi sử dụng các cú pháp đường phổ biến được xử lý bởi trình biên dịch C#. Hãy xem xét đoạn mã dưới đây:

async Task<int> ProcessWithAzure()
{
    object payload = await SomeSlowAzureFunc();
    int result = Process(payload);
    return result;
} 

async Task<int> ProcessWrapper()
{
    using(var tx = BuildTransaction())
    {
        return await ProcessWithAzure();
    }
}

Bạn không thể dễ dàng loại bỏ task ở đây vì việc chờ đợi nằm trong mệnh đề using.

Cho đến nay chúng ta biết rằng có cỗ máy trạng thái, và chúng ta có thể lược bỏ một số task đơn giản. Chúng ta chưa thảo luận về cấu trúc gọi là ValueTask có thể được sử dụng để tối ưu hóa thêm. Đã đến lúc tìm hiểu!

Tham khảo C# 9.0 Professional để học các tính năng mới nhất của C#!

Giá trị của ValueTask

Ý tưởng chính đằng sau ValueTask có thể được tóm tắt là mẫu số chung tốt nhất có thể cho tất cả các mã có tiềm năng (chúng ta sẽ nói về điều này ngay) bất đồng bộ. Các trường hợp là gì?

  1. Phương thức của bạn có bản chất bất đồng bộ (giống như Task)
  2. Phương thức của bạn cần tuân theo một chữ ký có tiềm năng bất đồng bộ (trả về ValueTask)
  3. Phương thức của bạn sử dụng phép thuật IValueTaskSource cấp thấp để cấp phát ít nhất có thể và tái sử dụng các đối tượng cơ bản.

Có một số lưu ý ở đây, nhưng nghĩ về ValueTask như một bước tiếp theo thân thiện với hiệu suất của lập trình bất đồng bộ là cách tiếp cận đúng đắn. Nếu bạn muốn tìm hiểu sâu hơn, hãy xem Task, Async Await, ValueTask, IValueTaskSource của Scooletz và Understanding the Whys, Whats, and Whens of ValueTask của Stephen Toub.

Tím là màu mới… tím

Chúng ta biết rằng chúng ta có thể đi theo con đường xanh dương (đồng bộ) và đỏ (bất đồng bộ) trong thiết kế của mình. Chúng ta đã biết rằng chúng ta không thể gọi đỏ từ xanh dương. Câu hỏi là liệu có một điểm trung gian nào đó có thể được tận dụng cho mã tập trung vào hiệu suất không?

Hãy một lần nữa xem xét codebase RavenDB như một ví dụ. Một trong những tính năng được giới thiệu cách đây một thời gian là khả năng sharding. Sharding trong bối cảnh cơ sở dữ liệu cho phép người dùng phân chia dữ liệu giữa các node. Về cơ bản, nếu kích thước dữ liệu của bạn vượt quá khả năng của một máy, bạn có thể cân nhắc scale up. Chúng ta đang nói về hàng terabyte dữ liệu và một đám đông các yêu cầu. Bạn có thể đọc thêm về nó trong tài liệu của RavenDB. Nhưng điều này có liên quan gì đến lập trình bất đồng bộ?

Cho đến khi sharding được ra mắt, bất cứ khi nào một node được yêu cầu một tài liệu, người ta biết rằng dữ liệu được giữ cục bộ. Node có thể truy vấn bộ nhớ cục bộ và, vì cơ chế cơ bản để lấy dữ liệu dựa trên các tệp ánh xạ bộ nhớ (memory mapped files), chỉ cần lấy chúng. Với sharding, tình huống khác nhiều. Nếu cơ sở dữ liệu:

  1. Không được shard – dữ liệu được lấy cục bộ.
  2. Được shard – dữ liệu CÓ THỂ yêu cầu một bước nhảy qua mạng, đến node giữ shard cụ thể đó.

Điều này biến con đường trước đây là xanh dương (đồng bộ) thành một thứ đôi khi là đỏ (bất đồng bộ) nhưng không phải lúc nào! Hãy gọi một phương thức như vậy là xanh dương + đỏ = tím (purple)!

Nếu tôi yêu cầu bạn nghĩ về một phương thức đôi khi thực thi đồng bộ và đôi khi không, có một ví dụ có thể đến trong đầu bạn. Hybrid caching! Nếu dữ liệu đã được lưu trong bộ nhớ, chúng ta có thể trả về ngay! Nếu không, chúng ta cần lấy qua mạng. Hãy thử phác thảo một đoạn mã như vậy!

Chúng ta biết rằng mẫu số chung chúng ta sẽ sử dụng là ValueTask. Hãy thiết kế một chữ ký.

ValueTask<int> PotentiallyLocalCall()

Bây giờ đến phần tiêu thụ. Điều chúng ta muốn làm là phân biệt đường dẫn nhanh hạnh phúc của thực thi đồng bộ. Để làm điều đó, chúng ta sẽ sử dụng một thuộc tính đặc biệt gọi là IsCompletedSuccessfully:

ValueTask<int> PotentiallyLocalCall() { ... }

ValueTask<int> ProcessWrapper()
{
   ValueTask<int> result = PotentiallyLocalCall();
   if (result.IsCompletedSuccessfully)
   {
      // đường dẫn nhanh đồng bộ
   }

   // đường dẫn chậm bất đồng bộ
}

Để làm cho ví dụ thực sự "tím", chúng ta cần xử lý cả hai đường dẫn.

ValueTask<int> PotentiallyLocalCall() { ... }

ValueTask<int> ProcessWrapper()
{
   ValueTask<int> vt = PotentiallyLocalCall();
   if (vt.IsCompletedSuccessfully)
   {
      // Đường dẫn XANH DƯƠNG. Truy cập kết quả trực tiếp
      return new ValueTask<int>(vt.Result + 42); // 42 là ma thuật, chúng ta cần một thứ gì đó
   }

   // Đường dẫn ĐỎ. Đường dẫn chậm bất đồng bộ
   return WrapAsync(vt);
}

static async ValueTask<int> WrapAsync(ValueTask<int> pending)
{
   var result = await pending; // và nhiều hơn nữa...
}

Bây giờ chúng ta có thể nói rằng hàm này thực sự có màu tím!

  1. Nó gọi một thứ gì đó có tiềm năng bất đồng bộ.
  2. Nó phân phối đường dẫn đồng bộ bằng cách truy cập trực tiếp vào kết quả.
  3. Nó để lại phần bất đồng bộ nặng nề cho một phương thức riêng biệt.

Không dễ dàng chút nào!

Phần đáng tiếc là luồng này đôi khi có thể phức tạp! Đặc biệt là khi bạn xây dựng một cơ sở dữ liệu tài liệu (document database) cần hoạt động với dữ liệu có tiềm năng được shard và có thể yêu cầu yêu cầu đến một node khác! Trong trường hợp này, việc tìm ra nơi async-await đang capture quá nhiều có thể không phải là một nhiệm vụ dễ dàng (ý chơi chữ). Và nó đã không dễ dàng.

Ví dụ được đề cập bao gồm một số phạm vi (scoping) và hai đường dẫn thực thi. Về phạm vi, tôi muốn nói đến việc sử dụng các mệnh đề using. Về hai đường dẫn thực thi, tôi muốn nói đến một câu lệnh if ở giữa.

async Task NotSoEasy()
{
    using(scope)
    using(scope2)
    {
        if(condition)
            await call1();
        else
            await call2();
    }
}

Điều này làm cho việc xử lý không hề dễ dàng. Việc loại bỏ scope được thực hiện với một cấu trúc dữ liệu giống như borrow. Trước lời gọi async thực tế, nó chỉ được "đẩy lên" để không dispose scope. Các lời gọi được tái cấu trúc một cách hiệu quả để gán giá trị vào một biến ValueTask được khai báo ở trên. Nếu chúng ta làm việc với đoạn mã được cung cấp ở trên, nó sẽ được chuyển đổi thành một cái gì đó như dưới đây. Lưu ý rằng phương thức không còn là async nữa!

Task NotSoEasy()
{
    using(var b = scope.AllowBorrow())
    using(var b2 = scope2.AllowBorrow())
    {
        ValueTask vt = condition ? call1 () : call2();
        if (vt.IsCompletedSuccessfully)
        {
            HandleResult(vt.Result);
            return Task.CompletedTask;
        }

        // Đường dẫn async chậm! Chúng ta không thành công trong lời gọi đồng bộ
        return HandleAsync(b.Borrow(), b2.Borrow(), vt);
    }
}

Đây không phải là điều dễ dàng nhất! Hãy xem PR thực tế.

Sử dụng nó mọi lúc, hay không?

Như thường lệ, loại săn lùng hiệu suất này chỉ nên được thực hiện khi được chỉ ra bởi các benchmark hoặc profiling. Theo thời gian, bạn học được cái gì đắt đỏ và cái gì không, nhưng sự thật cuối cùng luôn được tiết lộ bởi các hot path thực tế trong mã của bạn. Và sau đó, nếu bạn thấy quá nhiều màu đỏ ở đó, và bạn thấy một số tiềm năng để pha trộn một chút màu xanh dương, bạn có thể tô chúng thành màu tím.

Chỉ mục