Chúng ta đều yêu thích lập trình async. Cho đến khi chúng ta không còn yêu nó nữa. Hãy hỏi các kỹ sư đồng nghiệp đã được giao nhiệm vụ biến một đoạn code thành async. Họ có thể đề cập đến điều gì đó về màu sắc, như đỏ hay xanh dương và sẽ có những quy tắc cần tuân theo. Họ đang nói về điều gì vậy?
Mục lục
Team đỏ vs team xanh dương
Function coloring là một mô hình tư duy đơn giản mà người ta có thể sử dụng để làm việc với các ngôn ngữ hỗ trợ async. Cho dù là C#, JS hay TS, tất cả đều hỗ trợ các từ khóa tuyệt vời async và await. Nếu bạn sử dụng các phiên bản hiện đại của chúng, bạn có thể 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ể rút ra 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)
{
// Top secret implementation
return 42 + payload.GetHashCode();
}
Cả hai phương thức, ProcessWithAzureAsync cũng như ProcessLocally, 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 việc 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ý IO đúng cách. Phiên bản local không yêu cầu xử lý như vậy vì nó xây dựng payload cục bộ, mà không gọi các phụ thuộc từ xa của bên thứ ba.
Chúng ta hãy xem xét thêm một mức độ trừu tượng 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ệnh gọi Process bằng bất kỳ phương thức nào đã được giới thiệu trước đó không? Chúng ta chắc chắn 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ệnh gọi không đồng bộ đang nổi lên, khiến người gọi kết hợp hành vi không đồng bộ tương tự. Không có gì cấm chúng ta gọi các phương thức đồng bộ từ phương thức không đồng bộ! Nếu bạn muốn, chúng ta có thể rắc CombineAsync với một số lệnh gọi phương thức thông thường.
Bây giờ, vì màu đỏ thường được coi 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 cho phù hợp. Hãy tô màu các phương thức trả về Task không đồng bộ bằng màu đỏ và tất cả các phương thức đồng bộ bằng màu xanh dương. Điều này sẽ làm cho ProcessWithAzure có màu đỏ nhưng để lại Process và ProcessLocally có màu xanh dương.
Có điều đó, hãy liệt kê tất cả các quy tắc bạn cần tuân theo:
- xanh dương có thể gọi xanh dương
- đỏ có thể gọi xanh dương
- đỏ có thể gọi đỏ
- xanh dương không bao giờ có thể gọi đỏ
Quy tắc thứ 4 làm cho toàn bộ vấn đề hơi bi thảm (trừ khi bạn linh hoạt với những thứ như sync-over-async). Một khi bạn tận dụng phương thức không đồng bộ, bạn PHẢI (theo kiểu RFC) truyền bá nó lên trên. Người gọi phương thức không đồng bộ cũng phải không đồng bộ, v.v.
Máy trạng thái ẩn
Khi chúng ta nhìn vào code tuyệt đẹp mang tính chất không đồng bộ, được rắc bằng async-await, có một điều bị ẩn khỏi chúng ta. Đó là máy trạng thái async. Tại sao nó cần thiết?
Khi bạn nhìn vào các lệnh gọi không đồng bộ, code của chúng có thể được chia thành hai phần:
- Trước await đầu tiên
- Sau await đầu tiên
Code ở sau ranh giới await có thể cần “chờ” thực thi cho đến khi hoạt động không đồng bộ cơ bản kết thúc. Bây giờ, bạn có thể hỏi async-await không nên loại bỏ việc chờ đợi sao? Bạn nói đúng! Nó thực hiện điều này bằng cách giới thiệu khái niệm về tiếp nối và biến phương thức của bạn thành một máy trạng thái di chuyển về phía trước với mỗi lần await. Tiếp nối yêu cầu nắm bắt trạng thái của nó và đây là những gì máy trạng thái làm. Bên cạnh việc xử lý tiến về phía trước, nó cũng nắm bắt mọi thứ cần thiết để thực hiện bước di chuyển như vậy.
Nếu mô tả này trở nên phức tạp, hãy nghĩ xem máy trạng thái có thể trở nên phức tạp như thế nào để xử lý tất cả các chuyển đổi này! Nếu luồng được phân nhánh nhiều, chúng có thể thực sự khổng lồ.
Cho đến nay chúng ta biết rằng có máy trạng thái, chúng ta có thể bỏ qua một số task đơn giản. Chúng tôi 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 nó hơn nữa. Đã đến lú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ả code có khả năng không đồng bộ. Vậy các trường hợp là gì?
- Phương thức của bạn mang tính không đồng bộ (như Task)
- Phương thức của bạn cần tuân theo chữ ký có khả năng không đồng bộ (trả về ValueTask)
- Phương thức của bạn sử dụng ma thuật IValueTaskSource cấp thấp để phân bổ càng ít càng tốt và tái sử dụng các đối tượng cơ bản.
Có những 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 không đồng bộ là cách để đi.
Tím là màu mới ~~xanh dương đỏ~~ tím
Chúng ta biết rằng chúng ta có thể đi theo đường xanh dương (đồng bộ) và đường đỏ (không đồ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 số điểm trung gian nào đó có thể được tận dụng cho code tập trung vào hiệu suất không?
Trong quá trình sharding được ship, bất cứ khi nào một nút được yêu cầu tài liệu, người ta biết rằng dữ liệu được giữ cục bộ. Nút có thể truy vấn bộ lưu trữ cục bộ và, vì cơ chế cơ bản để truy xuất dữ liệu dựa trên các tệp được ánh xạ bộ nhớ, chỉ cần lấy chúng. Với sharding, tình huống khác xa. Nếu cơ sở dữ liệu là:
- Không sharded – dữ liệu được truy xuất cục bộ.
- Sharded – dữ liệu CÓ THỂ yêu cầu nhảy qua mạng, đến nút giữ shard đã cho.
Điều này lấy con đường trước đây là xanh dương (đồng bộ) và biến nó thành thứ gì đó đôi khi là đỏ (không đồ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!
ValueTask<int> PotentiallyLocalCall() { ... }
ValueTask<int> ProcessWrapper()
{
ValueTask<int> result = PotentiallyLocalCall();
if (result.IsCompletedSuccessfully)
{
// synchronous fast path
}
// asynchronous slow path
}
Để làm cho ví dụ thực sự có màu tím, chúng ta cần xử lý cả hai đường dẫn bây giờ.
ValueTask<int> PotentiallyLocalCall() { ... }
ValueTask<int> ProcessWrapper()
{
ValueTask<int> vt = PotentiallyLocalCall();
if (vt.IsCompletedSuccessfully)
{
// Đường BLUE. Truy cập trực tiếp kết quả
return new ValueTask<int>(vt.Result + 42); // 42 là ma thuật, chúng ta cần một số
}
// Đường RED. Đường dẫn chậm không đồ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 thực sự có màu tím!
- Nó gọi một thứ gì đó có khả năng không đồng bộ.
- Nó điều phối đường dẫn đồng bộ bằng cách sử dụng truy cập trực tiếp vào kết quả.
- Nó để lại phần không đồng bộ nặng cho một phương thức riêng biệt.
Không dễ dàng gì!
Phần không may là luồng này đôi khi có thể phức tạp! Đặc biệt, khi bạn xây dựng cơ sở dữ liệu tài liệu cần hoạt động với dữ liệu có thể được shard có thể yêu cầu yêu cầu đến một nút khác! Trong trường hợp này, việc tìm ra nơi mà async-await đang nắm bắt quá nhiều có thể không phải là một task tầm thường (chơi chữ). Và nó đã không như vậy.
Sử dụng nó luôn luôn, không!
Như thường lệ, loại săn hiệu suất này chỉ nên được thực hiện khi được chỉ ra bởi benchmark hoặc profiling. Theo thời gian, người ta học được điều gì tốn kém và điều gì không, nhưng sự thật cuối cùng luôn được tiết lộ bởi các đường dẫn nóng thực tế trong code của bạn. Và sau đó, nếu bạn thấy nhiều màu đỏ trong đó và bạn thấy một số tiềm năng để trộn một số màu xanh dương, bạn có thể sơn chúng thành màu tím.



