Task, Async, and Await Fundamentals
Before you start
You need: methods (Article 03) and exceptions (Article 07).
Time: about 45 minutes, plus the practice. This is the last new language feature in the track.
Learning objective
Write asynchronous methods that free the thread while waiting, and recognise the three async bugs that produce no error message.
Topics
- What asynchronous means, and what it does not
TaskandTask<T>asyncandawait- Naming and signatures
- Async all the way down
- The missing
await .Resultand.Wait()async void- Running work in parallel
- Cancellation
What asynchronous means
Asynchronous is about waiting, not about speed.
// Synchronous — the thread waits, doing nothing
public static string ReadStudentFile(string path)
{
string content = File.ReadAllText(path); // thread blocked here
return content;
}
// Asynchronous — the thread is released while the disk works
public static async Task<string> ReadStudentFileAsync(string path)
{
string content = await File.ReadAllTextAsync(path);
return content;
}
Both take the same wall-clock time to read one file. The difference is what the thread does while waiting.
Synchronously, the thread sits idle until the disk responds. Asynchronously, it is returned to the pool and can serve another request. In a console application with one user that is invisible. In the Web API of Track 10, it is the difference between handling 50 concurrent requests and 5,000.
Use async for I/O — files, databases, network calls. Do not use it for pure computation; there is nothing to wait for, and the machinery only adds overhead.
Task and Task<T>
A Task represents work that may not have finished yet.
| Type | Represents |
|---|---|
Task | Work that completes and returns nothing |
Task<T> | Work that completes and produces a T |
public static async Task SaveReportAsync(string content)
{
await File.WriteAllTextAsync("report.txt", content);
}
public static async Task<List<Student>> LoadStudentsAsync(string path)
{
string json = await File.ReadAllTextAsync(path);
List<Student> students = JsonSerializer.Deserialize<List<Student>>(json);
return students;
}
Task is the async equivalent of void; Task<T> is the async equivalent of returning T.
Note that LoadStudentsAsync declares Task<List<Student>> but returns a List<Student>. The compiler wraps it. This confuses everyone once.
async and await
public static async Task<decimal> CalculateTotalCollectedAsync(int schoolId)
{
List<FeePayment> payments = await _repository.GetPaymentsAsync(schoolId);
decimal total = 0m;
foreach (FeePayment payment in payments)
{
total = total + payment.Amount;
}
return total;
}
| Keyword | Means |
|---|---|
async | This method contains await and returns a Task |
await | Pause here, release the thread, resume when the task completes |
await unwraps the result. await on a Task<List<FeePayment>> gives you a List<FeePayment>, not a task.
async alone does nothing. A method marked async with no await runs entirely synchronously, and the compiler warns you:
warning CS1998: This async method lacks 'await' operators and will run synchronously.
Read that warning. It usually means an await was forgotten.
Naming and signatures
public async Task<Student> GetStudentByRollNumberAsync(int schoolId, string rollNumber)
{
}
Convention: an async method's name ends in Async. Every .NET library follows it, and it lets a reader see at a call site that a result must be awaited.
| Return type | Use for |
|---|---|
Task | Async method returning nothing |
Task<T> | Async method returning a value |
void | Event handlers only — see below |
Async all the way down
Once one method is async, its callers should be too.
public class FeeService
{
private readonly IFeeRepository _repository;
public FeeService(IFeeRepository repository)
{
_repository = repository;
}
public async Task<decimal> GetBalanceAsync(int schoolId, int studentId)
{
FeeAccount account = await _repository.GetByStudentIdAsync(schoolId, studentId);
if (account == null)
{
throw new StudentNotFoundException(studentId);
}
return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}
}
public static async Task Main(string[] args)
{
FeeService service = new FeeService(repository);
decimal balance = await service.GetBalanceAsync(1, 12);
Console.WriteLine($"Balance: {balance:N2}");
}
Main can be async Task — the runtime supports it, and it is how a console application awaits at the top level.
Breaking the chain with .Result is where deadlocks come from.
The missing await
The most common async bug, and it produces no error at all.
public async Task RecordPaymentAsync(int studentId, decimal amount)
{
await _feeRepository.RecordPaymentAsync(studentId, amount);
_auditRepository.LogAsync("Payment recorded", studentId); // not awaited
}
warning CS4014: Because this call is not awaited, execution of the current method
continues before the call is completed.
Three consequences, all silent:
- The method returns before the audit log is written.
- If
LogAsyncthrows, the exception is discarded entirely — no crash, no log entry, nothing. - The order of operations is no longer what the code appears to say.
await _auditRepository.LogAsync("Payment recorded", studentId);
Treat CS4014 as an error. In a project file:
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
In Visual Studio: right-click the project → Properties → Build → tick Treat warnings as errors. A warning nobody reads is not a safety net.
.Result and .Wait()
// Blocks the thread; can deadlock
Student student = _repository.GetByIdAsync(1, 12).Result;
_repository.SaveAsync(student).Wait();
.Result and .Wait() block the calling thread until the task finishes, which defeats the purpose of async and, in some contexts, deadlocks outright.
The classic deadlock: the thread waits for the task; the task needs that same thread to resume; neither proceeds. The application hangs with no exception and no message. It happens in ASP.NET applications and desktop UIs, and — infuriatingly — often not in a console application, so the bug survives testing.
Student student = await _repository.GetByIdAsync(1, 12);
Rule: never .Result, never .Wait(). Await instead, and make the caller async.
The one accepted exception is Main in old code that could not be async. Modern Main can be async Task, so even that is gone.
async void
// Wrong — an exception here cannot be caught
public async void SavePayment(int studentId, decimal amount)
{
await _repository.RecordPaymentAsync(studentId, amount);
}
async void cannot be awaited, so the caller cannot know when it finished or whether it failed. An exception inside it does not propagate — it crashes the process instead.
public async Task SavePaymentAsync(int studentId, decimal amount)
{
await _repository.RecordPaymentAsync(studentId, amount);
}
async void is legitimate only for an event handler, which the framework requires to return void. Even then, wrap the body in try/catch, because nothing else can catch it.
Running in parallel
// Sequential — 300 ms if each takes 100 ms
Student student = await _studentRepository.GetByIdAsync(1, 12);
FeeAccount account = await _feeRepository.GetByStudentIdAsync(1, 12);
List<ExamResult> results = await _examRepository.GetByStudentIdAsync(1, 12);
// Concurrent — roughly 100 ms, because they do not depend on each other
Task<Student> studentTask = _studentRepository.GetByIdAsync(1, 12);
Task<FeeAccount> accountTask = _feeRepository.GetByStudentIdAsync(1, 12);
Task<List<ExamResult>> resultsTask = _examRepository.GetByStudentIdAsync(1, 12);
await Task.WhenAll(studentTask, accountTask, resultsTask);
Student student = await studentTask;
FeeAccount account = await accountTask;
List<ExamResult> results = await resultsTask;
Start the tasks without awaiting, then Task.WhenAll. Note there is no await on the first three lines — awaiting there would make them sequential again.
Only for independent work. If the second call needs the first call's result, they must be sequential and no technique changes that.
One important caveat: an EF Core DbContext is not thread-safe. Running two queries concurrently on one context throws. Use separate contexts, or run them sequentially — Track 08 covers this.
Task.WhenAll throws if any task fails, and reports the first exception. Inspect Task.Exception on each to see them all.
Cancellation
public async Task<List<Student>> GetStudentsAsync(int schoolId, CancellationToken cancellationToken)
{
string json = await File.ReadAllTextAsync("students.json", cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return JsonSerializer.Deserialize<List<Student>>(json);
}
CancellationTokenSource source = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
List<Student> students = await GetStudentsAsync(1, source.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("The request took too long and was cancelled.");
}
Accept a CancellationToken and pass it down. A user who closes a page or a request that times out should not leave the server working on a result nobody will read. Track 10 shows ASP.NET Core supplying the token automatically.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
CS4014: Because this call is not awaited, execution continues before the call is completed | Forgot await | Add it. Treat this warning as an error |
CS1998: This async method lacks 'await' operators and will run synchronously | Marked async with nothing to await | Remove async, or add the missing await |
CS4033: The 'await' operator can only be used within an async method | Awaited inside a normal method | Mark the method async and return Task |
CS0029: Cannot implicitly convert 'Task<Student>' to 'Student' | Forgot to await the result | await it |
System.AggregateException | Exception surfaced through .Result or .Wait() | Use await instead |
| The application hangs with no error | Deadlock from .Result or .Wait() | Never block on a task — await it |
CS4014 is the dangerous one. It is only a warning, the program runs, and any exception in that un-awaited call disappears silently. In Solution Explorer, right-click the project → Properties → Build → set Treat warnings as errors, or add <TreatWarningsAsErrors>true</TreatWarningsAsErrors> to the .csproj.
Common mistakes
.Resultor.Wait()instead ofawait- Ignoring CS4014 — a call that is not awaited
- Ignoring CS1998 —
asyncwith noawait async voidon anything but an event handler- Awaiting each of several independent calls in sequence
awaitin a loop whereTask.WhenAllwould do- Using async for CPU work with no I/O
- Missing the
Asyncname suffix - Sharing one
DbContextacross concurrent tasks - Not passing a
CancellationTokendown the chain
Practice
- Write
ReadStudentFileAsyncusingFile.ReadAllTextAsyncand await it fromasync Task Main. - Mark a method
asyncwith noawaitin it. Read warning CS1998. - Call an async method without awaiting it. Read warning CS4014.
- Make the un-awaited method throw, and confirm the exception vanishes.
- Add
<TreatWarningsAsErrors>and confirm the build now fails on CS4014. - Replace an
awaitwith.Resultin a console app and confirm it appears to work. - Write
GetBalanceAsyncon a service, awaited fromMain, returning adecimal. - Write an
async voidmethod that throws. Try to catch it from the caller. - Convert it to
async Taskand confirm the exception can now be caught. - Await three independent repository calls in sequence and time it with a
Stopwatch. - Rewrite with
Task.WhenAlland compare the elapsed time. - Add an
awaitto one of the three task-creating lines and confirm the timing goes back up. - Make one of the three tasks throw, and see what
Task.WhenAllreports. - Add a
CancellationTokento an async method and cancel it after two seconds. - Rename an async method without the
Asyncsuffix, then explain to someone else why the convention exists.
Exercises 3, 4 and 8 are the three silent async failures. All three compile and run.
You can now
- Write an
async Taskmethod and await it - Say what async actually improves, and what it does not
- Spot a missing
awaitand name the three things it breaks - Explain why
.Resultand.Wait()are banned - Say why
async voidis unsafe outside an event handler - Run independent work concurrently with
Task.WhenAll
Review questions
- What does async actually improve, given that one file read takes the same time either way?
- What three things go wrong when a task is not awaited?
- Why is
async voidunsafe outside an event handler? - When does
Task.WhenAllhelp, and when does it not?