Skip to main content
Published / updated

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
  • Task and Task<T>
  • async and await
  • Naming and signatures
  • Async all the way down
  • The missing await
  • .Result and .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.

TypeRepresents
TaskWork 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;
}
KeywordMeans
asyncThis method contains await and returns a Task
awaitPause 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 typeUse for
TaskAsync method returning nothing
Task<T>Async method returning a value
voidEvent 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 LogAsync throws, 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 → PropertiesBuild → 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

MessageCauseFix
CS4014: Because this call is not awaited, execution continues before the call is completedForgot awaitAdd it. Treat this warning as an error
CS1998: This async method lacks 'await' operators and will run synchronouslyMarked async with nothing to awaitRemove async, or add the missing await
CS4033: The 'await' operator can only be used within an async methodAwaited inside a normal methodMark the method async and return Task
CS0029: Cannot implicitly convert 'Task<Student>' to 'Student'Forgot to await the resultawait it
System.AggregateExceptionException surfaced through .Result or .Wait()Use await instead
The application hangs with no errorDeadlock 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

  • .Result or .Wait() instead of await
  • Ignoring CS4014 — a call that is not awaited
  • Ignoring CS1998 — async with no await
  • async void on anything but an event handler
  • Awaiting each of several independent calls in sequence
  • await in a loop where Task.WhenAll would do
  • Using async for CPU work with no I/O
  • Missing the Async name suffix
  • Sharing one DbContext across concurrent tasks
  • Not passing a CancellationToken down the chain

Practice

  1. Write ReadStudentFileAsync using File.ReadAllTextAsync and await it from async Task Main.
  2. Mark a method async with no await in it. Read warning CS1998.
  3. Call an async method without awaiting it. Read warning CS4014.
  4. Make the un-awaited method throw, and confirm the exception vanishes.
  5. Add <TreatWarningsAsErrors> and confirm the build now fails on CS4014.
  6. Replace an await with .Result in a console app and confirm it appears to work.
  7. Write GetBalanceAsync on a service, awaited from Main, returning a decimal.
  8. Write an async void method that throws. Try to catch it from the caller.
  9. Convert it to async Task and confirm the exception can now be caught.
  10. Await three independent repository calls in sequence and time it with a Stopwatch.
  11. Rewrite with Task.WhenAll and compare the elapsed time.
  12. Add an await to one of the three task-creating lines and confirm the timing goes back up.
  13. Make one of the three tasks throw, and see what Task.WhenAll reports.
  14. Add a CancellationToken to an async method and cancel it after two seconds.
  15. Rename an async method without the Async suffix, 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 Task method and await it
  • Say what async actually improves, and what it does not
  • Spot a missing await and name the three things it breaks
  • Explain why .Result and .Wait() are banned
  • Say why async void is unsafe outside an event handler
  • Run independent work concurrently with Task.WhenAll

Review questions

  1. What does async actually improve, given that one file read takes the same time either way?
  2. What three things go wrong when a task is not awaited?
  3. Why is async void unsafe outside an event handler?
  4. When does Task.WhenAll help, and when does it not?

Next: Debugging and code quality