As far as I can tell, every request is already being run on a thread
pool (as empirically tested by logging the thread ID during each
request), so making all calls use async/await within your webmethods
will, at best, move the execution from your thread pool to a different
thread pool.
The 2nd part of the assumption is incorrect. The async / await calls, assuming they are IO calls, will not be offloaded to a different thread pool thread.
Essentially, while IO happens, the thread that encountered an await will be free to pick up other requests. This improves the throughput of the web application. The fundamental reason behind this is that IO is not done by the CPU but by the various IO devices on the PC (disk, network card, etc.); the CPU merely coordinates them. Synchronous calls will simply block the application thread waiting (essentially meaning one CPU core—it doesn't matter which—is performing the sync wait—OS scheduling has little effect on this outcome) for the IO device to finish, which is not an ideal measure for maximum throughput.
This is a pretty good read on the matter:
https://blog.stephencleary.com/2013/11/there-is-no-thread.html
It doesn't free up the socket, because, well the connection is still
open and the client is still waiting (synchronously or not) for a
response.
A simplified view:
Your server will bind a listener on a port (80 or 443 usually). When a request comes in, a new socket is created for every single connection (you can't have the same socket shared between 2 clients). The simplified workflow is like this:
- Server binds listener port.
- Connection incoming
- Socket is created between server and client.
- Request is assigned to a thread pool thread, and processing begins -> this is where your async happens.
- Listener is again free to serve a new connection. Repeat 2-4
Note that steps 4 and 5 happen in parallel.
Async in step 4 allows the physical thread to pick up multiple sockets from the listener.
There's a hard limit on how many requests can be processed at the same time. As you correctly identified, there is a limit on how many sockets you can have open, and you cannot simply close the socket on someone. That is true. However, the limit of sockets is in the range of tens of thousands, whereas the limit on threads is in the thousands. So in order to fully saturate your sockets, which is ideal 100% usage of hardware, you need to better manage your threads, which is where async-await comes in.
When a thread processing at step 4 encounters an await on async IO, it will simply return to the pool and be ready to process another request. The async IO device will send a notification to the CPU when it is done, so the processing of the request that was interrupted can continue. In the case of web APIs, the thread continuing after an await is not always the thread that encountered the await. This can be configured using ConfigureAwait
on applications that do care about thread affinity (not the case for web API). See https://stackoverflow.com/questions/18097471/what-does-synchronizationcontext-do and https://learn.microsoft.com/en-us/archive/msdn-magazine/2011/february/msdn-magazine-parallel-computing-it-s-all-about-the-synchronizationcontext for more details.
You can imagine this as a clown juggling 3-4 balls with just one hand. The thread is the clown. The balls in the air are async IO that are handled by IO devices. The ball in the clown's hand is the request currently being actively processed by that thread. If the clown wasn't allowed to throw balls in the air he'd be limited by the number of hands (one in this case) on how many balls he can handle.
Some more clarifications from comments:
- IO is async in nature. The synchronous IO wait happens in the application level APIs and libraries (even if they are provided by the OS).
- Async-await allows applications to fully adapt to the async nature of IO.
- We are not talking about Task.Run here, its use case is different, async-await is used there for convenience.