Paralel HTTP istekleri göndermek her web uygulamasında karşımıza çıkabilecek bir genel bir ihtiyaçtır. Bu ihtiyacı karşılamak .NET’e async/await yapısı geldiğinden beri oldukça basit olsa da paralel HTTP istekleri sayısı belli bir seviyenin üstüne çıktığında bazı problemlerle karşılaşmak işten bile değildir. Bu makalemde önce problemi bir kod örneği ile adreslemeye ve sonrasında çözüm önerimi sunmaya çalışacağım.
Paralel HTTP İstekleri Göndermek – Aşikâr Yol
.NET Core’da paralel HTTP istekleri göndermek için aslında çok fazla kod yazmamız gerekmiyor. Tek yapmamız gereken sihirli async/await sözcüklerini birazcık kullanmak.
public async Task<HttpResponseMessage[]> ParallelHttpRequests()
{
var tasks = new List<Task<HttpResponseMessage>>();
int numberOfRequests = 1000;
for (int i = 0; i < numberOfRequests; ++i)
{
tasks.Add(MakeRequestAsync(RandomWebsites.Uris[i]));
}
return await Task.WhenAll(tasks.ToArray());
}
private async Task<HttpResponseMessage> MakeRequestAsync(Uri uri)
{
try
{
using var httpClient = HttpClientFactory.Create();
return await httpClient.GetAsync(uri);
}
catch
{
// Diğer URLleri yüklemeye devam edebilmek için hataları
// göz ardı et. Gerçek bir uygulamada hatayı mutlaka
// loglamalısınız.
return new HttpResponseMessage();
}
}
Bu kod örneğinde MakeRequestAsync
metodumuzu çağıran ve buradan dönen Task
nesnelerini bir listeye depolayan bir for döngümüz var. Döngümüzün bitiminde ise tüm Task
ların bitmesini bekliyoruz. MakeRequestAsync
metodu da oldukça basit. Bir HttpClient
nesnesi yaratıyoruz ve bu nesnenin GetAsync
metodunu kullanarak isteğimizi gönderiyoruz. Sonrasında ise bu isteği belirten Task
ı döndürüyoruz. Bunlara ek olarak bir try/catch
bloğu da ekliyoruz ki gönderdiğimiz isteklerin herhangi biri hata aldığında tüm sistem hata verip sonlanmasın.
Bu kodu kullanarak kendi bilgisayarınızda çalışan bir konsol uygulaması ile kolaylıkla 1000 tane paralel HTTP isteği gönderebilirsiniz, zira bilgisayarınız, an itibariyle saniyede yüzlerce isteğe cevap veren bir sunucu değil ve büyük miktarda uygun port ve kaynağa sahip.
Peki ya kodunuzun, sınırlı kaynakları olan bir sunucu üzerinde çalışması gerekiyorsa? Sunucu üzerinde çalışan sizinkinden başka birçok uygulama varken 1000 istek yapmak için 1000 tane TCP soketi açmak pek de iyi bir fikir değil. Bu yöntemle sunucu üzerindeki tüm boştaki portları doldurmanız ve sunucudaki diğer uygulamaların açıkta port bulamamalarına sebep olmanız işten bile değil.
Gruplar Halinde Paralel HTTP İstekleri Göndermek
Bir şekilde paralel HTTP istekleri sayısını sınırlandırmak iyi bir fikir olacaktır. Bir önceki örneğimizde yaptığımız 1000 isteği bu sefer gruplar halinde yapmayı deneyelim.
public async Task<HttpResponseMessage[]> ParallelHttpRequestsInBatches()
{
var tasks = new List<Task<HttpResponseMessage>>();
int numberOfRequests = 1000;
int batchSize = 100;
int batchCount = (int)Math.Ceiling((decimal)numberOfRequests / batchSize);
for (int i = 0; i < batchCount; ++i)
{
for (int j = 0; j < batchSize; ++j)
{
tasks.Add(MakeRequestAsync(RandomWebsites.Uris[i * batchSize + j]));
}
await Task.WhenAll(tasks.ToArray());
}
return await Task.WhenAll(tasks.ToArray());
}
Bu kod örneğinde öncelikle bir grup büyüklüğü belirliyoruz ve bu grup büyüklüğü ile toplam yapacağımız istek sayısını kullanarak bize gereken toplam grup sayısını hesaplıyoruz. Sonrasında ise 2 adet for
döngümüz var. İlk for
döngüsünde grup sayısı üzerinden dönüyoruz. İkinci for
döngüsünde ise grup büyüklüğü üzerinden dönüyoruz. Yani birinci grubu doldurana kadar HTTP isteklerini gönderiyoruz, ardından isteklerin sonlanmasını bekliyoruz, sonra ikinci grubu dolduruyoruz, isteklerin sonlanmasını bekliyoruz, üçüncü grubu dolduruyoruz, vs.
Bu kodda MakeRequestAsync
metodu ilk kod örneği ile birebir aynı. O sebeple tekrar yazmadım. Ayrıca, normalde son grubu, istek göndereceğimiz adresler bittiği zaman kesecek bir kontrol bulunması gerekir (örneğin toplam 999 istek yapacak isek, ikinci for
döngüsü son iterasyonda 99. isteği gönderip sonlanmalı, 100. isteği göndermemeli.). Ana konuya daha rahat odaklanabilmek için böyle bir sınırlama da koymadım.
Bu kod üzerinde biraz düşünürseniz, tüm isteklerin gönderilebilmesi için gereken toplam zamanın aslında amaçladığımızdan fazla olabilmesine sebep olan bir tasarım sorununa sahip olduğunu göreceksiniz: Eğer isteklerin bir tanesi diğerlerinden daha uzun sürüyorsa kodumuz bir sonraki gruba geçmeden evvel o uzun süren isteğin de sonlanmasını bekleyecektir.
Bu konuda bir şey yapabilir miyiz? Elbette yapabiliriz!
Kurtarıcımız: SemaphoreSlim
SemaphoreSlim
, kodun belli bölümlerine girecek iş parçacığı (thread) sayısını sınırlamayı sağlayan bir sınıftır. Eğer daha önce Semaphore
sınıfını duyduysanız, onun daha yalınlaştırılmış, hafifletilmiş halidir. Burada SemaphoreSlim
sınıfını kullanmayı tercih ediyoruz, çünkü Semaphore
sınıfının sağladığı tüm özelliklere ihtiyacımız yok.
Şimdi paralel HTTP isteklerini daha zarif bir biçimde gerçekleştirelim.
public async Task<HttpResponseMessage[]> ParallelHttpRequestsInBatchesWithSemaphoreSlim()
{
var tasks = new List<Task<HttpResponseMessage>>();
int numberOfRequests = 1000;
int maxParallelRequests = 100;
var semaphoreSlim = new SemaphoreSlim(maxParallelRequests, maxParallelRequests);
for (int i = 0; i < numberOfRequests; ++i)
{
tasks.Add(MakeRequestWithSemaphoreSlimAsync(RandomWebsites.Uris[i], semaphoreSlim));
}
return await Task.WhenAll(tasks.ToArray());
}
private async Task<HttpResponseMessage> MakeRequestWithSemaphoreSlimAsync(Uri uri, SemaphoreSlim semaphoreSlim)
{
try
{
await semaphoreSlim.WaitAsync();
using var httpClient = HttpClientFactory.Create();
return await httpClient.GetAsync(uri);
}
catch
{
// Diğer URLleri yüklemeye devam edebilmek için hataları
// göz ardı et. Gerçek bir uygulamada hatayı mutlaka
// loglamalısınız.
return new HttpResponseMessage();
}
finally
{
semaphoreSlim.Release();
}
}
Bu kod, ilk yöntemde yazdığımız koda oldukça benziyor, fakat aynı anda yapılan paralel HTTP istekleri sayısını sınırlamak için bazı farklılıkları var. İlk olarak, yeni bir SemaphoreSlim
nesnesi yaratıyoruz ve aynı anda yapılabilecek maksimum istek sayısını veriyoruz (yukarıdaki örnekte 100). Ayrıca, yeni yazdığımız MakeRequestWithSemaphoreSlimAsync
metodumuza da bu nesneyi parametre olarak geçiyoruz.
MakeRequestWithSemaphoreSlimAsync
metoduna baktığımızda daha en başta şöyle ilginç bir satır görüyoruz:
await semaphoreSlim.WaitAsync();
Sihirli kısım burası. semaphoreSlim
nesnemizin üzerindeki WaitAsync
metodunu çağırdığımızda kodun devamının çalışması için boşta bir slot var mı kontrol ediliyor. Eğer boşluk varsa kodumuz çalışmaya devam ederek HttpClient
nesnesini yaratıp isteği gönderiyor. Kodumuz WaitAsync
satırını geçtiği zaman semaphoreSlim
nesnesi boştaki slot sayısını 1 azaltıyor. Örneğin, başlangıçta boşta 100 slotumuz vardı (semaphoreSlim
nesnesini yaratırken 100 olarak vermiştik). Artık boşta 99 slotumuz var. Paralel başka bir iş parçacığı (thread) buradan geçtiğinde 98’e düşecek. Başka bir tanesi daha geçtiğinde 97’ye, vs. Sayacımız 0’a ulaştığında ise bu kodun devamına ulaşmak isteyen tüm diğer iş parçacıkları (thread) asenkron bir biçimde WaitAsync
satırında bekleyecek.
Şimdi boştaki slot sayımız 0’a ulaştı. Peki nasıl tekrar artacak? Kullanımda olan bazı slotları serbest bırakmalıyız ki tekrar boşa çıksınlar.
semaphoreSlim.Release();
Bir iş parçacığı (thread) semaphoreSlim
nesnesi üzerindeki Release
metodunu çağırdığı zaman mecazi olarak der ki “Benim işim bitti. Başka bir iş parçacığı (thread) bu slotu kullanabilir. Benim kendisiyle bir işim kalmadı.”. Yani Release
metodunu çağırmak sayacı 1 arttırır. Böylelikle WaitAsync
metodunun çağrıldığı satırda bekleyen başka bir iş parçacığı (thread) çalışmaya devam eder.
1000 tane paralel HTTP isteği için çizimle göstermek oldukça güç, fakat naif bir çizim şu şekilde olabilir:
Çizimin pek iyi olmadığını itiraf etmeliyim, ancak çizimde ana nokta şu ki kodumuz bir sonraki isteğe geçmek için tüm isteklerin bitmesini beklemiyor. Örneğin ilk 100 istek gönderilmeye başlandıktan sonra 56. istek biraz uzun sürdü diyelim. Bu durumda biten isteklerin yerine sıradaki istekler (101’inci, 102’inci, vs.) hemen başlıyor.
Artık Biliyorsunuz
Bir uygulama kodlarken, uygulamanın çalışacağı sistemi düşünmeli ve her zaman sınırlı kaynaklara sahip olabileceğimiz ihtimalini göz önünde bulundurmalıyız. SemaphoreSlim
sınıfı ile bu sınırlı kaynakları daha zarif ve verimli şekilde yönetebiliriz. Paralel HTTP istekleri göndermek bunun için sadece bir örnek. Uygulamanızda ne zaman paralel işler yapmanız gerekirse bunu kullanabilirsiniz.