Truyền Trạng Thái (Pass the State) – Tối ưu hiệu suất với Lambda tĩnh trong C#

Bài viết gốc của Szymon Kulec @Scooletz

Pass the State

Bạn đang viết đoạn code đẹp nhất từ trước đến nay. Có thể là một đoạn snippet dựa trên LINQ, hoặc thứ gì đó khác yêu cầu bạn truyền một hàm. Bạn chỉ thêm đúng những gì cần thiết…

// Đoạn code ban đầu của bạn
public IEnumerable<Item> GetOversized(IEnumerable<Item> items)
{
    return items.Where(item => item.Size > 5)
}

cho đến khi bạn nhận ra rằng các tham số của hàm là không đủ và bạn cần lấy thêm một vài tham số “từ hư không”. Bạn sẽ làm gì trong trường hợp như vậy? Thật hấp dẫn khi chỉ cần thêm nó vào như hiện tại:

public IEnumerable<Item> GetOversized(IEnumerable<Item> items, int minOversize)
{
    return items.Where(item => item.Size > minOversize)
}

Đối với những đoạn snippet hiếm khi được thực thi, điều đó sẽ hoạt động tốt. Còn với những trường hợp nó nằm trong hot path (đường dẫn thực thi nóng) thì sao? Những trường hợp liên quan đến đồng thời (concurrency)? Hoặc những trường hợp liên quan đến việc huỷ bỏ với CancellationToken và những trường hợp không hề tầm thường khác? Hãy cùng tìm hiểu! Nhưng trước tiên, chúng ta cần hiểu những gì chúng ta có thể làm từ góc nhìn của C#.

Lambda phản công

C# cung cấp cho chúng ta một công cụ mạnh mẽ gọi là lambda. Nó cho phép chúng ta định nghĩa một hàm ẩn danh có thể được truyền như một Func hoặc Action. Đó thực chất chính là thứ chúng ta đã truyền vào .Where trong hai ví dụ dưới đây:

item => item.Size > minOversize

Bạn định nghĩa các tham số và sau đó, sau dấu mũi tên, bạn khai báo hàm ẩn danh này sẽ làm gì.

Có một cạm bẫy ẩn giấu khỏi bạn. Những hàm như vậy có thể “bắt giữ phạm vi” (capture the scope). Bắt giữ phạm vi có nghĩa là chúng có thể nắm giữ một số biến và tham số rồi bọc chúng trong một đối tượng. Hãy xem lại ví dụ thứ hai với mã giả sẽ được sinh ra:

public IEnumerable<Item> GetOversized(IEnumerable<Item> items, int minOversize)
{
    // minOversize đến từ tham số.
    // Nó sẽ bị bắt giữ và một đối tượng sẽ được cấp phát.
    // Điều này sẽ được biến thành thứ gì đó như:
    var captured = new GeneratedClassForMinOversize (minOversize)
    return items.Where(item => captured.Predicate(item))
}

Điều này có vẻ không phải là một vấn đề lớn, nhưng nếu những trường hợp như vậy chất đống trong một đường dẫn thực thi được gọi thường xuyên, bạn có thể bị “cháy túi” vì các cấp phát bộ nhớ. Bạn có thể hỏi ngay bây giờ: có điều gì tôi có thể làm để làm cho nó trở nên tường minh không? Có, có một thứ như vậy.

C# 9.0 đã giới thiệu khái niệm hàm ẩn danh tĩnh (static anonymous functions). Đây là những hàm cho phép bạn thêm static vào khai báo của chúng. Trong trường hợp này, static có nghĩa là việc bắt giữ phạm vi bị cấm.

public IEnumerable<Item> GetOversized(IEnumerable<Item> items, int minOversize)
{
    // Đoạn này sẽ không biên dịch được. Static + việc sử dụng minOversize mâu thuẫn với nhau
    return items.Where(static (item) => item.Size > minOversize)
}

Bây giờ, bạn có thể hỏi liệu có phải lúc nào cũng có thể không bắt giữ phạm vi hay không. Không may là không phải vậy. Để sử dụng hàm ẩn danh tĩnh, một trong hai điều kiện sau cần được đáp ứng:

  1. Hàm chỉ yêu cầu các tham số được cung cấp
  2. Có một overload chấp nhận việc truyền trạng thái (state) như một tham số bổ sung

Điều kiện đầu tiên khá đơn giản – bạn chỉ cần làm việc với những gì bạn có. Điều kiện thứ hai phức tạp hơn một chút. Tuy nhiên, một khi bạn nhìn thấy mẫu (pattern) này, bạn sẽ áp dụng nó trong bất kỳ trường hợp nào hỗ trợ. Hãy xem xét ví dụ sau:

public IEnumerable<T> Query(Func<T, bool> predicate);

Rõ ràng, nếu có một trạng thái bên ngoài nào đó được yêu cầu cho predicate, nó sẽ yêu cầu bắt giữ trạng thái trong phạm vi. Nhưng chúng ta thấy gì trong overload sau:

public IEnumerable<T> Query<TContext>(Func<T, TContext, bool> predicate,
    TContext ctx);

Có một tham số bổ sung, được gọi là context, mà bạn có thể truyền cho hàm. Điểm mấu chốt nằm ở chỗ nó cũng được truyền cho predicate. Nếu bạn có một context được định nghĩa rõ ràng, bạn chỉ cần truyền nó như một tham số bổ sung, giả định rằng phần triển khai bên dưới sẽ sử dụng nó một cách khôn ngoan. Một predicate như vậy sẽ trông như thế nào?

repo.Query((item, ctx) => (item) => item.Size > ctx, minOverSize);

Chúng ta có thể làm cho nó tốt hơn nữa, bằng cách đưa ra yêu cầu tường minh là không bắt giữ trạng thái. Tất cả những gì cần là một modifier static duy nhất!

repo.Query(
  // ctxMinOverSize là tham số sẽ truyền minOverSize vào lambda của chúng ta
  static (item, ctxMinOverSize) => (item) => item.Size > ctxMinOverSize,
  minOverSize);

Bây giờ chúng ta biết rằng nếu kết hợp lambda tĩnh và một tham số context, chúng ta có thể làm được rất nhiều mà không cần bắt giữ trạng thái (cấp phát bộ nhớ). Và chúng ta biết nên tìm kiếm điều gì!

Aggregate như thể không có ngày mai

Hãy bắt đầu khám phá với một vài trường hợp đơn giản. Đầu tiên là trường hợp tường minh nhất có thể. Đó là hàm gộp (aggregate) của một enumerable.

public static TAccumulate Aggregate<TSource,TAccumulate>(this IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate,TSource,TAccumulate> func);

Hãy phân tích nó:

  1. Nó là một extension method, chạy trên một enumerable
  2. Nó chấp nhận context dưới dạng tham số seed
  3. Nó có một hàm chấp nhận context và một phần tử của enumerable

Việc nó trả về giá trị kiểu context là một thiết kế cho hàm gộp. Điều đó không liên quan gì đến ý tưởng truyền context. Vậy chúng ta có thể sử dụng nó như thế nào?

var sumOfEven = numbers
   .Aggregate(0, static (a, item) => a + (item % 2 == 0 ? 1 : 0));

Như bạn thấy ở trên: chúng ta đã truyền context, chúng ta đã sử dụng lambda tĩnh, kết quả là không có context nào bị bắt giữ. Vậy chỉ có vậy thôi sao? Có những ví dụ có ý nghĩa nào khác không?

ConcurrentDictionary factory

Ví dụ tiếp theo ít bị gò ép hơn nhiều so với phép gộp. Một lần nữa, nó dựa trên một phương thức chấp nhận một delegate và một ít trạng thái. Trong trường hợp này, chúng ta đang xem xét ConcurrentDictionary, với một trong các overload của AddOrUpdate:

TValue AddOrUpdate<TArg>(TKey key, 
    Func<TKey,TArg,TValue> addValueFactory, 
    Func<TKey,TValue,TArg,TValue> updateValueFactory, 
    TArg factoryArgument);

Hàm này chấp nhận không chỉ một delegate mà là hai! Càng nhiều delegate càng vui! Nó hoạt động như thế nào? ConcurrentDictionary là một từ điển có thể xử lý nhiều tác nhân/luồng truy cập cùng một lúc. Một trong những kịch bản phổ biến là thêm hoặc cập nhật một giá trị. Một ví dụ không hay lắm (có những cách tốt hơn để làm điều này) là sử dụng từ điển đồng thời như một bộ đếm với các key khác nhau trong đó.

var counters = new ConcurrentDictionary<string, long> ();

// Không phải bộ đếm tốt nhất, chỉ để minh hoạ API
counters.AddOrUpdate("tag", 
    (_, v) => v, // addValueFactory, đặt giá trị ban đầu
    (_, prev, update) => prev + update, // updateValueFactory, cộng dồn
    1); // tham số factory ban đầu

Các trạng thái của một bộ đếm như vậy là gì?

  1. Một bộ đếm không tồn tại cho một key cụ thể và cần được tạo ra
  2. Một bộ đếm đã tồn tại cho một key cụ thể và cần được cập nhật

Để xử lý trường hợp đầu tiên, chúng ta muốn có:

  1. Một key
  2. Một delegate để tạo giá trị, chấp nhận key và context
  3. Một context vì delegate có thể cần nó
TValue AddOrUpdate<TArg>(TKey key,                   // key
    Func<TKey,TArg,TValue> addValueFactory,          // (key, ctx) => new
    Func<TKey,TValue,TArg,TValue> updateValueFactory, 
    TArg factoryArgument);                           // context

Tình huống gần như tương tự cho việc cập nhật. Tuy nhiên, việc cập nhật sẽ yêu cầu thêm một tham số nữa: giá trị trước đó.

TValue AddOrUpdate<TArg>(TKey key,                   // key
    Func<TKey,TArg,TValue> addValueFactory,          
    Func<TKey,TValue,TArg,TValue> updateValueFactory,// (key, prev, ctx) => new
    TArg factoryArgument);                           // context

Việc đồng bộ hoá quá trình thực thi của chúng được để lại cho phần triển khai của từ điển. Điều bạn có thể chắc chắn là, một khi phương thức này trả về, giá trị được trả về chính là giá trị mà một trong các phương thức đã đặt. Xin lưu ý: nhờ việc sử dụng tham số context bổ sung, chúng ta có thể sử dụng lambda tĩnh một lần nữa! Không cần bắt giữ context.

CancellationToken.Register

Chúng ta đang dần bước vào một khu vực đòi hỏi khắt khe hơn nhiều: vùng đất của lập trình bất đồng bộ. Và một trong những khía cạnh quan trọng nhất để phát triển mạnh ở vùng đất này là xử lý đúng đắn mọi việc huỷ bỏ có thể xảy ra. Cơ chế chính để xử lý việc huỷ bỏ thao tác đang diễn ra trong .NET là CancellationToken. Nó có một vài thuộc tính cơ bản có thể được dùng để xác định liệu chúng ta có nên huỷ bỏ ngay lập tức hay liệu việc huỷ bỏ có khả thi hay không:

// một vài kiểm tra cơ bản
CancellationToken ct;

ct.CanBeCanceled // token này có thể bị huỷ không? Không? Đừng bận tâm
ct.IsCancellationRequested // Nó đã được yêu cầu huỷ chưa? Rồi? Throw!

Giờ, sau khi đã đề cập đến các tuỳ chọn cơ bản, chúng ta cần tìm hiểu cách đăng ký cho một sự kiện huỷ bỏ xảy ra. Điều này có nghĩa là: chủ sở hữu của nguồn huỷ bỏ bên dưới đã yêu cầu huỷ bỏ thao tác đang diễn ra. Để đăng ký cho sự kiện như vậy, chúng ta sử dụng… phương thức Register. Hãy thử đăng ký một TaskCompletionSource cho sự kiện như vậy.

CancellationToken ct;
TaskCompletionSource tcs = new ();

ct.Register(() => tcs.TrySetCanceled());

Chúng ta đã làm được, phải không? Giờ hãy thử thêm modifier tĩnh mạnh mẽ để đảm bảo rằng chúng ta không bắt giữ phạm vi.

CancellationToken ct;
TaskCompletionSource tcs = new ();

// Lỗi biên dịch!
ct.Register(static () => tcs.TrySetCanceled());

Có vẻ như chúng ta vẫn bắt giữ phạm vi và không thể làm cho nó tĩnh! Hãy nhớ lại các ví dụ trước. Thông thường, nếu người thiết kế contract quan tâm đến hiệu suất, họ sẽ cung cấp một overload có trạng thái. Và đây chính là trường hợp như vậy:

CancellationToken ct;
TaskCompletionSource tcs = new ();

// Bây giờ nó có thể là tĩnh!
ct.Register(
  static (state) => ((TaskCompletionSource)state).TrySetCanceled(), 
  tcs); // state được truyền như tham số cuối cùng

Một lần nữa, điều này yêu cầu một chút công sức thủ công, vì bạn cần ép kiểu (cast) state xuống và truyền nó đúng cách. Trong một ứng dụng kinh doanh thông thường, điều này có thể không cần thiết, nhưng nếu bạn đang xây dựng một cơ sở dữ liệu, bạn có thể sử dụng kỹ thuật này để cấp phát ít hơn một chút.

Đây là trường hợp của RavenDB, một cơ sở dữ liệu phân tán, được xây dựng bằng .NET và sử dụng CancellationToken của BCL (thư viện lớp cơ sở) để báo hiệu các yêu cầu huỷ bỏ. PR #21205 giới thiệu những cải tiến như vậy bằng cách sử dụng overload truyền trạng thái giống như chúng ta đã làm ở trên. Một lần nữa, câu thần chú cần tuân theo rất đơn giản:

  1. Bạn nhận thấy một đăng ký với Register sử dụng phương thức không tĩnh
  2. Bạn làm cho lambda trở thành tĩnh và chờ đợi lời phàn nàn của trình biên dịch
  3. Nếu có một trạng thái duy nhất, bạn sử dụng overload truyền trạng thái
  4. Bạn được lợi

Áp dụng nó trong các hot path, và các đăng ký huỷ bỏ của bạn sẽ trở nên rẻ hơn rất nhiều. Và xin hãy nhớ: nếu bạn đang xây dựng hạ tầng như RavenDB, những điều này thực sự quan trọng.

Task.ContinueWith

Một mẫu tương tự như CancellationToken có thể được sử dụng với các continuation có thể được gắn vào task một cách thủ công. Đôi khi bạn cần gắn vào một task mà không sử dụng cơ chế async-await hào nhoáng. Trong trường hợp như vậy, bạn có thể sử dụng phương thức ContinueWith:

Task connection;
TaskCompletionSource tcs;

connection.ContinueWith ( (t) => {
    if (t.IsFaulted)
        tcs.TrySetException(t.Exception);
    else if (t.IsCanceled)
        tcs.TrySetCanceled();
    else
        tcs.TrySetResult(null);
});

Một lần nữa, chúng ta có thể tuân theo cùng một cách tiếp cận bằng cách làm cho phương thức trở thành tĩnh trước. Sau đó, nhận thấy lỗi biên dịch và sửa nó bằng overload truyền trạng thái và ép kiểu xuống.

Task connection;
TaskCompletionSource tcs;

// Lambda bây giờ là tĩnh
connection.ContinueWith ( static (t, state) => {
    var c = (TaskCompletionSource) state; // ép kiểu continuation xuống
    if (t.IsFaulted)
        c.TrySetException(t.Exception);
    else if (t.IsCanceled)
        c.TrySetCanceled();
    else
        c.TrySetResult(null);
}, tcs); // tcs được truyền như một tham số trạng thái

Để thấy điều này trong thực tế, hãy xem một PR khác của RavenDB #21234. Một lần nữa, chúng tôi đã cắt giảm một vài cấp phát bộ nhớ ở chỗ này chỗ kia. Điều này, nếu xét đến việc codebase của RavenDB đã được tối ưu hoá như thế nào, có thể tạo ra sự khác biệt đáng kể. Còn lại rất ít cấp phát để có thể tối ưu hoá chúng đi.

Tổng kết

Việc bắt giữ closure (closure capturing) có thể là một vấn đề thực sự trong các hot path khi thực thi .NET. May mắn thay, có những biện pháp đối phó dưới dạng lambda tĩnh và các overload truyền trạng thái, cho phép bạn xử lý những “vụn bánh mì” nhỏ còn bị bỏ lại trên bàn. Và sau đó, khi chúng còn lại ít hơn rất nhiều, RavenDB có thể còn nhanh hơn nữa.