When you say await task.ConfigureAwait(false) you transition to the thread-pool causing mapping to run under a null context as opposed to running under the previous context. That can cause different behavior. So if the caller wrote:
await Map(0, i => { myTextBox.Text = i.ToString(); return 0; }); //contrived...
Then this would crash under the following Map implementation:
var result = await task.ConfigureAwait(false);
return await mapper(result);
But not here:
var result = await task/*.ConfigureAwait(false)*/;
...
Even more hideous:
var result = await task.ConfigureAwait(new Random().Next() % 2 == 0);
...
Flip a coin about the synchronization context! This looks funny but it is not as absurd as it seems. A more realistic example would be:
var result =
someConfigFlag ? await GetSomeValue<T>() :
await task.ConfigureAwait(false);
So depending on some external state the synchronization context that the rest of the method runs under can change.
This also can happen with very simple code such as:
await someTask.ConfigureAwait(false);
If someTask is already completed at the point of awaiting it there will be no switch of context (this is good for performance reasons). If a switch is necessary then the rest of the method will resume on the thread pool.
This non-determinism a weakness of the design of await. It’s a trade-off in the name of performance.
The most vexing issue here is that when calling the API is is not clear what happens. This is confusing and causes bugs.
What to do?
Alternative 1: You can argue that it is best to ensure deterministic behavior by always using task.ConfigureAwait(false).
The lambda must make sure that it runs under the right context:
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext;
Map(..., async x => await Task.Factory.StartNew(
() => { /*access UI*/ },
CancellationToken.None, TaskCreationOptions.None, uiScheduler));
It’s probably best to hide some of this in a utility method.
Alternative 2: You can also argue that the Map function should be agnostic to the synchronization context. It should just leave it alone. The context will then flow into the lambda. Of course, the mere presence of a synchronization context might alter the behavior of Map (not in this particular case but in general). So Map has to be designed to handle that.
Alternative 3: You can inject a boolean parameter into Map that specifies whether to flow the context or not. That would make the behavior explicit. This is sound API design but it clutters the API. It seems inappropriate to concern a basic API such as Map with synchronization context issues.
Which route to take? I think it depends on the concrete case. For example, if Map is a UI helper function it makes sense to flow the context. If it is a library function (such as a retry helper) I’m not sure. I can see all alternatives make sense. Normally, it is recommended to apply ConfigureAwait(false) in all library code. Should we make an exception in those cases where we call user callbacks? What if we have already left the right context e.g.:
void LibraryFunctionAsync(Func<Task> callback)
{
await SomethingAsync().ConfigureAwait(false); //Drops the context (non-deterministically)
await callback(); //Cannot flow context.
}
So unfortunately, there is no easy answer.