Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: net/http: add Request.CopyTo #68501

Open
ainar-g opened this issue Jul 17, 2024 · 9 comments
Open

proposal: net/http: add Request.CopyTo #68501

ainar-g opened this issue Jul 17, 2024 · 9 comments
Labels
Milestone

Comments

@ainar-g
Copy link
Contributor

ainar-g commented Jul 17, 2024

Proposal Details

The Issue

Currently, the addition of new contexts to net/http.Requests always allocates. Even when net/http.Request.WithContext is used, which creates a shallow copy of the original request with the new context, there is still the allocation of a new Request value:

func (r *Request) WithContext(ctx context.Context) *Request {
	if ctx == nil {
		panic("nil context")
	}
	r2 := new(Request)
	*r2 = *r
	r2.ctx = ctx
	return r2
}

This is a problem, since the pattern of taking the original context, creating a child context with additional data, and creating a copy of the original request with the new context is very common in HTTP middlewares.

Proposal

I propose adding a new method, net/http.Request.CopyTo:

// CopyTo writes a shallow copy of r into r2 and sets its context to ctx. The
// provided ctx and r2 must be non-nil.
//
// For outgoing client request, the context controls the entire lifetime of a
// request and its response: obtaining a connection, sending the request, and
// reading the response headers and body.
//
// To create a new request with a context, use [NewRequestWithContext]. To make
// a deep copy of a request with a new context, use [Request.Clone]. To make
// a shallow copy of a request with a new context, use [Request.WithContext].
func (r *Request) CopyTo(ctx context.Context, r2 *Request) {
	if ctx == nil {
		panic("nil context")
	} else if r2 == nil {
		panic("nil r2")
	}
	*r2 = *r
	r2.ctx = ctx
}

(The name is inspired by github.com/miekg/dns.Msg.CopyTo and is bikesheddable.)

Request.WithContext can thus be rewritten into:

func (r *Request) WithContext(ctx context.Context) *Request {
	r2 := new(Request)
	r.CopyTo(ctx, r2)
	return r2
}

This allows HTTP middlewares to reuse Request structs by using sync.Pools or some other mechanism to reuse the memory.

Related

@gopherbot gopherbot added this to the Proposal milestone Jul 17, 2024
@gabyhelp
Copy link

Related Issues and Documentation

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

@mateusz834
Copy link
Member

You can copy the http.Request to a sync.Pooled *http.Request, consider:
This does not allocate, because WithContext gets inlined.

var sink *http.Request = new(http.Request)

func BenchmarkTest(b *testing.B) {
	r := http.Request{}
	for range b.N {
		*sink = *r.WithContext(context.Background())
	}
}
BenchmarkTest-12        76986970                15.07 ns/op            0 B/op          0 allocs/op

@ianlancetaylor
Copy link
Contributor

CC @neild @bradfitz

@ainar-g
Copy link
Contributor Author

ainar-g commented Jul 18, 2024

@mateusz834, thanks! That's an interesting optimization, but it seems like it depends heavily on the surrounding function and the compiler's ability to analyse the inlinability, so anyone reading should probably put that into a separate function:

func CopyRequestTo(ctx context.Context, r *http.Request, clone *http.Request) {
	*clone = *r.WithContext(ctx)
}

@mateusz834
Copy link
Member

@ainar-g If this gives real performance improvement we can always make sure that the WithContext gets inlined with a test, instead of adding a special API for that.

@neild
Copy link
Contributor

neild commented Jul 23, 2024

The motivation for this proposal is performance.

I think that the first step would be to demonstrate the real-world improvement from using this API. The HTTP server is pretty allocation heavy (and the representation of headers as a map makes allocations hard to avoid), and a middleware layer which is modifying an inbound request context is probably using context.WithValue which is also allocation-heavy. Does knocking off one allocation for a new Request make a practical difference? Microbenchmarks of Request.WithContext calls are going to be less interesting than an end-to-end test of the full serving path.

@ainar-g
Copy link
Contributor Author

ainar-g commented Jul 24, 2024

@mateusz834, that would be good to have, although it also feels a bit magical.

@neild, could you please provide a good example of measuring the full serving path, so that I could measure it? I may be misunderstanding what you're asking, but as far as I can see there is no way to benchmark something like an http.Server allocating the initial request and calling the server's handler, and so on.

As for the real-world improvement, this issue has been motivated by some benchmarks I've made on a middleware covering a relatively high-load API area. http.Request.WithContext in middlewares happened to be the main cause of allocations, as http.Request is a very large structure, that could not be easily optimized away (at least not until @mateusz834's optimization proposal).

@mateusz834
Copy link
Member

Just as a side note the current issue with WithContext exists because the escape-analysis cannot prove that the handler does not escape the *http.Request struct. Maybe instead of adding a API we can figure out a way to fix this at the compiler level?

We also have an issue #62653, which proposes a way to reduce allocations caused by interfaces, maybe the compiler can be improved with a similar idea? escape-analysis could analyse every functions with the same signature across entire app, and see whether arguments escape, not ideal but it could remove allocations in case like this, where *http.Request is probably never going to be escaped.

@neild
Copy link
Contributor

neild commented Jul 24, 2024

@neild, could you please provide a good example of measuring the full serving path, so that I could measure it?

Make a small HTTP server serving a static response, including a middleware layer that (say) adds a context value to each request. Maybe look up the context value in the request handler, to be a bit more realistic.

Then use something like wrk or hey to send some load to it and measure the max requests/second.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: Incoming
Development

No branches or pull requests

6 participants