Skip to content

Commit 75e5737

Browse files
Use HttpCompletionOption.ResponseHeadersRead to reduce allocations (#18)
`HttpClient.SendAsync` by default uses `HttpCompletionOption.ResponseContentRead` which buffers the entire response into a `MemoryStream` (creating a copy) before returning. `HttpCompletionOption.ResponseHeadersRead` returns control after the headers are read and then if the stream is needed, whenever `HttpResponseMessage.Content.ReadAsStreamAsync()` is called, the network stream is returned instead of the in-memory buffered copy. I did some benchmarks before and after using `HttpCompletionOption.ResponseContentRead` in the different `Expect` extension methods. These were the results: Before: ![image](https://user-images.githubusercontent.com/6766854/88821617-479e9c80-d199-11ea-9639-97ac6905398b.png) After: ![image](https://user-images.githubusercontent.com/6766854/88822010-b845b900-d199-11ea-969e-7c443c9d1e52.png) Note: All requests were done to this url: "https://jsonplaceholder.typicode.com/comments" The biggest impact was on `ExpectHttpSuccess` and on `ExpectJson`, so this PR proposes using `HttpCompletionOption.ResponseContentRead` there. I wasn't able to test `ExpectProtobuf`, but it's possible that it could benefit of it as well.
1 parent 5e0b738 commit 75e5737

File tree

9 files changed

+109
-4
lines changed

9 files changed

+109
-4
lines changed

StackExchange.Utils.sln

+11-2
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
2323
appveyor.yml = appveyor.yml
2424
build.cmd = build.cmd
2525
build.ps1 = build.ps1
26+
build.sh = build.sh
2627
global.json = global.json
2728
nuget.config = nuget.config
28-
build.sh = build.sh
2929
EndProjectSection
3030
EndProject
31-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Utils.Configuration", "src\StackExchange.Utils.Configuration\StackExchange.Utils.Configuration.csproj", "{7BADB8FA-9723-4B10-B71C-427F2431D4FE}"
31+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StackExchange.Utils.Configuration", "src\StackExchange.Utils.Configuration\StackExchange.Utils.Configuration.csproj", "{7BADB8FA-9723-4B10-B71C-427F2431D4FE}"
32+
EndProject
33+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{1516F0D0-4DB0-4DAA-9B4D-FE560C2FE9FE}"
34+
EndProject
35+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StackExchange.Utils.Benchmarks", "benchmarks\StackExchange.Utils.Benchmarks\StackExchange.Utils.Benchmarks.csproj", "{0BDD7CE1-8B4A-4556-96CE-3DAABDFD5DE9}"
3236
EndProject
3337
Global
3438
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -48,6 +52,10 @@ Global
4852
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
4953
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
5054
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Release|Any CPU.Build.0 = Release|Any CPU
55+
{0BDD7CE1-8B4A-4556-96CE-3DAABDFD5DE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
56+
{0BDD7CE1-8B4A-4556-96CE-3DAABDFD5DE9}.Debug|Any CPU.Build.0 = Debug|Any CPU
57+
{0BDD7CE1-8B4A-4556-96CE-3DAABDFD5DE9}.Release|Any CPU.ActiveCfg = Release|Any CPU
58+
{0BDD7CE1-8B4A-4556-96CE-3DAABDFD5DE9}.Release|Any CPU.Build.0 = Release|Any CPU
5159
EndGlobalSection
5260
GlobalSection(SolutionProperties) = preSolution
5361
HideSolutionNode = FALSE
@@ -56,6 +64,7 @@ Global
5664
{168B503A-428F-499D-99A7-8EFC47A5FEDF} = {F0CFAC4D-516B-45DC-8F66-D58E3B1C04E1}
5765
{33716E0F-FE40-4A7A-9F58-1026EF7EBCD2} = {9133A680-3A8F-4662-AA58-B59BBDD0A60E}
5866
{7BADB8FA-9723-4B10-B71C-427F2431D4FE} = {F0CFAC4D-516B-45DC-8F66-D58E3B1C04E1}
67+
{0BDD7CE1-8B4A-4556-96CE-3DAABDFD5DE9} = {1516F0D0-4DB0-4DAA-9B4D-FE560C2FE9FE}
5968
EndGlobalSection
6069
GlobalSection(ExtensibilityGlobals) = postSolution
6170
SolutionGuid = {F211D702-85D2-4159-9B42-60B6177497B7}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
using BenchmarkDotNet.Attributes;
4+
using BenchmarkDotNet.Jobs;
5+
6+
namespace StackExchange.Utils.Benchmarks.Benchmarks
7+
{
8+
[MemoryDiagnoser]
9+
[SimpleJob(RuntimeMoniker.Net472)]
10+
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
11+
public class ResponseBuffering
12+
{
13+
private static string _jsonUrl = "https://jsonplaceholder.typicode.com/photos";
14+
15+
public class SamplePhoto
16+
{
17+
public int albumId { get; set; }
18+
public int id { get; set; }
19+
public string title { get; set; }
20+
public string url { get; set; }
21+
public string thumbnailUrl { get; set; }
22+
}
23+
24+
25+
[Benchmark(Description = "ExpectHttpSuccess with buffering")]
26+
public async Task<bool> ExpectHttpSuccessWithBuffering() => (await Http.Request(_jsonUrl).ExpectHttpSuccess().GetAsync()).Success;
27+
28+
[Benchmark(Description = "ExpectHttpSuccess without buffering")]
29+
public async Task<bool> ExpectHttpSuccessWithOutBuffering() => (await Http.Request(_jsonUrl).WithoutResponseBuffering().ExpectHttpSuccess().GetAsync()).Success;
30+
31+
32+
33+
[Benchmark(Description = "ExpectJson with buffering")]
34+
public async Task<List<SamplePhoto>> ExpectJsonWithBuffering() => (await Http.Request(_jsonUrl).ExpectJson<List<SamplePhoto>>().GetAsync()).Data;
35+
36+
[Benchmark(Description = "ExpectJson without buffering")]
37+
public async Task<List<SamplePhoto>> ExpectJsonWithoutBuffering() => (await Http.Request(_jsonUrl).WithoutResponseBuffering().ExpectJson<List<SamplePhoto>>().GetAsync()).Data;
38+
39+
40+
[Benchmark(Description = "ExpectString with buffering")]
41+
public async Task<bool> ExpectStringWithBuffering() => (await Http.Request(_jsonUrl).ExpectString().GetAsync()).Success;
42+
43+
[Benchmark(Description = "ExpectString without buffering")]
44+
public async Task<string> ExpectStringWithOutBuffering() => (await Http.Request(_jsonUrl).WithoutResponseBuffering().ExpectString().GetAsync()).Data;
45+
46+
47+
[Benchmark(Description = "ExpectByteArray with buffering")]
48+
public async Task<bool> ExpectByteArrayWithBuffering() => (await Http.Request(_jsonUrl).ExpectByteArray().GetAsync()).Success;
49+
50+
[Benchmark(Description = "ExpectByteArray without buffering")]
51+
public async Task<byte[]> ExpectByteArrayWithOutBuffering() => (await Http.Request(_jsonUrl).WithoutResponseBuffering().ExpectByteArray().GetAsync()).Data;
52+
53+
}
54+
}
55+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using BenchmarkDotNet.Running;
2+
3+
namespace StackExchange.Utils.Benchmarks
4+
{
5+
public class Program
6+
{
7+
public static void Main(string[] args) => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
8+
}
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFrameworks>net472;netcoreapp3.1</TargetFrameworks>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="BenchmarkDotNet" Version="0.12.1" />
10+
<ProjectReference Include="../../src/StackExchange.Utils.Http\StackExchange.Utils.Http.csproj" />
11+
</ItemGroup>
12+
</Project>

src/StackExchange.Utils.Http/Extensions.Expect.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ internal static Func<HttpResponseMessage, Task<T>> WithOptions(IRequestBuilder b
3232
using (var streamReader = new StreamReader(responseStream)) // Stream reader
3333
using (builder.GetSettings().ProfileGeneral?.Invoke("Deserialize: JSON"))
3434
{
35-
if (responseStream.Length == 0)
35+
if (builder.BufferResponse && responseStream.Length == 0)
3636
{
3737
return default;
3838
}

src/StackExchange.Utils.Http/Extensions.Modifier.cs

+9
Original file line numberDiff line numberDiff line change
@@ -201,5 +201,14 @@ public static IRequestBuilder WithProtocolVersion(this IRequestBuilder builder,
201201
builder.Message.Version = version;
202202
return builder;
203203
}
204+
205+
/// <summary>
206+
/// Indicates that the response's content shouldn't be buffered, setting the HttpCompletionOption accordingly.
207+
/// </summary>
208+
public static IRequestBuilder WithoutResponseBuffering(this IRequestBuilder builder)
209+
{
210+
builder.BufferResponse = false;
211+
return builder;
212+
}
204213
}
205214
}

src/StackExchange.Utils.Http/Http.cs

+4-1
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ internal static async Task<HttpCallResponse<T>> SendAsync<T>(IRequestBuilder<T>
5757
{
5858
// Get the pool
5959
var pool = builder.Inner.ClientPool ?? settings.ClientPool;
60+
61+
var completionOption = builder.Inner.BufferResponse ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead;
62+
6063
// Send the request
61-
using (response = await pool.Get(builder.Inner).SendAsync(request, cancellationToken))
64+
using (response = await pool.Get(builder.Inner).SendAsync(request, completionOption, cancellationToken))
6265
{
6366
// If we haven't ignored it, throw and we'll log below
6467
// This isn't ideal cntrol flow behavior, but it's the only way to get proper stacks

src/StackExchange.Utils.Http/HttpBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ internal class HttpBuilder : IRequestBuilder
1515
public IEnumerable<HttpStatusCode> IgnoredResponseStatuses { get; set; } = Enumerable.Empty<HttpStatusCode>();
1616
public TimeSpan Timeout { get; set; }
1717
public IWebProxy Proxy { get; set; }
18+
public bool BufferResponse { get; set; } = true;
1819
public IHttpClientPool ClientPool { get; set; }
1920
public event EventHandler<HttpExceptionArgs> BeforeExceptionLog;
2021
private readonly string _callerName, _callerFile;

src/StackExchange.Utils.Http/IRequestBuilder.cs

+7
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ public interface IRequestBuilder
4141
[EditorBrowsable(EditorBrowsableState.Never)]
4242
TimeSpan Timeout { get; set; }
4343

44+
/// <summary>
45+
/// Indicate if the response content should be buffered (e.g. for access later, vs. only streamed when false).
46+
/// Sets the HttpCompletionOption to use on this request accordingly.
47+
/// </summary>
48+
[EditorBrowsable(EditorBrowsableState.Never)]
49+
bool BufferResponse { get; set; }
50+
4451
/// <summary>
4552
/// The Proxy to use when making requests
4653
/// </summary>

0 commit comments

Comments
 (0)