一:背景 1. 一個有趣的話題 最近在看 硬體異常 相關知識,發現一個有意思的空引用異常問題,拿出來和大家分享一下,為了方便講述,先上一段有問題的代碼。 namespace ConsoleApp2 { internal class Program { static Person person = n ...
我有用戶給我報告一個記憶體不足的問題,經過了調查,找到了依然是使用已經被標記過時的 HttpWebRequest 進行文件推送,推送過程中,由於 System.Net.RequestStream 將會完全將推送的文件全部讀取到記憶體,導致了在 x86 應用下,推送超過 500MB 的文件,基本上都會拋出 OutOfMemoryException 異常
這是一個 .NET Core 和 .NET Framework 行為的差異。在 .NET Framework 下,調用 WebRequest.Create 方法創建一個 HttpWebRequest 對象,使用 HttpWebRequest 對象調用 GetRequestStream 方法即可獲取請求的 Stream 用於寫入數據,寫入的數據可以是一個文件的信息
在 .NET Framework 下,將會在 GetRequestStream 方法時,嘗試和伺服器建立連接。對 RequestStream 寫入內容,將會發送給到伺服器。然而在 .NET Core 裡面,這個邏輯和網路優化是衝突的,而且 HttpWebRequest 這個 API 設計本身就存在缺陷。為了讓 dotnet 底層的網路通訊方式統一,在 dotnet core 3.1 及更高版本,讓 HttpWebRequest 底層走的和 HttpClient 相同的邏輯。當然,我沒有考古 dotnet core 3.1 以前的故事
在 dotnet 6 下,調用 GetRequestStream 方法時,將不會立刻和伺服器建立連接,這是和 dotnet framework 最大的不同。在 dotnet 6 下,調用 GetRequestStream 方法將立刻返回一個 System.Net.RequestStream 對象,大概代碼如下
public override Stream GetRequestStream()
{
return InternalGetRequestStream().Result;
}
private Task<Stream> InternalGetRequestStream()
{
_requestStream = new RequestStream();
return Task.FromResult((Stream)_requestStream);
}
對 System.Net.RequestStream 對象進行寫入時,由於 dotnet 6 下的 GetRequestStream 不會和伺服器建立連接,因此寫入的數據也不會立刻發送給伺服器。這也就是大家將會發現在 dotnet 6 下調用 GetRequestStream 方法將會返回特別快速的原因
既然 RequestStream 不會立刻發送出去,為了不丟失數據,就只能緩存到記憶體。大家看看 RequestStream 的實現是多麼簡單,以下代碼就是從 dotnet 官方倉庫拷貝的,刪除了部分不重要的邏輯。可以看到在 RequestStream 的實現裡面,其實就是封裝一個 MemoryStream 而已,而且只支持寫入,寫入的內容就放入到 MemoryStream 裡面
namespace System.Net
{
// Cache the request stream into a MemoryStream. This is the
// default behavior of Desktop HttpWebRequest.AllowWriteStreamBuffering (true).
// Unfortunately, this property is not exposed in .NET Core, so it can't be changed
// This will result in inefficient memory usage when sending (POST'ing) large
// amounts of data to the server such as from a file stream.
internal sealed class RequestStream : Stream
{
private readonly MemoryStream _buffer = new MemoryStream();
public RequestStream()
{
}
public override void Flush()
{
// Nothing to do.
}
public override Task FlushAsync(CancellationToken cancellationToken)
{
// Nothing to do.
return cancellationToken.IsCancellationRequested ?
Task.FromCanceled(cancellationToken) :
Task.CompletedTask;
}
public override long Length
{
get
{
throw new NotSupportedException();
}
}
public override long Position
{
get
{
throw new NotSupportedException();
}
set
{
throw new NotSupportedException();
}
}
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
ValidateBufferArguments(buffer, offset, count);
_buffer.Write(buffer, offset, count);
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
ValidateBufferArguments(buffer, offset, count);
return _buffer.WriteAsync(buffer, offset, count, cancellationToken);
}
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? asyncCallback, object? asyncState)
{
ValidateBufferArguments(buffer, offset, count);
return _buffer.BeginWrite(buffer, offset, count, asyncCallback, asyncState);
}
public override void EndWrite(IAsyncResult asyncResult)
{
_buffer.EndWrite(asyncResult);
}
public ArraySegment<byte> GetBuffer()
{
ArraySegment<byte> bytes;
bool success = _buffer.TryGetBuffer(out bytes);
Debug.Assert(success); // Buffer should always be visible since default MemoryStream constructor was used.
return bytes;
}
}
}
也如上面代碼的註釋,在 .NET 6 使用此方法 POST 一段大一點的數據,將會非常的浪費記憶體。這就是上文說的,對於 x86 應用來說,如果發送一個超過 500MB 的文件,基本上都會拋出記憶體不足。使用 MemoryStream 時,申請的記憶體都是兩倍兩倍申請的,超過 500MB 的數據,將會在 MemoryStream 申請 1GB 的記憶體空間,對於 x86 的應用來說,基本上能用的記憶體就是只有 2GB 空間,就為了上傳一個文件,申請一段 1GB 的連續空間,對大部分應用來說,即使現在剩餘的空間還有超過 1GB 但是剩餘的空間卻不是連續的,存在一定記憶體碎片
大家可以看到在 RequestStream 裡面,連讀取的方法都標記不可用,那在什麼使用用到呢。可以看到 RequestStream 多實現了 GetBuffer 方法,這個方法將可以獲取所有的數據
在調用 GetResponse 時,才會真的使用 RequestStream 的數據。在 dotnet 6 的調用 GetResponse 方法實現如下
public override WebResponse GetResponse()
{
try
{
_sendRequestCts = new CancellationTokenSource();
return SendRequest(async: false).GetAwaiter().GetResult();
}
catch (Exception ex)
{
throw WebException.CreateCompatibleException(ex);
}
}
底層調用的是 SendRequest 方法,咱再來看看這個方法是如何使用 RequestStream 數據
private async Task<WebResponse> SendRequest(bool async)
{
var request = new HttpRequestMessage(new HttpMethod(_originVerb), _requestUri);
bool disposeRequired = false;
HttpClient? client = null;
try
{
client = GetCachedOrCreateHttpClient(async, out disposeRequired);
if (_requestStream != null)
{
// 在這裡使用到 RequestStream 數據
ArraySegment<byte> bytes = _requestStream.GetBuffer();
request.Content = new ByteArrayContent(bytes.Array!, bytes.Offset, bytes.Count);
}
// Copy the HttpWebRequest request headers from the WebHeaderCollection into HttpRequestMessage.Headers and
// HttpRequestMessage.Content.Headers.
foreach (string headerName in _webHeaderCollection)
{
// The System.Net.Http APIs require HttpRequestMessage headers to be properly divided between the request headers
// collection and the request content headers collection for all well-known header names. And custom headers
// are only allowed in the request headers collection and not in the request content headers collection.
// 拷貝 Head 邏輯
}
request.Headers.TransferEncodingChunked = SendChunked;
_sendRequestTask = async ?
client.SendAsync(request, _allowReadStreamBuffering ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead, _sendRequestCts!.Token) :
Task.FromResult(client.Send(request, _allowReadStreamBuffering ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead, _sendRequestCts!.Token));
HttpResponseMessage responseMessage = await _sendRequestTask.ConfigureAwait(false);
HttpWebResponse response = new HttpWebResponse(responseMessage, _requestUri, _cookieContainer);
return response;
}
finally
{
if (disposeRequired)
{
client?.Dispose();
}
}
}
可以看到在 HttpWebRequest 底層是通過 HttpClient 來發送網路請求,在如上面代碼註釋,將 RequestStream 的數據取出作為 ByteArrayContent 進行發送。這是一個很浪費的行為,因為如果能直接使用 HttpClient 進行網路請求,那直接使用 Stream 即可,可以減少一次記憶體的拷貝和記憶體占用
也如上面代碼,可以看到,完全可以使用 HttpClient 代替 HttpWebRequest 的調用。而且也如上面代碼,可以看到 HttpWebRequest 是將請求存放在 _requestStream
欄位,天然就不支持復用,從性能和 API 設計,都不如 HttpClient 好用
可以通過如下方式獲取本文的源代碼,先創建一個空文件夾,接著使用命令行 cd 命令進入此空文件夾,在命令行裡面輸入以下代碼,即可獲取到本文的代碼
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 7a8217d8c6f6915360f1e25b06f3166c955b8e0e
以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
獲取代碼之後,進入 BujeardalljelKaifeljaynaba 文件夾
那此記憶體大量占用問題可以如何解決呢?十分簡單,換成 HttpClient 即可
原本 HttpWebRequest 底層就是調用 HttpClient 實現發送網路請求,由因為 HttpWebRequest 的 API 限制,導致了只能將文件的數據先全部讀取到記憶體,再進行發送。如果換成 HttpClient 的話,扔一個 StreamContent 進去即可
上傳大文件的時候,還有另外一個坑,那就是上傳超時的問題。在 dotnet 6 改了行為,原本的 HttpWebRequest 是分為兩個階段,一個是建立連接的超時判斷,另一個是獲取響應階段,在建立連接和獲取響應中間的上傳數據是不會有超時影響的。但是在 dotnet 6 採用了 HttpClient 作為底層,預設的超時時間是包含整個網路請求活動,也就是建立連接到上傳數據完成這個時間不能超時。這個坑將會影響到原本在 .NET Framework 能跑的好好的邏輯,升級到 dotnet 6 將會在上傳文件時拋出超時異常。解決方法請看 dotnet 6 使用 HttpClient 的超時機制
博客園博客只做備份,博客發佈就不再更新,如果想看最新博客,請到 https://blog.lindexi.com/
本作品採用知識共用署名-非商業性使用-相同方式共用 4.0 國際許可協議進行許可。歡迎轉載、使用、重新發佈,但務必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含鏈接:http://blog.csdn.net/lindexi_gd ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。如有任何疑問,請與我[聯繫](mailto:[email protected])。