Best practice cho performance trong C#

Mục tiêu của bài viết này là cung cấp một danh sách không đầy đủ các code mẫu cần tránh, vì chúng rủi ro hoặc performance kém. Hy vọng bài viết sẽ giúp ích cho bạn trong quá trình phát triển ứng dụng.

Cũng lưu ý rằng các dịch vụ web của Comdy dựa trên mã hiệu suất cao, do đó cần phải tránh các code mẫu không hiệu quả. Còn trong hầu hết các ứng dụng thông thường, một số mẫu sẽ không tạo ra sự khác biệt đáng chú ý.

Cuối cùng nhưng không kém phần quan trọng, một số điểm đã được trình bày trong nhiều bài viết (chẳng hạn như ConfigureAwait), vì vậy tôi không trình bày chi tiết về chúng. Mục đích của bài viết này là có một danh sách các điểm cần lưu ý, thay vì mô tả kỹ thuật chuyên sâu của từng điểm trong số đó.

ConfigureAwait

Nếu mã của bạn có thể được gọi từ ngữ cảnh đồng bộ hóa (synchronous), hãy sử dụng ConfigureAwait(false) trên mỗi cuộc gọi đang chờ của bạn.

Tuy nhiên, lưu ý rằng ConfigureAwait chỉ có ý nghĩa khi sử dụng với từ khóa await.

Ví dụ: code này không có ý nghĩa gì:

// Using ConfigureAwait doesn't magically make this call safe
var result = ProcessAsync().ConfigureAwait(false).GetAwaiter().GetResult();

Tránh sử dụng async void

Không bao giờ sử dụng async void. Một exception sẽ được ném ra trong phương thức async void và thường dừng toàn bộ ứng dụng.

Nếu bạn không thể trả về Task trong phương thức của mình (ví dụ: vì bạn đang implement một interface), hãy di chuyển code không đồng bộ sang một phương thức khác và gọi nó:

interface IInterface
{
    void DoSomething();
}

class Implementation : IInterface
{
    public void DoSomething()
    {
        // This method can't return a Task,
        // delegate the async code to another method
        _ = DoSomethingAsync();
    }

    private async Task DoSomethingAsync()
    {
        await Task.Delay(100);
    }
}

Tránh sử dụng async khi có thể

Có thể bạn sẽ viết code như sau:

public async Task CallAsync()
{
    var client = new Client();
    return await client.GetAsync();
}

Mặc dù code trên chính xác về mặt cú pháp, nhưng việc sử dụng từ khóa async là không cần thiết ở đây và có thể có chi phí đáng kể. Hãy cố gắng xóa nó bất cứ khi nào có thể:

public Task CallAsync()
{
    var client = new Client();
    return client.GetAsync();
}

Tuy nhiên, hãy nhớ rằng bạn không thể sử dụng cách trên khi mã của bạn được bao bọc trong các khối (như try/catch hoặc using):

public async Task Correct()
{
    using (var client = new Client())
    {
        return await client.GetAsync();
    }
}

public Task Incorrect()
{
    using (var client = new Client())
    {
        return client.GetAsync();
    }
}

Trong phương thức Incorrect ở trên, vì tác vụ không được chờ bên trong khối using, biến client có thể bị xử lý (disposed) trước khi cuộc gọi hàm GetAsync hoàn tất.

Ưu tiên biểu thức Lambda thay vì MethodGroup

Hãy xem xét đoạn code sau:

public IEnumerable<int> GetItems()
{
    return _list.Where(i => Filter(i));
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

Resharper đề nghị viết lại mã mà không có hàm lambda, có thể trông gọn gàng hơn:

public IEnumerable<int> GetItems()
{
    return _list.Where(Filter);
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

Thật không may, làm như vậy sẽ dẫn tới việc cấp phát bộ nhớ heap cho mỗi cuộc gọi tới phương thức Filter. Thật vậy, mệnh đề Where khi được biên dịch sẽ là:

public IEnumerable<int> GetItems()
{
    return _list.Where(new Predicate<int>(Filter));
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

Điều này có thể tác động đáng kể đến performance nếu có nhiều phần tử trong _list.

Việc sử dụng biểu thức lambda sẽ kích hoạt trình biên dịch tối ưu hóa để lưu delegate vào một trường tĩnh, tránh việc cấp phát bộ nhớ heap cho mỗi cuộc gọi tới phương thức Filter. Điều này chỉ hoạt động nếu phương thức Filter là tĩnh. Nếu không muốn như vậy, bạn có thể tự lưu delegate vào bộ nhớ cache như sau:

private Predicate<int> _filter;

public Constructor()
{
    _filter = new Predicate<int>(Filter);
}

public IEnumerable<int> GetItems()
{
    return _list.Where(_filter);
}

private bool Filter(int element)
{
    return i % 2 == 0;
}

Convert enum thành string

Gọi phương thức Enum.ToString() trong .NET rất tốn kém, vì reflection được sử dụng trong nội bộ để chuyển đổi và gọi một phương thức ảo trên struct gây ra hiện tượng boxing. Điều này nên tránh càng nhiều càng tốt.

Thông thường, enum có thể được thay thế bằng const string như sau:

// In both cases, you can use Numbers.One, Numbers.Two, ...
public enum Numbers
{
    One,
    Two,
    Three
}

public static class Numbers
{
    public const string One = "One";
    public const string Two = "Two";
    public const string Three = "Three";
}

Nếu bạn thực sự cần một enum, thì hãy xem xét việc lưu trữ giá trị được chuyển đổi vào một Dictionary để phân bổ chi phí.

So sánh Enum

Lưu ý: điều này không còn đúng trong .NET Core kể từ phiên bản 2.1, việc tối ưu hóa được thực hiện tự động bởi JIT.

Khi sử dụng enums làm cờ, bạn có thể sử dụng phương thức Enum.HasFlag:

[Flags]
public enum Options
{
    Option1 = 1,
    Option2 = 2,
    Option3 = 4
}

private Options _option;

public bool IsOption2Enabled()
{
    return _option.HasFlag(Options.Option2);
}

Code này gây ra hai boxing: một để chuyển đổi Options.Option2 sang Enum và một cho lệnh HasFlag gọi ảo trên struct. Điều này làm cho đoạn code đơn giản trên tốn nhiều chi phí làm giảm performance. Thay vào đó, bạn nên hy sinh khả năng đọc và sử dụng các toán tử nhị phân:

public bool IsOption2Enabled()
{
    return (_option & Options.Option2) == Options.Option2;
}

Phương thức Equals trong struct

Khi sử dụng phương thức Equals trong struct (ví dụ: khi được sử dụng làm khóa cho Dictionary), bạn cần ghi đè các phương thức EqualsGetHashCode. Các phương thức mặc định sử dụng reflection và rất chậm. Việc triển khai được tạo bởi Resharper thường đủ tốt.

Thông tin thêm: https://devblogs.microsoft.com/premier-developer/performance-implication-of-default-struct-equality-in-c/

Tránh boxing không cần thiết khi sử dụng struct với interface

Hãy xem xét đoạn code sau:

public class IntValue : IValue
{
}

// another class
public void DoStuff()
{
    var value = new IntValue();

    LogValue(value);
    SendValue(value);
}

public void SendValue(IValue value)
{
    // ...
}

public void LogValue(IValue value)
{
    // ...
}

Để tránh cấp phát bộ nhớ heap chúng ta có thể chuyển class IntValue thành kiểu struct. Nhưng vì các phương thức AddValueSendValue có tham số là một interface - interface là kiểu tham chiếu, do đó giá trị sẽ được boxing tại mỗi lần gọi phương thức, làm mất đi lợi ích của “tối ưu hóa”. Trên thực tế, nó sẽ phân bổ nhiều bộ nhớ heap hơn nếu IntValue là một class, vì giá trị sẽ được boxing độc lập cho mỗi lần gọi.

Nếu bạn viết một API có tham số là một struct, hãy thử sử dụng phương thức generic như sau:

public struct IntValue : IValue
{
}

public void DoStuff()
{
    var value = new IntValue();

    LogValue(value);
    SendValue(value);
}

public void SendValue<T>(T value) where T : IValue
{
    // ...
}

public void LogValue<T>(T value) where T : IValue
{
    // ...
}

Những phương thức generic thoạt nhìn thì có vẻ vô dụng, nhưng nó thực sự cho phép tránh cấp phát bộ nhớ heap và boxing khi IntValue là một struct.

Task.Run / Task.Factory.StartNew

Trừ khi bạn có lý do để sử dụng Task.Factory.StartNew, hãy luôn ưu tiên Task.Run để bắt đầu một nhiệm vụ nền. Task.Run sử dụng các giá trị mặc định an toàn hơn và quan trọng hơn là nó tự động mở tác vụ trả về, có thể ngăn chặn các lỗi nhỏ với các phương thức không đồng bộ. Hãy xem xét chương trình sau:

class Program
{
    public static async Task ProcessAsync()
    {
        await Task.Delay(2000);
        Console.WriteLine("Processing done");
    }
    
    static async Task Main(string[] args)
    {
        await Task.Factory.StartNew(ProcessAsync);
        Console.WriteLine("End of program");
        Console.ReadLine();
    }
}

Mặc dù có await nhưng "End of program" sẽ được hiển thị trước khi "Processing done". Điều này là do Task.Factory.StartNew sẽ trả về Task<Task> và mã chỉ chờ tác vụ bên ngoài. Mã đúng sẽ là await Task.Factory.StartNew(ProcessAsync).Unwrap() hoặc await Task.Run(ProcessAsync).

Chỉ có ba trường hợp sử dụng cho Task.Factory.StartNew là:

  • Bắt đầu một công việc trên một công cụ lập lịch khác.
  • Thực thi tác vụ trên một chuỗi chuyên dụng (sử dụng TaskCreationOptions.LongRunning).
  • Xếp hàng nhiệm vụ trên hàng đợi threadpool global (sử dụng TaskCreationOptions.PreferFairness).
Lập Trình C#
Bài Viết Liên Quan:
int[] và int[,] trong C#: Ai nhanh hơn
Trung Nguyen 10/10/2020
int[] và int[,] trong C#: Ai nhanh hơn

Hiểu được sự khác biệt giữa các loại mảng trong C# sẽ giúp bạn chọn cấu trúc dữ liệu chính xác cho mọi trường hợp.

Struct và class trong C#: Ai nhanh hơn
Trung Nguyen 09/10/2020
Struct và class trong C#: Ai nhanh hơn

Trong bài viết này, tôi sẽ so sánh sự khác biệt về hiệu suất giữa struct và class trong C#: Ai nhanh hơn.

Đọc ghi file (File I/O) trong C#
Trung Nguyen 26/04/2020
Đọc ghi file (File I/O) trong C#

Hướng dẫn này sẽ giúp bạn tìm hiểu về đọc ghi file (File I/O) trong C# và sử dụng các lớp tiện ích để đọc ghi file.

Reflection trong C#
Trung Nguyen 19/04/2020
Reflection trong C#

Reflection trong C# là gì? Ứng dụng của Reflection trong C#. Cách khai báo và sử dụng Reflection trong C#.