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: x/exp/future: new package to implement futures #56461

Open
DeedleFake opened this issue Oct 27, 2022 · 5 comments
Open

proposal: x/exp/future: new package to implement futures #56461

DeedleFake opened this issue Oct 27, 2022 · 5 comments
Labels
Milestone

Comments

@DeedleFake
Copy link

Futures have been proposed previously in various forms, most notably in #17466 where it was turned down due to being too unimportant to be done as a language change. Since then, generics have been added to the language, making it quite simple to implement futures in a type-safe way without needing any language changes. So here's a proposal to do just that.

Conceptually, a future functions similarly to a one-time use channel that can yield the value that it's given more than once. The benefit over just a normal channel is that it simplifies a 1:M case where multiple receivers need to wait on the same piece of data from a single source. This covers some of the cases, for example, that #16620 is meant to handle.

Here's a possible implementation:

package future

import (
	"fmt"
	"sync"
	"time"
)

type Future[T any] struct {
	done chan struct{}
	val  T
}

func New[T any]() (f *Future[T], complete func(T)) {
	var once sync.Once
	f = &Future[T]{
		done: make(chan struct{}),
	}

	return f, func(val T) {
		once.Do(func() {
			f.val = val
			close(f.done)
		})
	}
}

// Unsure on the name. Maybe Ready or something instead?
func (f *Future[T]) Done() <-chan struct{} {
	return f.done
}

// Maybe Value instead of Get? Blocking in Value feels weird to me, though.
func (f *Future[T]) Get() T {
	<-f.done
	return f.val
}

And an example of the usage:

func example() *future.Future[string] {
  f, complete := future.New[string]()
  go func() {
    time.Sleep(3 * time.Second) // Long-running operation of some kind or something.
    complete("Voila.")
  }()
  return f
}

func main() {
  r := example()
  fmt.Println(r.Get())
}

This API is patterned after context.Context. The exposure of the done channel makes it simple to wait for a future in a select case, but it's not necessary to use it to get the value in a safe way.

If the package was deemed to be useful after sitting in x/exp, it could be promoted to either x/sync or directly into sync.

@gopherbot gopherbot added this to the Proposal milestone Oct 27, 2022
@seankhliao
Copy link
Member

Sounds like something that can be prototyped outside of the Go project to demonstrate its worth.

@DeedleFake
Copy link
Author

It could indeed. I've created an external package with the proposed implementation.

@rittneje
Copy link

I would expect Get() to return (T, error) to account for the failure case. (And likewise the callback would be func(T, error).)

Also, it could be useful to change the signature to Get(context.Context) (T, error) , or at least have a GetWithContext method.

@DeedleFake
Copy link
Author

I would expect Get() to return (T, error) to account for the failure case. (And likewise the callback would be func(T, error).)

I think that's unnecessary. Much like a channel, if you want to return an error and another value, just use a struct. Or, if #56462 or something similar got accepted, use that.

Also, it could be useful to change the signature to Get(context.Context) (T, error) , or at least have a GetWithContext method.

I thought of that, but it just seemed like overkill. If you want to use a context, you can just do

select {
case <-ctx.Done():
  // ...
case <-f.Done():
  // ...
}

I see no need to complicate the implementation for an easily handled, and probably unlikely, scenario.

@apparentlymart
Copy link

apparentlymart commented Nov 2, 2022

I feel in two minds about this proposal.

I think if it's intentionally constrained to be like a "write once, ready many" channel -- no additional "value add" capabilities such as automatic context tracking, error handling, etc -- then it could be a nice utility helper for a relatively common pattern that would hopefully be easy to understand for anyone who is already familiar with channels.

What's proposed above does seem to meet that criteria: Future.Get is analogous to <-ch, calling the "complete" function is analogous to ch<- ..., and Future.Done compensates for the fact that only first-class channels can work with select to wait for any one of multiple events by exposing the minimal possible channel-based public API.

If this were added then I would expect to use it in a similar way to how I typically use channels today: largely only as an implementation detail inside a library that encapsulates some concurrent work and not exposed prominently in the public API design.

But at the same time, that also reduces the value of it being in the standard library: if I won't typically be exposing Future values in my public API then there's no reason why I need to agree with other libraries about which Future implementation I'm using, and so it's fine to use either an inline implementation or an third-party library to handle this.

If this did start to become a significant part of public-facing library APIs then that's where I start to become more concerned about it, because it seems like that would significantly change the "texture" of Go code using this facility:

It's pretty rare today for a high-level library (as opposed to low-level synchronization helpers) to start an operation in the background and return an object representing the future result. Instead, library functions typically block and expect their caller to start a new goroutine if the blocking is inconvenient. It would be unfortunate if Go ended up in a similar position as some other ecosystems where there are parallel sets of libraries for "blocking style" vs. "async style" approaches to the same problem.

For now then, my conclusion is a conservative "no" vote (also represented in the reactions to the original issue), not because I don't think this would be useful but because I don't think it passes the typical bar for inclusion in the standard library unless we expect it to be broadly used in the public APIs of shared libraries, and the consequences of that concern me. I am curious to see whether and how deedles.dev/syncutil might be adopted in real-world code, though.

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

5 participants