1

I'm having issues creating an asynchronous web service using the Task Parallel Library with ASP.NET Web API 2. I make an asynchronous call to a method StartAsyncTest and create a cancellation token to abort the method. I store the token globally and then retrieve it and call it from a second method CancelAsyncTest. Here is the code:

// Private Global Dictionary to hold text search tokens
private static Dictionary<string, CancellationTokenSource> TextSearchLookup
    = new Dictionary<string, CancellationTokenSource>();

/// <summary>
/// Performs an asynchronous test using a Cancellation Token
/// </summary>
[Route("StartAsyncTest")]
[HttpGet]
public async Task<WsResult<long>> StartAsyncTest(string sSearchId)
{
    Log.Debug("Method: StartAsyncTest; ID: " + sSearchId + "; Message: Entering...");

    WsResult<long> rWsResult = new WsResult<long>
    {
        Records = -1
    };

    try
    {
        var rCancellationTokenSource = new CancellationTokenSource();
        {
            var rCancellationToken = rCancellationTokenSource.Token;

            // Set token right away in TextSearchLookup
            TextSearchLookup.Add("SyncTest-" + sSearchId, rCancellationTokenSource);

            HttpContext.Current.Session["SyncTest-" + sSearchId] =
                rCancellationTokenSource;

            try
            {
                // Start a New Task which has the ability to be cancelled 
                var rHttpContext = (HttpContext)HttpContext.Current;

                await Task.Factory.StartNew(() =>
                {
                    HttpContext.Current = rHttpContext;

                    int? nCurrentId = Task.CurrentId;

                    StartSyncTest(sSearchId, rCancellationToken);

                }, TaskCreationOptions.LongRunning);
            }
            catch (OperationCanceledException e)
            {
                Log.Debug("Method: StartAsyncText; ID: " + sSearchId
                    + "; Message: Cancelled!");
            }
        }
    }
    catch (Exception ex)
    {
        rWsResult.Result = "ERROR";
        if (string.IsNullOrEmpty(ex.Message) == false)
        {
            rWsResult.Message = ex.Message;
        }
    }

    // Remove token from Dictionary
    TextSearchLookup.Remove(sSearchId);
    HttpContext.Current.Session[sSearchId] = null;
    return rWsResult;
}

private void StartSyncTest(string sSearchId, CancellationToken rCancellationToken)
{
    // Spin for 1100 seconds
    for (var i = 0; i < 1100; i++)
    {
        if (rCancellationToken.IsCancellationRequested)
        {
            rCancellationToken.ThrowIfCancellationRequested();
        }

        Log.Debug("Method: StartSyncTest; ID: " + sSearchId
            + "; Message: Wait Pass #" + i + ";");

        Thread.Sleep(1000);
    }

    TextSearchLookup.Remove("SyncTest-" + sSearchId);

    HttpContext.Current.Session.Remove("SyncTest-" + sSearchId);
}

[Route("CancelAsyncTest")]
[HttpGet]
public WsResult<bool> CancelAsyncTest(string sSearchId)
{
    Log.Debug("Method: CancelAsyncTest; ID: " + sSearchId
        + "; Message: Cancelling...");

    WsResult<bool> rWsResult = new WsResult<bool>
    {
        Records = false
    };

    CancellationTokenSource rCancellationTokenSource =
        (CancellationTokenSource)HttpContext.Current.Session["SyncTest-" + sSearchId];

    // Session doesn't always persist values. Use TextSearchLookup as backup
    if (rCancellationTokenSource == null)
    {
        rCancellationTokenSource = TextSearchLookup["SyncTest-" + sSearchId];
    }

    if (rCancellationTokenSource != null)
    {
        rCancellationTokenSource.Cancel();

        TextSearchLookup.Remove("SyncTest-" + sSearchId);
        HttpContext.Current.Session.Remove("SyncTest-" + sSearchId);

        rWsResult.Result = "OK";
        rWsResult.Message = "Cancel delivered successfully!";
    }
    else
    {
        rWsResult.Result = "ERROR";
        rWsResult.Message = "Reference unavailable to cancel task"
            + " (if it is still running)";
    }

    return rWsResult;
}

After I deploy this to IIS, the first time I call StartAsyncTest and then CancelAsyncTest (via the REST endpoints), both requests go through and it cancels as expected. However, the second time, the CancelAsyncTest request just hangs and the method is only called after StartAsyncTest completes (after 1100 seconds). I don't know why this occurs. StartAsyncTest seems to highjack all threads after it's called once. I appreciate any help anyone can provide!

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
vinays84
  • 394
  • 4
  • 14
  • First, do you mind if I ask why the task you want to perform is asynchronous? Does it take up a lot of processor power, or is it because you have to wait on something like I/O? – Slothario Nov 22 '19 at 16:55
  • Also, while I can't say this is a definitive answer, there's a lot you can get wrong if you pass HttpContext to a different thread -- are you just using it to keep track of threads? Use a concurrent dictionary to keep track of that. – Slothario Nov 22 '19 at 17:01
  • 1
    Does every request get a unique `sSearchId`? It would cause issues if not. You can probably drop the `Session` usage as I doubt a CTS will serialize/deserialize correctly for the usual flavour of that. Also, change your sync wait to async; you can replace that whole `Task.Factory.StartNew...` with simply `await Task.Delay(TimeSpan.FromSeconds(1100.0), rCancellationToken);`, which will throw a `TaskCanceledException` if the CTS is cancelled. – sellotape Nov 22 '19 at 17:43
  • Yes, the real use case (this is just an example to show the problem) is searching a text file for a string, which takes up a lot of processor power. The `HttpContext` is passed to the thread because calling `Task.Factory.StartNew` doesn't persist it. Also, every `sSearchId` is unique – vinays84 Nov 22 '19 at 18:27
  • Regarding the `Dictionary` that holds your cancellation sources in a static variable, take a look at this: [Lifetime of ASP.NET Static Variable](https://stackoverflow.com/questions/8919095/lifetime-of-asp-net-static-variable) – Theodor Zoulias Nov 22 '19 at 20:49

2 Answers2

4

I store the token globally and then retrieve it and call it from a second method CancelAsyncTest.

This is probably not a great idea. You can store these tokens "globally", but that's only "global" to a single server. This approach would break as soon as a second server enters the picture.

That said, HttpContext.Current shouldn't be assigned to, ever. This is most likely the cause of the odd behavior you're seeing. Also, if your real code is more complex than ThrowIfCancellationRequested - i.e., if it's actually listening to the CancellationToken - then the call to Cancel can execute the remainder of StartSyncTest from within the call to Cancel, which would cause considerable confusion over the value of HttpContext.Current.

To summarize:

  • I recommend doing away with this approach completely; it won't work at all on web farms. Instead, keep your "task state" in an external storage system like a database.
  • Don't pass HttpContext across threads.
Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • I'm not sure how I would store the `CancellationToken` in an external DB? – vinays84 Dec 04 '19 at 15:14
  • @vinays84: You can't directly. You'll have to write your own system. E.g., you could have a `CancellationRequested` column that is polled by the executing task. Or you could use a pub/sub system like SignalR. – Stephen Cleary Dec 04 '19 at 20:53
-1

A colleague offered a alternative call to Task.Factory.StartNew (within StartAsyncTest):

await Task.Factory.StartNew(() =>
{
    StartSyncTest(sSearchId, rCancellationToken);
},
rCancellationToken, 
TaskCreationOptions.LongRunning,
TaskScheduler.FromCurrentSynchronizationContext());

This implementation seemed to solve the asynchronous issue. Now future calls to CancelAsyncTest succeed and cancel the task as intended.

vinays84
  • 394
  • 4
  • 14