SmtpClient.SendMailAsync在抛出特定异常时会导致死锁

19
我正在尝试为基于VS2013项目模板的ASP.NET MVC5网站设置电子邮件确认,参考了示例AccountController。我已经使用SmtpClient实现了IIdentityMessageService,尽可能保持简单:
public class EmailService : IIdentityMessageService
{
    public async Task SendAsync(IdentityMessage message)
    {
        using(var client = new SmtpClient())
        {
            var mailMessage = new MailMessage("some.guy@company.com", message.Destination, message.Subject, message.Body);
            await client.SendMailAsync(mailMessage);
        }
    }
}

调用它的控制器代码直接来自模板(提取为单独的操作,因为我想排除其他可能的原因):

public async Task<ActionResult> TestAsyncEmail()
{
    Guid userId = User.Identity.GetUserId();
    
    string code = await UserManager.GenerateEmailConfirmationTokenAsync(userId);
    var callbackUrl = Url.Action("ConfirmEmail", "Account", new { userId = userId, code = code }, protocol: Request.Url.Scheme);
    await UserManager.SendEmailAsync(userId, "Confirm your account", "Please confirm your account by clicking <a href=\"" + callbackUrl + "\">here</a>");

    return View();
}

然而,当邮件发送失败时,我遇到了奇怪的行为,但只有在主机不可达的特定情况下才会出现。示例配置:

<system.net>
    <mailSettings>
        <smtp deliveryMethod="Network">
            <network host="unreachablehost" defaultCredentials="true" port="25" />
        </smtp>
    </mailSettings>
</system.net>

在这种情况下,请求似乎发生了死锁,从未将任何内容返回给客户端。如果邮件由于其他原因(例如主机积极拒绝连接)发送失败,则异常会被正常处理,并且我会收到YSOD。
查看Windows事件日志,似乎在同一时间范围内抛出了一个InvalidOperationException,并显示消息“异步模块或处理程序在仍有异步操作挂起时完成。”; 如果我尝试在控制器中捕获SmtpException并在catch块中返回ViewResult,则会在YSOD中获得相同的消息。因此,我认为await的操作在任何情况下都无法完成。
据我所知,我遵循了在其他SO帖子中概述的所有异步/等待最佳实践(例如HttpClient.GetAsync(...)使用await/async时永远不会返回),主要是“一路使用异步/等待”。我还尝试过使用ConfigureAwait(false),但没有改变。由于代码只有在抛出特定异常时才会死锁,我认为通常情况下模式是正确的,但在这种情况下发生了某些内部问题,但由于我对并发编程相当陌生,我觉得我可能是错的。
我做错了什么吗?我可以始终在SendAsync方法中使用同步调用(即SmtpClient.Send()),但感觉应该按原样工作。

1
иҜ·жҹҘзңӢStephen ClearyеңЁжҚ•иҺ·SendMailAsyncз©әж–№жі•ејӮеёёж–№йқўзҡ„еӣһзӯ”гҖӮејӮжӯҘз©әж–№жі•жңүж—¶дјҡжҲҗдёәй—®йўҳе„ҝз«ҘгҖӮ - Erik Philips
1
@ErikPhilips - 我在示例中没有看到任何async void方法(无论是实现还是调用)- 你是指特定的行吗? - Alexei Levenkov
1
作为解决方法,您可以尝试手动解析主机并更早地失败...同时查看源代码以获取见解-希望对您有所帮助... - Alexei Levenkov
2
我记得有一个相关的问题,有一个解决方法...在这里:从SignalR hub发送异步邮件 - noseratio - open to work
1
@regexen,尝试一下我的WithNoContext,从这里获取,看看是否有所不同。 - noseratio - open to work
显示剩余5条评论
1个回答

15

尝试使用这个实现,只需使用client.SendMailExAsync而不是client.SendMailAsync。如果有任何区别,请告诉我们:

public static class SendMailEx
{
    public static Task SendMailExAsync(
        this System.Net.Mail.SmtpClient @this,
        System.Net.Mail.MailMessage message,
        CancellationToken token = default(CancellationToken))
    {
        // use Task.Run to negate SynchronizationContext
        return Task.Run(() => SendMailExImplAsync(@this, message, token));
    }

    private static async Task SendMailExImplAsync(
        System.Net.Mail.SmtpClient client, 
        System.Net.Mail.MailMessage message, 
        CancellationToken token)
    {
        token.ThrowIfCancellationRequested();

        var tcs = new TaskCompletionSource<bool>();
        System.Net.Mail.SendCompletedEventHandler handler = null;
        Action unsubscribe = () => client.SendCompleted -= handler;

        handler = async (s, e) =>
        {
            unsubscribe();

            // a hack to complete the handler asynchronously
            await Task.Yield(); 

            if (e.UserState != tcs)
                tcs.TrySetException(new InvalidOperationException("Unexpected UserState"));
            else if (e.Cancelled)
                tcs.TrySetCanceled();
            else if (e.Error != null)
                tcs.TrySetException(e.Error);
            else
                tcs.TrySetResult(true);
        };

        client.SendCompleted += handler;
        try
        {
            client.SendAsync(message, tcs);
            using (token.Register(() => client.SendAsyncCancel(), useSynchronizationContext: false))
            {
                await tcs.Task;
            }
        }
        finally
        {
            unsubscribe();
        }
    }
}

3
那个方案可行;异常会像通常预期的那样被捕获,向上冒泡到调用栈并导致YSOD。看起来为了做一件看似简单的事情而写了很多代码(!),但我可以看出它有可能迅速变得复杂。无论如何,因为它解决了问题,所以我将其标记为已接受。感谢您的所有帮助! - regexen
1
根据快速测试,似乎在没有 Task.Yield 的情况下也能正常工作。 - regexen
1
根据您当前的实现,执行IO操作时仍然“浪费”一个线程... @BornToCode 我不这么认为。Task.Run(() => FuncAsync()) 基本上与 Task.Run(async () => await FuncAsync()) 执行相同,但没有额外的异步状态机微开销。在两种情况下,都使用了相同的 Task.Run 重载 - noseratio - open to work
1
@Gary,创建一个带有超时的CancellationTokenSource(https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtokensource.-ctor?view=net-5.0#System_Threading_CancellationTokenSource__ctor_System_Int32_),并将其令牌传递给`SendMailExImplAsync`函数。 - noseratio - open to work
1
@noseratio。完美,这似乎运行得很好。 - Gary
显示剩余8条评论

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