Async holes: StringContent

Spread the love

In a previous post, I introduced the concept of “async holes” — those unexpected gaps and obstacles when using asynchronous APIs in .NET. This time I will tell the tale of the async hole in StringContent.

System.Net.Http.StringContent is the derived class of choice for testing code written using ASP.NET Web API. It is derived from ByteArrayContent and in turn from HttpContent. Here is a simple client-side use case for HttpContent, demonstrating how to stream content from an HTTP REST response:

public class MyClient
{
    private readonly HttpClient client;

    public MyClient(HttpClient client)
    {
        this.client = client;
    }

    public async Task DownloadAsync(string name, Stream output)
    {
        using (HttpResponse response = await this.client.GetAsync(name))
        {
            HttpContent content = response.Content;
            await content.CopyToAsync(output);
        }
    }
}

In cases where the content may be quite large (say, a huge file download), you could provide a FileStream to avoid needing to buffer the entire response in memory at once. We also have the flexibility of providing a MemoryStream when we know that we want the data to end up only in memory — the most common case in a unit test. In that situation, you would likely also provide the content from a fixed memory buffer and avoid any network I/O; for that purpose, we have StringContent and/or ByteArrayContent. Now this is where the async hole comes in. Let’s use this code snippet to demonstrate the problem:

private static async Task ReadContentAsync()
{
    Encoding encoding = Encoding.UTF8;
    string text = "hello!";

    HttpContent content = new StringContent(text, encoding);

    Log("Copying to stream...");
    using (MemoryStream stream = new MemoryStream())
    {
        await content.CopyToAsync(stream);

        string result = encoding.GetString(stream.ToArray());
        Log("Result: '{0}'", result);
    }
}

private static void Log(string format, params object[] args)
{
    string text = string.Format(CultureInfo.InvariantCulture, format, args);
    Console.WriteLine("[{0}] {1}", Thread.CurrentThread.ManagedThreadId, text);
}

In this example, you will note that all pieces of data are 100% in-memory. Hence, you might expect that the stream copy would follow the pattern of most MemoryStream use cases and complete 100% synchronously. Sadly, that is not the case, according to the output:

[1] Copying to stream...
[4] Result: 'hello!'

The stream copy has a pesky thread switch! To see why, let’s fire up ILSpy and “Open from GAC…” the System.Net.Http assembly. After a bit of digging, we can determine that HttpContent.CopyToAsync calls an abstract method SerializeToStreamAsync. In the case of StringContent, that method is overridden by the base class ByteArrayContent, with an implementation similar to the following:

protected override Task SerializeToStreamAsync(Stream stream, TransportContext context)
{
    return Task.Factory.FromAsync(stream.BeginWrite, stream.EndWrite, this.content, this.offset, this.count, null);
}

So rather than calling WriteAsync, it uses the legacy APM methods Begin/EndWrite instead. Since MemoryStream does not override the legacy asynchronous methods (only the Task-based Read/WriteAsync are overridden), the thread pool implementation from the Stream base class takes over.

For users of .NET 4.7.1 and below, the above situation holds. However, .NET Core 2.0 in its typical fashion has made some advances. From the ByteArrayContent source code, we can see that SerializeToStreamAsync uses WriteAsync instead. Thus, until and unless you switch to CoreFX, mind the thread switch from StringContent.

Leave a Reply

Your email address will not be published. Required fields are marked *