.NET 8'den .NET 11'e Diagnostics'te Değişenler: System.Runtime Meter, Yeni dotnet-trace Profile'ları, collect-linux, Runtime Async Stack'leri ve In-Process Crash Report
.NET 9 eski EventCounter'ları System.Runtime Meter'ıyla değiştirdi, .NET 10 dotnet-trace'in varsayılanlarını ve collect-linux'u getirdi, .NET 11 ise kayıpsız event tamponu, runtime async ve process içi crash report ekledi. Eski ve yeni metric adlarının eşleme tablosunu veriyor, cpu-sampling ve Block modu hatalarını, runtime async'in askıya alma öncesi ve sonrası stack'lerini ve crash report çıktısını .NET 10 ve 11'de gerçek koşularla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (container'da runtime 10.0.15) ve .NET 11 (SDK 11.0.100, runtime 11.0.0) ile derlenip denenmiştir; dotnet-trace, dotnet-counters ve dotnet-gcdump 10.0.750501 kullanılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) .NET 11'e geçiyor ve sürüm notlarında runtime async'in stack trace'leri sadeleştirdiğini okudun. Checkout'ta üç katmanlı bir async zincir var: CheckoutAsync, PriceAsync'i, o da LoadCampaignRulesAsync'i await ediyor. En içteki method bir Task.Delay'i bekleyip thread pool'da devam ettikten sonra bir profiler ya da new StackTrace() stack'e baktığında PriceAsync ve CheckoutAsync'i görür mü? Runtime async açıkken ve kapalıyken cevap değişir mi? Cevap runtime async bölümündeki iki çıktıda.
Bir Bakışta: .NET 8'den .NET 11'e
Bu seride kullandığımız araçların ve runtime özelliklerinin önemli bir kısmı son üç sürümde geldi ya da değişti. Eski bir blog yazısını, runbook'u ya da dashboard'u okurken hangisinin hangi sürüme ait olduğunu bilmek gerekiyor:
| Sürüm | Değişiklik | Seride nerede |
|---|---|---|
| .NET 8 | ASP.NET Core ve Kestrel metric'leri Meter tabanlı (http.server.request.duration) | 02 |
| .NET 9 | System.Runtime Meter'ı; eski EventCounter'ların yerini alan dotnet.* metric'leri | 02, bu yazı |
| dotnet-counters 9.0.652701 | --dsrouter ile iOS/Android uygulamalarına bağlanma | Bu yazı |
| .NET 10 ve araçlar 10.x | Varsayılan profile'lar dotnet-common + dotnet-sampled-thread-time; cpu-sampling collect'ten kalktı; collect-linux ve --probe; dnx ile kurulumsuz çalıştırma; database profile'ı | 01, 03, 07 |
| .NET 10 runtime | AllocationSampled event'i (rastgele allocation örneklemesi) | 04, 06 |
| .NET 11 | Kayıpsız (Block) event tamponu; runtime async ve sadeleşen live stack'ler; async profiler event'lerinin tek modelde birleşmesi; Linux ve macOS'ta process içi crash report; DOTNET_RuntimeAsync'in kaldırılması | Bu yazı |
| dotnet-monitor 10.0.5 | .NET 11 runtime'ına major roll-forward | 08 |
.NET 11 kısmı 10 Kasım 2026'daki GA (genel kullanıma açık sürüm) için güncellenmiş "What's new in .NET 11 runtime" sayfasına göre; aşağıda o sayfadaki iddiaları tek tek denedim.
.NET 8 ve 9: Metric'ler Meter'a Taşınıyor
02'de, .NET 8 ve öncesinde dotnet-counters'ın eski System.Runtime EventCounter'larına düştüğünü gördük ve hangi eski adın hangi yeni metric'e karşılık geldiğini bu yazıya bırakmıştık. Microsoft Learn iki listeyi ayrı sayfalarda tutuyor, birebir bir eşleme tablosu vermiyor. Aşağıdaki tabloyu iki sayfadaki tanımları (hangi .NET API'sinin değerini raporladıkları) karşılaştırarak çıkardım:
| Eski EventCounter (.NET 8 ve öncesi) | Yeni metric (.NET 9+) | Fark |
|---|---|---|
cpu-usage | dotnet.process.cpu.time | Yüzde yerine saniye, cpu.mode ile user/system ayrımı |
time-in-gc, total-pause-time-by-gc | dotnet.gc.pause.time | Son GC'ye (garbage collector; kullanılmayan object'leri temizleyen runtime bileşeni) göre yüzde yerine toplam pause süresi; oranı sen hesaplıyorsun |
alloc-rate | dotnet.gc.heap.total_allocated | Aralık başına değil kümülatif byte |
gen-0-gc-count, gen-1-gc-count, gen-2-gc-count | dotnet.gc.collections | Tek metric, gc.heap.generation attribute'u |
gen-0-size ... loh-size, poh-size | dotnet.gc.last_collection.heap.size | Generation başına, attribute ile |
gc-fragmentation | dotnet.gc.last_collection.heap.fragmentation.size | Yüzde yerine byte |
gc-committed | dotnet.gc.last_collection.memory.committed_size | |
working-set | dotnet.process.memory.working_set | MB yerine byte |
exception-count | dotnet.exceptions | Exception tipi attribute olarak |
monitor-lock-contention-count | dotnet.monitor.lock_contentions | |
threadpool-thread-count, threadpool-queue-length, threadpool-completed-items-count | dotnet.thread_pool.thread.count, dotnet.thread_pool.queue.length, dotnet.thread_pool.work_item.count | |
active-timer-count, assembly-count | dotnet.timer.count, dotnet.assembly.count | |
methods-jitted-count, il-bytes-jitted | dotnet.jit.compiled_methods, dotnet.jit.compiled_il.size | Yeni: dotnet.jit.compilation.time |
Karşılığı olmayanlar da var: gc-heap-size (GC.GetTotalMemory) ve gen-0-gc-budget'in yeni listede doğrudan bir eşi yok. Dashboard'unu taşırken birim değişikliklerine dikkat et; eski "yüzde" grafiği yeni metric'e bağlayınca sayılar saniye ya da byte olarak gelir ve oranı sorguda hesaplaman gerekir. Bir de dotnet-counters 9.0.652701 ile gelen --dsrouter seçeneği var: mobil uygulamalarda diagnostic port'u bir TCP bağlantısına yönlendiren dotnet-dsrouter'ı başlatıp ona bağlanıyor, böylece iOS ve Android'deki .NET uygulamaları da aynı araçla izlenebiliyor.
.NET 10: dotnet-trace'in Yeni Varsayılanları, collect-linux ve dnx
03'te ayrıntısıyla anlattığımız değişiklik, eski rehberleri takip edenlerin ilk takıldığı yer: cpu-sampling profile'ı collect'ten kaldırıldı. Eski bir rehberdeki komutu .NET 10 lab'ında çalıştırınca:
docker exec profseries-api dotnet-trace collect -p 1 --profile cpu-sampling --duration 00:00:00:02
[ERROR] The specified profile 'cpu-sampling' does not apply to `dotnet-trace collect`.
Karşılığı --profile dotnet-sampled-thread-time,dotnet-common; profile vermezsen zaten bu ikisi açılıyor. cpu-sampling adı artık yalnızca collect-linux'ta ve orada kernel'in gerçek CPU örneklemesi anlamına geliyor. Bu ad değişikliğinin arkasındaki fikri (örneklenen şeyin CPU değil duvar saati olması) unutma; eski bir rehberdeki "CPU profili" ekran görüntüsü aslında bekleyen thread'leri de sayıyordu.
Aynı sürümde gelen diğer üç şey serinin başka yazılarında kullanıldı: collect-linux kernel'in user_events altyapısıyla native frame'leri ve bütün process'leri tek trace'e alıyor (kernel 6.4+ ve CONFIG_USER_EVENTS şart; lab ortamımızda bu yüzden çalışmadı, 03), dnx araçları kurmadan tek seferlik çalıştırıyor (01), database profile'ı Entity Framework Core (EF Core) ve ADO.NET (.NET'in temel veritabanı erişim katmanı) komutlarını trace'e alıyor (07). Runtime tarafında .NET 10'un getirdiği AllocationSampled event'i, eski AllocationTick'in sabit adımlı örneklemesinin hatasını düzeltiyor (04).
.NET 11: Kayıpsız Event Tamponu
EventPipe'ın tamponu dolduğunda varsayılan davranış event'leri düşürmek; uygulama yavaşlamaz ama trace eksik kalır. dotnet-trace 10.0.750501 bunu değiştiren --buffering-mode Block seçeneğini getirdi: tampon doluyken event üreten thread'ler yer açılana kadar bekliyor. Dokümana göre bu mod .NET 11 runtime'ı istiyor ve uygulamayı yavaşlatabiliyor. Önce .NET 10 lab'ında denedim:
docker exec profseries-api dotnet-trace collect -p 1 --buffering-mode Block --duration 00:00:00:02 -o /tmp/b.nettrace
The runtime version being traced does not support the 'Block' buffering mode, which requires .NET 11+. Retry with --buffering-mode Drop or omit --buffering-mode.
Aynı uygulamayı yeniden derlemeden .NET 11 runtime'ında çalıştırmak için 09'daki image'ın çıktısını .NET 11 ASP.NET Core image'ına kopyalayıp major roll-forward açtım:
FROM profseries-api:lab09 AS lab
FROM mcr.microsoft.com/dotnet/aspnet:11.0
WORKDIR /app
COPY --from=lab /app .
COPY --from=lab /tools /tools
ENV PATH="$PATH:/tools" DOTNET_ROLL_FORWARD=Major
USER $APP_UID
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]
İlk denemede ortam değişkenini DOTNET_RollForward diye yazdım ve uygulama "You must install or update .NET to run this application" diyerek açılmadı; doğru ad DOTNET_ROLL_FORWARD. Düzeltilmiş image'da aynı komut sorunsuz trace aldı. dotnet-gcdump da Block modunu kendiliğinden kullanıyor: 05 ve 09'da .NET 10'da her gcdump'ta gördüğümüz "the target runtime does not support non-lossy (Block) buffering" uyarısı .NET 11'de çıkmadı:
docker exec profseries-api dotnet-gcdump collect -p 1 -o /tmp/x.gcdump
Writing gcdump to '/tmp/x.gcdump'...
Finished writing 812867 bytes.
Pratik anlamı şu: büyük bir heap'in gcdump'ı .NET 10'da eksik kalabiliyordu, .NET 11'de kalmıyor. Block modunu dotnet-trace'te ise ancak eksiksiz trace'in uygulamanın yavaşlamasından daha önemli olduğu durumlarda aç.
.NET 11: Runtime Async ve Sadeleşen Stack'ler
.NET 11'in en büyük runtime değişikliği runtime async: async method'ları derleyicinin ürettiği state machine'ler yerine runtime'ın kendisi askıya alıp devam ettiriyor. .NET'in kendi kütüphaneleri bu modla derlenmiş geliyor; kendi kodun için net11.0 hedefleyen projede açman gerekiyor:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>runtime-async=on</Features>
</PropertyGroup>
Preview dönemindeki DOTNET_RuntimeAsync ortam değişkeni kaldırıldı; kapatmak için projede <UseRuntimeAsync>false</UseRuntimeAsync> kullanılıyor. Dokümandaki iddiayı Bookstore'un checkout zincirini taklit eden küçük bir programla denedim. Kurallar cache'te olduğu için ilk await senkron tamamlanıyor, ardından gerçek bir askıya alma geliyor:
using System.Diagnostics;
// checkout -> fiyat -> kampanya kuralları: Bookstore'un checkout'u gibi üç async katman
await CheckoutAsync(crash: args.FirstOrDefault() == "crash");
static async Task CheckoutAsync(bool crash)
{
decimal total = await PriceAsync(crash);
Console.WriteLine($"total {total}");
}
static async Task<decimal> PriceAsync(bool crash)
{
string[] rules = await LoadCampaignRulesAsync(crash);
return 100m - rules.Length;
}
static async Task<string[]> LoadCampaignRulesAsync(bool crash)
{
// kurallar zaten cache'te: await senkron tamamlanıyor
string[] rules = await ValueTask.FromResult<string[]>(["BOOK25", "BOOK50"]);
Console.WriteLine("-- before a real suspension");
Console.WriteLine(new StackTrace());
await Task.Delay(10);
Console.WriteLine("-- after resuming on the thread pool");
Console.WriteLine(new StackTrace());
if (crash)
throw new InvalidOperationException("Campaign rules could not be loaded");
return rules;
}
.NET 11 SDK'sıyla bir kez özellik açık, bir kez -p:Features= ile kapalı derleyip çalıştırdım. Askıya almadan önceki stack, runtime async açıkken:
at Program.<<Main>$>g__LoadCampaignRulesAsync|0_2(Boolean crash)
at Program.<<Main>$>g__PriceAsync|0_1(Boolean crash)
at Program.<<Main>$>g__CheckoutAsync|0_0(Boolean crash)
at Program.<Main>$(String[] args)
at Program.<Main>(String[] args)
Kapalıyken aynı nokta 13 frame: her async method için AsyncMethodBuilderCore.Start ve method'un kendisi iki kez görünüyor. Dokümandaki 13'e 5 karşılaştırması birebir tuttu. Asıl öğretici olan ikinci çıktı, Task.Delay'den sonra thread pool'da devam eden stack. Runtime async açıkken:
at Program.<<Main>$>g__LoadCampaignRulesAsync|0_2(Boolean crash)
at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
at System.Threading.Tasks.Task.TrySetResult()
at System.Threading.Tasks.Task.DelayPromise.CompleteTimedOut()
at System.Threading.TimerQueueTimer.Fire(Boolean isThreadPool)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerDoWork(PortableThreadPool threadPoolInstance)
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
at System.Threading.Thread.StartCallback(Thread* pThread)
Tahmin sorusunun cevabı: hayır, iki modda da görmez. Gerçek bir askıya almadan sonra PriceAsync ve CheckoutAsync artık stack'te değil; onlar devam ettirilmeyi bekleyen continuation'lar. Runtime async kapalıyken aynı stack 15 frame ve araya AsyncStateMachineBox, ExecutionContext.RunInternal, AwaitTaskContinuation giriyor; açıkken 9 frame. Yani runtime async araya giren altyapı frame'lerini azaltıyor ama "bu kodu kim çağırdı" sorusunu async sınırının ötesinde cevaplamıyor. Profiling için pratik sonucu: 03'teki flame graph'larda thread pool'dan başlayan kısa stack'ler göreceksin ve çağıran endpoint'i stack'ten değil, request'e bağlı activity'lerden bulman gerekecek. Exception stack trace'leri ise dokümanın da söylediği gibi iki modda zaten aynı; zinciri ExceptionDispatchInfo topluyor. Debugger tarafında breakpoint'ler runtime async method'larında doğru bağlanıyor ve await sınırlarında derleyici altyapısına atlamadan adım adım ilerlenebiliyor. Bunu bu makinede bir IDE ile denemedim, sürüm notlarından aktarıyorum. Aynı notlara göre async profiler event'leri artık hem runtime async hem state machine method'larını aynı modelle veriyor; araçlar iki yolu ayrı ayrı ele almak zorunda kalmıyor.
.NET 11: Process İçinde Crash Report
Linux ve macOS'ta crash teşhisi şimdiye kadar process dışında çalışan createdump'a dayanıyordu. .NET 11, mobil platformlardan gelen process içi bir raporlayıcıyı bu sistemlere de getirdi: process ölmeden önce managed stack'i, modül listesini ve runtime durumunun bir kısmını yazıyor. Minidump kapalıyken DOTNET_EnableCrashReport=1 ya da DOTNET_EnableCrashReportOnly=1 ile açılıyor. Yukarıdaki programı crash argümanıyla, .NET 11 SDK container'ında çalıştırdım:
DOTNET_EnableCrashReport=1 dotnet /tmp/on/Bookstore.AsyncStacks.dll crash
Unhandled exception. System.InvalidOperationException: Campaign rules could not be loaded
at Program.<<Main>$>g__LoadCampaignRulesAsync|0_2(Boolean crash) in /src/Bookstore.AsyncStacks/Program.cs:line 30
at Program.<<Main>$>g__PriceAsync|0_1(Boolean crash) in /src/Bookstore.AsyncStacks/Program.cs:line 14
at Program.<<Main>$>g__CheckoutAsync|0_0(Boolean crash) in /src/Bookstore.AsyncStacks/Program.cs:line 8
at Program.<Main>$(String[] args) in /src/Bookstore.AsyncStacks/Program.cs:line 4
at Program.<Main>(String[] args)
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
.NET Crash Report v1.0.0
Build: 11.0.26.53018 @Commit: 297acf930fb0f5c07fd4e51d78e521b4ad2e5d1f
Cmdline: dotnet
pid: 69
signal 6 (SIGABRT)
--- thread 0x45 (crashed) ---
managed exception: System.InvalidOperationException (0x80131509)
#00 [0] System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw + 0x11 (token=0x6007dad)
#01 [0] System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess + 0x37 (token=0x60081a7)
#02 [0] System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification + 0x33 (token=0x60081a6)
#03 [1] Program.<Main> + 0xb (token=0x6000003)
#04 [0] System.Environment.CallEntryPoint + 0x1c (token=0x60004a8)
--- thread 0x4d ---
#00 [0] ThreadWaitInfo.Wait + 0x46 (token=0x6003ea6)
...
modules:
[0] System.Private.CoreLib {5bfafa8d-0165-4b17-8d55-9999fdc89f94}
[1] Bookstore.AsyncStacks {b3354c5b-4c98-4012-adcb-3784260e423e}
(Çıktıyı kısalttım.) Bu denemede rapor hata çıkışına (stderr) yazıldı, ayrı bir dosya oluşmadı; container'da bu, raporun kubectl logs'a düşmesi demek. İki ayrıntı dikkat çekiyor. Rapor her thread'in managed frame'lerini metadata token'ı ve offset'iyle veriyor; satır numarası yok, ama modül listesindeki GUID'lerle (her derlemeye özgü benzersiz kimlik) doğru PDB'yi (derleyicinin ürettiği, adres ve satır bilgisini taşıyan sembol dosyası) bulup çözebiliyorsun. Çökme anındaki crashed thread'in stack'i ise yalnızca Main'e kadar: exception async zincirden yukarı taşınıp en üstte yeniden fırlatıldığı için asıl zincir exception mesajındaki stack trace'te. Değişkeni koymadan aynı çökmede yalnızca 7 satırlık exception çıktısı geldi. Bu, dump'ın yerini tutmuyor (bellek yok, heap yok); ama dump almanın pahalı ya da yasak olduğu ortamlarda çökme anındaki thread'leri ücretsiz kayda geçiriyor. Dump tarafı Debugging ve Problem Çözme serisinin "Crash ve Memory Dump Analizi: dotnet-dump, SOS Komutları ve Container'da Dump Almak" yazısında; SOS orada .NET'in debugger extension'ı olarak anlatılıyor.
Eski Rehberleri Okumak
İnternetteki .NET diagnostics içeriğinin çoğu .NET Core 3.1 ile .NET 6 arasında yazıldı. Bir rehberi uygulamadan önce şu dört şeye bakıyorum:
- PerfCollect: Linux'ta LTTng ve perf kullanan bir bash script'i. Microsoft Learn'de hâlâ anlatılıyor, ama .NET 10'da aynı ihtiyaç (native frame'ler, kernel event'leri) için
dotnet-trace collect-linuxvar; yalnızca managed tarafı gerekiyorsa düzcollectyeterli. cpu-samplingprofile'ı ve "CPU profili" ifadesi:collect'te artık hata veriyor; örneklenen şeyin duvar saati olduğunu akılda tut.- Eski EventCounter adları ("% Time in GC", "CPU Usage",
gen-2-gc-count): .NET 9+ uygulamada yayınlanmıyor; yukarıdaki tabloyla yeni adlara çevir. - "Profiling yalnızca Windows'ta" varsayımı: Visual Studio'nun CPU Usage, allocation, async, counters, Events Viewer ve Database araçları Linux ve WSL (Windows Subsystem for Linux; Windows içinde çalışan Linux ortamı) hedefinde de çalışıyor (07), TraceEvent Linux'ta çalışıyor (06), dotnet-monitor her ortamda (08).
Üretimde Dikkat
.NET 11'e geçiş, araç sürümlerini de kontrol etmek demek. Bu seride kullandığımız dotnet-trace, dotnet-counters ve dotnet-gcdump 10.0.750501, .NET 11 runtime'ına major roll-forward ile bağlandı; dotnet-monitor ise 10.0.5 sürümüyle .NET 11'i destekliyor. Ama Block modu gibi yeni özellikler hem yeni aracı hem yeni runtime'ı istiyor; image'larındaki araç sürümünü runtime ile birlikte güncelle. Runtime async'i açınca live stack'lerin değiştiğini hesaba kat: stack derinliğine ya da belirli frame adlarına dayanan özel bir profiling script'in, bir log zenginleştiricin ya da alarm kuralın varsa .NET 11'de tekrar dene. Dashboard'ları System.Runtime Meter'ına taşırken eski ve yeni adları bir süre paralel tut; birimler değiştiği için grafikler ilk günden yanlış alarm üretebilir.
Process içi crash report'u açmaya karar verirsen raporun gittiği yeri düşün: stderr'e gidiyorsa log sistemin onu satır satır parçalayabilir ve log saklama kuralların ona da uygulanır. İçinde secret yok ama komut satırı, modül listesi ve stack'ler var.
Sık Düşülen Tuzaklar
- .NET 10'da
--buffering-mode Blockdenemek; runtime .NET 11 değilse araç hata verip duruyor. - Runtime async'in, askıya alınmış bir zincirde çağıran method'ları stack'e geri getireceğini sanmak.
DOTNET_RuntimeAsync'i .NET 11 GA'da hâlâ ayar sanmak; kaldırıldı, proje property'si kullanılıyor.- Roll-forward için
DOTNET_RollForwardyazmak; ortam değişkeninin adıDOTNET_ROLL_FORWARD. - Eski bir rehberdeki
dotnet-trace collect --profile cpu-samplingkomutunu CI script'inde bırakmak. - Process içi crash report'u dump'ın yerine koymak; bellek ve heap içermiyor.
Küçük Bir Deneme
★☆☆ Kendi dashboard'undaki ya da runbook'undaki eski EventCounter adlarını listele ve her birini yukarıdaki tabloyla yeni metric'e çevir; birimi değişenleri işaretle.
★★☆ Bookstore.AsyncStacks'i runtime async açık ve kapalı derleyip dotnet-trace ile 10 saniyelik bir trace al; 06'daki Bookstore.TraceStats'in cpu komutuyla iki trace'te ortalama stack derinliğini ve AsyncStateMachineBox frame'lerinin payını karşılaştır.
★★★ 09'daki checkout vakasını .NET 11 roll-forward image'ıyla tekrarla: aynı yükte p99, allocation ve GC pause .NET 10'dan farklı mı? Yalnızca runtime değişti; kod aynı. Fark varsa hangi araçla, nerede gördüğünü 09'daki rapor şablonuyla yaz.
Bu yazıyla seri bitiyor. Haritadan (01) başlayıp metric'leri, CPU ve allocation profillerini, heap snapshot'larını, PerfView'i, IDE araçlarını ve production'da otomatik toplamayı gördük, sonra hepsini tek bir vakada sırayla kullandık. Elinde kalması gereken tek alışkanlık şu: bir performans değişikliğinden önce ve sonra, aynı yükle ölç.