W 2015 roku async/await to była nowość (C# 5.0, 2012), ale w bardzo ograniczonej formie. W 2026 roku masz ValueTask, IAsyncEnumerable<T> oraz Parallel.ForEachAsync.
W tym wpisie poznasz Task vs ValueTask, ConfigureAwait, async streams, parallel async processing, i cancellation tokens. To fundamenty nowoczesnego C#!
// Problem: synchroniczny kod blokuje thread
void DownloadFile(string url)
{
using var client = new HttpClient();
// ❌ BLOCKING - thread czeka na I/O!
var response = client.GetAsync(url).Result; // Blokuje thread!
var content = response.Content.ReadAsStringAsync().Result; // Blokuje!
File.WriteAllText("file.txt", content);
// Thread był zablokowany przez cały czas I/O (sekundy/minuty)!
// W aplikacji z 100 requestami = 100 zablokowanych threadów = deadlock risk
}
// Web API endpoint - KATASTROFA
[HttpGet]
public string GetData()
{
var data = _service.FetchFromDatabase().Result; // ❌ Blokuje ASP.NET thread!
return data;
// ASP.NET ma ograniczoną pulę threadów (~100-200)
// Zablokowanie threadów = aplikacja nie odpowiada!
}
Konsekwencje synchronous blocking
// Przykład: Thread pool starvation
class BadService
{
public void ProcessRequests()
{
var tasks = new List<Task>();
for (int i = 0; i < 1000; i++)
{
tasks.Add(Task.Run(() =>
{
// Synchroniczny I/O w thread pool!
var result = DownloadFileSync($"http://api.com/{i}"); // ❌
ProcessData(result);
}));
}
Task.WaitAll(tasks.ToArray());
// Problem:
// 1. 1000 threadów z thread pool zablokowanych na I/O
// 2. Thread pool exhaustion
// 3. Aplikacja przestaje odpowiadać
// 4. Deadlock risk
}
}
🔍 Dlaczego async/await?
Synchronous I/O - thread czeka (blocked) podczas I/O operacji Asynchronous I/O - thread jest zwolniony, może robić coś innego
W web app: 1 thread może obsłużyć 1000+ concurrent requestów dzięki async/await!
Task i Task<T> - fundamenty
Task - reprezentacja async operacji
// Task - reprezentuje operację która może się jeszcze nie zakończyć
Task task = DoSomethingAsync();
// Task ma status
Console.WriteLine(task.Status); // Running, RanToCompletion, Faulted, Canceled
// Task<T> - Task który zwraca wartość
Task<string> taskWithResult = DownloadAsync("http://example.com");
// Await - czeka na zakończenie i pobiera wynik
string result = await taskWithResult;
async/await - podstawy
🎉 C# 5.0 - async/await
async - oznacza metodę jako asynchroniczną await - czeka na Task bez blokowania thread
// Async method - zwraca Task
async Task DownloadFileAsync(string url)
{
using var client = new HttpClient();
// await - czeka bez blokowania thread!
var response = await client.GetAsync(url); // ✅ Non-blocking
var content = await response.Content.ReadAsStringAsync(); // ✅ Non-blocking
await File.WriteAllTextAsync("file.txt", content);
// Thread był zwolniony podczas I/O - mógł obsługiwać inne requesty!
}
// Async method z return value
async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
// Użycie
string data = await FetchDataAsync("http://example.com");
❌ Synchronous - blocking
string DownloadFile(string url)
{
var client = new HttpClient();
// Blokuje thread podczas I/O
var response = client.GetAsync(url).Result;
var content = response.Content
.ReadAsStringAsync().Result;
return content;
}
// Thread zablokowany ~1-5 sekund
// W web app: 100 requestów = deadlock
✅ Asynchronous - non-blocking
async Task<string> DownloadFileAsync(string url)
{
var client = new HttpClient();
// Thread zwolniony podczas I/O
var response = await client.GetAsync(url);
var content = await response.Content
.ReadAsStringAsync();
return content;
}
// Thread zwolniony - może obsługiwać inne
// W web app: 1 thread = 1000+ requestów ✨
Jak działa async/await - pod maską
// Co naprawdę robi await?
async Task<string> FetchDataAsync()
{
var response = await httpClient.GetAsync(url); // Punkt 1
return await response.Content.ReadAsStringAsync(); // Punkt 2
}
// Punkt 1: await httpClient.GetAsync(url)
// 1. Wywołuje GetAsync - zwraca Task<HttpResponseMessage>
// 2. Jeśli Task NIE jest ukończony:
// - Kompilator tworzy state machine
// - Metoda natychmiast wraca do callera (zwraca Task)
// - Thread jest ZWOLNIONY
// - Continuation jest zarejestrowna (co zrobić gdy Task się ukończy)
// 3. Gdy Task się ukończy:
// - Continuation jest wykonywana (kod po await)
// - Może być na innym threadzie!
// Async state machine (generated by compiler)
[AsyncStateMachine(typeof(FetchDataStateMachine))]
Task<string> FetchDataAsync()
{
var stateMachine = new FetchDataStateMachine();
stateMachine.builder = AsyncTaskMethodBuilder<string>.Create();
stateMachine.Start();
return stateMachine.builder.Task;
}
ValueTask i ValueTask<T> - performance optimization
Problem z Task - heap allocation
// Task to class - każdy Task = heap allocation
async Task<int> GetCachedValueAsync(string key)
{
// Cache hit - wartość jest już dostępna
if (_cache.TryGetValue(key, out int value))
{
return value; // ❌ Ale Task musi być alokowany na heapie!
}
// Cache miss - async fetch
return await FetchFromDatabaseAsync(key);
}
// Problem:
// - 90% requestów to cache hit (synchronous return)
// - Ale każdy call alokuje Task na heapie
// - W hot path to miliony alokacji/s
// - GC pressure
ValueTask - zero-allocation dla sync path
🎉 C# 7.0 - ValueTask<T>
ValueTask<T> - struct, może reprezentować synchronous result BEZ alokacji!
// ValueTask - struct, nie wymaga alokacji dla sync path
async ValueTask<int> GetCachedValueAsync(string key)
{
// Cache hit - synchronous return, ZERO allocations! ✨
if (_cache.TryGetValue(key, out int value))
{
return value; // ValueTask wraps value directly - no heap!
}
// Cache miss - async fetch
return await FetchFromDatabaseAsync(key); // Task is allocated here
}
// Zalety:
// ✅ Cache hit (90%) - zero allocations
// ✅ Cache miss (10%) - Task allocated (unavoidable)
// ✅ Overall - 90% reduction w allocations!
Task vs ValueTask - kiedy czego używać?
Feature
Task<T>
ValueTask<T>
Type
Class (reference type)
Struct (value type)
Heap allocation
Always
Only for async path
Performance
Good
Better (w sync path)
Można await wiele razy?
✅ Tak
❌ Tylko raz!
Można .Result?
✅ Tak
❌ Nie
Można WhenAll/WhenAny?
✅ Tak
❌ Trzeba .AsTask()
Kiedy używać
Default choice
Hot paths z często sync results
// ✅ Używaj ValueTask gdy:
// - Często synchronous result (cache hit, validation, etc.)
// - Hot path (wykonywane miliony razy)
// - Performance critical
// ❌ NIE używaj ValueTask gdy:
// - Zawsze async (I/O, network, database)
// - Potrzebujesz await wiele razy
// - Potrzebujesz Task.WhenAll/WhenAny
// - Nie jest performance bottleneck
// Przykład 1: Cache - DOBRZE dla ValueTask
async ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out User user))
return user; // Sync - zero allocations!
return await _db.GetUserAsync(id);
}
// Przykład 2: Pure I/O - używaj Task
async Task<string> DownloadAsync(string url)
{
return await _httpClient.GetStringAsync(url); // Zawsze async - Task OK
}
⚠️ ValueTask - zasady użycia!
❌ NIE await ValueTask więcej niż raz - undefined behavior!
❌ NIE używaj .Result na ValueTask - exception!
✅ Jeśli potrzebujesz await wiele razy - użyj .AsTask()
✅ ValueTask to optimization - używaj tylko gdy profiler pokazuje problem
ValueTask<int> task = GetValueAsync();
// ❌ BŁĄD - await więcej niż raz!
int result1 = await task;
int result2 = await task; // ❌ Undefined behavior!
// ✅ Jeśli potrzebujesz - użyj .AsTask()
Task<int> taskCopy = task.AsTask();
int result1 = await taskCopy;
int result2 = await taskCopy; // ✅ OK
ConfigureAwait - context capturing
Problem - synchronization context capture
// W ASP.NET / WPF / WinForms - jest SynchronizationContext
// await domyślnie wraca na ORYGINALNY context
// ASP.NET example
async Task HandleRequestAsync()
{
var data = await FetchDataAsync(); // Punkt 1
// Po await - kontynuacja wraca na ASP.NET request context
ProcessData(data); // Ten kod MUSI być na tym samym contexcie
}
// Punkt 1: await FetchDataAsync()
// 1. Thread jest zwolniony
// 2. Task się wykonuje (może być na innym threadzie)
// 3. Task się kończy
// 4. Kontynuacja (kod po await) jest schedulowana NA ORYGINALNY CONTEXT
// 5. ASP.NET request context zabiera thread z pool
// 6. Kontynuacja jest wykonywana
// Problem w library code:
async Task LibraryMethodAsync()
{
var result1 = await Step1Async(); // Context capture
var result2 = await Step2Async(); // Context capture
var result3 = await Step3Async(); // Context capture
// Każdy await scheduluje continuation na oryginalny context
// W library nie potrzebujesz context - overhead!
}
ConfigureAwait(false) - performance optimization
🎉 ConfigureAwait(false)
.ConfigureAwait(false) - NIE wracaj na oryginalny context!
// ConfigureAwait(false) - continuation może być na dowolnym threadzie
async Task LibraryMethodAsync()
{
var result1 = await Step1Async().ConfigureAwait(false); // Bez context
var result2 = await Step2Async().ConfigureAwait(false); // Bez context
var result3 = await Step3Async().ConfigureAwait(false); // Bez context
// Kontynuacje mogą być na dowolnych threadach - szybciej!
return ProcessResults(result1, result2, result3);
}
// Zalety:
// ✅ Performance - brak overhead schedulowania na oryginalny context
// ✅ Mniej contention - nie czeka na oryginalny thread
// ✅ Lepsze dla library code
ConfigureAwait - zasady użycia
// ✅ Używaj ConfigureAwait(false) w:
// - Library code (NIE aplikacja końcowa)
// - Kod który NIE potrzebuje oryginalnego context
// - Performance critical paths
// ❌ NIE używaj ConfigureAwait(false) w:
// - UI code (WPF, WinForms) - musisz być na UI thread
// - ASP.NET controllers - jeśli używasz HttpContext po await
// - Kod który MUSI być na oryginalnym context
// Przykład: Library (używaj ConfigureAwait)
public class HttpService
{
public async Task<string> GetDataAsync(string url)
{
using var client = new HttpClient();
var response = await client.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
// Library nie potrzebuje context - ConfigureAwait(false)!
}
}
// Przykład: ASP.NET Controller (NIE używaj ConfigureAwait)
[ApiController]
public class UsersController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetUser(int id)
{
var user = await _service.GetUserAsync(id); // Bez ConfigureAwait
// Po await używasz HttpContext, User, itp. - MUSISZ być na request context
return Ok(user);
}
}
// Przykład: WPF/WinForms (NIE używaj ConfigureAwait)
async void Button_Click(object sender, EventArgs e)
{
var data = await LoadDataAsync(); // Bez ConfigureAwait
// Po await modyfikujesz UI - MUSISZ być na UI thread
textBox.Text = data;
}
🔍 ConfigureAwait - golden rule
Library code - ZAWSZE ConfigureAwait(false) Application code - NIE używaj ConfigureAwait (chyba że wiesz co robisz)
🔥 IAsyncEnumerable<T> - async streams (C# 8)
Problem - zwracanie stream danych
// Przed C# 8 - musisz załadować WSZYSTKO do pamięci
async Task<List<User>> GetAllUsersAsync()
{
var users = new List<User>();
// Fetch 1 milion users
for (int page = 0; page < 1000; page++)
{
var pageData = await FetchPageAsync(page); // 1000 users
users.AddRange(pageData);
}
return users; // ❌ 1 milion users w pamięci!
}
// Konsument musi czekać na WSZYSTKO
var allUsers = await GetAllUsersAsync(); // Czeka na 1 milion!
foreach (var user in allUsers)
{
ProcessUser(user); // Mógłbyś zacząć wcześniej!
}
IAsyncEnumerable<T> - streaming data!
🎉 C# 8 - IAsyncEnumerable<T>
async IAsyncEnumerable<T> - yield return w async! Stream danych!
// C# 8 - async streams!
async IAsyncEnumerable<User> GetAllUsersAsync()
{
for (int page = 0; page < 1000; page++)
{
var pageData = await FetchPageAsync(page); // 1000 users
foreach (var user in pageData)
{
yield return user; // ✅ Stream jeden po drugim!
}
}
// Nie trzeba trzymać wszystkiego w pamięci! ✨
}
// Konsument dostaje dane od razu jak są dostępne
await foreach (var user in GetAllUsersAsync())
{
ProcessUser(user); // Zaczyna od razu po pierwszym page!
}
// Zalety:
// ✅ Memory efficient - nie trzeba wszystkiego w pamięci
// ✅ Responsiveness - zaczyna od razu
// ✅ Backpressure - producer czeka na consumer
await foreach - consuming async streams
// await foreach - jak foreach ale dla async streams
async Task ProcessAllUsersAsync()
{
await foreach (var user in GetUsersStreamAsync())
{
// Async processing
await ProcessUserAsync(user);
// Każdy element jest async-await eligible
}
}
// WithCancellation - cancellation support
await foreach (var user in GetUsersStreamAsync().WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
break;
await ProcessUserAsync(user);
}
// ConfigureAwait w async streams
await foreach (var user in GetUsersStreamAsync().ConfigureAwait(false))
{
// Bez context capture
await ProcessUserAsync(user);
}
Praktyczne przykłady - async streams
// Przykład 1: Streaming paginated API
async IAsyncEnumerable<Product> GetAllProductsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
int page = 0;
bool hasMore = true;
while (hasMore)
{
ct.ThrowIfCancellationRequested();
var response = await _httpClient.GetAsync($"/api/products?page={page}", ct);
var products = await response.Content.ReadFromJsonAsync<List<Product>>(ct);
if (products == null || products.Count == 0)
{
hasMore = false;
}
else
{
foreach (var product in products)
{
yield return product;
}
page++;
}
}
}
// Użycie
await foreach (var product in GetAllProductsAsync())
{
Console.WriteLine($"{product.Name}: ${product.Price}");
}
// Przykład 2: Real-time data stream
async IAsyncEnumerable<StockPrice> GetStockPricesAsync(
string symbol,
[EnumeratorCancellation] CancellationToken ct = default)
{
while (!ct.IsCancellationRequested)
{
var price = await FetchCurrentPriceAsync(symbol);
yield return price;
await Task.Delay(1000, ct); // Poll every second
}
}
// Użycie
await foreach (var price in GetStockPricesAsync("AAPL", cts.Token))
{
Console.WriteLine($"AAPL: ${price.Value}");
if (price.Value > 200)
break; // Stop when price > $200
}
// Przykład 3: Database streaming
async IAsyncEnumerable<Order> GetLargeResultSetAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var command = new SqlCommand("SELECT * FROM Orders", connection);
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
yield return new Order
{
Id = reader.GetInt32(0),
CustomerId = reader.GetInt32(1),
Total = reader.GetDecimal(2)
};
}
}
// Przykład 1: Download wiele plików
async Task DownloadFilesAsync(List<string> urls, CancellationToken ct)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 5,
CancellationToken = ct
};
await Parallel.ForEachAsync(urls, options, async (url, token) =>
{
Console.WriteLine($"Downloading {url}...");
await DownloadFileAsync(url, token);
Console.WriteLine($"Downloaded {url}");
});
}
// Przykład 2: Process orders concurrently
async Task ProcessOrdersAsync(List<Order> orders)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
await Parallel.ForEachAsync(orders, options, async (order, ct) =>
{
await ValidateOrderAsync(order, ct);
await ChargePaymentAsync(order, ct);
await SendEmailAsync(order, ct);
});
}
// Przykład 3: Async enumerable source
async Task ProcessStreamAsync(IAsyncEnumerable<User> users)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 10
};
await Parallel.ForEachAsync(users, options, async (user, ct) =>
{
await UpdateUserAsync(user, ct);
});
}
// Przykład 4: With progress reporting
async Task ProcessWithProgressAsync(List<string> items)
{
int completed = 0;
int total = items.Count;
await Parallel.ForEachAsync(items, async (item, ct) =>
{
await ProcessItemAsync(item, ct);
int current = Interlocked.Increment(ref completed);
Console.WriteLine($"Progress: {current}/{total}");
});
}
Parallel.ForEachAsync vs Task.WhenAll
Feature
Parallel.ForEachAsync
Task.WhenAll
Concurrency control
✅ MaxDegreeOfParallelism
❌ Wszystkie naraz
Memory usage
Lower (controlled)
Higher (wszystkie tasks)
Server friendly
✅ Nie overwhelm
❌ Może overwhelm
Kiedy używać
Dużo items, ograniczona concurrency
Mało items, wszystkie naraz OK
CancellationToken - canceling async operations
Problem - nie można zatrzymać async operations
// Bez cancellation - nie można zatrzymać
async Task LongRunningOperationAsync()
{
for (int i = 0; i < 1000; i++)
{
await ProcessItemAsync(i);
// Użytkownik kliknął "Cancel" - ale nie możesz zatrzymać! 😱
}
}
// User experience:
// 1. User klika "Start"
// 2. Operacja trwa 10 minut
// 3. User klika "Cancel"
// 4. Nic się nie dzieje - operacja dalej trwa
// 5. User frustrated 😤
CancellationToken - cooperative cancellation
// CancellationToken - cooperative cancellation
async Task LongRunningOperationAsync(CancellationToken ct)
{
for (int i = 0; i < 1000; i++)
{
// Check if cancellation requested
ct.ThrowIfCancellationRequested(); // Throws OperationCanceledException
await ProcessItemAsync(i, ct);
}
}
// Użycie - CancellationTokenSource
var cts = new CancellationTokenSource();
// Start operation
var task = LongRunningOperationAsync(cts.Token);
// User klika "Cancel"
cts.Cancel(); // Sygnalizuje cancellation
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation canceled by user");
}
// User experience:
// 1. User klika "Start"
// 2. Operacja zaczyna
// 3. User klika "Cancel"
// 4. Operacja NATYCHMIAST się kończy ✨
// 5. User happy 😊
CancellationToken - best practices
// Pattern 1: ThrowIfCancellationRequested
async Task ProcessAsync(CancellationToken ct)
{
for (int i = 0; i < items.Count; i++)
{
ct.ThrowIfCancellationRequested(); // Throw if canceled
await ProcessItemAsync(items[i], ct);
}
}
// Pattern 2: IsCancellationRequested (no throw)
async Task ProcessAsync(CancellationToken ct)
{
for (int i = 0; i < items.Count; i++)
{
if (ct.IsCancellationRequested)
{
Console.WriteLine("Cancellation requested");
return; // Graceful exit
}
await ProcessItemAsync(items[i], ct);
}
}
// Pattern 3: Register callback
async Task ProcessAsync(CancellationToken ct)
{
using (ct.Register(() => Console.WriteLine("Canceling...")))
{
await LongOperationAsync(ct);
}
}
// Pattern 4: Timeout
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // 30s timeout
try
{
await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Timeout!");
}
// Pattern 5: Linked tokens (combine multiple)
var cts1 = new CancellationTokenSource(); // User cancellation
var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(60)); // Timeout
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cts1.Token,
cts2.Token
);
// Canceled gdy KTÓRYKOLWIEK się cancel
await ProcessAsync(linkedCts.Token);
CancellationToken - praktyczne przykłady
// Przykład 1: HTTP request z cancellation
async Task<string> DownloadAsync(string url, CancellationToken ct)
{
using var client = new HttpClient();
// HttpClient respects CancellationToken!
var response = await client.GetAsync(url, ct);
return await response.Content.ReadAsStringAsync(ct);
}
// Przykład 2: Async foreach z cancellation
async Task ProcessUsersAsync(CancellationToken ct)
{
await foreach (var user in GetUsersAsync(ct))
{
ct.ThrowIfCancellationRequested();
await ProcessUserAsync(user, ct);
}
}
// Przykład 3: Scenariusz na UI (WPF/Blazor)
class ViewModel
{
private CancellationTokenSource? _cts;
public async Task StartAsync()
{
_cts = new CancellationTokenSource();
try
{
await LongOperationAsync(_cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Canceled");
}
}
public void Cancel()
{
_cts?.Cancel();
}
}
// Przykład 4: ASP.NET with request timeout
[HttpGet]
public async Task<IActionResult> GetData(CancellationToken ct)
{
// ASP.NET automatically cancels when request is aborted
var data = await _service.FetchDataAsync(ct);
return Ok(data);
}
Podsumowanie
Async/Await - fundamenty nowoczesnego C#!
✅ Problem synchronous blocking - thread starvation, deadlocks
W kolejnym wpisie poznasz LINQ - query syntax, method chaining, deferred execution, custom operators!
Zadanie dla Ciebie 🎯
Stwórz async downloader z progress reporting i cancellation:
// Interface
interface IDownloader
{
Task<DownloadResult> DownloadAsync(
string url,
IProgress<DownloadProgress> progress,
CancellationToken ct);
}
record DownloadProgress(long BytesDownloaded, long TotalBytes, double Percentage);
record DownloadResult(bool Success, string FilePath, TimeSpan Duration);
// Użycie:
var downloader = new Downloader();
var cts = new CancellationTokenSource();
var progress = new Progress<DownloadProgress>(p =>
Console.WriteLine($"{p.Percentage:F1}%"));
var result = await downloader.DownloadAsync(
"http://example.com/large-file.zip",
progress,
cts.Token);
Wymagania:
async/await dla I/O operations
Progress reporting co 1% lub co 1MB
CancellationToken support
Retry logic (3 próby)
Timeout 30 sekund
🎯 BONUS: Async Web Crawler
Stwórz web crawler używając WSZYSTKICH async patterns!
Do zaimplementowania:
Crawler core:
async IAsyncEnumerable<Page> CrawlAsync(string startUrl, int maxDepth)
Parallel.ForEachAsync dla concurrent crawling
MaxDegreeOfParallelism = 10
Depth-first lub breadth-first traversal
Performance optimizations:
ValueTask dla already-visited URLs (cache hit)
ConfigureAwait(false) w library code
Connection pooling (HttpClient singleton)
Cancellation support:
CancellationToken propagation
Graceful shutdown
Timeout per page (5 sekund)
Progress reporting:
IProgress<CrawlProgress>
Real-time stats (pages/s, errors, depth)
Features:
Robots.txt respecting
URL deduplication
Error handling & retry
Domain filtering
Przykład użycia:
var crawler = new WebCrawler();
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
var progress = new Progress<CrawlProgress>(p =>
Console.WriteLine($"Pages: {p.PagesProcessed}, Depth: {p.CurrentDepth}, Errors: {p.Errors}"));
var options = new CrawlOptions
{
MaxDepth = 3,
MaxConcurrency = 10,
AllowedDomains = new[] { "example.com" },
RespectRobotsTxt = true
};
await foreach (var page in crawler.CrawlAsync(
"https://example.com",
options,
progress,
cts.Token))
{
Console.WriteLine($"Crawled: {page.Url} ({page.Links.Count} links)");
// Process page content
await ProcessPageAsync(page, cts.Token);
}
Console.WriteLine("Crawl completed!");
Ten projekt demonstruje pełną moc async/await - IAsyncEnumerable, Parallel.ForEachAsync, ValueTask, CancellationToken, wszystko razem! 🚀🕷️