Entity Framework中的SaveChanges()与SaveChangesAsync(),以及Find()与FindAsync()的区别

115
我一直在寻找上述2对方法之间的区别,但是没有找到任何清楚解释它们的文章,也没有找到什么时候使用其中之一的指南。
那么,SaveChanges()SaveChangesAsync()之间有什么区别呢?
以及Find()FindAsync()之间呢?
在服务器端,当我们使用Async方法时,还需要添加await。因此,我不认为这在服务器端是异步的。
这只有帮助防止客户端浏览器上的UI阻塞吗?或者它们之间有什么利弊呢?

2
异步编程远不止于在客户端应用中阻止客户端UI线程的阻塞。我相信很快就会有专家回答这个问题。 - jdphenix
3个回答

214
任何时候,当您需要在远程服务器上执行操作时,您的程序会生成请求、发送请求,然后等待响应。我将使用 SaveChanges()SaveChangesAsync() 作为示例,但同样适用于 Find()FindAsync()
假设您有一个包含100多个项目的列表 myList,您需要将它们添加到数据库中。要插入这些项目,您的函数可能如下所示:
using(var context = new MyEDM())
{
    context.MyTable.AddRange(myList);
    context.SaveChanges();
}

首先创建一个MyEDM实例,将列表myList添加到表格MyTable中,然后调用SaveChanges()将更改持久化到数据库。虽然这可以按您希望的方式工作并提交记录,但在提交完成之前,您的程序无法执行其他任何操作。这可能需要很长时间,具体取决于您提交的内容。如果您提交的是记录更改,则实体必须逐个提交。(我曾经遇到过更新耗时2分钟的情况)!

为了解决这个问题,您可以选择两种方法中的一种。第一种方法是启动一个新线程来处理插入操作。虽然这将释放调用线程以继续执行,但您创建了一个新线程,它只会坐在那里等待。这没有必要,这就是async await模式解决的问题。

对于I/O操作,await很快成为您的好朋友。我们可以从上面的代码片段中使用这个模式:

using(var context = new MyEDM())
{
    Console.WriteLine("Save Starting");
    context.MyTable.AddRange(myList);
    await context.SaveChangesAsync();
    Console.WriteLine("Save Complete");
}

虽然这是一个非常小的改动,但对代码的效率和性能有着深远的影响。那么会发生什么呢?代码一开始相同,您创建了一个MyEDM实例并将您的myList添加到MyTable中。但是当您调用await context.SaveChangesAsync()时,代码的执行返回到调用函数!因此,在等待所有那些记录提交的同时,您的代码可以继续执行。假设包含上述代码的函数具有public async Task SaveRecords(List<MyTable> saveList)签名,则调用函数可能如下所示:

public async Task MyCallingFunction()
{
    Console.WriteLine("Function Starting");
    Task saveTask = SaveRecords(GenerateNewRecords());

    for(int i = 0; i < 1000; i++){
        Console.WriteLine("Continuing to execute!");
    }

    await saveTask;
    Console.Log("Function Complete");
}

我不知道为什么会有这样的函数,但它的输出展示了 async await 的工作原理。首先,让我们来看看会发生什么。

执行进入 MyCallingFunction,控制台上会输出 Function StartingSave Starting,然后调用函数SaveChangesAsync()。此时,执行返回到MyCallingFunction并进入for循环,最多写入“Continuing to Execute”1000次。当SaveChangesAsync()完成时,执行返回到SaveRecords函数,将Save Complete写入控制台。一旦SaveRecords中的所有内容都完成,执行将继续在MyCallingFunction中进行,并回到SaveChangesAsync()完成时的位置。感到困惑了吗?下面是一个输出示例:

Function Starting
Save Starting
Continuing to execute!
Continuing to execute!
Continuing to execute!
Continuing to execute!
Continuing to execute!
....
Continuing to execute!
Save Complete!
Continuing to execute!
Continuing to execute!
Continuing to execute!
....
Continuing to execute!
Function Complete!

或者是这样的:

Function Starting
Save Starting
Continuing to execute!
Continuing to execute!
Save Complete!
Continuing to execute!
Continuing to execute!
Continuing to execute!
....
Continuing to execute!
Function Complete!

这就是 async await 的美妙之处,你的代码可以在等待某些操作完成时继续运行。实际上,你会有一个更像这样的函数作为你的调用函数:

public async Task MyCallingFunction()
{
    List<Task> myTasks = new List<Task>();
    myTasks.Add(SaveRecords(GenerateNewRecords()));
    myTasks.Add(SaveRecords2(GenerateNewRecords2()));
    myTasks.Add(SaveRecords3(GenerateNewRecords3()));
    myTasks.Add(SaveRecords4(GenerateNewRecords4()));

    await Task.WhenAll(myTasks.ToArray());
}

在这里,你有四个不同的保存记录函数同时执行。使用async await来完成MyCallingFunction会比按顺序调用各个SaveRecords函数要快得多。

我还没有涉及到的一件事是await关键字。它的作用是停止当前函数的执行,直到等待的Task完成。因此,在原始MyCallingFunction中,只有当SaveRecords函数完成后,才会将Function Complete写入控制台。

长话短说,如果你有使用async await的选项,应该使用它,因为它会大大提高应用程序的性能。


17
99%的时间,我仍然需要等待从数据库中接收到值后才能继续。我是否应该继续使用异步?异步是否允许100人异步连接我的网站?如果我不使用异步,这是否意味着所有100个用户都必须依次排队等待? - MIKE
13
值得注意的是:从线程池中生成新线程会让ASP很难过,因为你实际上夺走了ASP的一个线程(这意味着该线程无法处理其他请求或执行任何操作,因为它被卡在阻塞调用中)。然而,如果您使用await,即使在调用SaveChanges之后您不需要做任何其他事情,ASP也会说:“啊哈,这个线程返回等待异步操作,这意味着我可以让这个线程同时处理一些其他请求!” 这使得您的应用程序水平扩展更好。 - sara
3
实际上,我已经进行了异步基准测试,并发现它比同步更慢。而且,您是否见过典型的ASP.Net服务器中有多少个可用线程?就像成千上万一样。因此,处理进一步请求时用尽线程的可能性非常小,即使您有足够的流量来饱和所有这些线程,您的服务器也能在这种情况下保持稳定吗?声称在任何地方都使用异步可以提高性能是完全错误的。 在某些场景下可能有效,但在大多数普通情况下实际上会更慢。 进行基准测试并查看结果。 - user3766657
2
@MIKE,虽然单个用户必须等待数据库返回数据才能继续,但使用您的应用程序的其他用户则不必如此。当IIS为每个请求创建一个线程(实际上比这更复杂)时,您正在等待的线程可以用于处理其他请求,这对于可伸缩性非常重要。想象一下每个请求,它不是使用1个线程全职工作,而是使用许多较短的线程,在其他地方可以重复使用(即另一个请求)。 - Bart Calixto
2
我想强调的是,由于EF不支持同时进行多个保存操作,因此您应该始终使用await等待SaveChangesAsync。 https://learn.microsoft.com/en-us/ef/core/saving/async 此外,使用这些异步方法实际上有很大的优势。例如,在保存数据或执行大量操作时,您可以在WebApi中继续接收其他请求,或者在桌面应用程序中不冻结界面以改善用户体验。 - tgarcia
显示剩余4条评论

5
我的进一步解释将基于下面的代码片段。
请注意:本文中的HTML标签已被保留。
using System;
using System.Threading;
using System.Threading.Tasks;
using static System.Console;

public static class Program
{
    const int N = 20;
    static readonly object obj = new object();
    static int counter;

    public static void Job(ConsoleColor color, int multiplier = 1)
    {
        for (long i = 0; i < N * multiplier; i++)
        {
            lock (obj)
            {
                counter++;
                ForegroundColor = color;
                Write($"{Thread.CurrentThread.ManagedThreadId}");
                if (counter % N == 0) WriteLine();
                ResetColor();
            }
            Thread.Sleep(N);
        }
    }

    static async Task JobAsync()
    {
       // intentionally removed
    }

    public static async Task Main()
    {
       // intentionally removed
    }
}

案例一

static async Task JobAsync()
{
    Task t = Task.Run(() => Job(ConsoleColor.Red, 1));
    Job(ConsoleColor.Green, 2);
    await t;
    Job(ConsoleColor.Blue, 1);
}

public static async Task Main()
{
    Task t = JobAsync();
    Job(ConsoleColor.White, 1);
    await t;
}

enter image description here

备注:由于JobAsync的同步部分(绿色)花费的时间比任务t(红色)更长,因此在await t的时候任务t已经完成。结果是,延续部分(蓝色)会在与绿色一样的线程上运行。

Main方法中的同步部分(白色)将在绿色部分完成后开始运行。这就是为什么异步方法中的同步部分有问题的原因。

案例2

static async Task JobAsync()
{
    Task t = Task.Run(() => Job(ConsoleColor.Red, 2));
    Job(ConsoleColor.Green, 1);
    await t;
    Job(ConsoleColor.Blue, 1);
}

public static async Task Main()
{
    Task t = JobAsync();
    Job(ConsoleColor.White, 1);
    await t;
}

enter image description here

备注: 本案例与第一种情况相反。 JobAsync 的同步部分(绿色)旋转的时间比任务t(红色)短,所以在执行到await t时,任务t尚未完成,导致后续过程(蓝色)在另一个线程上运行。

Main 的同步部分(白色)在绿色同步部分完成旋转后仍然持续旋转。

第三种情况

static async Task JobAsync()
{
    Task t = Task.Run(() => Job(ConsoleColor.Red, 1));
    await t;
    Job(ConsoleColor.Green, 1);
    Job(ConsoleColor.Blue, 1);
}

public static async Task Main()
{
    Task t = JobAsync();
    Job(ConsoleColor.White, 1);
    await t;
}

enter image description here

备注:这个案例将解决之前异步方法中同步部分的问题。任务t会立即等待,因此继续执行(蓝色部分)在不同的线程上运行,与绿色部分并行。 Main函数的同步部分(白色部分)将与JobAsync立即并行。

如果您想添加其他案例,请随意编辑。


-2

这个说法是不正确的:

在服务器端使用异步方法时,我们也需要添加 await。

你不需要添加 "await",await 只是 C# 中一个方便的关键字,它使你能够在调用后编写更多的代码行,而那些其他行只有在保存操作完成后才会执行。但正如你指出的那样,你可以通过调用 SaveChanges 而不是 SaveChangesAsync 来实现这一点。

但从根本上讲,异步调用涉及到更多的内容。这里的想法是,如果在保存操作进行时,服务器上还有其他工作可以做,那么你应该使用 SaveChangesAsync。不要使用 "await"。只需调用 SaveChangesAsync,然后继续并行执行其他任务。这包括在 Web 应用程序中,在保存完成之前向客户端返回响应。但当然,你仍然需要检查保存的最终结果,以便在失败时将其通知给用户或以某种方式记录下来。


8
如果您不等待这些调用,那么可能会出现在相同的 DbContext 实例上同时运行查询或保存数据的情况,而 DbContext 不是线程安全的。此外,await 使异常处理更加容易。如果没有 await,您将不得不存储任务并检查它是否出错,但是如果不知道任务何时完成,除非使用 '.ContinueWith',否则您将不知道何时进行检查,这需要比 await 更多的考虑。 - Pawel
31
这个答案有误导性。如果没有使用await调用异步方法,它就会变成“烧掉不管”。该方法将在某个时候完成,但你永远不会知道何时完成,如果它抛出异常,你也永远不会收到任何消息,而且你无法与其完成进行同步。这种潜在危险的行为应该经过认真考虑,而不是只依靠一个简单(而且不正确)的规则:“客户端使用await,服务器端不使用await”的方式来调用。 - John Melville
1
这是我在文档中读到但没有真正考虑过的非常有用的知识。所以,你有以下两个选项:
  1. 使用SaveChangesAsync()进行“Fire and forget”,就像John Melville所说的那样...在某些情况下对我很有用。
  2. 使用await SaveChangesAsync()进行“Fire, return to the caller, and then execute some 'post-save' code after the save is completed”。 非常有用的信息。谢谢。
- Parrhesia Joe
这篇文章是错误的,即使您不需要触发和忘记,也应始终使用SaveChangesAsync。调用SaveChanges会启动新线程并浪费服务器资源。 - Shadow

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