다음을 통해 공유


Grokking ContextAwareResult (.Net internals)

(This is the sequel to grokking LazyAsyncResult, which could be considered a prerequisite.)

You'll see if you look through Socket async API internals that they aren't just using the LazyAsyncResult class, they're actually calling this thing called ContextAwareResult (and so are a few of the other .Net network async APIs). What is this beast?

The class blurb is "This class will ensure that the correct context is restored on the thread before invoking a user callback."

If you've ever seen ContextAwareResult in action, what you normally eventually see is a call stack like this:

ContextAwareResult_in_action

I.e. ContextAwareResult at some point in time captures an ExecutionContext, and some other point in time (when the async operation completes) uses that execution context for invoking the callback, via ExecutionContext.Run(). Still, after peeking at the code it took me some untangling and learning about LazyAsyncResult to properly understand the question "when is the execution context captured?"

Basically it is not such a long class and it boils down to handling what I think are the two mainline scenarios after you call the Begin*() API which is going to return you a ContextAwareResult:

Scenario a) the async result actually completes synchronously during the call to Begin*()! This is fabulous news, because the execution context now doesn't even need to be captured and restored at all, because the current execution context is already the right one!

Scenario b) the async result is discovered to need to complete asynchronously. This is slightly less than fabulous news, because now an execution context needs to be captured and stored on the AsyncResult. (Unless there is no AsyncCallback, which I will call out as being outside of Scenarios a and b, in fact let's call it Scenario c). The context must be captured during the implementation of Begin*(), and obviously before the user's callback is called. The call chain here is thus: *.Begin*() -> ContextAwareResult.FinishPostingAsyncOp() -> CaptureOrComplete(), which should *either* captures the context (if needed) or calls the user completion immediately - hence the name.

Of course since this is an internal framework class, there must to be some optimization tricks here right? :) Of course there is. Some performance testing probably led to a useful observation: any time you are newing up a lot of ContextAwareResults inside repeated calls to a particular Begin*() implementation, you may find the calls are actually getting called with the same exact AsyncCallback over and over again (but perhaps with different AsyncState). Therefore in the framework, a class like Socket implementing (e.g.) BeginReceive() can keep an set of cache fields like (e.g.) 'ReceiveClosure', and each time it needs to use a ContextAwareResult to return asynchronously it passes the cached callback into FinishPostingAsyncOp(). Which then flows it to CaptureOrComplete(). If CaptureOrComplete() detects that the AsyncCallback is the same delegate on the same context as before, it can just copy the cached ExecutionContext, instead of capturing it all over again, which I guess must be kind of costly.

Finally to complete the async operation, ContextAwareResult.Complete() overrides of LazyAsyncResult.Complete() in order to hook the callback of the user's AsyncCallback and actually run the callback with ExecutionContext.Run(). The upshot of this is that the user's callback where they will call End*() will be running with the same execution context as when they called Begin*().

Inquiring minds will now want to know about scenario d): what if there is no execution context when we call Begin*()? Simple: there's nothing to capture! And so ContextAwareResult.Complete() can just invoke the AsyncCallback directly without restoring any execution context! This is good to know, as sometimes you are don't actually need the ExecutionContext to be restored, so suppressing your ExecutionContext before calling Begin*() can be a way to get a performance boost.