Home > Enterprise >  How make multiple calls to async function collapse into 1 task?
How make multiple calls to async function collapse into 1 task?

Time:08-22

I have a C# async function like "SlowRewriteFolder()" and I have multiple calls of this function coming in asynchronously.

If a call to this function is already processing, I want subsequent callers to not kick off the work that this function does again and instead wait on the same result (especially while the first one is still in progress).

How can I make it so that the Task created for the first call is shared among subsequent callers while it is still in progress?

I have considered caching the Task instance and returning that if it is available and clearing it when the work is complete, but is that the best approach?

CodePudding user response:

I have considered caching the Task instance and returning that if it is available and clearing it when the work is complete, but is that the best approach?

-ish. You'll need to ensure that "return or restart" logic is thread-safe.

Something like this (using a "dumb" lock(): this can be improved to use a lock-free approach, but anyway...):

public class FolderRewriter
{
    private readonly Object lockObj = new Object();
    private Task< IReadOnlyList<String> >? cachedTask;

    public Task< IReadOnlyList<String> > SlowRewriteFolderAsync()
    {
        Task< IReadOnlyList<String> >? task = this.cachedTask;

        if( task is null || task.IsCompleted )
        {
           lock( this.lockObj )
           {
                if( this.cachedTask is null || this.cachedTask.IsCompleted )
                {
                    this.cachedTask = this.SlowRewriteFolderImplAsync();
                    task = this.cachedTask;
                }
                else
                {
                    task = this.cachedTask;
                }
            }
        }

        return task;
    }
    
    private static Int32 _invokeCount = 0;

    private async Task< IReadOnlyList<String> > SlowRewriteFolderImplAsync()
    {
        Int32 c = Interlocked.Increment( ref _invokeCount );
        
        await Task.Yield();
        
        const String FMT = nameof(SlowRewriteFolderImplAsync)   "(), UtcNow: {0:o} Invoke Count: {1:N0}";
        FMT.FmtInv( DateTime.UtcNow, c ).Dump();
        
        return Array.Empty<String>();
    }
}

Usage:

async Task Main()
{
    FolderRewriter rewriter = new FolderRewriter();
    
    // First:
    {
        Task< IReadOnlyList<String> > task0 = rewriter.SlowRewriteFolderAsync();
        Task< IReadOnlyList<String> > task1 = rewriter.SlowRewriteFolderAsync();
        Task< IReadOnlyList<String> > task2 = rewriter.SlowRewriteFolderAsync();

        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( task0, task1 ) );
        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( task1, task2 ) );

        IReadOnlyList<String> result0 = await task0.ConfigureAwait(false);
        IReadOnlyList<String> result1 = await task1.ConfigureAwait(false);
        IReadOnlyList<String> result2 = await task2.ConfigureAwait(false);
        
        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( result0, result1 ) );
        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( result0, result2 ) );
    }
    
    // Second:
    {
        Task< IReadOnlyList<String> > task0 = rewriter.SlowRewriteFolderAsync();
        Task< IReadOnlyList<String> > task1 = rewriter.SlowRewriteFolderAsync();
        Task< IReadOnlyList<String> > task2 = rewriter.SlowRewriteFolderAsync();

        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( task0, task1 ) );
        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( task1, task2 ) );

        IReadOnlyList<String> result0 = await task0.ConfigureAwait(false);
        IReadOnlyList<String> result1 = await task1.ConfigureAwait(false);
        IReadOnlyList<String> result2 = await task2.ConfigureAwait(false);
        
        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( result0, result1 ) );
        System.Diagnostics.Debug.Assert( Object.ReferenceEquals( result0, result2 ) );
    }
    
    string x = "foo";
}

Gives me this output in Linqpad - as you can see, the SlowRewriteFolderImplAsync method is only invoked twice, not 6 times:

enter image description here

Note that it gets far more gnarly if you want to use CancellationToken with SlowRewriteFolderAsync as the SlowRewriteFolderImplAsync will only have access to the CancellationToken of the first invocation, so subsequent invocations cannot be canceled.

CodePudding user response:

If you only ever want the task to run once with multiple callers then the easy way is with Lazy<T>.

Try this:

public Lazy<Task<List<String>>> SlowRewriteFolderAsyncLazy =>
    new Lazy<Task<List<String>>>(() => SlowRewriteFolderAsync());

You then call it like this:

Lazy<Task<List<String>>> lazy = SlowRewriteFolderAsyncLazy;
Task<List<String>> task = lazy.Value;
List<String> value = await task;

The task within the Lazy<> type doesn't begin to run until the first caller invokes the .Value property, so this is safe to define SlowRewriteFolderAsyncLazy as a property.

All subsequent callers get the same completed task.

  • Related