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: io: register close finalizers on Pipe #53362

Closed
dsnet opened this issue Jun 13, 2022 · 12 comments
Closed

proposal: io: register close finalizers on Pipe #53362

dsnet opened this issue Jun 13, 2022 · 12 comments

Comments

@dsnet
Copy link
Member

dsnet commented Jun 13, 2022

Problem Statement

A semi-common problem in Go is the io.Reader <-> io.Writer inversion problem:

  • You have an io.Writer on hand, but an API is expecting an io.Reader, OR
  • You have an io.Reader on hand, but an API is expecting an io.Writer.

One such example of this problem is #51092.

The typical way to solve this problem is to use an io.Pipe and a goroutine to shuffle data between the two. For example:

bodyReader, httpWriter := io.Pipe()
gzipWriter := gzip.NewWriter(httpWriter)
...
go func() {
	io.Copy(gzipWriter, ...)
}()

... // use bodyReady

In this pattern, it is utterly paramount that either bodyReader.Close or httpWriter.Close eventually get called, otherwise we have a goroutine leak.

However, practice shows that even experience Go programmers often fail to close the pipe in all circumstances, resulting a deadlocked resource leak.

For example, the gzio.CompressReader example present a possible resource leak depending on how it is adapted for user code. The example relies on bodyReader.Close being called by a subsequent call to http.Client.Do, but this will not happen if http.NewRequest returns an error. In the example, this is fine, since the arguments to constructor are all valid. However, in a real use-case, you can imagine that ts.URL be dynamically constructed and happens to be an invalid URL. This would cause http.NewRequest to return an error and circumvent http.Client.Do having an opportunity to call bodyReader.Close.

In almost all cases of resource leaks, one of the endpoints (either the io.PipeReader or io.PipeWriter) have disappeared and been GC'd and the other endpoint is left hanging, not realizing that it will never make progress.

Proposal

The current logic of io.Pipe is as follows:

func Pipe() (*PipeReader, *PipeWriter) {
    p := &pipe{...}
    return &PipeReader{p}, &PipeWriter{p}
}

I propose, we augment it to be like:

var errClosedPipeGCd = errors.New("io: read/write on closed pipe because it was garbage collected")

func Pipe() (*PipeReader, *PipeWriter) {
    p := &pipe{...}
    pr := &PipeReader{p}
    pw := &PipeWriter{p}
    runtime.SetFinalizer(pr, func(pr *PipeReader) { pr.CloseWithError(errClosedPipeGCd ) })
    runtime.SetFinalizer(pw, func(pw *PipeWriter) { pw.CloseWithError(errClosedPipeGCd ) })
    return pr, pw
}

Essentially, we close either endpoint once it has been GC'd, informing the other end that there is no possibility of progress being made.

I see two reasonable semantics for the finalizer:

  1. We document that this is specified behavior and users can depend on this assistance from the GC for deadlock avoidance and goroutine leakage.
  2. We only do this when running under the race detector, where we complain loudly if a Read or Write call ever sees the sentinel errClosedPipeGCd error, which suggests that a resource leak is occurring.

\cc @josharian

@dsnet dsnet added the Proposal label Jun 13, 2022
@gopherbot gopherbot added this to the Proposal milestone Jun 13, 2022
@ianlancetaylor ianlancetaylor added this to Incoming in Proposals (old) Jun 13, 2022
@rsc
Copy link
Contributor

rsc commented Jun 15, 2022

This seems like a pretty major behavior change, like when people suggest garbage collecting stuck goroutines. This garbage collects a reason that a goroutine is stuck, unsticking it.

The nice thing about deadlocks and stuck goroutines is all you have to do is look at the stacks and they're right there. Why is this important to change the semantics of?

@dsnet
Copy link
Member Author

dsnet commented Jun 15, 2022

The nice thing about deadlocks and stuck goroutines is all you have to do is look at the stacks and they're right there.

That presumes that someone is actually looking for them. The reality is that even experienced Go programmers write buggy code that results in goroutine leaks which results in deadlocks or high memory consumption that can go unnoticed for a some time. It is better if such failures can be detected earlier on.

Semantic 2 proposed above would not change the behavior, but print out in race mode when a resource leak is detected.

@rsc
Copy link
Contributor

rsc commented Jun 15, 2022

This proposal has been added to the active column of the proposals project
and will now be reviewed at the weekly proposal review meetings.
— rsc for the proposal review group

@rsc rsc moved this from Incoming to Active in Proposals (old) Jun 15, 2022
@josharian
Copy link
Contributor

The difficulty here is managing the lifetimes of the reader and writer, and ensuring that eventually one of them gets closed. In my experience, the code that calls io.Pipe in a good position to gauge that lifetime.

This suggests an alternative solution:

// PipeContext calls io.Pipe. When ctx is done, both the reader and writer are closed with the error from ctx.Err.
func PipeContext(ctx context.Context) (*io.PipeReader, *io.PipeWriter)

This can't live in package io, because dependencies.

It can be implemented straightforwardly outside of the standard library, at the cost of an additional goroutine (wrap io.Pipe) or code duplication (copy+modify io.Pipe).

Having something like it in the standard library would help with discoverability and documentation. If it existed, I'd be tempted to deprecate io.Pipe, on the grounds that it is hard to use safely.

@dsnet
Copy link
Member Author

dsnet commented Jun 16, 2022

I like the idea of PipeContext (in addition to what's proposed).

It's not clear to me that io can't depend on context, but if we wanted to break this edge, we could do:

func PipeContext(ctx interface{ Done() <-chan struct{} }) (*PipeReader, *PipeWriter)

or more simply as just:

func PipeCancelable(done <-chan struct{}) (*PipeReader, *PipeWriter)

@josharian
Copy link
Contributor

@dsnet I think it'd have to be:

func PipeContext(ctx interface{ Done() <-chan struct{}; Err() error }) (*PipeReader, *PipeWriter)

so that the error from the context closure can be propagated to CloseWithError. That's a bit of a mouthful. :)

@gopherbot
Copy link

Change https://go.dev/cl/412955 mentions this issue: compress/gzip: always close bodyReader in Example_compressingReader

gopherbot pushed a commit that referenced this issue Jun 22, 2022
For #53362
Fixes #53414

Change-Id: I352164e70c136eed210c7ee4ceba5dc631f81f94
Reviewed-on: https://go-review.googlesource.com/c/go/+/412955
Auto-Submit: Ian Lance Taylor <iant@google.com>
Reviewed-by: Joseph Tsai <joetsai@digital-static.net>
Reviewed-by: Ian Lance Taylor <iant@google.com>
Run-TryBot: Ian Lance Taylor <iant@google.com>
Reviewed-by: Alex Rakoczy <alex@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
@rsc
Copy link
Contributor

rsc commented Jun 22, 2022

It's unclear to me that io really needed to have Pipe at all.
If we were starting over, maybe it would be better to leave it out.
Do we need to put more variants of Pipe (PipeContext, PipeCancelable) in?
Why not leave them to external packages?
An external implementation could also revisit the zero-copy / synchronous behavior of io.Pipe.
In many cases you might be happier with a buffered / async version too.

@josharian
Copy link
Contributor

josharian commented Jun 22, 2022

It's unclear to me that io really needed to have Pipe at all. If we were starting over, maybe it would be better to leave it out. [...] Why not leave them to external packages?

I am sympathetic to this. But this whole conversation started (over in Gopher Slack) with the need for io.Pipe to deal with lacunae in the standard library, like #51092, or hooking up json.Encoder (writer) to http.Request.Body (reader) for the common task of making an HTTP request with a JSON body.

As a result, the standard library itself needs a way to document and/or explain the correct way to write the code. See as a data point the continuing conversation at https://go-review.googlesource.com/c/go/+/412955.

Also, for core functionality, people will empirically reach for error-prone std functions over safer third party libraries. Putting io.Pipe variants in an x repo would help with the second challenge, as x has the requisite trust factor. Another way to help that sociological problem would be to "bless" a third party library by referring to it in the io.Pipe docs. I assume Google is unwilling to do that for anything but x repos.

Do we need to put more variants of Pipe (PipeContext, PipeCancelable) in?

FWIW, since contexts are cancellable, PipeContext covers PipeCancellable. :)

But yes, a buffered version would be nice to have sometimes.

(That said, if this doesn’t go into std or x in any form, I will create a third-party library that does this right, so that I can use it.)

@rsc
Copy link
Contributor

rsc commented Jun 29, 2022

It seems clear we need a good answer for the gzip/json/http problem, but I'm not sure io.PipeContext improves that situation dramatically, given that the 1-line change in CL 412955 makes it not needed for that. It seems like we haven't found the obviously right answer yet.

@rsc
Copy link
Contributor

rsc commented Jul 13, 2022

Based on the discussion above, this proposal seems like a likely decline.
— rsc for the proposal review group

@rsc rsc moved this from Active to Likely Decline in Proposals (old) Jul 13, 2022
@rsc rsc moved this from Likely Decline to Declined in Proposals (old) Jul 20, 2022
@rsc
Copy link
Contributor

rsc commented Jul 20, 2022

No change in consensus, so declined.
— rsc for the proposal review group

@rsc rsc closed this as completed Jul 20, 2022
jproberts pushed a commit to jproberts/go that referenced this issue Aug 10, 2022
For golang#53362
Fixes golang#53414

Change-Id: I352164e70c136eed210c7ee4ceba5dc631f81f94
Reviewed-on: https://go-review.googlesource.com/c/go/+/412955
Auto-Submit: Ian Lance Taylor <iant@google.com>
Reviewed-by: Joseph Tsai <joetsai@digital-static.net>
Reviewed-by: Ian Lance Taylor <iant@google.com>
Run-TryBot: Ian Lance Taylor <iant@google.com>
Reviewed-by: Alex Rakoczy <alex@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
@golang golang locked and limited conversation to collaborators Jul 20, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
No open projects
Development

No branches or pull requests

4 participants