Part 1 covered the basics. Part 2 covered mid-level concepts - LINQ, generics, delegates, OOP pillars. This closing part covers the topics that come up specifically once an interview turns toward concurrency and production-shaped code - the concepts that separate "I've used async/await" from "I understand what it's actually doing, and where it can quietly go wrong." Same format throughout: a plain-English explanation, an analogy, a real code example, and a practice question for every topic.
Topic 1: async and await, What's Actually Happening
The single most important thing to internalize about async/await: it does not create a new thread. When code hits an await, the current thread is released back to the thread pool to go do other work, and execution resumes later, possibly on a different thread, once the awaited operation completes. This is what makes async code scale: a thread isn't sitting idle, blocked, doing nothing while waiting for a slow database call or HTTP request.
Think of a restaurant waiter who takes an order, hands it to the kitchen, and immediately goes to serve other tables instead of standing at the kitchen window waiting for the dish to finish cooking. The waiter, the thread, is never blocked - they're released to do other useful work, and come back once notified the food is ready.
// WITHOUT async - the thread BLOCKS entirely for
// the full duration of the slow operation
public IActionResult GetPosts()
{
var posts = _context.Posts.ToList(); // thread
// sits idle
// the WHOLE time
return Ok(posts);
}
// WITH async - the thread is RELEASED during the wait
public async Task<IActionResult> GetPostsAsync()
{
var posts = await _context.Posts.ToListAsync();
// ^ thread released HERE, goes to serve other
// requests, resumes here once the DB responds
return Ok(posts);
}
// async void - AVOID, except for actual event handlers
// Cannot be awaited, exceptions are difficult to catch
async void BadExample() { await DoSomethingAsync(); }
// The rule: "await all the way up" - once one method
// in a call chain is async, everything calling it
// should be async too, all the way to the entry point
Practice question: Explain, in your own words, why async/await improves scalability for a web API even though the actual database query still takes the same amount of time either way.
Topic 2: Task, Task, ValueTask, and Task
Task represents an asynchronous operation with no return value. Task represents one that returns a value of type T once complete. Task specifically is the standard return type for an async ASP.NET Core controller action, since IActionResult is what represents the actual HTTP response being built.
// Task - async work, no return value
public async Task SaveLogAsync(string message)
{
await _logService.WriteAsync(message);
}
// Task<T> - async work that returns a value
public async Task<Post> GetPostAsync(int id)
{
return await _context.Posts.FindAsync(id);
}
// Task<IActionResult> - the standard shape of an
// async ASP.NET Core controller action
[HttpGet("{id}")]
public async Task<IActionResult> GetPost(int id)
{
var post = await _postService.GetByIdAsync(id);
if (post == null)
return NotFound(); // IActionResult
return Ok(post); // ALSO IActionResult -
// this is exactly why
// the return type needs
// to be the interface,
// not one specific result
// type - both NotFound()
// and Ok() implement it
}
// ValueTask<T> - a struct-based alternative to Task<T>,
// avoids a heap allocation when the result is ALREADY
// available synchronously (like a cache hit) - a real
// performance optimization for hot paths, but adds
// complexity, so it's used selectively, not by default
public ValueTask<int> GetCachedValueAsync(string key)
{
if (_cache.TryGetValue(key, out int value))
return new ValueTask<int>(value); // no allocation,
// result is
// already here
return new ValueTask<int>(FetchFromDatabaseAsync(key));
}
Practice question: Why does NotFound() and Ok(post) both being valid return values inside the same method require the method's return type to be Task rather than something more specific?
Topic 3: Task.WhenAll and Task.WhenAny
When multiple independent async operations don't depend on each other's results, running them sequentially with separate await calls wastes time - the total wait becomes the sum of every operation. Task.WhenAll starts them all at once and waits for every one to finish, so the total time becomes the slowest one, not the sum.
// Sequential - SLOW, adds up every wait
var posts = await GetPostsAsync(); // waits 100ms
var tags = await GetTagsAsync(); // THEN waits 80ms
var comments = await GetCommentsAsync(); // THEN waits 60ms
// Total: 240ms
// Parallel with Task.WhenAll - FAST, runs together
var postsTask = GetPostsAsync(); // starts immediately
var tagsTask = GetTagsAsync(); // starts immediately
var commentsTask = GetCommentsAsync(); // starts immediately
await Task.WhenAll(postsTask, tagsTask, commentsTask);
var posts = postsTask.Result; // already complete,
var tags = tagsTask.Result; // safe to read
var comments = commentsTask.Result; // .Result here now
// Total: 100ms - the duration of the SLOWEST one
// Task.WhenAny - resolves as soon as the FIRST task
// completes, useful for timeout patterns or "whichever
// responds first" scenarios
var timeoutTask = Task.Delay(5000);
var dataTask = GetDataFromSlowServiceAsync();
var winner = await Task.WhenAny(dataTask, timeoutTask);
if (winner == timeoutTask)
{
throw new TimeoutException("Operation took too long");
}
Practice question: Given three independent async methods that each take about 200ms, write code using Task.WhenAll to run them concurrently, and explain roughly how much total time this saves compared to awaiting each one sequentially.
Topic 4: CancellationToken
A CancellationToken represents a request for cancellation, not a forced stop. Nothing gets automatically killed the moment cancellation is requested - the running async code has to actively check the token, or call something that checks it internally like ToListAsync(token), and choose to stop.
// A method that respects cancellation
public async Task ProcessItemsAsync(
List<int> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
// Actively checking - throws OperationCanceledException
// if cancellation was requested, stopping the loop
cancellationToken.ThrowIfCancellationRequested();
await ProcessOneItemAsync(item);
}
}
// Passing the token INTO async framework methods -
// many built-in async methods accept a token and
// check it internally for you
public async Task<List<Post>> GetPostsAsync(
CancellationToken cancellationToken)
{
return await _context.Posts
.ToListAsync(cancellationToken);
// EF Core checks this token during the DB call
}
// Where the token actually comes from - ASP.NET Core
// automatically provides one tied to the HTTP request,
// cancelled if the client disconnects early
[HttpGet]
public async Task<IActionResult> GetAll(
CancellationToken cancellationToken)
{
var posts = await _postService.GetAllAsync(cancellationToken);
return Ok(posts);
}
// Manually creating and triggering cancellation
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5)); // auto-cancel
// after 5 seconds
try
{
await LongRunningOperationAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled");
}
Practice question: Why does calling cancellationToken.ThrowIfCancellationRequested() inside a loop matter, versus just checking the token once at the very start of a long-running method?
Topic 5: Thread Safety and lock
When multiple threads access and modify the same shared data at the same time, without coordination, the result can be corrupted or unpredictable - a race condition. The lock keyword ensures only one thread can execute a specific block of code at a time.
public class Counter
{
private int _count = 0;
private readonly object _lockObject = new();
public void Increment()
{
lock (_lockObject)
{
_count++;
// Only ONE thread can be inside this block
// at any given moment - other threads trying
// to enter WAIT until this one exits
}
}
}
// Without the lock, this specific line:
// _count++;
// is NOT actually atomic - it's really three steps:
// read _count, add 1, write it back. Two threads
// interleaving those three steps can genuinely LOSE
// an increment, producing a wrong final count
Think of a single-occupancy restroom with a lock on the door. Only one person can be inside at a time - anyone else who arrives waits outside until the door unlocks, preventing the chaos of two people trying to use the same space simultaneously.
Practice question: Explain why _count++ can produce an incorrect final result when called from multiple threads simultaneously without a lock, even though it looks like one simple operation.
Topic 6: ConcurrentDictionary and Thread-Safe Collections
A regular Dictionary is not thread-safe - concurrent reads and writes from multiple threads can corrupt its internal state or throw exceptions. ConcurrentDictionary is specifically designed to handle concurrent access safely, without you needing to manually add locks around every operation.
// A regular Dictionary under concurrent access -
// GENUINELY UNSAFE, can throw or corrupt data
var unsafeDict = new Dictionary<string, int>();
// Multiple threads calling unsafeDict[key] = value
// simultaneously can throw InvalidOperationException
// or leave the dictionary in a broken internal state
// ConcurrentDictionary - safe by design
var safeDict = new ConcurrentDictionary<string, int>();
// Thread-safe add or update in one atomic operation
safeDict.AddOrUpdate(
"views",
addValue: 1, // if key doesn't exist
updateValueFactory: (key, oldValue) => oldValue + 1
); // if it does
// Thread-safe get-or-add
int value = safeDict.GetOrAdd("counter", 0);
// TryGetValue, TryAdd, TryRemove - all thread-safe,
// non-throwing variants
bool found = safeDict.TryGetValue("views", out int views);
// Other thread-safe collections worth knowing:
// ConcurrentBag<T> - unordered thread-safe collection
// ConcurrentQueue<T> - thread-safe FIFO queue
// ConcurrentStack<T> - thread-safe LIFO stack
Practice question: Why would a regular Dictionary used as a page-view counter, incremented by many concurrent web requests, eventually produce an incorrect total count?
Topic 7: The Classic Async Deadlock
Calling .Result or .Wait() on an async method, instead of awaiting it, can cause a genuine deadlock in certain contexts - classic ASP.NET, WPF, WinForms, contexts with a synchronization context. This is one of the most commonly tested async gotchas in interviews specifically because it's a real, painful production bug pattern.
// THE DEADLOCK TRAP
public IActionResult GetPost(int id)
{
var post = GetPostAsync(id).Result; // BLOCKS the
// current thread,
// waiting for the
// async method
return Ok(post);
}
public async Task<Post> GetPostAsync(int id)
{
await Task.Delay(100); // simulate async work
return await _context.Posts.FindAsync(id);
// In certain contexts, THIS await tries to resume
// back on the SAME thread that called .Result -
// but that thread is BLOCKED waiting for .Result
// to return. Neither side can proceed. Deadlock.
}
// THE FIX - await all the way up, never mix
// synchronous blocking with async code
public async Task<IActionResult> GetPost(int id)
{
var post = await GetPostAsync(id); // correctly
// awaited, no
// blocking, no
// deadlock risk
return Ok(post);
}
// ConfigureAwait(false) - a partial mitigation seen in
// older library code, tells the awaited task "you don't
// need to resume on the original context" - reduces
// deadlock risk in SOME cases, but "await all the way
// up" is the more reliable, modern fix
Practice question: Explain, step by step, why calling .Result on an async method from within an ASP.NET (classic, non-Core) request context can deadlock, while calling the same async method from a Console app usually does not.
Topic 8: Reflection
Reflection lets code inspect and interact with types, methods, and properties at runtime, rather than everything being fixed at compile time. It's genuinely powerful, and genuinely has a real performance cost - it's a tool reached for deliberately, not a default approach.
// Getting type information at runtime
Type postType = typeof(Post);
Console.WriteLine(postType.Name); // "Post"
Console.WriteLine(postType.Namespace);
// Inspecting properties dynamically
foreach (var property in postType.GetProperties())
{
Console.WriteLine($"{property.Name}: {property.PropertyType}");
}
// Creating an instance dynamically, without knowing
// the concrete type at compile time
object instance = Activator.CreateInstance(postType);
// Reading and setting a property value via reflection
var titleProperty = postType.GetProperty("Title");
titleProperty.SetValue(instance, "Hello via Reflection");
var value = titleProperty.GetValue(instance);
// Where reflection actually shows up in real code -
// this is EXACTLY how many frameworks work under
// the hood, including:
// - JSON serializers (reading property names/values)
// - Dependency injection containers (finding constructors)
// - ORMs like EF Core (mapping properties to columns)
// - Unit testing frameworks (finding [Test] methods)
Practice question: Using reflection, write code that lists the names of all public methods defined on a given class.
Topic 9: Attributes
Attributes attach metadata to code - classes, methods, properties - that can be read at runtime, commonly via reflection. They don't change what the code does directly; they annotate it with information other code can act on.
// Built-in attributes you already use constantly
[HttpGet]
[Route("posts/{id}")]
public async Task<IActionResult> GetPost(int id) { }
[Required]
[MaxLength(200)]
public string Title { get; set; }
[Obsolete("Use GetByIdAsync instead")]
public Post GetById(int id) { }
// Defining your own custom attribute
[AttributeUsage(AttributeTargets.Method)]
public class LogExecutionTimeAttribute : Attribute { }
public class PostService
{
[LogExecutionTime]
public void SlowOperation() { }
}
// Reading a custom attribute via reflection - this is
// how frameworks actually ACT on attributes, since an
// attribute alone does nothing without something
// reading and reacting to it
var method = typeof(PostService).GetMethod("SlowOperation");
var hasAttribute = method.GetCustomAttribute<LogExecutionTimeAttribute>() != null;
if (hasAttribute)
{
Console.WriteLine("This method should have its execution time logged");
}
Practice question: What is the relationship between attributes and reflection? Can an attribute do anything on its own without reflection, or something equivalent, reading it?
Topic 10: Records
A record is a reference type, by default, specifically designed for immutable data, with built-in value-based equality - meaning two records with identical property values are considered equal, without you needing to hand-write Equals() and GetHashCode() yourself.
// Traditional class - reference equality by default,
// requires manually overriding Equals/GetHashCode
// for value-based comparison (covered in Part 2)
public class PostClass
{
public string Title { get; set; }
public string Slug { get; set; }
}
// Record - concise syntax, VALUE-based equality
// built in automatically
public record Post(string Title, string Slug);
var post1 = new Post("Hello", "hello");
var post2 = new Post("Hello", "hello");
Console.WriteLine(post1 == post2); // TRUE -
// value equality,
// automatically
Console.WriteLine(post1.Equals(post2)); // TRUE
// Records are IMMUTABLE by default - properties are
// init-only, cannot be changed after construction
// post1.Title = "New Title"; // does NOT compile
// "with" expressions - create a MODIFIED COPY,
// original stays completely unchanged
var post3 = post1 with { Title = "Updated Title" };
Console.WriteLine(post1.Title); // still "Hello"
Console.WriteLine(post3.Title); // "Updated Title"
// Records also get a useful ToString() automatically
Console.WriteLine(post1); // "Post { Title = Hello, Slug = hello }"
Practice question: Why might a record be a better choice than a class for representing an immutable DTO, data transfer object, passed between layers of an application?
Topic 11: Memory Fundamentals - Stack vs Heap
This ties directly back to the value vs reference type distinction from Part 1, but from the memory allocation angle specifically. Value types typically live on the stack, fast, automatically reclaimed when a method returns. Reference types live on the heap, managed by the garbage collector, covered in Part 2.
void ExampleMethod()
{
int number = 5; // value type - lives on
// the STACK, in this
// method's own frame
var post = new Post(); // reference type - the
// ACTUAL Post object
// lives on the HEAP;
// 'post' itself (the
// reference/pointer) is
// on the stack
} // when ExampleMethod() returns, the STACK FRAME
// is simply popped - 'number' and the 'post'
// REFERENCE are gone instantly. The actual Post
// OBJECT on the heap survives until the garbage
// collector determines nothing references it anymore
// Why this matters for interview-level understanding:
// Stack allocation/deallocation is extremely fast -
// just moving a pointer. Heap allocation requires the
// GC to eventually track and reclaim that memory,
// which is real, measurable overhead at scale - this
// is exactly WHY boxing (Part 2) has a real cost, and
// why minimizing unnecessary heap allocations matters
// in performance-sensitive code
Practice question: Explain why creating a million small, short-lived objects inside a tight loop can create real garbage collection pressure, connecting this back to what you learned about GC generations in Part 2.
Topic 12: yield return and Iterators
yield return lets you write a method that produces a sequence of values one at a time, lazily - each value is only computed when actually requested, rather than the whole sequence being built and held in memory upfront. This is exactly the mechanism that makes IEnumerable and deferred execution, from Part 2, actually possible under the hood.
// WITHOUT yield - builds the ENTIRE list in memory
// before returning anything at all
public List<int> GetSquaresList(int count)
{
var result = new List<int>();
for (int i = 0; i < count; i++)
{
result.Add(i * i);
}
return result; // all 'count' values already computed
// and held in memory before the
// caller gets anything back
}
// WITH yield - produces ONE value at a time, on demand
public IEnumerable<int> GetSquares(int count)
{
for (int i = 0; i < count; i++)
{
yield return i * i;
// execution PAUSES here after each value,
// resuming only when the NEXT value is
// actually requested by the caller
}
}
// Calling it - values are computed lazily, one at a
// time, as the foreach loop actually asks for each one
foreach (var square in GetSquares(1000000))
{
if (square > 100) break;
// Because of yield, this STOPS the underlying
// method early too - only a handful of squares
// were ever actually computed, not all 1,000,000
}
// This is EXACTLY the mechanism behind LINQ's
// deferred execution from Part 2 - Where, Select, and
// most LINQ methods are implemented using yield return
// internally, which is WHY they don't run until iterated
Practice question: Write a method using yield return that produces an infinite sequence of Fibonacci numbers, and explain why this would be impossible to write using a regular method that returns a List.
Topic 13: Dependency Injection
Dependency Injection, DI, is a pattern where a class receives, is "injected with," the objects it depends on from the outside, rather than creating those dependencies itself internally. ASP.NET Core has a built-in DI container, and understanding the three standard lifetimes is a near-universal interview topic for anyone working with the framework.
// WITHOUT DI - PostService creates its own dependency
// directly, tightly coupled to one specific
// implementation, hard to swap or test in isolation
public class PostService
{
private readonly SqlPostRepository _repository = new();
public List<Post> GetAll() => _repository.GetAll();
}
// WITH DI - the dependency is INJECTED via the
// constructor, PostService depends only on the
// ABSTRACTION (interface), not a specific implementation
public class PostService
{
private readonly IPostRepository _repository;
public PostService(IPostRepository repository)
=> _repository = repository;
// ^ this dependency is provided from OUTSIDE,
// not created here - makes swapping
// implementations, and unit testing with a
// mock IPostRepository, straightforward
public List<Post> GetAll() => _repository.GetAll();
}
// Registering services in Program.cs, and the three
// standard lifetimes
builder.Services.AddTransient<IPostRepository, SqlPostRepository>();
// Transient - a NEW instance created every single
// time it's requested, anywhere
builder.Services.AddScoped<IPostRepository, SqlPostRepository>();
// Scoped - ONE instance per HTTP request, shared
// across everything within that same request, but a
// NEW one for the next request - the most common
// choice for things like a DbContext
builder.Services.AddSingleton<IPostRepository, SqlPostRepository>();
// Singleton - ONE instance for the ENTIRE application
// lifetime, shared by every request - use carefully,
// since shared mutable state across concurrent
// requests needs to be genuinely thread-safe
Practice question: Explain the practical difference between Scoped and Singleton lifetimes, and why registering a class that holds a database connection as Singleton could cause serious problems under concurrent web traffic.
Key Lessons
async/await releases the current thread during a wait rather than creating a new one - this is the entire mechanism behind why async scales.
Task.WhenAll runs independent async operations in parallel, reducing total wait time from the sum of every operation to just the slowest one.
CancellationToken is a cooperative signal, not a forced stop - code must actively check it or pass it into something that does.
Regular Dictionary is not thread-safe; ConcurrentDictionary is designed specifically for concurrent access without manual locking.
Calling .Result or .Wait() on async code instead of awaiting it is a real, well-known deadlock trap in certain synchronization contexts.
Reflection and attributes work together - attributes are inert metadata until something using reflection actually reads and acts on them.
Records give built-in, value-based equality and immutability with far less code than a hand-written class doing the same thing.
Stack allocation, value types and local variables, is fast and automatic; heap allocation, reference types, is managed by the garbage collector and carries real, measurable overhead at scale.
yield return is the actual mechanism behind LINQ's deferred execution from Part 2 - it produces values lazily, one at a time, rather than building an entire collection upfront.
Dependency Injection lifetimes - Transient, Scoped, Singleton - control how long a registered service instance is shared, and choosing the wrong one, especially Singleton for something not genuinely thread-safe, is a real, common production bug source.
The Series, Complete
This closes out the full three-part C# interview prep series. Part 1 covered the basics - variables, strings, collections, control flow, methods, enums, structs. Part 2 covered mid-level concepts - LINQ, IEnumerable vs IQueryable, generics, delegates, interfaces vs abstract classes, garbage collection, the four pillars of OOP, and lambda expressions. Part 3 covered advanced concepts - async/await, Task-based patterns, concurrency, thread safety, reflection, memory fundamentals, iterators, and dependency injection.
Summary
The advanced concepts in this part are where interview questions stop testing whether you know C# syntax and start testing whether you understand what's actually happening at runtime - which thread is doing what, when memory gets allocated and reclaimed, and what a keyword like await or lock genuinely does underneath the syntax. Together, all three parts of this series cover the range of C# knowledge that shows up constantly in real coding interviews, not as a syntax quiz, but as a test of whether the underlying mental model is genuinely there.
Originally published at TechStack Blog: https://www.techstackblog.com/post.html?slug=csharp-interview-prep-advanced-part3
Part 1 of this series (Basics): https://www.techstackblog.com/post.html?slug=csharp-interview-prep-basics-part1
Part 2 of this series (Mid-Level): https://www.techstackblog.com/post.html?slug=csharp-interview-prep-midlevel-part2
More from TechStack Blog: C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals
Top comments (0)