Chuyển tới nội dung chính

12 bài viết được gắn thẻ "C#"

Ngôn ngữ C# ở mức thực dụng — async, bất biến, pattern matching và những cái bẫy hay gặp.

Xem tất cả thẻ

Gọi .Result khi nào thì deadlock, khi nào thì không?

· 15 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

.Result và .Wait() block thread hiện tại. Khi await tạm dừng, nó bắt lại SynchronizationContext đang hiện hành để chạy phần code phía sau đúng trên context đó. Nếu context chỉ cho phép một thread tại một thời điểm — UI thread của WPF, request context của ASP.NET Framework — mà thread đó lại đang bị .Result block, thì continuation không bao giờ vào được: deadlock. Console và ASP.NET Core không có SynchronizationContext, nên continuation chạy trên thread pool và không kẹt. Cách sửa thật sự là async all the way.

Đoạn code dưới đây chạy hoàn hảo trong một console app, và treo cứng khi bạn copy nguyên xi vào một controller của ASP.NET Framework hoặc một nút bấm trong WPF:

public string GetCustomerName(int id)
{
return GetCustomerAsync(id).Result; // treo ở đây
}

private async Task<string> GetCustomerAsync(int id)
{
var response = await _http.GetStringAsync($"/customers/{id}");
return Parse(response).Name;
}

Không có exception, không có stack trace, không có timeout. Thread đứng im vĩnh viễn. Chuyện này không phải do HttpClient, cũng không phải do async "bị lỗi" — nó là hệ quả trực tiếp của một quyết định thiết kế trong cách await hoạt động.

Vì sao EF Core bắn 201 query cho 1 màn hình danh sách? Cách sửa N+1

· 15 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

Một danh sách 200 dòng mà log ghi 201 câu SQL chính là N+1: một truy vấn lấy danh sách, rồi mỗi dòng lại thêm một truy vấn để nạp dữ liệu liên quan. Gốc rễ là navigation property bị nạp rời rạc thay vì nạp chung. Cách sửa mặc định là projection bằng Select xuống DTO. Include chỉ hợp khi cần entity thật, và nếu Include từ hai collection trở lên thì phải thêm AsSplitQuery để khỏi nổ cartesian.

Bạn mở trang danh sách khách hàng, API trả về sau bốn giây, nhưng CPU của database gần như không nhúc nhích và câu SQL nào nhìn riêng lẻ cũng chạy trong vài mili-giây. Bật log lên thì hoá ra vấn đề không nằm ở một câu chậm, mà ở 201 câu nhanh xếp hàng nối đuôi nhau.

Đó là N+1 query. Nó không làm code sai, không ném exception, không bị unit test bắt, và trên máy local với hai mươi dòng dữ liệu seed thì nó thậm chí còn nhanh hơn bản sửa đúng. Nó chỉ lộ ra khi bảng có dữ liệu thật và độ trễ mạng tới database khác không.

Đổi id trên URL ra dữ liệu người khác? Chặn IDOR ở một tầng duy nhất

· 16 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

IDOR xảy ra khi endpoint nhận id từ request rồi nạp thẳng bản ghi, chỉ kiểm tra người gọi đã đăng nhập chứ không kiểm tra người gọi có quyền trên đúng bản ghi đó. Đổi id sang GUID hay mã hoá id không sửa được lỗi, vì id vẫn rò ra ở chỗ khác. Cách sửa là bắt buộc mọi truy vấn phải đi qua một tầng ràng buộc quyền sở hữu ở server, thay vì rải lệnh kiểm tra trong từng controller.

Bạn có một endpoint rất đỗi bình thường: GET /api/orders/1043 trả về chi tiết đơn hàng. Nó nằm sau [Authorize], token hợp lệ mới gọi được, và trên giao diện thì người dùng chỉ bấm được vào đơn của chính họ.

Rồi một người đăng nhập bằng tài khoản thật của họ, sửa 1043 thành 1044 và bấm gửi. Nếu server trả về đơn hàng của người khác thì đó là Insecure Direct Object Reference, gọi tắt là IDOR. Không có payload, không có kỹ thuật gì, chỉ là một con số bị đổi.

Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi

· 15 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

Chọn lifetime là chọn thời điểm container tạo instance và thời điểm nó dispose. Transient tạo mới mỗi lần resolve, Scoped một instance cho mỗi HTTP request, Singleton một instance cho cả vòng đời process. Quy tắc quan trọng nhất: service sống lâu không được nhận trực tiếp service sống ngắn. Inject một repository Scoped vào một Singleton là bạn vừa giữ DbContext đó sống tới lúc app tắt. Cách sửa là inject IServiceScopeFactory rồi tự mở scope khi cần.

Dòng đăng ký gây ra lỗi này trông vô hại tới mức review không ai dừng lại:

builder.Services.AddDbContext<CrmDbContext>(o => o.UseSqlServer(conn)); // Scoped
builder.Services.AddScoped<ICustomerRepository, EfCustomerRepository>();
builder.Services.AddSingleton<CustomerCacheRefresher>(); // ← thuốc nổ

App qua được CI và lên staging. Lên môi trường có tải, log bắt đầu xuất hiện A second operation was started on this context instance before a previous operation completed, hoặc Cannot access a disposed context instance, hoặc tệ hơn là không có exception nào cả mà chỉ có dữ liệu cũ trả về mãi không đổi. Bài này giải thích cơ chế đằng sau, và code của cả bản hỏng lẫn bản sửa.

Vì sao .NET ngốn 96% RAM container dù app đang rảnh? Server GC và cách giảm 65% bộ nhớ

· 13 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

Một container ASP.NET Core 9 chiếm 987 MiB trên trần 1 GiB (96%) dù suốt 2 giờ không nhận request nào. Đây không phải rò rỉ bộ nhớ: mức RAM đứng yên tuyệt đối qua nhiều lần đo, dấu hiệu của heap bị giữ lại chứ không phải rò rỉ. Nguyên nhân là Server GC — mặc định của ASP.NET Core, tạo một heap cho mỗi CPU core và rất ít trả bộ nhớ về hệ điều hành. Đặt DOTNET_gcServer=0 và DOTNET_GCConserveMemory=5 giảm bộ nhớ anon từ 822 MB xuống 280 MB (−65%), đo ổn định sau 30 phút. Đánh đổi: Workstation GC cho throughput thấp hơn khi tải cao và nhiều core.

Bài này ghi lại một buổi chẩn đoán thật trên VPS 4 core / 8 GB RAM đang chạy song song 9 site WordPress và một API .NET. Mọi con số bên dưới là số đo thật, không phải ví dụ minh hoạ.