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

Add AsyncLazy<T>.Dispose() method #1218

Merged
merged 1 commit into from
Aug 31, 2023
Merged

Conversation

AArnott
Copy link
Member

@AArnott AArnott commented Jul 13, 2023

This addresses a common need for a class with an AsyncLazy<T> field where the value is disposable to ensure on the class disposal that it can dispose of the value if and when it is constructed.

Problem to be solved

To simplify and optimize a common (and often improperly done) pattern of disposing the value, that would typically appear like this:

class Owner : IDisposable {
  AsyncLazy<IFoo> lazy;

  public void Dispose() {
    if (lazy.IsValueCreated) {
      lazy.GetValue().Dispose();
    }
  }
}

The problems with the pattern above include:

  1. They abandon an undisposed value if the value factory is started after disposal.
  2. It blocks disposal on an async operation. (IAsyncDisposable owners could avoid the sync block)
  3. Folks sometimes forget the condition and construct the value unconditionally.
  4. Folks sometimes use IsValueFactoryCompleted, which abandons an undisposed value that has begun construction but isn't done.

Solution

Add a simple Dispose() method on the AsyncLazy<T> class itself that does the right thing, in the most efficient way possible:

  1. If the value is already constructed, dispose the value on the caller's thread, if disposable.
  2. If the value's construction has already begun but not completed, schedule a task to dispose of the value after construction if the value ends up being disposable.
  3. If the value factory has not started yet, disable the value factory, causing any future calls for the value to throw ObjectDisposedException.

Some important design decisions apply and are worth debate prior to merging:

  1. Should AsyncLazy<T> itself implement IDisposable?
    I'm leaning on 'no' because that may trigger analyzers to force everyone to dispose of their AsyncLazy<T> objects, even when the constructed value does not itself implement IDisposable.
  2. Should GetValue and GetValueAsync methods throw ObjectDisposedException after Dispose is called, or should they just return the disposed value?
    I'm leaning toward throwing at the moment, but returning the disposed value will resemble the existing behavior of folks who are currently disposing of the value by hand. But returning it would mean the value is constructed, only to be disposed of, possibly amplifying race conditions with disposal and a caller's first use of the value. I am on the fence on this one.
  3. What should IsValueCreated and IsValueFactoryCompleted return after disposal? Should it vary based on whether the value factory has actually started/completed?
  4. Should we add an IsDisposed property?
    Default answer is 'no'.

Alternative design

Expose this as an extension method on AsyncLazy<T> that only appears when T : IDisposable. That would be great, except that quite often T doesn't derive from IDisposable although the constructed value may implement IDisposable. A simple example of this is brokered service proxies, which tend to be declared simply as IFoo but the concrete type also implements IDisposable. As this is a fairly common scenario, I decided to expose the Dispose method regardless of T.

Copy link
Member

@lifengl lifengl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that is a nice function to be added.

src/Microsoft.VisualStudio.Threading/AsyncLazy`1.cs Outdated Show resolved Hide resolved
src/Microsoft.VisualStudio.Threading/AsyncLazy`1.cs Outdated Show resolved Hide resolved
@jdrobison
Copy link
Member

Some opinions on your debate points:

Should AsyncLazy<T> itself implement IDisposable?
I'm leaning on 'no' because that may trigger analyzers to force everyone to dispose of their AsyncLazy<T> objects, even when the constructed value does not itself implement IDisposable.

I think if AsyncLazy<T> doesn't implement IDisposable, there could be a problem with discovering the new Dispose method. Perhaps that could be addressed by adding a new analyzer to ensure that Dispose is called if T is disposable. Not sure how difficult writing that analyzer would be.

Should GetValue and GetValueAsync methods throw ObjectDisposedException after Dispose is called, or should they just return the disposed value?
I'm leaning toward throwing at the moment, but returning the disposed value will resemble the existing behavior of folks who are currently disposing of the value by hand. But returning it would mean the value is constructed, only to be disposed of, possibly amplifying race conditions with disposal and a caller's first use of the value. I am on the fence on this one.

I like throwing rather than returning the disposed value. You make a fair point about compatibility, but the caller will already have consciously opted into the new behavior by calling Dispose in the first place.

What should IsValueCreated and IsValueFactoryCompleted return after disposal? Should it vary based on whether the value factory has actually started/completed?

IMHO, the behavior of these properties shouldn't change after disposal. They still return information that might be meaningful to the caller, regardless of whether the AsyncLazy<T> itself has been disposed.


After writing all of that, I started wondering how my answers would change if the new method was called DisposeValue instead of Dispose:

  1. Clearly AsyncLazy<T> wouldn't need to implement IDisposable.
  2. It wouldn't make sense for GetValue[Async] to throw ObjectDisposedException, since the AsyncLazy<T> itself can't be disposed.
  3. The behavior of IsValueCreated and IsValueFactoryCompleted still doesn't change, but I think the point is no longer up for debate.
  4. I suppose there could be some value in an IsValueDisposed. It might even make sense for it to return bool?, where null means that the value was never instantiated, therefore there was nothing to dispose.

AsyncLazy<T> not implementing IDisposable means it can't be used in a using clause or declaration, but I think the need for that would be rare and one can always fall back to try/finally if necessary.

@trippwill
Copy link
Member

I agree with @jdrobison's analysis. I like his musings on DisposeValue especially. Explicitness is good since AsyncLazy itself isn't disposable, just that the inner Value might be.

@AArnott
Copy link
Member Author

AArnott commented Aug 30, 2023

Thank you for your feedback. In trying to apply @jdrobison's suggestions, it occurs to me that if DisposeValue() is called before the value factory is ever invoked, that nothing will be disposed of, and a subsequent GetValueAsync() call will return an undisposed value. Or we'd create it but immediately dispose of it.
IMO a more reasonable approach is to block access to the value after disposal has been requested so there is no risk of creating a value that should have been disposed of already had it been created.
I'm still thinking through the ramifications of what that means for the rest of the feedback. More comments welcome.

@CyrusNajmabadi
Copy link

CyrusNajmabadi commented Aug 30, 2023 via email

@AArnott
Copy link
Member Author

AArnott commented Aug 30, 2023

@CyrusNajmabadi Thanks for reviewing!

The reason we lift it here and not for IEquatable<T> is because IEquatable<T> doesn't own or create a value. AsyncLazy<T> constructs the value and owns it forever after. And T is frequently disposable. Implementing responsible and correct disposal of a async lazily-initialized value is complex, and I'm trying to make that easy to get right.

@CyrusNajmabadi
Copy link

CyrusNajmabadi commented Aug 30, 2023 via email

@AArnott
Copy link
Member Author

AArnott commented Aug 30, 2023

I don't know if there is a philosophical difference between Lazy<T> and this one. But I don't own that one, and I am not aware of a common need to dispose a value that hasn't yet been fully constructed by a Lazy<T> either. I'm just looking at usage patterns of AsyncLazy<T> and see a need to address.

@AArnott
Copy link
Member Author

AArnott commented Aug 30, 2023

As for supporting IAsyncDisposable, yes I've decided that my next iteration will support that.

@CyrusNajmabadi
Copy link

CyrusNajmabadi commented Aug 30, 2023 via email

@AArnott
Copy link
Member Author

AArnott commented Aug 30, 2023

I'd prefer if all of this was owned by whoever owns the asynclazy itself.

I'm not sure what you mean by that.
I own the class. And the behavior isn't changing unless the owner of the instance opts in by calling the new DisposeValue method.

@CyrusNajmabadi
Copy link

CyrusNajmabadi commented Aug 30, 2023 via email

@AArnott
Copy link
Member Author

AArnott commented Aug 30, 2023

It's a fair thought, but since roslyn's AsyncLazy<T> class adds CancellationToken support that Lazy<T> doesn't have, your suggestion seems kinda inconsistent with the guidance your team has followed.
And aggregating CancellationToken support here the way Roslyn does is something we're considering porting over, with the idea in mind that maybe roslyn can move over to this version.

@AArnott AArnott force-pushed the disposableLazy branch 2 times, most recently from 02440b6 to 6222545 Compare August 30, 2023 22:04
Copy link
Member

@lifengl lifengl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left a comment, as I still feel it is necessary to add an extra MemoryBarrier inside the code in DisposeValueAsync side.

/// <summary>
/// A value set on the <see cref="value"/> field when this object is disposed.
/// </summary>
private static readonly Task<T> DisposedSentinel = Task.FromException<T>(new ObjectDisposedException(nameof(AsyncLazy<T>)));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if nameof(AsyncLazy<T>) is now the right parameter for the ObjectDisposedException, since that's not what's disposed. However, if you tried to smuggle something like nameof(T) into the exception, you'd have a different exception for each T.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm... At the moment I'm leaning on going ahead with the AsyncLazy value, since that's what's throwing. It may be odd to throw ODE from AsyncLazy but with a type name that belongs to something else.

@CyrusNajmabadi
Copy link

CyrusNajmabadi commented Aug 30, 2023 via email

@AArnott
Copy link
Member Author

AArnott commented Aug 31, 2023

@CyrusNajmabadi The idea is to make it easy for the AsyncLazy owner to ensure the value is disposed if it were ever created. But it also blocks creation of the value if the value factory hasn't been invoked by that point.

This addresses a common need for a class with an `AsyncLazy<T>` field where the value is disposable to ensure on the class disposal that it can dispose of the value if and when it is constructed.
@AArnott AArnott merged commit 3dab397 into microsoft:main Aug 31, 2023
6 checks passed
@AArnott AArnott deleted the disposableLazy branch October 24, 2023 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants