2.10 — 8. .NET Runtime và Ecosystem
Code C# của bạn không chạy trực tiếp. Nó được biên dịch thành IL — một ngôn ngữ trung gian — rồi tới lúc chạy mới được JIT dịch tiếp thành mã máy của đúng CPU đang chạy. Hai hệ quả đáng nhớ: lần gọi đầu tiên của một phương thức luôn chậm hơn các lần sau, và cùng một file .dll chạy được trên mọi nền tảng. Phần thứ hai của bài là bộ thu gom rác, nơi có một hành vi khiến rất nhiều người hoảng: .NET ở chế độ Server GC cố tình giữ lại bộ nhớ đã cấp phát, nên biểu đồ RAM của container leo tới 96% rồi đứng yên — trong khi ứng dụng hoàn toàn khoẻ mạnh.
Mục tiêu bài học
Sau bài này bạn có thể:
- Mô tả đường đi từ mã C# tới mã máy, và vị trí của IL, JIT, AOT.
- Giải thích thế hệ của GC và vì sao đối tượng sống ngắn lại rẻ.
- Nêu khác biệt giữa Workstation GC và Server GC, và khi nào đổi.
- Chọn phiên bản .NET theo lịch LTS thay vì theo số mới nhất.
- Đánh giá một gói NuGet trước khi đưa vào dự án.
Nội dung bài học
2.10.1 — Từ C# tới mã máy
Bước 1 — biên dịch. dotnet build dùng Roslyn dịch C# thành IL (Intermediate Language), đóng vào file .dll. IL không phụ thuộc CPU hay hệ điều hành.
Bước 2 — JIT. Lúc chạy, mỗi phương thức được dịch sang mã máy ngay trước lần gọi đầu tiên, rồi kết quả được giữ lại cho các lần sau.
Hai điều suy ra từ đây:
- Lần gọi đầu luôn chậm hơn. Đây là một phần của hiện tượng "khởi động nguội", và là lý do endpoint đầu tiên sau khi deploy hay có độ trễ cao bất thường.
- JIT biết CPU thật. Nó có thể dùng tập lệnh mà máy đang chạy hỗ trợ — điều mà biên dịch sẵn từ trước không làm được.
Native AOT là hướng ngược lại: dịch thẳng sang mã máy lúc build. Đổi lấy khởi động gần như tức thì và bộ nhớ thấp, nhưng mất khả năng dùng reflection tự do và phải cố định nền tảng đích. Hợp với công cụ dòng lệnh và serverless; xem Native AOT deployment.
2.10.2 — Bộ thu gom rác
Bạn không gọi free() trong C#. GC tự tìm những đối tượng không còn ai tham chiếu tới và thu hồi.
Nó chia bộ nhớ thành ba thế hệ, dựa trên một quan sát: phần lớn đối tượng chết rất trẻ.
| Thế hệ | Chứa gì | Dọn thường xuyên | Chi phí |
|---|---|---|---|
| Gen 0 | Đối tượng mới sinh | Rất thường xuyên | Rất rẻ |
| Gen 1 | Sống sót qua một lần dọn Gen 0 | Thỉnh thoảng | Trung bình |
| Gen 2 | Sống lâu | Hiếm | Đắt |
Đối tượng lớn hơn 85.000 byte đi thẳng vào Large Object Heap, vốn được dọn cùng Gen 2 và không được nén mặc định.
Hệ quả thực tế: đối tượng sống ngắn gần như miễn phí. Tạo nhiều object nhỏ trong một request rồi vứt đi là chuyện bình thường. Thứ gây đau là đối tượng sống lâu vô ý — một cache tĩnh phình mãi, một sự kiện quên gỡ đăng ký — vì chúng leo lên Gen 2 và làm mỗi lần dọn Gen 2 nặng thêm.
Chi tiết: Fundamentals of garbage collection.
2.10.3 — Workstation GC và Server GC
Đây là chỗ sinh ra một trong những báo động giả phổ biến nhất khi chạy .NET trong container.
| Workstation GC | Server GC | |
|---|---|---|
| Số heap | 1 | Một heap cho mỗi CPU |
| Mục tiêu | Độ trễ thấp | Thông lượng cao |
| Bộ nhớ dùng | Ít | Nhiều, và giữ lại |
| Mặc định cho | Ứng dụng desktop | ASP.NET Core |
Server GC cố tình không trả bộ nhớ lại cho hệ điều hành ngay, vì trả rồi xin lại rất tốn. Kết quả: biểu đồ RAM của container leo lên sát giới hạn rồi nằm yên, kể cả khi ứng dụng đang rảnh.
Nhìn vào dashboard thì trông như rò rỉ bộ nhớ. Nó không phải rò rỉ. Nhưng nó vẫn có thể giết container, vì orchestrator nhìn con số đó và ra tay.
Bài Vì sao .NET ngốn 96% RAM container dù app đang rảnh? đi vào chi tiết và đo tác động của việc chuyển sang Workstation GC:
<PropertyGroup>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
Đây là đánh đổi có thật — bớt bộ nhớ, giảm thông lượng — nên hãy đo trước khi đổi. Nhưng với container bị giới hạn RAM chặt, nó thường là lựa chọn đúng.
2.10.4 — SDK, Runtime, và cách đóng gói
SDK = công cụ build + runtime. Cần trên máy lập trình viên và máy CI.
Runtime = chỉ đủ để chạy. Cần trên máy chủ production.
Hai cách đóng gói khi publish:
| Framework-dependent | Self-contained | |
|---|---|---|
| Kích thước | Nhỏ (vài MB) | Lớn (60 MB trở lên) |
| Yêu cầu máy đích | Phải cài .NET runtime | Không cần gì |
| Vá lỗi bảo mật | Cập nhật runtime là xong | Phải build lại và deploy lại |
Dòng cuối là điều người ta hay quên. Self-contained tiện khi triển khai, nhưng mỗi lỗ hổng trong runtime đều bắt bạn phát hành lại ứng dụng.
Xem .NET application deployment.
2.10.5 — Chọn phiên bản: LTS hay mới nhất
.NET phát hành mỗi tháng 11, xen kẽ hai loại:
- LTS (số chẵn: 8, 10...) — hỗ trợ 3 năm.
- STS (số lẻ: 9, 11...) — hỗ trợ 18 tháng.
Với hệ thống nghiệp vụ chạy nhiều năm, chọn LTS. Với dự án cần tính năng mới nhất và sẵn sàng nâng cấp mỗi năm, STS cũng được — miễn là đội biết rằng hạn hỗ trợ đến rất nhanh.
Lịch chính thức: .NET support policy. Chạy phiên bản đã hết hỗ trợ nghĩa là không còn nhận bản vá bảo mật — đó là rủi ro, không phải sự bất tiện.
2.10.6 — Hệ sinh thái
| Tầng | Thành phần |
|---|---|
| Runtime | CLR, GC, JIT |
| Thư viện cơ sở | System.*, LINQ, System.Text.Json |
| Web | ASP.NET Core, SignalR, Minimal API |
| Dữ liệu | Entity Framework Core, Dapper |
| Framework ứng dụng | ABP Framework, Orchard Core |
| Kiểm thử | xUnit, NUnit, Moq, Testcontainers |
| Đóng gói | NuGet |
Đánh giá một gói NuGet trước khi thêm vào: mỗi gói là một phụ thuộc bạn phải bảo trì lâu dài.
- Lần phát hành gần nhất cách đây bao lâu? Hai năm không cập nhật là dấu hiệu xấu.
- Có hỗ trợ phiên bản .NET bạn đang dùng không?
- Giấy phép có dùng được trong bối cảnh thương mại không?
- Nó kéo theo bao nhiêu phụ thuộc gián tiếp?
- Việc này có thể viết bằng thư viện chuẩn trong 50 dòng không?
Câu cuối đáng hỏi nhất. Một phụ thuộc là một thứ có thể hỏng, có thể có lỗ hổng, và có thể bị bỏ rơi.
2.10.7 — Rà lại dự án của bạn
Danh sách rà soát runtime và hệ sinh thái
- •Phiên bản .NET đang dùng vẫn còn trong thời hạn hỗ trợ.
- •Dự án chạy trong container đã cân nhắc Workstation GC so với Server GC, có đo trước khi chọn.
- •Không có cache tĩnh nào phình vô hạn, và mọi đăng ký sự kiện đều được gỡ.
- •Máy chủ production chỉ cài runtime, không cài SDK.
- •Nếu dùng self-contained, có quy trình build lại khi runtime có bản vá bảo mật.
- •Mọi gói NuGet trong dự án đều còn được bảo trì và tương thích phiên bản đang dùng.
- •Đã đo thời gian khởi động nguội, và cân nhắc AOT nếu nó là vấn đề.
Bài tập áp dụng
Bài 1 — Nhìn thấy IL
Build một ứng dụng console nhỏ, mở file .dll bằng ILSpy hoặc ildasm. Tìm phương thức Main và đọc IL của nó. Đối chiếu từng dòng với mã C# gốc.
Tiêu chí hoàn thành: bạn chỉ ra được ít nhất một chỗ mà IL không tương ứng một-một với C#.
Gợi ý và lời giải — Bài 1
Gợi ý. Không cần cài công cụ nặng. Trang sharplab.io hiển thị IL ngay trên trình duyệt khi bạn gõ C#. Chọn chế độ IL ở menu bên phải.
Lời giải — C# gốc:
int a = 5;
int b = 3;
int c = a + b;
Console.WriteLine(c);
IL tương ứng:
ldc.i4.5 // đẩy hằng số 5 lên ngăn xếp
stloc.0 // lưu vào biến cục bộ 0 (a)
ldc.i4.3 // đẩy 3
stloc.1 // lưu vào biến cục bộ 1 (b)
ldloc.0 // nạp a
ldloc.1 // nạp b
add // cộng hai giá trị trên đỉnh ngăn xếp
stloc.2 // lưu vào biến cục bộ 2 (c)
ldloc.2 // nạp c
call void [System.Console]System.Console::WriteLine(int32)
ret
Ba điều đọc ra được.
-
IL là máy ngăn xếp. Không có thanh ghi. Mọi phép toán lấy toán hạng từ đỉnh ngăn xếp và đẩy kết quả trở lại. Đây là lý do IL độc lập với kiến trúc CPU — nó không giả định gì về số thanh ghi của máy thật.
-
Tên biến biến mất.
a,b,ctrở thànhloc.0,loc.1,loc.2. Tên chỉ còn trong file.pdb, và đó là lý do thiếu file đó thì dấu vết ngăn xếp không có số dòng. -
Nhiều thứ trong C# không có trong IL. Đây là phần chính của bài — các cấu trúc sau đều được trình biên dịch biến đổi trước khi sinh IL:
| Cấu trúc C# | Thực chất trong IL |
|---|---|
foreach | Gọi GetEnumerator, vòng while, khối try/finally gọi Dispose |
using | Khối try/finally gọi Dispose |
async/await | Một máy trạng thái dạng struct, với phương thức MoveNext |
yield return | Một lớp máy trạng thái cài IEnumerator |
| Biểu thức lambda | Một lớp ẩn danh chứa các biến bị bắt |
string nội suy | Gọi string.Format hoặc DefaultInterpolatedStringHandler |
Bài thử đáng làm nhất. Dán một phương thức async vào sharplab và xem IL. Bạn sẽ thấy trình biên dịch sinh ra một struct riêng với một trường <>1__state và phương thức MoveNext. Đây chính là bằng chứng cụ thể cho câu "async không tạo luồng" mà Module 6 sẽ giải thích: await chỉ là một điểm mà máy trạng thái tạm dừng và ghi nhớ vị trí, không có luồng nào bị chiếm.
Vì sao nên biết điều này. Không phải để viết IL, mà để hiểu rằng C# và thứ thật sự chạy là hai thứ khác nhau. Khi bạn đọc một dấu vết ngăn xếp có tên như MoveNext hoặc <GetLeadsAsync>d__12, bạn sẽ biết đó là máy trạng thái do trình biên dịch sinh ra chứ không phải lỗi lạ.
Bài 2 — Đo khởi động nguội và ảnh hưởng của JIT
Gọi cùng một tác vụ 10 lần liên tiếp ngay sau khi khởi động, ghi thời gian từng lần. Chỉ ra ảnh hưởng của JIT ở vài lần đầu.
Tiêu chí hoàn thành: bạn có bảng 10 dòng và giải thích được vì sao lần đầu chậm hơn hẳn.
Gợi ý và lời giải — Bài 2
Gợi ý. Để hiệu ứng rõ, tác vụ phải gọi tới nhiều phương thức chưa từng chạy — LINQ, tuần tự hoá JSON, so sánh chuỗi. Nếu chỉ cộng hai số thì gần như không thấy gì.
Lời giải.
static string Process(List<Lead> leads)
{
var top = leads.Where(l => l.Value > 1000)
.OrderByDescending(l => l.Value)
.GroupBy(l => l.CreatedAt.Month)
.Select(g => new { Month = g.Key, Total = g.Sum(x => x.Value), Count = g.Count() })
.ToList();
return JsonSerializer.Serialize(top);
}
for (int i = 1; i <= 10; i++)
{
var sw = Stopwatch.StartNew();
Process(leads);
sw.Stop();
Console.WriteLine($"{i,3} {sw.Elapsed.TotalMilliseconds,8:F2}");
}
Kết quả đo thật trên .NET 9, chế độ Release, 5.000 bản ghi:
lần thời gian (ms)
1 107.80 <- chậm gấp 18 lần
2 6.20
3 7.62
4 5.41
5 5.69
6 6.11
7 5.82
8 5.60
9 5.64
10 4.04 <- tầng tối ưu đã vào
Ba thứ xảy ra trong 107,80 mili-giây đầu tiên:
- JIT biên dịch. Mỗi phương thức được dịch từ IL sang mã máy lần đầu tiên nó được gọi. Ở đây gồm cả phương thức của bạn lẫn hàng chục phương thức nội bộ của LINQ và
System.Text.Json. - Nạp assembly. Các thư viện liên quan được đọc từ đĩa và chuẩn bị.
- Sinh mã cho generic. Mỗi kiểu giá trị dùng với generic cần một bản mã riêng, sinh ra lúc chạy.
Từ lần thứ hai, mã máy đã nằm trong bộ nhớ nên chỉ còn chi phí thực thi thật.
Vì sao lần thứ 10 lại nhanh hơn lần thứ 2. Đây là tiered compilation. .NET biên dịch nhanh và thô ở tầng 0 để chương trình chạy sớm, rồi âm thầm biên dịch lại ở tầng 1 với đầy đủ tối ưu cho những phương thức được gọi nhiều. Vì vậy hiệu năng còn cải thiện dần trong vài trăm lần gọi đầu.
Hệ quả thực tế — bốn điểm:
- Đo hiệu năng phải bỏ qua các lần chạy đầu. Đây chính là lý do mọi bài benchmark trong giáo trình này đều làm nóng trước, và là lý do công cụ
BenchmarkDotNettồn tại. - Request đầu tiên sau khi triển khai luôn chậm. Người dùng xui xẻo nhận được nó. Cách xử lý là gửi vài request làm nóng trước khi đưa thể hiện mới vào vòng phục vụ — phần readiness probe ở bài 2.9 chính là chỗ móc vào.
- Với hàm serverless, đây là vấn đề lớn. Mỗi lần khởi động nguội trả lại toàn bộ chi phí này, và người dùng cảm nhận trực tiếp.
- Có cách giảm. ReadyToRun biên dịch sẵn một phần mã lúc publish, đổi lại image lớn hơn. Native AOT biên dịch thẳng sang mã máy, khởi động gần như tức thì nhưng không dùng được với mọi thư viện.
Bài 3 — So sánh hai chế độ thu gom rác
Chạy cùng một ứng dụng với ServerGarbageCollection bật và tắt, trong container giới hạn 512 MB. So sánh mức RAM khi nhàn rỗi và thông lượng khi tải cao.
Tiêu chí hoàn thành: bạn nêu được tiêu chí chọn giữa hai chế độ, và một trường hợp mà chế độ mặc định là sai.
Gợi ý và lời giải — Bài 3
Gợi ý. Khác biệt cốt lõi: Workstation GC dùng một luồng thu gom; Server GC dùng một luồng cho mỗi nhân CPU, kèm theo một vùng heap riêng cho mỗi nhân. Hãy nghĩ xem điều đó ảnh hưởng thế nào tới bộ nhớ nhàn rỗi.
Lời giải — cấu hình:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
Hoặc bằng biến môi trường, tiện để thử mà không build lại:
docker run -m 512m -e DOTNET_gcServer=1 crm-api
docker run -m 512m -e DOTNET_gcServer=0 crm-api
So sánh:
| Workstation GC | Server GC | |
|---|---|---|
| Số luồng thu gom | 1 | 1 cho mỗi nhân CPU |
| Số vùng heap | 1 | 1 cho mỗi nhân |
| RAM khi nhàn rỗi | Thấp | Cao hơn đáng kể |
| Thông lượng khi tải cao | Thấp hơn | Cao hơn |
| Thời gian tạm dừng | Ngắn, thường xuyên | Dài hơn, ít lần hơn |
| Mặc định của | Ứng dụng console, desktop | ASP.NET Core |
Trường hợp mặc định là sai — đây là phần chính của bài. ASP.NET Core bật Server GC theo mặc định, hợp lý với máy chủ chuyên dụng. Nhưng trong container nhỏ thì nó phản tác dụng:
Container 512 MB trên máy chủ 16 nhân
-> Server GC tạo 16 vùng heap
-> Bộ nhớ nền chiếm phần lớn hạn mức
-> Ứng dụng bị hệ điều hành giết vì hết bộ nhớ, dù xử lý rất ít việc
Triệu chứng đặc trưng là container bị kết thúc với mã OOMKilled trong khi biểu đồ cho thấy lưu lượng rất thấp. Nhiều người phản ứng bằng cách tăng hạn mức bộ nhớ, và điều đó có tác dụng — nhưng tốn tiền cho một vấn đề cấu hình.
Cách xử lý:
# Cách 1: tắt Server GC cho container nhỏ
DOTNET_gcServer=0
# Cách 2: giữ Server GC nhưng giới hạn số vùng heap
DOTNET_GCHeapCount=2
# Cách 3: đặt hạn mức bộ nhớ cứng để GC tự điều chỉnh
DOTNET_GCHeapHardLimit=0x10000000 # 256 MB
Hai điều cần biết thêm:
- .NET nhận biết giới hạn của container từ .NET Core 3.0 trở đi, nên nó đọc được hạn mức cgroup thay vì nhìn RAM của máy chủ. Nhưng số nhân CPU thì vẫn có thể nhìn thấy toàn bộ máy chủ nếu bạn không đặt giới hạn CPU — và đó chính là nguồn gốc của 16 vùng heap trong ví dụ trên. Vì vậy hãy đặt cả
--cpuschứ không chỉ-m. - Đừng chỉnh GC trước khi đo. Phần lớn vấn đề bộ nhớ trong ứng dụng .NET không phải do GC mà do cấp phát quá nhiều đối tượng ngắn hạn, hoặc do rò rỉ vì giữ tham chiếu không cần thiết. Chỉnh GC khi chưa biết nguyên nhân là đoán mò.
Cách đo cho đúng. Dùng dotnet-counters để xem tần suất thu gom theo từng thế hệ và tổng thời gian dừng, rồi mới quyết định:
dotnet-counters monitor --process-id <pid> --counters System.Runtime
Module 19 trình bày quy trình chẩn đoán đầy đủ, và Module 15 bàn về cấu hình GC trong Kubernetes.
Tự kiểm tra
Frequently asked questions
Mã C# đi qua những bước nào trước khi thành mã máy?
Roslyn biên dịch C# thành IL và đóng vào file .dll; IL không phụ thuộc CPU hay hệ điều hành. Lúc chạy, JIT dịch từng phương thức sang mã máy ngay trước lần gọi đầu tiên rồi giữ lại kết quả. Vì vậy lần gọi đầu luôn chậm hơn, và cùng một .dll chạy được trên nhiều nền tảng.
Vì sao đối tượng sống ngắn lại rẻ?
Vì GC chia bộ nhớ thành ba thế hệ dựa trên quan sát rằng phần lớn đối tượng chết rất trẻ. Gen 0 được dọn rất thường xuyên và rất rẻ. Thứ đắt là Gen 2, nơi chứa đối tượng sống lâu. Nên tạo nhiều object nhỏ trong một request rồi vứt đi là bình thường; thứ gây đau là cache tĩnh phình mãi hoặc sự kiện quên gỡ đăng ký.
Vì sao container .NET hay hiển thị mức RAM rất cao?
Vì ASP.NET Core mặc định dùng Server GC, chế độ này tạo một heap cho mỗi CPU và cố tình không trả bộ nhớ lại cho hệ điều hành ngay, do trả rồi xin lại rất tốn. Biểu đồ RAM vì thế leo sát giới hạn rồi nằm yên. Đó không phải rò rỉ, nhưng vẫn có thể khiến orchestrator giết container.
Khi nào nên chuyển sang Workstation GC?
Khi chạy trong container bị giới hạn RAM chặt và mức bộ nhớ đang là vấn đề thật. Đây là đánh đổi có thật: bớt bộ nhớ nhưng giảm thông lượng, vì chỉ còn một heap thay vì một heap cho mỗi CPU. Nên đo cả hai chỉ số trước khi quyết định.
Framework-dependent và self-contained khác nhau thế nào?
Framework-dependent cho gói nhỏ vài MB nhưng máy đích phải cài sẵn .NET runtime. Self-contained đóng gói cả runtime nên chạy ở đâu cũng được, đổi lại nặng hơn 60 MB và quan trọng hơn: mỗi lỗ hổng bảo mật trong runtime đều bắt bạn build lại và deploy lại ứng dụng.
Nên chọn phiên bản .NET nào?
Với hệ thống nghiệp vụ chạy nhiều năm thì chọn bản LTS, tức các số chẵn, được hỗ trợ 3 năm. Bản STS số lẻ chỉ được hỗ trợ 18 tháng. Chạy phiên bản đã hết hỗ trợ nghĩa là không còn nhận bản vá bảo mật, nên đó là rủi ro chứ không chỉ là bất tiện.
Kết luận
Ba điều đáng nhớ nhất:
- JIT giải thích vì sao lần gọi đầu luôn chậm. Biết điều này thì không còn hoảng khi thấy request đầu tiên sau deploy có độ trễ cao.
- RAM cao trong container .NET thường không phải rò rỉ. Đó là Server GC đang giữ bộ nhớ đúng như thiết kế.
- Mỗi gói NuGet là một cam kết bảo trì. Câu hỏi đáng giá nhất trước khi thêm: thư viện chuẩn làm được việc này không?
Tham khảo
- Introduction to .NET — tổng quan nền tảng
- Fundamentals of garbage collection — thế hệ và LOH
- .NET application deployment — framework-dependent và self-contained
- Native AOT deployment
- .NET support policy — lịch LTS và STS
- .NET CLI —
dotnet build,publish,add package
Điều hướng
- Bài trước: 2.8 — 7. Hosting và Cloud cơ bản
- Bài tiếp theo: 2.10 — Mở rộng và đào sâu
- Về module: Trang mục lục
Bài liên quan
- Vì sao .NET ngốn 96% RAM container dù app đang rảnh? Server GC và cách giảm 65% bộ nhớ — Một container .NET 9 chiếm 987 MiB trên trần 1 GiB dù suốt 2 giờ không có request nào.