-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Start moving workspace events to fire on background threads #78134
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
Start moving workspace events to fire on background threads #78134
Conversation
This addresses a major portion of #78091 There are still some WorkspaceChanged events that haven't been switched over. This is big enough without doing all those and I'd like to get a ci run on this to see how broken tests are. Additionally, this didn't tackle adding the WorkspaceChangedEventOptions, that can be added later if we determine it would provide value.
...anguageServer/Protocol/Features/Diagnostics/DiagnosticAnalyzerService_IncrementalAnalyzer.cs
Outdated
Show resolved
Hide resolved
src/VisualStudio/Core/Def/LanguageService/AbstractCreateServicesOnTextViewConnection.cs
Outdated
Show resolved
Hide resolved
src/VisualStudio/Xaml/Impl/Implementation/XamlProjectService.cs
Outdated
Show resolved
Hide resolved
@@ -596,6 +603,13 @@ protected internal Task ScheduleTask(Action action, string? taskName = "Workspac | |||
return _workQueue.WaitUntilCurrentBatchCompletesAsync(); | |||
} | |||
|
|||
[SuppressMessage("Style", "VSTHRD200:Use \"Async\" suffix for async methods", Justification = "This is a Task wrapper, not an asynchronous method.")] | |||
internal Task ScheduleBackgroundTask(Func<Task> action) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interesting. slightly surprised this returns a Task.
|
||
namespace Microsoft.CodeAnalysis; | ||
|
||
internal sealed class WorkspaceEventRegistration(Action unregisterAction) : IDisposable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
possible fro this to be a struct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Anythings possible! But, I don't know if it's desirable. I would expect most users to interact with this as a stored IDisposable, which means boxing if this is a struct. Also, this is only allocated during event registration, so not the highest of traffic paths.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That was my thinking:
- These are allocated rarely (i.e. maybe a few each time you open a document.)
- If we made it a struct I'd assume we would have to block copying of it, otherwise semantics might get a bit strange there.
I also proposed a type (versus directly returning IDisposable) because one thing I noticed while looking at existing uses of this is some users of WorkspaceChanged would benefit from a "wait until my handler has gotten all notifications up to this point", which probably means this gets a bit more state.
} | ||
|
||
return Task.WhenAll(eventHandlerTasks); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interesting. i was not expecting this. i woudl not have thought that raising the event would have us wait on the receiver completing their work.
I'd be moer of a fan of the event just firing on the BG. and potentially telling receivers: you need to be synchronous. if you need async, add an item to your own async BG queue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is the one I'm thinking of, it's a strange method in that it returns a Task representing the completion of the work...which all of the callers then ignore. I'd absolutely say we should change this signature if it wasn't the fact that's a breaking change...
src/Workspaces/Core/Portable/Workspace/Workspace_EventsAsync.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/AsyncEventMap.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/AsyncEventMap.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/AsyncEventMap.cs
Outdated
Show resolved
Hide resolved
Overall, this is giving me a little oogyness. I like teh high level idea. But i want to be on the same page on some of the details. Esp. as this would likely become a new public API, and we'll want to feel very confident about it. |
2) Remove Obsolete attribute as that will require making the new registration public 3) Remove delegate passed to WorkspaceRegistration construction. 4) Switch new code to use SemaphoreSlim instead of NonReentrantLock 5) Rename methods on AsyncEventHandlerSet
Do we need the async callback version at all. I don't mind these being events. Just issued in the bg. If we do need the async version, I'd like to know the case. It's possible we might go with a public fire-synchronously model, and have a could internal async registration apis for the very special case. |
I think it was mostly to support handlers that needed to run on the main thread to do the switch itself without resorting to JTF usage. However, maybe the registration could just take in a flag indicating the threading context it prefers to be called on, and then we don't need the whole task part of the api. @jasonmalinowski -- thoughts? |
I'd love if we either:
basically, don't design the work here about what is (hopefully) a single case, or potentially a case we can remove :) |
That was one reason I had in mind; I also wasn't sure if you'd see cases in external code where in a WorkspaceChanged handler they're trying to update some shared state and need to acquire a lock. And generally, from an API perspective, it's a bit easier to allow for asynchrony (and maybe folks are returning Task.CompletedTask) rather than not having it, and then a use case discovering you needed it. There's a bit of a bias from our codebase since we have AsyncBatchingWorkQueue, we tend to use it in most of the places using WorkspaceChanged, but other consumers might not be as lucky -- we probably should try to find some external uses if we can to see what they're all doing. We could make the threading context explicit, but I think in the case of Inline Rename it could still start off the UI thread where it does filtering, and then move to the UI thread once it knows something needs to happen. But it could also do that "fire and forget". If the concern about adding more overloads, then of course having an overload take the configuration isn't helping that.... I don't have a problem though with having an overload that just takes Action and calls the other method as a simplification. Or maybe for round one (since we're right now focusing on making an API for internal use) we just make it synchronous and find a different approach for the handlers that might need async. My 'just make it async' was more the general habit to avoid discovering later we needed it. But we could keep it that way...and maybe use ValueTask instead. (I do have a temptation to file a C# LDM proposal for a 'sync' modifier on a method that makes it clear that yes, I want the compiler to transform it to task-returning, but yes, I know it's synchronous without the ugliness of the pragma.) |
…el that takes in an eventoptions that allows picking the callback thread
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
private void AddEventHandler<TEventArgs>(EventHandler<TEventArgs> eventHandler, WorkspaceEventType eventType) | ||
where TEventArgs : EventArgs | ||
{ | ||
// Require main thread on the callback as this is used from publicly exposed eventss |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Require main thread on the callback as this is used from publicly exposed eventss | |
// Require main thread on the callback as this is used from all the existing publicly exposed events | |
// which historically ran on the UI thread, and which extenders almost certainly have taken a | |
// dependency on. |
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
2) Add assert 3) Formatting changes
…lls in LspWorkspaceRegistrationServices weren't doing matched calls to workspace changed registration
test insertion doesn't show any regressions: https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/629923 |
...rFeatures/Core/Classification/Syntactic/SyntacticClassificationTaggerProvider.TagComputer.cs
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not entirely done, but leaving comments so far.
@@ -11,20 +11,21 @@ internal partial class TaggerEventSources | |||
{ | |||
private sealed class DocumentActiveContextChangedEventSource(ITextBuffer subjectBuffer) : AbstractWorkspaceTrackingTaggerEventSource(subjectBuffer) | |||
{ | |||
private WorkspaceEventRegistration? _documentActiveContextChangedDisposer; | |||
|
|||
// Require main thread on the callback as RaiseChanged implementors may have main thread dependencies. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was this a generic concern about risk, or do we actually have a known case where we still have a dependency? What's surprising here to me is we have a bunch of event sources, not just this one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't do the transitive look into whether advisers to AbstractTaggerEventSource.Changed have main thread dependencies.
Generally, I wanted to be conservative in the callbacks moved off the main thread in this PR. I plan to have another PR after this one that will finish off the workspace event switch to the new API and at that point, I'll reprofile and see if any of the remaining main thread callbacks are showing up in the profile.
@@ -14,11 +14,8 @@ private DiagnosticIncrementalAnalyzer CreateIncrementalAnalyzer(Workspace worksp | |||
private DiagnosticIncrementalAnalyzer CreateIncrementalAnalyzerCallback(Workspace workspace) | |||
{ | |||
// subscribe to active context changed event for new workspace | |||
workspace.DocumentActiveContextChanged += OnDocumentActiveContextChanged; | |||
_ = workspace.RegisterDocumentActiveContextChangedHandler(args => RequestDiagnosticRefresh()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
....is this ever unsubscribed???
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nope. It wasn't before, and I considered addressing that outside the scope of this PR. :)
@@ -56,9 +57,12 @@ public AbstractCreateServicesOnTextViewConnection( | |||
listenerProvider.GetListener(FeatureAttribute.CompletionSet), | |||
threadingContext.DisposalToken); | |||
|
|||
Workspace.DocumentOpened += QueueWorkOnDocumentOpened; | |||
_workspaceDocumentOpenedDisposer = Workspace.RegisterDocumentOpenedHandler(QueueWorkOnDocumentOpened); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You have correctly maintained this code's behavior, but as we're continuing to push on load performance, this is really not ideal that we're forcing the VisualStudioWorkspace to be created when a C# file is opened. If all you're doing is opening a loose file...that was extra work for no real benefit, maybe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe. If you have the chance, can you log a bug and I'll take a look as a separate issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed #78355
...IntegrationTest/New.IntegrationTests/InProcess/ITextViewWindowVerifierInProcessExtensions.cs
Show resolved
Hide resolved
if (disposer == null) | ||
{ | ||
disposer = RegisterHandler(eventType, (Action<EventArgs>)Handler, WorkspaceEventOptions.RequiresMainThreadOptions); | ||
|
||
lock (_gate) | ||
{ | ||
_disposableEventHandlers[(eventHandler, eventType)] = (AdviseCount: 1, disposer); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did we jump out of the lock here? Since there's a theoretical race if you had two parallel subscriptions where the TryGetValue misses both times, and then hits this path and the write happens twice. In that case AdviseCount should be 2, not 1.
(I can't imagine this ever happening in practice.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had concerns about calling RegisterHandler inside the lock, but looking a bit closer, maybe that concern was unnecessary. I'll change this to not transition into the lock multiple times and we can chat more about this tomorrow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jasonmalinowski -- How does this look? Can we chat tomorrow on any remaining concerns you may have?
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
2) Rename some methods/members in Workspace_EventsLegacy.cs file 3) Fix potential multi-threaded issue in AddLegacyEventHandler by only taking the lock once 4) Be a bit more paranoid in WorkspaceEventRegistration.Dispose
src/Workspaces/Core/Portable/Workspace/Workspace_EventsLegacy.cs
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have no idea why this file is here rather than the Workspaces layer. Or minimally, why it's in the C# folder...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really great piece of work here -- there's a lot of really subtle behavior and edge cases that we could break here, and I you've done a really good job trying to keep those working while also keeping everything quite clean. Well done; this will be good to see how it does once it's in.
/// <summary> | ||
/// Registers a handler that is fired whenever the current solution is changed. | ||
/// </summary> | ||
internal WorkspaceEventRegistration RegisterWorkspaceChangedImmediateHandler(Action<WorkspaceChangeEventArgs> handler, WorkspaceEventOptions? options = null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I imagine the options passed here don't matter right, since this one gets called synchronously and bypasses the queue? Should this not take options here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's better to take in the options, for both consistency and for possible future considerations. However, I will update the comment to better reflect that the passed in threading preferences won't be adhered to.
src/Workspaces/Core/Portable/Workspace/Workspace_Registration.cs
Outdated
Show resolved
Hide resolved
} | ||
|
||
[SuppressMessage("Style", "VSTHRD200:Use \"Async\" suffix for async methods", Justification = "This is a Task wrapper, not an asynchronous method.")] | ||
internal Task ScheduleTask(EventArgs args, EventHandlerSet handlerSet) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe call this AddToEventHandlerWorkQueue or something, so it's very clear this isn't doing the thread scheduling that the existing method is doing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'm going to avoid this one in this PR. All the "ScheduleTask" methods go through this method, so they are all kind of misnamed now. Add to that the whole Task returning behaviors for these methods, and I think this needs to be thought through a bit more.
// Verify there are handlers which require the main thread before performing the thread switch. | ||
if (list.Any(static x => x.HandlerSet.HasMatchingOptions(isMatch: static options => options.RequiresMainThread))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you consider having us kick off the work to the main thread prior to doing the background thread ones, and then awaiting the task once we're done? At least that way there's a bit more happening in parallel and the change is fairly trivial.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concurrent handler invocation is definitely something we should consider and worthy of it's own effort as there are a lot of possibilities there with a lot of potential snags.
internal record struct WorkspaceEventOptions(bool RequiresMainThread) | ||
{ | ||
public static readonly WorkspaceEventOptions DefaultOptions = new(RequiresMainThread: false); | ||
public static readonly WorkspaceEventOptions RequiresMainThreadOptions = new(RequiresMainThread: true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On one hand, call it the "main thread" is bugging me here since from the workspace perspective it doesn't really have a concept of the main thread like JTF, it's just "the scheduler we schedule things to". On the other hand, we all know what this means and I can't come up with a better name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will keep it for now as I can't come up with something better (foreground, ui, main all have this same association).
This addresses a major portion of #78091
There are still some WorkspaceChanged events that haven't been switched over. This is big enough without doing all those and I'll have a followup PR finish the rest of the roslyn workspace events.