Situation: there is an asp.net mvc application. There is a button by clicking on which starts the process of generating a certain report. A report can be generated for quite some time, for example, several minutes. After starting the generation, it is also necessary to show the user a modal window with the progress of generation and the possibility of cancellation. I implemented it like this.

private static readonly IList<DownloadTask> DownloadStates = new List<DownloadTask>(); public ActionResult Export() { var task = new DownloadTask(Guid.NewGuid()); DownloadStates.Add(task); string zipFilename = string.Format("{0}.zip", task.Id); GenerateAsync(zipFilename, task); return Json(task.Id, JsonRequestBehavior.AllowGet); } 

This action is called when you click on the report generation button. This is what happens here. First, a task is created. The code for this class looks like this.

 public class DownloadTask { public DownloadTask() { } public DownloadTask(Guid id) { Id = id; State = DownloadState.NotStarted; CancellationTokenSource = new CancellationTokenSource(); } public Guid Id { get; set; } public double CurrentProgress { get; set; } // состояние задачи - запущена, отменена, ошибка, завершена и тд public DownloadState State { get; set; } } 

The task is placed in the DownloadStates collection.

Here is the code for the asynchronous method GenerateAsync

 public async Task<Guid> GenerateAsync(string filename, DownloadTask currentTask) { await Task.Factory.StartNew(() => { try { currentTask.State = DownloadState.Processing; foreach (var page in pages) { // обновление прогресса currentTask.CurrentProgress = // ... // обработка данных } currentTask.State = DownloadState.Completed; } catch { currentTask.State = DownloadState.Faulted; // обработка ошибки } }).ConfigureAwait(false); return currentTask.Id; } 

That is, clicking on the button we send a request to generate a report. A task is created, a unique Guid is assigned to it, the task is placed in the DownloadStates task collection and after that the asynchronous GenerateAsync method is launched. After starting this method, the server sends the created task to the client in the form of a response. The asynchronous method that is important is started without await on the principle of fire and forget. Further communication with it by the generation process takes place through the DownloadStates collection.

After that, when the server responds with the task ID, the client starts the setInterval function in which a request is sent to the server every 200 ms to find out the progress of the task and display this progress in a modal window. Here is the action code for getting current progress.

 public ActionResult Progress(Guid taskId) { var task = DownloadStates.FirstOrDefault(x => x.Id == taskId); string state = ""; bool isFaulted = false; bool isCancelled = false; if (task != null) { switch (task.State) { case DownloadState.Faulted: isFaulted = true; DownloadStates.Remove(task); break; case DownloadState.Processing: state = "processing"; break; // ... прочие ветки } } else isFaulted = true; return Json(new { progress = task != null && task.State != DownloadState.Completed ? task.CurrentProgress * 100 : 100, text = state, isFaulted, isCancelled }, JsonRequestBehavior.AllowGet); } 

Here the following happens: if the task is executed, then the response returns the current progress to the client (it is stored in the taskbar in the DownloadStates collection and is updated inside the asynchronous method). If an exception occurs during the execution of the asynchronous method, the client returns isFaulted = true and the client receives this value, stops the timer and stops sending requests for progress (the task ended with an error) and displays an error message. If the task has the status Completed, then the progress is set to 100% and the timer on the client also stops and then the request to load the generated report is executed.

In short, it works like this. This scheme really works, but I don’t have much experience with writing such asynchronous code and that’s why I’m tortured with doubts if I did everything right? For example, when calling the GenerateAsync asynchronous method, I don’t call await so as not to wait for it to complete, but immediately return the new task ID to the client. Therefore, I can find out about the error only by setting the isFaulted property of my tasks in the collection stored in the controller and reading this property when receiving the current progress.

I would like to hear the opinion of more experienced programmers about the potential problems of my code (memory leaks, incomplete tasks, unhandled exceptions, maybe you had to put a lock somewhere or could block something or something else?). Are there any weak points here (and I’m sure they are) that it would be worthwhile to improve, especially considering the multi-threaded execution and the potential launch of several tasks at the same time. Thank you in advance!

PS Unfortunately, the use of SignalR turned out to be impossible for reasons beyond my control, so I cannot consider the option to do everything on SignalR

  • there is no protection for multi-thread access to the global list DownloadStates - Igor
  • Added a label [inspection code], it is in this case needed. But since there is a limit of 5 marks on the question, I had to remove one. If it was better to remove another - edit) - Nick Volynkin
  • @Igor please tell me if you could show an example of a situation where the absence of a block would lead to an error? I just naively thought that since I use Guid as identifiers, I cannot make a mistake with addressing the wrong task (or almost impossible) - Pupkin
  • one
    The problem is not in guides, but in the possible destruction of the internal structure of the list. That is why the documentation for the List<> says: "Any instance members are not guaranteed to be thread safe." - Pavel Mayorov
  • By the way, using ConcurrentDictionary<,> solves two problems at once - thread safety and a long list search with a large number of tasks. - Pavel Mayorov

1 answer 1

Main problems

  • No lock on downloadstates. The List class is not thread safe; access to it must be manually synchronized.
  • Task.Factory.StartNew does not necessarily launch a task in a new thread. Instead, you should use Task.Factory.Run .
  • The only more or less reliable method for performing background tasks in ASP.NET is QueueBackgroundWorkItem .
  • Your approach is not very applicable in live applications, if there are two or more web servers, and, accordingly, two copies of the application.

It is much safer not to run background tasks in ASP.NET itself, but to put tasks into the database, and make a win-service that will get them from there, execute, and report through the database about progress.


More details:

Sync Lists

List works on simple arrays inside. Those. the search for FirstOrDefault in it is done by FirstOrDefault over the elements from index 0 to N-1.

Suppose you have two tasks running - 0 and 1.

  1. Task 0 fulfilled.
  2. A request for Progress for Task 0 arrived. Reached the line with Remove
  3. A request for Progress for Task 1 arrived. I went to the FirstOrDefault search, reached index 1
  4. The request for Progress for Task 0 took and deleted its item from the list!
  5. In Progress for Task 1, the call to FirstOrDefault ... drops? returns null?

QueueBackgroundWorkItem

In ASP.NET, there is a mechanism for the appliqué's layout. At some point, IIS can take and restart the process in which the application runs. By default, this happens every 29 hours. May occur on the limit of memory. Or by inactivity. A regular task from the Thread Pool or any other background thread will be simply nailed when recycling, instantly and without any notification.

Task created through QueueBackgroundWorkItem will receive a shutdown notification (via CancellationToken). In addition, he will have 90 seconds to respond to this notification. Not super reliably, but better than nothing.

Problems with two web servers

If you have two backends, then you have two independent copies of the application. Two lists DownloadStates. If the request to start a task came to one server, and the request for progress came to another, then the user will receive isFaulted.

It is solved by transferring the download queue to the database or to any other shared storage (for example, a service with a queue).

  • thank!. I would like to clarify a few details. 1) lock on downloads to DownloadStates. I am not good at these questions, so could you give an example of a situation where the absence of a block would lead to an error? I just naively thought that since I use Guid as identifiers, I cannot make a mistake with addressing the wrong task (or almost impossible) 2) QueueBackgroundWorkItem - tell me what is it preferable to tasks? 3) Your approach is not very applicable in live applications, if there are two or more web servers - could you say in more detail why this is so? - Pupkin
  • @Pupkin It's simple. If you have two backends with a balancer in front of them - it is not at all necessary that the Progress request is attached to the same server where the Export request fell before. - Pavel Mayorov
  • @PavelMayorov no backend one. And if you imagine that there are two, then how to avoid it? - Pupkin
  • How to avoid - you also wrote. Database + separate service to perform tasks. It is possible and without a base, the main thing is to render the service. - Pavel Mayorov
  • @Pupkin added a little detail - PashaPash