如何防止应用在所有fire-and-forget任务完成之前终止?

3
我有一个应用程序定期启动一些 fire-and-forget 任务,主要是为了记录日志,我的问题是当应用程序关闭时,任何当前正在运行的 fire-and-forget 任务都会被中止。我想要防止这种情况发生,所以我正在寻找一种机制,允许我在关闭应用程序之前 await 所有正在运行的 fire-and-forget 操作完成。我不想处理它们可能出现的异常,我不关心这些。我只想给它们完成的机会(可能带有超时,但这不是问题的一部分)。
你可以认为这个要求使我的任务不真正是 fire-and-forget,这其中有一些道理,所以我想澄清这一点:
  1. 这些任务在本地上是 fire-and-forget 的,因为启动它们的方法对它们的结果不感兴趣。
  2. 这些任务在全局上不是 fire-and-forget 的,因为整个应用程序关心它们。
以下是问题的最小演示:
static class Program
{
    static async Task Main(string[] args)
    {
        _ = Log("Starting"); // fire and forget
        await Task.Delay(1000); // Simulate the main asynchronous workload
        CleanUp();
        _ = Log("Finished"); // fire and forget

        // Here any pending fire and forget operations should be awaited somehow
    }

    private static void CleanUp()
    {
        _ = Log("CleanUp started"); // fire and forget
        Thread.Sleep(200); // Simulate some synchronous operation
        _ = Log("CleanUp completed"); // fire and forget
    }

    private static async Task Log(string message)
    {
        await Task.Delay(100); // Simulate an async I/O operation required for logging
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}");
    }
}

输出:

11:14:11.441 Starting
11:14:12.484 CleanUp started
Press any key to continue . . .

“CleanUp completed”和“Finished”条目没有被记录,因为应用程序过早终止,并且待处理任务被中止。有没有办法在关闭之前等待它们完成?
顺便说一下,这个问题是受到@SHAFEESPS的一个 最近的问题 的启发的,但很遗憾被关闭了。
澄清一下:上面提供的最小示例包含一种类型的fire-and-forget操作,即Task Log方法。真实世界应用程序启动的fire-and-forget操作是多样化的,有些甚至返回通用任务,如Task或Task。
还可能出现一个fire-and-forget任务会触发次要的fire-and-forget任务,这些也应该被允许开始并等待。

你的实际应用程序是一个控制台应用程序吗?还是一个图形用户界面(WinForms,WPF等)?还是其他什么东西? - Idle_Mind
@Idle_Mind 目前这是一个控制台应用程序,但我正在考虑将其转换为一个Windows服务。 - Theodor Zoulias
2个回答

2
也许可以加入一个计数器来等待退出?这样基本上就是“点火并忘记”的模式。
我只是将LogAsync移到了自己的方法中,以免每次调用Log时都需要丢弃。我想这也解决了在程序退出时调用Log时可能出现的微小竞态条件。
public class Program
{
    static async Task Main(string[] args)
    {
        Log("Starting"); // fire and forget
        await Task.Delay(1000); // Simulate the main asynchronous workload
        CleanUp();
        Log("Finished"); // fire and forget

        // Here any pending fire and forget operations should be awaited somehow
        var spin = new SpinWait();

        while (_backgroundTasks > 0)
        {
            spin.SpinOnce();
        }
    }

    private static void CleanUp()
    {
        Log("CleanUp started"); // fire and forget
        Thread.Sleep(200); // Simulate some synchronous operation
        Log("CleanUp completed"); // fire and forget
    }

    private static int _backgroundTasks;

    private static void Log(string message)
    {
        Interlocked.Increment(ref _backgroundTasks);
        _ = LogAsync(message);
    }

    private static async Task LogAsync(string message)
    {
        await Task.Delay(100); // Simulate an async I/O operation required for logging
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}");
        Interlocked.Decrement(ref _backgroundTasks);
    }
}

谢谢Jerry的答案!你的解决方案很有趣,看起来也能工作,但有两个我不喜欢的地方:1. 我更愿意避免主线程在等待“fire-and-forget”任务时一直自旋。相反,我更愿意以异步方式等待它们。2. Log只是我应用程序启动的多个“fire-and-forget”任务之一,我不想在每一个任务中都使用Interlocked操作。这不是一个阻碍问题,但如果有其他不那么侵入性的解决方案,我更愿意选择它。 - Theodor Zoulias

2

一个合理的做法是在记录器内部拥有一个内存队列(这适用于其他类似功能符合您的标准的情况),该队列被单独处理。然后,您的日志方法只需像这样:

private static readonly BlockingCollection<string> _queue = new BlockingCollection<string>(new ConcurrentQueue<string>());
public static void Log(string message) {
   _queue.Add(message);
}

该操作非常快速且不会阻塞调用者,是异步完成的,意味着在将来某个时间(或失败)完成。调用者不知道或不关心结果,因此这是一个"fire-and-forget"任务。

但是,该队列需要单独处理(通过将日志消息插入到最终目的地,如文件或数据库),可能在单独的线程或通过await(和线程池线程)进行全局处理,这并不重要。

然后,在应用程序退出之前,您只需要通知队列处理器不再需要更多项目,并等待其完成即可,例如:

_queue.CompleteAdding(); // no more items
_processorThread.Join(); // if you used separate thread, otherwise some other synchronization construct.

编辑:如果您希望队列处理是异步的 - 您可以使用这个AsyncCollection(作为nuget包可用)。然后您的代码将变为:

class Program {
    private static Logger _logger;
    static async Task Main(string[] args) {
        _logger = new Logger();
        _logger.Log("Starting"); // fire and forget
        await Task.Delay(1000); // Simulate the main asynchronous workload
        CleanUp();
        _logger.Log("Finished"); // fire and forget
        await _logger.Stop();
        // Here any pending fire and forget operations should be awaited somehow
    }

    private static void CleanUp() {
        _logger.Log("CleanUp started"); // fire and forget
        Thread.Sleep(200); // Simulate some synchronous operation
        _logger.Log("CleanUp completed"); // fire and forget
    }
}

class Logger {
    private readonly AsyncCollection<string> _queue = new AsyncCollection<string>(new ConcurrentQueue<string>());
    private readonly Task _processorTask;
    public Logger() {
        _processorTask = Process();
    }

    public void Log(string message) {
        // synchronous adding, you can also make it async via 
        // _queue.AddAsync(message); but I see no reason to
        _queue.Add(message);
    }

    public async Task Stop() {
        _queue.CompleteAdding();
        await _processorTask;
    }

    private async Task Process() {
        while (true) {
            string message;
            try {
                message = await _queue.TakeAsync();
            }
            catch (InvalidOperationException) {
                // throws this exception when collection is empty and CompleteAdding was called
                return;
            }

            await Task.Delay(100);
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}");
        }
    }
}

或者您可以使用单独的专用线程来同步处理项目,通常是这样做的。

编辑2:这里是一个参考计数的变体,它不会对“fire and forget”任务的性质做出任何假设:

static class FireAndForgetTasks {

    // start with 1, in non-signaled state
    private static readonly CountdownEvent _signal = new CountdownEvent(1);

    public static void AsFireAndForget(this Task task) {
        // add 1 for each task
        _signal.AddCount();
        task.ContinueWith(x => {
            if (x.Exception != null) {
                // do something, task has failed, maybe log 
            }
            // decrement 1 for each task, it cannot reach 0 and become signaled, because initial count was 1
            _signal.Signal();
        });
    }

    public static void Wait(TimeSpan? timeout = null) {
        // signal once. Now event can reach zero and become signaled, when all pending tasks will finish
        _signal.Signal();
        // wait on signal
        if (timeout != null)
            _signal.Wait(timeout.Value);
        else
            _signal.Wait();
        // dispose the signal
        _signal.Dispose();
    }
}

您的样例变成了:
static class Program {
    static async Task Main(string[] args) {
        Log("Starting").AsFireAndForget(); // fire and forget
        await Task.Delay(1000); // Simulate the main asynchronous workload
        CleanUp();
        Log("Finished").AsFireAndForget(); // fire and forget
        FireAndForgetTasks.Wait();
        // Here any pending fire and forget operations should be awaited somehow
    }

    private static void CleanUp() {
        Log("CleanUp started").AsFireAndForget(); // fire and forget
        Thread.Sleep(200); // Simulate some synchronous operation
        Log("CleanUp completed").AsFireAndForget(); // fire and forget
    }

    private static async Task Log(string message) {
        await Task.Delay(100); // Simulate an async I/O operation required for logging
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}");
    }
}

正如你在问题中提到的,你的任务并不是真正的“点火然后忘记”,只是从本地角度来看。因此,你需要在全局范围内注册这些任务,并且这是一种实现的方式之一。 - Evk
是的,这是合理的。这种方法的问题在于我无法使其以不添加人为限制的方式工作(比如串行化任务的执行)。Jerry的解决方案(链接:https://dev59.com/p1cItIcB2Jgan1zntlly#64726363),使用引用计数而不是注册任务的方式,看起来更有前途。 - Theodor Zoulias
1
@TheodorZoulias 我添加了自己的引用计数变体,使用CountdownEvent - Evk
1
是的,这样的事情是实现细节,调用者不应该自己去做。 - Evk
1
我喜欢这个解决方案,因为每个操作都不需要了解等待实现的细节。老实说,我以前从来没有真正使用过CountdownEvent,也不知道它的存在,可能是因为这是少数几种情况之一,其中类似它的东西才有用。我在GitHub上查了一下,它在实现中使用了Interlocked.Increment/Decrement和SpinWait哈哈。给Evk点赞,不重复造轮子! - Jerry
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接