Skip to content

Add eager mapping operation for Async #178

Open

Description

@treeowl

I noticed that Async has a Functor instance, which changes the result of an Async. But it (necessarily) does so entirely lazily; there's no way to ensure that computation is completed by the time wait returns. I suggest adding the following (by whatever name you like):

mapAsync :: (a -> Solo b) -> Async a -> Async b
mapAsync f (Async a w) = Async a $ w >>= \case
  Left e -> pure (Left e)
  Right r | MkSolo r' <- f r -> pure (Right r')

Activity

  1. phadej commented on Jan 6, 2026

    @phadej
    Contributor

    there's no way to ensure that computation is completed by the time wait returns.

    This is true, but your function is also misleading, as it will compute the b in the thread which calls the wait, not in the (originally spawned) asynchronous thread.

  2. treeowl commented on Jan 6, 2026

    @treeowl
    ContributorAuthor

    there's no way to ensure that computation is completed by the time wait returns.

    This is true, but your function is also misleading, as it will compute the b in the thread which calls the wait, not in the (originally spawned) asynchronous thread.

    They're equally misleading in that way. The documentation for both should reflect that.

  3. treeowl commented on Jan 6, 2026

    @treeowl
    ContributorAuthor

    Moreover, in both cases, the calculation is performed once per invocation of wait, even within the same thread. If you're mapping over an Async, you probably don't want to do anything too expensive there.

  4. simonmar commented on Jan 7, 2026

    @simonmar
    Owner

    I'm not sure I'm convinced (a) there's a problem that needs fixing and (b) if there was, that this is the right solution.

    On (a) do you have something you really wanted to do that you couldn't?

    On (b)

    • This doesn't prevent the computation being repeated by each call to wait, which is something else that one might expect to be able to do?
    • The use of Solo seems a bit weird - you can still write something that defers the work.

    Instead I think I would wrap wait in something that caches its result, there are various ways to do that.

  5. treeowl commented on Jan 16, 2026

    @treeowl
    ContributorAuthor

    I'm not sure I'm convinced (a) there's a problem that needs fixing and (b) if there was, that this is the right solution.

    On (a) do you have something you really wanted to do that you couldn't?

    I was looking at this module for an unrelated (and uninteresting) reason, and happened to notice the Functor instance. What is it for? As we've both mentioned, the computation it introduces is repeated for each wait call, so it's probably best used as a lightweight shim when passing an Async to something that needs an Async of a slightly different type—say, if you have an Async (a, b) and need an Async a. Typically, the result of wait will be used immediately, yes, but occasionally it might be stored in a data structure, in which case it would be best if it weren't a silly thunk.

    On (b)

    * This doesn't prevent the computation being repeated by each call to `wait`, which is something else that one might expect to be able to do?
    

    It does not, and neither does fmap. The current Async design doesn't give us any way to avoid that, and I can't think of any similar design that would. We can't create a new thread to perform the computation, because we're in an entirely pure context.

    * The use of `Solo` seems a bit weird - you can still write something that defers the work.
    

    I believe the use of Solo offers as much flexibility to the caller as possible without breaking the abstraction barrier. It allows the caller to decide exactly how much of the computation should be performed before returning.

    -- Discard the second component to avoid leaking it, but defer an expensive function
    -- call.
    (`mapAsync` m) $ \(a, _) -> MkSolo (f a)
    
    -- Force the full calculation
    (`mapAsync` m) $ \a -> MkSolo $! g a

    In principle, the most powerful such function would be

    bazooka :: Async a -> (a -> STM b) -> Async b
    bazooka (Async tid m) f = Async tid (m >>= f)

    But (as I read it) the use of STM is an implementation detail, and bazooka doesn't feel like something anyone would likely want.

    Instead I think I would wrap wait in something that caches its result, there are various ways to do that.

    I would imagine that in most cases, fmap for Async isn't very useful if you can tweak the wait call that receives it. After all, it's a shim. I am curious how you would wrap wait to avoid this sort of recalculation, though.

  6. simonmar commented on Jan 19, 2026

    @simonmar
    Owner

    My high-level view on this is that the need to control evaluation order in this way is relatively rare, so there needs to be a strong motivation to add something to an API for the purposes of evaluation-order control only. I'm not seeing a strong motivation here, and there are easy workarounds.

    The Function instance is occasionally useful, and (at least in my experience) the fact that it doesn't get forced by wait has never been a problem. Often you're immediately consuming the result, which forces it anyway, and it's rare to wait more than once.

    The use of STM isn't an implementation detail - there are operations like waitSTM for example. Your bazooka is potentially useful I'd say (but needs a better name!)

    To cache wait you could use something like https://hackage-content.haskell.org/package/extra-1.8.1/docs/Control-Concurrent-Extra.html#v:once

    I'll leave this PR open in case others have thoughts, but for now I'm not persuaded to do anything.

  7. treeowl commented on Jan 19, 2026

    @treeowl
    ContributorAuthor

    The use of STM isn't an implementation detail - there are operations like waitSTM for example. Your bazooka is potentially useful I'd say (but needs a better name!)

    Couldn't waitSTM hypothetically be implemented like this?

    waitSTM = unsafeIOtoSTM . wait

    If you're happy to have bazooka, that's good enough to implement my mapAsync, so that's fine by me. Perhaps call it afterWaitSTM, to indicate that it adds an action to perform after each wait?

  8. simonmar commented on Jan 20, 2026

    @simonmar
    Owner

    The use of STM isn't an implementation detail - there are operations like waitSTM for example. Your bazooka is potentially useful I'd say (but needs a better name!)

    Couldn't waitSTM hypothetically be implemented like this?

    waitSTM = unsafeIOtoSTM . wait

    Goodness me no 😆

    unsafeIOToSTM is really unsafe, there's no guarantee this would respect the STM semantics, even if wait is just a call to atomically as it is now.

  9. treeowl commented on Jan 20, 2026

    @treeowl
    ContributorAuthor

    For my own curiosity, what makes that approach unsafe for this particular operation? If you can't even use it for this, does it have any use at all?

  10. simonmar commented on Jan 20, 2026

    @simonmar
    Owner

    Well, it is indeed debatable whether unsafeIOToSTM has any truly safe uses. Obviously you can't do any side effects in there because the effects can't be rolled back if the transaction aborts. Typically it's used for debugging where the unsafety doesn't matter. Is wait a side effect? Well, the context of this is you were asking whether we can consider STM an implementation detail, so we don't know the implementation of wait - it might mutate some internal data structures, and therefore we can't assume it's safe to use with unsafeIOToSTM.

    But suppose we just want to know whether it would be "safe" in some sense to define waitSTM = unsafeIOToSTM . wait with the current definition of wait - frankly I don't know. Maybe. It relies on an assumption that the STM computation inside the Async doesn't write to anything, which is probably true right now but we have no way to enforce that, and if you want to add afterWaitSTM then the user could violate it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions