.NET’te Threading ve Asynchrony Gerçekleri: Benchmark, Gözlemlenebilirlik ve Production…
Konuşmak; belki düzelir umududur… Susmak ise; artık değişmeyecek gerçeği…
.NET’te Threading ve Asynchrony Gerçekleri: Benchmark, Gözlemlenebilirlik ve Production Pattern’leri
Konuşmak; belki düzelir umududur… Susmak ise; artık değişmeyecek gerçeği…

Görsel Daniil Komov’a aittir
- yy’da Galileo’nun söylediği bir söz bugünün yazılım mühendisliği için hala geçerli. “Doğa kitabı matematik diliyle yazılmıştır.” Galileo Galilei.
Bir kodun iyi olduğunu belirtmek bir şey, ölçmek ise başka bir şeydir. Bir de mimar olarak “bu daha hızlı olur” dememiz bir şey ama kanıt sunmamız başka bir şey olur. .NET’te Threading ve Asynchrony Gerçekleri başlığının ilk ikisinde bazı iddialar kimileri için sertti kimileri içinse tartışmalı. Şimdi sıra kanıtlara geldi; BenchmarkDotNet’nin nanosaniye hassasiyetinde sayıları, k6'nın production yükü altında ölçtüğü latency dağılımları ve Grafana’nın dashboard’unda ki tırmanışlara bakacağız.
Birinci kısımda async/await’in arkasındaki yapıyı tanıdık. Çünkü analiz etmek istediğimiz şeyin önce nasıl bir mantıkta çalıştığını bilmek önemliydi. İkinci kısımda ise bu yapının yanlış kullanılması durumunda sistemlerin nasıl yıkılabileceğini anlatmaya çalıştım. İki farklı ortamda çalıştırılan aynı uygulamanın BenchmarkDotNet ile metod başına ölçümü ve makro seviyede k6 + Prometheus + Grafana ile gerçek sistem davranışı.
Local ortam özellikleri: Mac M2 Pro (10 Core)
Google Cloud: e2-standard-2 (2 vCPU)
Test Mimarisi
Yapmış olduğum ölçümlerin güvenilir sonuçlar vermesi için ortamların net olması gerekmekteydi. Hem kişisel bilgisayarımda hem de cloud ortamda iki farklı yapı kuruldu.
Mac Setup: İdeal Koşul
Apple M2 Pro üzerinde Docker Compose ile çalışan 4 servis: .NET 8 API, k6, Prometheus ve Grafana. API containerlarına 2 CPU + 1GB RAM sınırı koydum. Bu sınır olmasaydı 10 core modern bir CPU 1000 paralel iş için bile zorlanmayacaktı. Yine de network latency olmaması, containerların aynı host’ta bulunması ve kaynağında paylaşımlı olması tam bir happy path durumu oluşturdu. Burada aladığım değerler en iyi sonuçlar oldu.

Local Setup
GCP Setup: Production Ready
GCP’de Frankfurt region’unda iki ayrı VM ayağa kaldırdım. Birinci VM’de (sut-dotnet): API + Prometheus + Grafana çalıştı İkinci VM’de (load-k6): sadece yük üreteceği için k6 tek başına çalıştı. Her iki VM’de e2-standart-2 kullandı.
Burada k6'yı ayrı bir VM’de ayağa kaldırmanın sebebi sut-dotnet’e ait kaynakları tüketmemesi içindir. Bu ortamda network latency var, VM’ler izole, k6 ayrı CPU, monitoring servisleri ise sut-dotnet ile kaynakları paylaşıyor. Kısacası gerçek bir microservice deployment’ından çok bir farkı yok.

GCP Setup
Test Sernaryosu — İki Ortamda Aynı
Her iki ortamda da aynı 3 endpoint test edildi:
- /sync — Thread.Sleep(100) ile request thread’ini bloklar
- /true-async — await Task.Delay(100)’ile thread’i serbest bırak
- /true-async — await Task.Run(() => Thread.Sleep(100)) ile “halının altına süpürme”
Yük profili k6 ramping ile uygulandı: 0 -> 100 -> 500 -> 1000 paralel kullanıcı toplam 3 dk. Her senaryo arasında ise 60sn’lik bekleme ile sistemin toparlanmasına olanak sağlandı.
Mikro Seviye: BenchmarkDotNet ile Method Ölçümü
Bir kod parçasının ne kadar hızlı olduğunu söylemek için en doğru yollardan biri de izole bir ortamda binlerce kez çalıştırıp ortalamasını almaktır diyebilirim. İşte burada devreye BenchmarkDotNet devreye giriyor. JIT warmup, GC izolasyonu, varyans analizi gibi bir çok gerekli işlemi tek başına hallediyor.
[MemoryDiagnoser]
public class AsyncBenchmarks
{
[Params(10, 100, 1000)]
public int Concurrency;
[Benchmark(Baseline = true)]
public void SyncWork()
{
var threads = new Thread[Concurrency];
for (int i = 0; i < Concurrency; i++)
{
threads[i] = new Thread(() => Thread.Sleep(100));
threads[i].Start();
}
foreach (var t in threads) t.Join();
}
[Benchmark]
public async Task TrueAsync()
{
var tasks = new Task[Concurrency];
for (int i = 0; i < Concurrency; i++)
tasks[i] = Task.Delay(100);
await Task.WhenAll(tasks);
}
[Benchmark]
public async Task FakeAsync()
{
var tasks = new Task[Concurrency];
for (int i = 0; i < Concurrency; i++)
tasks[i] = Task.Run(() => Thread.Sleep(100));
await Task.WhenAll(tasks);
}
}
Yukarıda ki kod ile 3 farklı metod aynı işi yapıyor. 100ms’lik bir bekleme süresini Concurrency kadar paralel başlatıp bitmesini bekliyor. Concurrency parametresi 10,100 ve 1000 olarak değiştiriliyor ve her bir senaryo 3 kere çalıştırıldığında toplam *3 metod 3 concurreny = 9** senaryo denenmiş oluyor.
Sonuçlar — Mac M2 Pro
| Method | Concurrency | Mean | Ratio | Allocated |
|-----------|-------------|-----------|--------|-----------|
| SyncWork | 10 | 102.1 ms | 1.00x | 1.4 KB |
| TrueAsync | 10 | 101.1 ms | 0.99x | 2.0 KB |
| FakeAsync | 10 | 101.1 ms | 0.99x | 1.0 KB |
| SyncWork | 100 | 108.5 ms | 1.00x | 14.4 KB |
| TrueAsync | 100 | 101.2 ms | 0.93x | 17.9 KB |
| FakeAsync | 100 | 410.4 ms | 3.78x | 7.5 KB |
| SyncWork | 1000 | 172.3 ms | 1.00x | 144.2 KB |
| TrueAsync | 1000 | 102.2 ms | 0.59x | 176.3 KB |
| FakeAsync | 1000 | 962.1 ms | 5.59x | 72.3 KB |
Gözlemler;
- TrueAsync ölçekten bağımsız bir şekilde sabit kaldı. 10 paralel iş için 101ms, 100 iş için 101ms ve 1000 iş için 102ms. Concurrency 100x artmasına rağmen sadece 1ms’lik bir fark gördük
- SyncWork lineer bir şekilde bozulmaya başladı. 10 iş için 102ms olan çıktı 100 iş için 108ms ve 1000 iş için ise 172ms’ye çıktı. Yaklaşık %69'luk bir artış oldu.
- FakeAsync 1000 concurrency’de ~ 9.4 kat daha yavaş. Terminal üzerinden gözlemlerken bazı işlerin 700ms bazılarının 1.2 saniye sürmesi de sistemin ne kadar öngürülemez olduğunu gösterdi.
Sonuçlar — GCP e2-standard-2
| Method | Concurrency | Mean | Ratio | Allocated |
|-----------|-------------|-----------|--------|-----------|
| SyncWork | 10 | 104.2 ms | 1.00x | 1.4 KB |
| TrueAsync | 10 | 100.6 ms | 0.97x | 2.0 KB |
| FakeAsync | 10 | 182.1 ms | 1.75x | 1.0 KB |
| SyncWork | 100 | 130.8 ms | 1.00x | 14.4 KB |
| TrueAsync | 100 | 100.7 ms | 0.77x | 17.9 KB |
| FakeAsync | 100 | 394.0 ms | 3.01x | 7.5 KB |
| SyncWork | 1000 | 450.8 ms | 1.00x | 144.0 KB |
| TrueAsync | 1000 | 101.3 ms | 0.23x | 176.3 KB |
| FakeAsync | 1000 | 987.7 ms | 2.20x | 78.7 KB |
Aynı kodun daha küçük kaynakta ne kadar dramatik sonuçlar çıkardığını görmek çok daha etkili oldu.
- TrueAsync yine sabit kaldı. 10'da 100ms, 100'de 100ms ve 1000'de 101ms. Donanım küçülmesine rağmen true-async kendisini bozmadı. Çünkü hiçbir zaman thread’e ihtiyaç duyulmadı
- SyncWork 1000'de 4.4x yavaşladı. Mac’te 172ms olan süre GCP’de 450ms. 2 vCPU 1000 thread’i yönetmek için yetersiz kaldı.
- FakeAsync 10 paralel işte bir zarar etti. Mac setup’ta 101ms süren iş GCP ortamında 182ms sürdü. Küçük container’larda “async kılığında sync” çalıştırmak basit bir yükte bile fark edilebilecek derece yavaşlık sebebi oluyor.
Her iki ortamın değerlerini alıp excell tarafında kıyas yaptığımda çok ilginç bir şey fark ettim.
| Senaryo | Mac M2 Pro | GCP 2 vCPU | Fark |
|---------------------|------------|------------|-----------|
| SyncWork @ 1000 | 172.3 ms | 450.8 ms | 2.6x |
| TrueAsync @ 1000 | 102.2 ms | 101.3 ms. | ~aynı |
| FakeAsync @ 1000 | 962.1 ms | 987.7 ms | ~aynı |
Yukarıda ki tabloda TrueAsync, 5 kat güçlü bir donanımda bile yaklaşık 0.9 kat daha hızlı. SyncWork ise donanım iyileştiğinde 2.6 kat hızlanıyor. Peki ama neden? Çünkü iş yapıyor, daha çok CPU = daha hızlı bitiş.
Bu, async/await’in temel doğasını matematiksel olarak ifade ediyor.
TrueAsync CPU’ya bağlı değil, beklemeye bağlı. Donanım ne kadar güçlü ya da zayıf olursa olsun Task.Delay(100) her zaman 100ms sürecektir. Aslında bu bize ölçeklenebilirliğin tam bir karşılığını göstermektedir.
Dikkat çekici bir konu daha var. Allocation kısmı. GCP çıktısında 1000 iş için SyncWork 144 KB, TrueAsync 176 KB ve FakeAsync’in 78 KB hafıza tükettiğini görmüşsünüzdür. Bu bizi yanıltmasın. BenchmarkDotNet’in MemoryDiagnoser’i managed heap allocation’ını ölçüyor bknz. Burada gördüğümüz maliyetler aslında kod katmanının maliyetleridir. Buz dağının görünmeyen yüzünde ise maliyetler çok daha farklıdır.
Kısacası; aynı işin true-async versiyonu, fake-async versiyonuna göre 1000 concurrency’de ~9.4 kat daha hızlı. Görünüşte ikisi de async ama arka planda olanlar çok daha farklı. Şimdi sıra Grafana tarafına geldi.
Makro Seviye: k6 + Grafana ile Sistem Davranışı
İlk yaptığımız test bize metod seviyesinde nelerin olduğunu gösterdi. Ama gerçek production şartlarında durum ne? 1000 paralel HTTP isteğinde sistem ne yapıyor? Bunun için k6 ile uygulamayı yük testine sokup değerleri gözlemliyoruz.
Test Setup — k6 Ramping Senaryosu
export const options = {
scenarios: {
ramp: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 100 },
{ duration: '60s', target: 500 },
{ duration: '60s', target: 1000 },
{ duration: '30s', target: 0 },
],
},
},
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.05'],
},
};
Her endpoint için ayrı test koşuldu. Her test arasına 60sn’lik bir cooldown arası verildi. Bunu hem ThreadPool’un soğuması hem de Hill Climbing’in scale-up’ının sıfırlanması için yaptık.
Mac Sonuçları
| Metrik | Sync | True-Async | Fake-Async |
|---------------------|--------- |------------ |----------- |
| Toplam Request | 163,017 | 733,797 | 181,337 |
| RPS | 905 | 4,074 | 1,007 |
| p50 latency (ms) | 511 | 105 | 465 |
| p95 latency (ms) | 795 | 123 | 694 |
| p99 latency (ms) | 915 | 134 | 774 |
| Hata oranı | %0 | %0 | %0 |
| Peak ThreadPool | ~200 | ~10 | ~220 |
| Peak Queue Length | ~420 | ~0 | ~320 |
True-async 4.5x daha yüksek RPS üretti. Aynı 3 dakikada 4 kat daha çok request işlendi. p50 latency 5 kat daha hızlı ve belki en çarpıcı bulgu: ThreadPool sadece 10 thread’le çalıştı.


Workers 200, Queue 420 değerlerini gördü. Latancey tavan yapmış Hill Climbing geç kaldı. (sync için sonuçlar)


TrueAsync için sonuçlar


FakeAsync için Sonuçlar
GCP Sonuçları
| Metrik | Sync | True-Async | Fake-Async |
|---------------------|--------- |------------ |----------- |
| Toplam Request | 123,667 | 322,024 | 171,748 |
| RPS | 687 | 1,789 | 954 |
| p50 latency (ms) | 704 | 123 | 506 |
| p95 latency (ms) | 992 | 760 | 692 |
| p99 latency (ms) | 1,223 | 1,119 | 802 |
| Hata oranı | %0 | %0 | %0 |
| Peak ThreadPool | ~525 | ~280 | ~510 |
| Peak Queue Length | ~525 | ~280 | ~510 |
GCP sonuçları daha gerçekçi: 2 vCPU’lu bir container production’da tipik bir mikroservis ölçeği.
Burada üç ilginç gözlem var:
- True-async hala kazanıyor, ama fark daha az. Mac’te 4.5x olan RPS farkı, GCP’de 2.6x’e iniyor (1,789 vs 687). Yine de p50 latency tarafında 5.7x avantaj devam ediyor (123ms vs 704ms).
- True-async’te p95 tail’i şiştı. Mac’te 123ms olan p95, GCP’de 760ms’ye çıktı. Bunun nedeni container’a verdiğimiz kaynaklar.
- True-async’in ThreadPool’u 280'e çıktı. Mac’te 10 thread’de kalan async kod, GCP’de Hill Climbing tetikledi. Bu mesele async’in başarısızlığı değil sadece 2 vCPU’da yetersiz CPU olması. Çok normal bir davranış.





GCP ortamında metriklerin grafanaya yansıması
Üç yazılık serinin başında söylediğim şeyi tefkrar etmekte fayda var: bir şeyin adının değişmiş olması yaptığı işin değiştiği anlamına gelmiyor. async anahtar kelimesi kodumuzu tek başına “asenkron” yapmaz, derleyiciye nasıl davranacağını söyler. Asıl iş await ile bekleyen şeyin gerçekten thread-less olmasıdır. Test ettik, ölçtük ve gördük. Doğru async ile yanlış async arasında 9.4 kat performans farkı var. Bu donanım değişse bile kapanmayan bir fark. Artık sistemin arkasında nelerin nasıl olduğunu biliyoruz. Tercihlerimizi bunlara göre yapmak elimizde.
Zaman ayırıp okuduğunuz için teşekkür ederim.
Burada yer alan tüm kaynaklar;
https://github.com/omrcm/article/tree/main/src/threading-bench adresinden erişebilirsiniz.
메타데이터
- post_id
- ded1b31af404
- slug
- nette-threading-ve-asynchrony-gerçekleri-benchmark-gözlemlenebilirlik-ve-production-ded1b31af404
- url
- https://medium.com/@omurucum/nette-threading-ve-asynchrony-ger%C3%A7ekleri-benchmark-g%C3%B6zlemlenebilirlik-ve-production-ded1b31af404
- canonical_url
- https://medium.com/@omurucum/nette-threading-ve-asynchrony-ger%C3%A7ekleri-benchmark-g%C3%B6zlemlenebilirlik-ve-production-ded1b31af404
- author_url
- https://medium.com/@omurucum
- status
- ok
- fetched_at
- 2026-06-09 15:37:30