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 把异步网络调用重新变成同步调用。
备注:内容仅供参考。
