首页 > 解决方案 > 为什么“覆盖异步 void OnPaint”中的 Await 会抛出 OutOfMemoryException 或 ArgumentException?

问题描述

我正在使用一些现有代码并尝试将 WinForms 应用程序中的图形系统从顺序转换为并发。到目前为止一切顺利,我已经很好地转换了所有内容,并在整个应用程序中添加了异步任务/等待。这最终导致回到override void OnPaint(PaintEventArgs e). 我可以简单地更改voidasync void,这将使我能够编译并继续我的快乐方式。但是,在运行测试以模拟延迟时遇到了一些我不明白的行为。

请参阅以下代码。

protected override async void OnPaint(PaintEventArgs e)
{
    await PaintAsync(e);
}

private async Task<Color> ColorAsync()
{
    await Task.Delay(1000);  //throws ArgumentException - Parameter is not valid
    //Thread.Sleep(1000);    //works

    Color color = Color.Blue;

    //this also throws
    //color = await Task<Color>.Run(() => 
    //{
    //    Thread.Sleep(1000);

    //    return Color.Red;
    //});

    return color;
}

private async Task PaintAsync(PaintEventArgs e)
{
    //await Task.Delay(1000);   //throws OutOfMemoryException 
    //Thread.Sleep(1000);       //works

    try
    {
        e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

        PointF start = new PointF(121.0F, 106.329636F);
        PointF end = new PointF(0.9999999F, 106.329613F);

        using (Pen p05 = new Pen(await ColorAsync(), 1F))
        {
            p05.DashStyle = System.Drawing.Drawing2D.DashStyle.Custom;
            p05.DashPattern = new float[] { 4, 2, 1, 3 };
            e.Graphics.DrawLine(p05, start, end);
        }

        base.OnPaint(e);
    }
    catch(Exception ex)
    {
        Debug.WriteLine(ex.Message);
    }
}

代码中包含的注释以显示有问题的行。 OnPaint()已经异步,然后等待PaintAsync()完成。这很重要,因为没有等待它会崩溃。图形上下文将在PaintAsync完成之前被释放。到目前为止一切顺利,一切都说得通。

为了模拟延迟,然后我将其添加await Task.DelayPaintAsync例程中,以模拟正在完成的一些工作。这会抛出一个OutOfMemoryException. 为什么?系统没有内存不足。另外,如果相关,我正在编译为 x64。这让我觉得 GDI+ 不喜欢延迟,也许有一个需要访问它的最小阈值,或者 Windows 破坏了句柄?因此,我尝试Thread.Sleep()查看是否有任何区别。有用。有 1 秒的暂停,然后画线。然后我重复同样的测试,但使用由PaintAsync,调用的子例程ColorAsync()。这次ArgumentException是错误。我认为在幕后都是一样的原因。

这里发生了什么?似乎有一些基本的东西我不明白。

我没有延迟,而是尝试在await Task.Run()内部添加一个ColorAsync(显示已注释掉),有趣的是这也会抛出。为什么?这让我觉得OnPaint不想等待任何事情,就像存在上下文切换问题一样。但它不介意 await on PaintAsync。或等待ColorAsyncPaintAsync. 但如果我等待Task.Run一个问题?

OnPaint如果没有这些例外,我该如何等待?

编辑 1

这是一个额外的例子来说明我的困惑并帮助人们理解我在问什么。以下似乎完美无缺。

protected override async void OnPaint(PaintEventArgs e)
{
    await PaintAsync2(e);
}

private async Task<Color> ColorAsync()
{
    Color color = Color.Blue;

    color = await Task<Color>.Run(() =>
    {
        return Color.Red;
    });

    return color;
}

private async Task PaintAsync2(PaintEventArgs old)
{
    using (Graphics g = CreateGraphics())
    {
        var e = new PaintEventArgs(g, old.ClipRectangle);

        try
        {
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

            PointF start = new PointF(121.0F, 106.329636F);
            PointF end = new PointF(0.9999999F, 106.329613F);

            using (Pen p05 = new Pen(await ColorAsync(), 1F))
            {
                p05.DashStyle = System.Drawing.Drawing2D.DashStyle.Custom;
                p05.DashPattern = new float[] { 4, 2, 1, 3 };
                g.DrawLine(p05, start, end);
            }

            base.OnPaint(e);
        }
        catch (Exception ex)
        {
            Debug.WriteLine(ex.Message);
        }

    }
}

为什么这行得通,但不是第一个版本?评论表明第一个版本失败了,因为通过等待我将上下文更改为不同的非 UI 线程,从而破坏了传递给 OnPaint() 的图形上下文。所以在第二个版本中,我忽略了传递给 OnPaint 例程的上下文,并执行 CreateGraphics 来建立一个新的图形上下文。但是,正如 MSFT 文档中所述,规则是相同的。

https://docs.microsoft.com/en-us/dotnet/api/system.windows.forms.control.creategraphics?view=netframework-4.7.2

CreateGraphics() 是线程安全的,允许在非 UI 线程上建立图形上下文。但是,只能从创建它的线程访问该上下文。所以,如果等待其他任务导致线程上下文更改,我不应该仍然得到同样的错误吗?ColorAsync 启动一个任务,该任务在单独的线程上运行,返回 Color.Red 并等待它。然后将该颜色传递给图形上下文等。但它可以工作。

为什么一个有效,而另一个无效?另外,用 CreateGraphics() 这样做是不是一个糟糕的设计?即使这是有效的,我是否有一些理由不应该这样做,我会后悔?假设我使用 OnPaint 异步触发一堆后台线程,每个线程负责渲染 UI 的不同方面。每个人都打开自己的图形上下文等。据我所知,这不应该锁定 UI,对吧?在架构上,我可以想到很多我不想这样做的原因。虽然没有深入讨论所有这些细节,但只关注手头的简单原则驱动问题,这有什么问题?

编辑 2

这是我的理论。调用 Control.OnPaint 的代码是什么样的?大概是这样的?

using (Graphics g = CreateGraphics())
{
    var clip = new Rectangle(0, 0, this.Width, this.Height);
    var args = new PaintEventArgs(g, clip);

    if (OnPaint != null)
    {
        OnPaint(args);
    }
}

我所能想到的是,如果 OnPaint 是“async void”,那么它将在完成之前返回,在这种情况下,g.Dispose() 将在使用结束时被调用,然后上下文将被销毁。但是,通过在 PaintAync2 中调用 CreateGraphics,我正在创建一个保证保持打开状态的新图形上下文......

标签: c#asynchronousasync-await

解决方案


我的编辑 2 就是答案。我不知道为什么人们需要上肥皂盒,做出假设,并告诉你你所做的一切都是错误的(即使他们不知道你打算做什么),而不是仅仅回答简单的问题这是被问到的。我所做的只是了解错误的根源,而不是陷入一场大的设计模式辩论。发布的大多数评论都是错误的。Await 没有切换到不同的线程上下文。

在对这些评论感到沮丧之后,我决定查看 System.Windows.Forms.Control 是否有可用的源代码。原来有。我不知道,这是一个巨大的帮助。

看到这个

https://referencesource.microsoft.com/#system.windows.forms/winforms/managed/system/winforms/control.cs

还有这个

https://referencesource.microsoft.com/#system.windows.forms/winforms/managed/system/winforms/PaintEvent.cs,1b5e97abf89104c1

从源头来看,这里是 Control.OnPaint 的调用者

PaintEventArgs 是从 dc 创建的

pevent = new PaintEventArgs(dc, clip);

然后包裹在using()中,也就是说PaintWithErrorHandling返回后会被销毁。PaintWithErrorHandling 就是调用 OnPaint。

using (pevent)
{
    try
    {
        if ((m.WParam == IntPtr.Zero) && GetStyle(ControlStyles.AllPaintingInWmPaint) || doubleBuffered)
        {
            PaintWithErrorHandling(pevent, PaintLayerBackground);
        }
    }
    ...
}

查看 PaintEventArgs 我们看到 Graphics 在访问时被实例化。

public System.Drawing.Graphics Graphics
{
    get
    {
        if (graphics == null && dc != IntPtr.Zero)
        {
            oldPal = Control.SetUpPalette(dc, false /*force*/, false /*realize*/);
            graphics = Graphics.FromHdcInternal(dc);
            graphics.PageUnit = GraphicsUnit.Pixel;
            savedGraphicsState = graphics.Save(); // See ResetGraphics() below
        }
        return graphics;
    }
}

然后在调用 Dispose 时销毁

protected virtual void Dispose(bool disposing)
{
    if (disposing)
    {
        //only dispose the graphics object if we created it via the dc.
        if (graphics != null && dc != IntPtr.Zero)
        {
            graphics.Dispose();
        }
    }

    ....
}

由于覆盖被标记为异步无效,它将在完成之前返回并且图形上下文被破坏。然而,这令人困惑,因为没有等待它不会崩溃,而是使用 Thread.Sleep。这样做的原因是,如果异步函数不等待任何内容,它将同步执行。即使 OnPaint 正在等待 PaintAsync,因为 PaintAsync 并没有等待,一直回到它同步执行的行。这对我来说是一个有趣的启示。我的印象是,标记为 async 且没有等待的函数会同步执行,但仅在该函数的上下文中执行。任何等待此函数的异步函数都会异步执行它,但事实并非如此。

例如,下面的代码。

protected override void OnPaint(PaintEventArgs e)
{
    PaintAsync(e);

    Debug.WriteLine("done painting");
}

private async Task PaintAsync(PaintEventArgs e)
{
    Thread.Sleep(5000);

    base.OnPaint(e);
}

Visual Studio 在 PaintAsync 方法下放了一条绿线,上面写着:“此异步方法缺少 'await' 运算符,将同步运行。”

然后在调用 PaintAsync 的 OnPaint 函数中,另一条绿线带有警告,上面写着:“由于未等待此调用,因此在调用完成之前继续执行当前方法。考虑将 'await' 运算符应用于通话的结果。”

根据警告,我希望 PaintAsync(在该函数的上下文中)同步执行。这意味着线程将在等待 Thread.Sleep 时阻塞。然后我希望 OnPaint 在 PaintAsync 完成之前立即返回。然而,这不会发生。一切都像同步一样执行。

以下代码也同步执行。

protected override async void OnPaint(PaintEventArgs e)
{
    await PaintAsync(e);

    Debug.WriteLine("done painting");
}

private async Task PaintAsync(PaintEventArgs e)
{
    Thread.Sleep(5000);

    base.OnPaint(e);
}

即使我在 OnPaint 中添加了“async void”和“await”,但一切执行完全相同……因为在任何地方都没有等待新任务。

我会将此称为 Visual Studio 智能感知中的错误。它声明事物将在 OnPaint 中异步执行,但事实并非如此。


推荐阅读