PAYATHON 2026

Web Forms 中 HttpClient.SendAsync.Result 疑似死锁

支付阿杰

结论

不要在 Web Forms 的请求线程里用 .Result 或 .Wait() 同步等待 HttpClient 的异步操作。应该把方法改为 Async Function ... As Task(Of PaymentRequestECommerceResponse),对 SendAsync 和 ReadAsStringAsync 使用 Await,并让整条调用链一直保持异步,直到页面事件或 HTTP Handler。

示例还创建了一个 HttpClientHandler,但没有把它传给 HttpClient:

Dim handler As New HttpClientHandler
Dim client As New HttpClient

如果 PrepareHttpClientAndHandler 为 handler 配置了客户端证书、代理或 TLS 选项,client 实际上不会使用这些配置。创建客户端时需要传入该处理程序:

New HttpClient(handler)

为什么在 Web Forms 中可能卡住

ASP.NET Web Forms 通常有请求级的 SynchronizationContext。执行以下代码时:

client.SendAsync(httpRequestMessage).Result

当前请求线程会被同步阻塞,而异步操作的后续代码可能还要回到同一个请求上下文中执行。请求线程在等待异步操作,异步操作又在等待请求线程,因此可能发生死锁。

控制台程序通常没有这样的同步上下文,所以同一段代码在控制台中可能可以正常运行。

不过,代码停在 SendAsync(...).Result 并不一定意味着发生了死锁。TLS 握手、客户端证书、DNS、代理、防火墙和连接超时也可能造成长时间等待。应先去掉同步阻塞,再检查这些网络问题。

异步实现

这份实现做了以下调整:

  • 返回 Task(Of PaymentRequestECommerceResponse)。
  • 用 Await 替代 .Result。
  • 把配置好的 HttpClientHandler 传给 HttpClient。
  • 用 Using 释放请求、响应和客户端。
  • 用 InvariantCulture 格式化金额。
  • 不手动添加 Host 请求头,除非支付接口明确要求覆盖该请求头。
Imports System.Globalization
Imports System.Net.Http
Imports System.Text
Imports System.Threading
Imports System.Threading.Tasks
Imports Newtonsoft.Json

Public Async Function MakePaymentRequestAsync(
    swishNumberToPay As String,
    amount As Decimal,
    message As String,
    instructionUUID As String,
    Optional cancellationToken As CancellationToken = Nothing
) As Task(Of PaymentRequestECommerceResponse)

    Try
        Dim requestData = New PaymentRequestECommerceData() With {
            .payeePaymentReference = _payeePaymentReference,
            .callbackUrl = _callbackUrl,
            .payerAlias = swishNumberToPay,
            .payeeAlias = _merchantAlias,
            .amount = Math.Round(amount, 2).ToString(
                "0.00",
                CultureInfo.InvariantCulture
            ),
            .currency = "SEK",
            .message = message
        }

        Dim requestURL As String = URL.ProductionPaymentRequest & instructionUUID
        Dim requestMethod As HttpMethod = HttpMethod.Put

        Select Case _environment
            Case "EMULATOR"
                requestURL = URL.EmulatorPaymentRequest & instructionUUID

            Case "SANDBOX"
                requestURL = URL.SandboxPaymentRequest
                requestMethod = HttpMethod.Post
        End Select

        Using handler As New HttpClientHandler()
            PrepareHttpClientHandler(handler)

            Using client As New HttpClient(handler, disposeHandler:=True)
                client.Timeout = TimeSpan.FromSeconds(30)

                Using request As New HttpRequestMessage(requestMethod, requestURL)
                    request.Content = New StringContent(
                        JsonConvert.SerializeObject(requestData),
                        Encoding.UTF8,
                        "application/json"
                    )

                    ' HttpClient 会根据 RequestUri 自动生成 Host。
                    ' 只有接口明确要求覆盖 Host 时才设置:
                    ' request.Headers.Host = request.RequestUri.Host

                    Using response As HttpResponseMessage =
                        Await client.SendAsync(
                            request,
                            HttpCompletionOption.ResponseHeadersRead,
                            cancellationToken
                        )

                        Dim errorMessage As String = String.Empty
                        Dim location As String = String.Empty

                        If response.IsSuccessStatusCode Then
                            If response.Headers.Location IsNot Nothing Then
                                location = response.Headers.Location.ToString()
                            End If
                        Else
                            errorMessage =
                                Await response.Content.ReadAsStringAsync()
                        End If

                        Return New PaymentRequestECommerceResponse() With {
                            .[Error] = errorMessage,
                            .Location = location
                        }
                    End Using
                End Using
            End Using
        End Using

    Catch ex As OperationCanceledException
        Return New PaymentRequestECommerceResponse() With {
            .[Error] = "Payment request was canceled or timed out.",
            .Location = String.Empty
        }

    Catch ex As Exception
        Return New PaymentRequestECommerceResponse() With {
            .[Error] = ex.ToString(),
            .Location = String.Empty
        }
    End Try
End Function

如果现有的 PrepareHttpClientAndHandler 会同时配置两个对象,可以让它接收代码中实际使用的实例:

Using handler As New HttpClientHandler()
    Using client As New HttpClient(handler, disposeHandler:=True)
        PrepareHttpClientAndHandler(handler, client)

        ' 继续发送请求……
    End Using
End Using

无论采用哪种写法,client 都必须由这个 handler 构造。分别创建两个互不关联的对象,处理程序上的配置不会作用于客户端。

在 Web Forms 页面中调用

页面需要启用异步处理:

<%@ Page Language="VB" Async="true" CodeBehind="Payment.aspx.vb"
    Inherits="MyApplication.Payment" %>

然后在异步页面事件中直接使用 Await:

Protected Async Sub PayButton_Click(
    sender As Object,
    e As EventArgs
) Handles PayButton.Click

    Dim result = Await MakePaymentRequestAsync(
        swishNumberTextBox.Text,
        Decimal.Parse(amountTextBox.Text),
        messageTextBox.Text,
        Guid.NewGuid().ToString()
    )

    If String.IsNullOrEmpty(result.Error) Then
        resultLabel.Text = result.Location
    Else
        resultLabel.Text = Server.HtmlEncode(result.Error)
    End If
End Sub

在页面生命周期中启动异步工作时,也可以使用 RegisterAsyncTask:

Protected Sub Page_Load(sender As Object, e As EventArgs) Handles Me.Load
    RegisterAsyncTask(
        New PageAsyncTask(
            Async Function()
                Dim result =
                    Await MakePaymentRequestAsync(
                        "46700000000",
                        100D,
                        "Order payment",
                        Guid.NewGuid().ToString()
                    )

                resultLabel.Text =
                    If(String.IsNullOrEmpty(result.Error),
                       result.Location,
                       Server.HtmlEncode(result.Error))
            End Function
        )
    )
End Sub

不要把调用改成下面这样,否则又会引入同步阻塞:

Dim result = MakePaymentRequestAsync(...).Result

在异步 HTTP Handler 中调用

如果代码位于自定义 Handler 中,可以实现 HttpTaskAsyncHandler:

Imports System.Threading.Tasks
Imports System.Web

Public Class PaymentHandler
    Inherits HttpTaskAsyncHandler

    Public Overrides Async Function ProcessRequestAsync(
        context As HttpContext
    ) As Task

        Dim result = Await MakePaymentRequestAsync(
            context.Request("payer"),
            Decimal.Parse(context.Request("amount")),
            context.Request("message"),
            context.Request("instructionUUID")
        )

        context.Response.ContentType = "application/json"
        context.Response.Write(
            Newtonsoft.Json.JsonConvert.SerializeObject(result)
        )
    End Function
End Class

仍然没有发出请求时的检查项

完成异步改造后,如果支付服务端仍然没有日志,可以继续检查:

  • PrepareHttpClientHandler 配置的客户端证书是否确实绑定到了当前 HttpClientHandler。
  • 证书是否包含私钥,IIS 应用程序池身份是否有权访问该私钥。
  • 服务器支持的 TLS 版本与证书链是否匹配。
  • IIS 所在机器能否解析目标域名并访问目标端口。
  • 系统代理、防火墙或出口网络是否有限制。
  • requestURL 是否正确,特别是生产、沙箱和模拟环境之间的路径差异。
  • 异常是否被 Catch 转换为返回对象后遭到忽略。调用方应该记录完整的 Error。
  • 是否设置了合理的 HttpClient.Timeout 或传入取消令牌,以免网络故障造成无限等待。

如果调用频率较高,通常应该复用 HttpClient,避免每次请求都创建新的连接池。如果不同请求需要动态更换客户端证书或 Handler 配置,则要按配置管理可复用的客户端,不能随意共享。无论采用哪种生命周期策略,都不要用 .Result 把异步网络调用重新变成同步调用。

备注:内容仅供参考。