Thiết kế lại pipeline NServiceBus một lần nữa: Daniel Marbach bị “nerd sniping”

Bài viết này thuộc chuỗi Cải tiến NServiceBus 10.

Daniel và tôi đã nhiều lần cải tiến pipeline của NServiceBus: tăng tốc gấp 10 lần vào năm 2017 và thêm một lần tăng tốc gấp 5 lần vào năm 2022. Chúng tôi từng nghĩ rằng công việc đã hoàn tất.

Vì vậy, khá bất ngờ, đây là câu chuyện về cách tôi vô tình “nerd snipe” Daniel để anh ấy lại thiết kế pipeline NServiceBus một lần nữa.

Chỉ từ một nhận xét nhỏ của tôi, Daniel đã khiến pipeline khởi động nhanh hơn 330 lần và chạy nhanh hơn 25–29% trên nhánh xử lý thành công, đồng thời vẫn tạo ra 0 allocation trên nhánh này.

Tôi có cảm thấy hơi áy náy không? Có. Tôi sẽ vượt qua chuyện đó chứ? Có lẽ cũng có.

Đặt bẫy

Trong một buổi họp nhóm, chúng tôi thảo luận về tất cả thay đổi cần thiết để NServiceBus hỗ trợ đầy đủ assembly trimming và biên dịch ahead-of-time, hay AOT, như đã trình bày trong bài viết trước của chuỗi này.

Có nhiều nhóm cảnh báo khác nhau liên quan đến trimming. Mỗi nhóm cần một chiến lược riêng để đạt khả năng tương thích với trimming. Chẳng hạn, tuần tự hóa XML chưa từng được thiết kế cho trimming, vì vậy chúng tôi xác định nó hoàn toàn không thể trim. Người dùng muốn bật trimming sẽ cần dùng một serializer như System.Text.Json, vốn được thiết kế cho mục đích này. Trong các trường hợp khác, mã dùng Reflection có thể được thay thế bằng source generator để tạo mã tại thời điểm biên dịch.

Pipeline đặt ra một thách thức rất khác. Như đã mô tả trong bài viết Thực thi nhanh hơn 10 lần với compiled expression trees, thiết kế pipeline năm 2017 sử dụng Reflection.Emit để biên dịch các biểu thức thành mã mới. Mã này chứa toàn bộ behavior của pipeline trong một kiểu delegate có tốc độ rất cao và được thực thi cho mỗi message đi qua.

Vấn đề là Reflection.Emit không tương thích với trimming. Bạn không thể loại bỏ một phần lớn runtime .NET rồi lại biên dịch mã mới có thể cần đến những thành phần vừa bị loại bỏ.

Giải pháp quen thuộc là dùng source generator, nhưng cách này không hữu ích lắm trong trường hợp của pipeline. Source generator hoạt động rất tốt với những vấn đề có thể phân tích tĩnh tại thời điểm biên dịch. Tuy nhiên, quyết định behavior nào được đưa vào pipeline lại diễn ra trong thời gian chạy.

Chúng tôi có thực sự muốn thiết kế lại hoàn toàn cách định nghĩa và đăng ký behavior, cùng với tất cả breaking change đi kèm không?

Đó là lúc tôi nói:

“Này Daniel, sẽ thật tuyệt nếu cậu có thể thiết kế lại pipeline một cách thần kỳ, làm cho nó nhanh hơn 10 lần và đồng thời tương thích với trimming, đúng không?”

Điều đó rõ ràng là bất khả thi. Chúng tôi đã tối ưu hiệu năng pipeline rất nhiều lần. Chắc chắn mọi cải tiến tiếp theo sẽ giống như cố vắt nước từ một hòn đá, phải không?

Dù tôi không nhớ hôm đó Daniel đã nói gì ngoài câu có lẽ là “Ồ, ý hay đấy, David”, dường như có điều gì đó trong đầu anh ấy đã đáp lại: “Để tôi lấy bia đã.”

Quay lại bảng thiết kế

Cách tiếp cận expression tree năm 2017 đã trải qua ít nhất 7 vòng thiết kế trước khi chúng tôi chọn được giải pháp cuối cùng. Khi đó, chúng tôi tin rằng mình đã đưa pipeline đi xa nhất có thể.

Nhưng không gian thiết kế luôn thay đổi. Thời điểm đó, chúng tôi làm việc với .NET Framework 4.7 và mới bắt đầu hỗ trợ .NET Core trong NServiceBus. Kể từ đó, chúng tôi có thêm nhiều công cụ ngôn ngữ trong bộ công cụ của mình. Có lẽ những con đường từng đi vào ngõ cụt nay đáng để khám phá lại.

Một trong các lựa chọn thiết kế pipeline được đánh giá vào năm 2017 là functional trampolining, một kỹ thuật sử dụng continuation bất biến để tránh call stack quá sâu. Một frame có trạng thái nhỏ, có thể thay đổi, trong context của message hiện tại sẽ theo dõi vị trí hiện tại trong chuỗi behavior. “Trampoline” tăng chỉ số trạng thái và đảm nhiệm việc dispatch, từ đó chuyển quyền thực thi đến behavior tiếp theo.

Vấn đề của functional trampolining là cần có một orchestrator để theo dõi pipeline đang chạy đến bước nào, xử lý delegate next() và chuyển việc thực thi sang behavior tiếp theo trong chuỗi. Tất cả đều tạo thêm overhead. Năm 2017, compiled expression tree nhanh hơn, nên chúng tôi chọn cách đó.

Tuy nhiên, Daniel biết ý tưởng nền tảng của functional trampolining thân thiện hơn nhiều với code trimming. Anh quyết định kiểm tra xem các công cụ mới mà C# cung cấp có thể giúp loại bỏ phần overhead bổ sung hay không.

Bước đột phá

Default interface member, hay DIM, cho phép gọi tĩnh node tiếp theo trong một chuỗi mà không cần biên dịch động.

DIM cho phép interface cung cấp một implementation mặc định mà mọi implementation của interface đó tự động kế thừa. Trong trường hợp này, Daniel sử dụng default interface method để mỗi behavior có thể tự tạo invoker của riêng mình.

Kết quả là prewired invoker: một chuỗi continuation có kiểu tĩnh, được xây dựng từ các invoker node. Mỗi node gọi interface method mà behavior tương ứng kế thừa theo mặc định.

Thay vì sinh mã trong thời gian chạy, chúng tôi xây dựng chuỗi bằng generic. Mỗi behavior nối trực tiếp behavior kế tiếp vào một continuation có kiểu xác định.

Đã lược bỏ một số tối ưu runtime chuyên sâu và các chi tiết khác để ví dụ dễ đọc hơn, nhưng một invoker node có thể trông như sau:

abstract class InvokerNode
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public abstract Task Invoke(IBehaviorContext context);
}

sealed class InvokerNode<TInContext, TOutContext>(
    IBehavior<TInContext, TOutContext> behavior,
    Func<TOutContext, Task> next) : InvokerNode
{
    public override Task Invoke(IBehaviorContext context)
    {
        return behavior.Invoke(context as TInContext, next);
    }
}

Mỗi behavior của NServiceBus vốn đã triển khai IBehavior. Các default interface member nội bộ cung cấp cơ chế tạo invoker node cho từng behavior:

public interface IBehavior
{
    internal InvokerNode CreateInvokerNode(InvokerNode? next);
}

public interface IBehavior<in TInContext, out TOutContext> : IBehavior
{
    Task Invoke(TInContext context, Func<TOutContext, Task> next);

    InvokerNode IBehavior.CreateInvokerNode(InvokerNode? next)
    {
        Func<TOutContext, Task> nextFunc = next is not null
            ? next.Invoke as Func<TOutContext, Task>
            : CompletePipelineDelegate;
        return new InvokerNode<TInContext, TOutContext>(this, nextFunc);
    }
}

Pipeline builder duyệt qua các behavior theo thứ tự ngược, gọi CreateInvokerNode() trên từng behavior. Mỗi behavior tạo node có kiểu riêng và liên kết node đó với node kế tiếp. Kết quả là một chuỗi bất biến, trong đó mỗi node biết chính xác cách gọi behavior của mình và chuyển quyền điều khiển tiếp tục.

Chúng tôi vẫn phải xây dựng chuỗi invocation khi ứng dụng khởi động, nhưng không còn cần biên dịch động tốn kém, đồng thời không tương thích với trimming, để nối các thành phần lại với nhau. Không expression tree. Không bước sinh mã. Không có một factory trung tâm cần cập nhật thủ công mỗi khi thêm stage mới.

Mã production có thêm một số tối ưu, chẳng hạn giảm allocation, so với ví dụ trên. Tuy nhiên, bước đột phá thực sự là default interface implementation. Chúng cho phép diễn đạt cơ chế này theo cách compiler có thể hiểu tại thời điểm build, điều vài năm trước vẫn chưa thể thực hiện.

Vì sao đây là một cải tiến tuyệt vời?

Lợi ích về hiệu năng là kết quả dễ nhận thấy nhất. Các benchmark cho thấy:

  • Khởi động nhanh hơn 330 lần: không còn biên dịch expression tree khi khởi động.
  • Thực thi nhanh hơn 25–29% trên nhánh xử lý thành công.
  • Không có allocation trên nhánh xử lý thành công.
  • Giữ nguyên profile allocation trên nhánh exception, khoảng 1,3 KB.

Những con số này rất ấn tượng, nhưng lợi ích về kiến trúc có thể còn quan trọng hơn trong dài hạn. Cách tiếp cận mới tương thích với các kịch bản trimming và AOT vì không sử dụng sinh mã động hay Reflection.Emit. Nhờ đó, kích thước binary nhỏ hơn, hỗ trợ trimming tốt hơn và loại bỏ một trong những trở ngại lâu nay trên con đường hướng đến các mô hình triển khai .NET tương lai.

Điều đó đồng nghĩa với việc giảm thời gian chờ endpoint sẵn sàng xử lý công việc và có thêm thời gian để thực hiện đúng nhiệm vụ của chúng.

Nếu muốn xem trực tiếp implementation, bạn có thể theo dõi quá trình phát triển qua các pull request sau:

Behavior hiện có thay đổi gì không?

Nếu bạn đã viết custom behavior thì không có gì thay đổi. Các implementation hiện có vẫn sử dụng cùng public interface và tự động hưởng lợi từ cách nối pipeline mới.

Cơ chế nối mới nằm bên trong framework. Behavior của bạn vẫn triển khai cùng method Invoke như trước. NServiceBus sẽ xử lý phần còn lại.

Nếu bạn chưa từng tạo behavior cho NServiceBus, có lẽ đây là lúc tìm hiểu cách xây dựng behavior NServiceBus của riêng bạn. Để có thông tin tổng quát hơn, hãy đọc bài viết Infrastructure soup và tìm hiểu những cross-cutting concern mà pipeline NServiceBus được thiết kế để giải quyết.

Tổng kết

Pipeline lại một lần nữa được tăng tốc theo cách tưởng như không thể, đồng thời đã sẵn sàng cho các kịch bản assembly trimming và biên dịch ahead-of-time trong tương lai. Tuy vậy, phần lớn người dùng sẽ không bao giờ cần quan tâm đến thay đổi này, vì mọi behavior hiện có đều được hưởng lợi mà không cần chỉnh sửa.

Sự kết hợp đó rất quan trọng với chúng tôi. NServiceBus cần tiếp tục dễ mở rộng, trong khi chúng tôi không ngừng loại bỏ các chi phí runtime và rào cản kiến trúc ngày càng quan trọng trong các ứng dụng .NET hiện đại.

Đây là một chiến thắng lớn cho phe chính nghĩa! Nhưng cái giá phải trả là gì?

Vậy là tôi đã “nerd snipe” Daniel để anh ấy nâng cấp pipeline. Trái với mọi dự đoán, kết quả vừa tương thích với trimming vừa nhanh hơn đáng kể so với implementation trước.

Nhưng điều đáng trách nhất là… tôi nghĩ mình có thể sống chung với chuyện đó. Nếu phải làm lại từ đầu, tôi vẫn sẽ làm. Một chút áy náy là cái giá nhỏ phải trả cho hiệu năng của NServiceBus.

Vì vậy, tôi sẽ học cách sống chung với nó.

Bởi vì tôi có thể sống chung với nó. Tôi có thể sống chung với nó…

Về các tác giả

David Boike

David Boike là kỹ sư phần mềm tại Particular Software. Anh hứa sẽ không tiếp tục đưa ra những nhận xét bâng quơ về các mục tiêu tưởng như bất khả thi, vì anh biết Daniel rất dễ bị cuốn vào những thử thách như vậy.

Daniel Marbach

Daniel Marbach là kỹ sư phần mềm tại Particular Software. Anh biết David đang nói dối.

Tham khảo

  1. David là tác giả bài viết và cũng là đối thủ của Daniel. Thực ra không phải. Daniel và tôi là bạn rất thân, nhưng đó là chuyện khác.
  2. Và theo đó, một trong những thứ đầu tiên bị trim đi sẽ hợp lý là chính mã compiler.
  3. Nếu nhìn theo cách khác, có 5 năm giữa hai cải tiến hiệu năng đầu tiên, sau đó là 4 năm giữa cải tiến trước và cải tiến lần này. Nếu thời gian giữa các lần cải tiến tiếp tục giảm tuyến tính, liệu chúng ta có thể chờ đợi một cải tiến đột phá khác chỉ trong 3 năm nữa không? Hãy quay lại vào năm 2029 để biết câu trả lời.
  4. Andrew Lock giải thích rất xuất sắc các tính năng mới của .NET, chẳng hạn như tính năng này, đồng thời làm rõ vì sao chúng quan trọng.
  5. Nếu quan tâm, mã nguồn của chúng tôi là mã nguồn mở.
  6. Phiên bản đầu tiên của prewired invoker sử dụng 130 dòng mã factory switch-case viết cứng để ánh xạ từng kiểu context với kiểu InvokerNode<TIn, TOut> tương ứng.
  7. Daniel đã trả đũa tôi trong quá trình biên tập bài viết. Sau khi tôi nhắc đến nguồn gốc thuật ngữ “nerd sniping” từ XKCD, tôi bị cuốn vào bài toán “lưới vô hạn các điện trở một ohm”. Vì từng học Kỹ thuật Điện, tôi đã đi sâu vào một chuỗi tìm hiểu dẫn đến Physics Stack Exchange và cuối cùng nhận ra rằng mình không còn nhớ đủ giải tích để hiểu phần toán học đang diễn ra.