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

Posted

技术标签:

【中文标题】SmtpClient.SendMailAsync 在抛出特定异常时导致死锁【英文标题】:SmtpClient.SendMailAsync causes deadlock when throwing a specific exception 【发布时间】:2015-04-04 16:25:27 【问题描述】:

我正在尝试根据 VS2013 项目模板中的示例 AccountController 为 ASP.NET MVC5 网站设置电子邮件确认。我已经使用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-ed 操作在任何一种情况下都无法完成。

据我所知,我遵循 SO 上其他帖子(例如 HttpClient.GetAsync(...) never returns when using await/async)中概述的所有 async/await 最佳实践,主要是“一直使用 async/await”。我也尝试过使用ConfigureAwait(false),没有任何变化。由于代码只有在抛出特定异常时才会死锁,所以我认为一般模式在大多数情况下是正确的,但在这种情况下,内部发生了一些事情使其不正确;但由于我对并发编程很陌生,所以我觉得我可能是错的。

我做错了什么吗?我总是可以在 SendAsync 方法中使用同步调用(即SmtpClient.Send()),但感觉应该按原样工作。

【问题讨论】:

看看Stephen Cleary's answer on catching an exception on a void method(SendMailAsync)。 Async Void 方法有时是有问题的孩子。 @ErikPhilips - 我在示例中没有看到任何 async void 方法(已实现或已调用) - 您的意思是某些特定的行吗? 作为解决方法,您可以尝试手动解析主机并提前失败...还请查看 the source 以获取见解 - 希望它会有所帮助... 我记得一个有解决方法的相关问题......它是:Sending async mail from SignalR hub @regexen,试试我的WithNoContext from here,看看有什么不同。 【参考方案1】:

试试这个实现,只需使用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();
        
    

【讨论】:

那个有效;正如人们通常所期望的那样,异常被捕获,调用堆栈冒泡,我得到一个 YSOD。似乎有很多代码可以做一些看起来很简单的事情(!),但我可以看到它是如何快速变得复杂的。无论如何标记为已接受,因为它确实解决了它。感谢您的所有帮助! 根据快速测试,似乎没有 Task.Yield 也可以工作。 通过您当前的实现,您在执行 IO 操作时仍然在“浪费”一个线程...... @BornToCode 我不是。 Task.Run(() =&gt; FuncAsync())Task.Run(async () =&gt; await FuncAsync()) 基本相同,但没有添加异步状态机微开销。在这两种情况下,都使用相同的Task.Run override。 @BornToCode, no Task.Run 不会阻止任何返回 Task 的函数,例如 SendMailExImplAsync(除非这样的函数内部有阻塞等待,而我的没有)。如果您想了解更多信息,请查看Task.Run implementation。 @Gary,创建一个你想要的CancellationTokenSource with timeout并将其令牌传递给SendMailExImplAsync

以上是关于SmtpClient.SendMailAsync 在抛出特定异常时导致死锁的主要内容,如果未能解决你的问题,请参考以下文章