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

Fix more lock contention #59638

Merged
merged 14 commits into from
Mar 8, 2022

Conversation

jasonmalinowski
Copy link
Member

@jasonmalinowski jasonmalinowski commented Feb 18, 2022

Fixes AB#1473249.

@jasonmalinowski jasonmalinowski self-assigned this Feb 18, 2022
@jasonmalinowski jasonmalinowski marked this pull request as ready for review February 18, 2022 21:08
@jasonmalinowski jasonmalinowski requested a review from a team as a code owner February 18, 2022 21:08
@@ -0,0 +1,41 @@
# Locking and Synchronization
Copy link
Member Author

Choose a reason for hiding this comment

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

This document was long overdue, so I used this as an opportunity to write this down. The content here though is not "new" nor is it changed by this PR in any way.

else holds onto a VisualStudioProjectOptionsTracker that has a lock, so we avoid any deadlocks there.

VisualStudioWorkspaceImpl has a nested type OpenFileTracker that has it's own lock to guard it's own fields. It should call nothing
outside of itself while holding that lock.
Copy link
Member

Choose a reason for hiding this comment

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

i both stronglya ppreciate this doc, while recognizing that the complexity here makes me angry.

Copy link
Member

Choose a reason for hiding this comment

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

in general, do we also have some of these comments on those types? e.g. does OpenFileTracker have comments saying "absolutely never do X"?

Copy link
Member

Choose a reason for hiding this comment

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

question still applies @jasonmalinowski

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll add comments appropriately.

would acquire a project lock. To this end, a few bits of information that may seem to be "project specific" are actually stored
in maps in the VisualStudioWorkspaceImpl; specifically we maintain a list of the output paths of a project which we use to convert
metadata references to project references. This list is maintained in the workspace itself to avoid having to reach back to a
project and ask it for information which might violate this lock hierarchy.
Copy link
Member

Choose a reason for hiding this comment

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

this si very good to know. i wonder if it's something we could trap somehow to detect when we're doing this wrong.

foreach (var (documentId, textLoader) in documentsToChange)
{
if (!s.Workspace.IsDocumentOpen(documentId))
if (!_project._workspace.IsDocumentOpen(documentId))
Copy link
Member

Choose a reason for hiding this comment

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

here's my terror. we're in the workspace lock, calling back into the workspace. How do we know IsDocumentOpen takes no lock itself?

Copy link
Member Author

Choose a reason for hiding this comment

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

So if it takes the main workspace lock a second time, we'll know because it'll deadlock. 😄 I agree though yeah I don't have a good answer for how to enforce the hierarchy, generally though.

if (object.Equals(field, newValue))
{
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

this was very confusing to read since i didn't see why this needed to be in a lock. having there be a ref-read here was not expected :)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, actual use of ref is...surprising. I can't think when I last used it before this. 😄

Copy link
Member

@sharwell sharwell left a comment

Choose a reason for hiding this comment

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

Comments related to the threading switch do not align with the new implementation

@@ -160,6 +161,8 @@ public VisualStudioWorkspaceImpl(ExportProvider exportProvider, IAsyncServicePro
exportProvider.GetExportedValue<IDiagnosticUpdateSourceRegistrationService>(),
exportProvider.GetExportedValue<IAsynchronousOperationListenerProvider>(),
_threadingContext), isThreadSafe: true);

_workspaceListener = Services.GetRequiredService<IWorkspaceAsynchronousOperationListenerProvider>().GetListener();
Copy link
Member

Choose a reason for hiding this comment

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

💭 This could be exposed by a private protected property in Workspace:

private protected IAsynchronousOperationListener Listener => _taskQueue.Listener;

@jasonmalinowski jasonmalinowski requested a review from a team as a code owner February 23, 2022 00:58
@sharwell sharwell dismissed their stale review February 23, 2022 01:17

Blocking issue was resolved

var solutionChanges = new SolutionChangeAccumulator(s);
updateSolution(solutionChanges, oldValue);
return solutionChanges;
}).AsTask().GetAwaiter().GetResult();
Copy link
Member

Choose a reason for hiding this comment

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

is it desirable to be sync here? as opposed to async, whcih you push up all the way to callers.

Copy link
Member Author

Choose a reason for hiding this comment

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

Same comment as here: https://github.com/dotnet/roslyn/pull/59638/files/1268f8f6cc2a854311b427ac6a9701fd9742286f..bcc398b3ede4373f53e2584e1ac8fe421b6ec5ac#r810348018 Sorry should have left that unresolved for you to see the answer.

Basically, the sync path here is taken by the legacy project system, that that point we're better off synchronously acquiring the locks since that'll get priority. So other than switching to be a JTF.Run() higher level, we can't really make this properly sync. And I suspect that might actually be worse for perf given SemaphoreSlim's prioritization for synchronous waters.

Copy link
Member Author

Choose a reason for hiding this comment

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

Somehow you broke GitHub, the current code here anyways is:

                _workspace.ApplyBatchChangeToWorkspace(solutionChanges =>
                {
                    updateSolution(solutionChanges, oldValue);
                });

So the wart for the .GetResult() is gone.

@@ -1983,38 +1983,49 @@ public void RemoveProjectOutputPath_NoLock(SolutionChangeAccumulator solutionCha

private void RefreshMetadataReferencesForFile(object sender, string fullFilePath)
{
using (_gate.DisposableWait())
var asyncToken = _workspaceListener.BeginAsyncOperation(nameof(RefreshMetadataReferencesForFile));
_threadingContext.RunWithShutdownBlockAsync(async cancellationToken =>
Copy link
Member

Choose a reason for hiding this comment

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

we have a .CompletesAsyncToken extension so you don't need the try/finally

Copy link
Member

Choose a reason for hiding this comment

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

oh wait.. is this one of those weird tasks?

Copy link
Member Author

Choose a reason for hiding this comment

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

The try/finally was gotten rid of per @sharwell's suggestion to use just the using var token; in this case it's returning JoinableTasks so I'm not sure our existing helpers work, and as @sharwell pointed out we don't need it since RunWithShutdownBlockAsync still runs the lambda immediately.

Copy link
Member Author

Choose a reason for hiding this comment

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

(GitHub should be showing this comment as outdated since this was already fixed, not sure but @CyrusNajmabadi broke it somehow.)

{
_projectToRuleSetFilePath.Add(project.Id, ruleSetFilePathFunc);
}
Contract.ThrowIfFalse(ImmutableInterlocked.TryAdd(ref _projectToRuleSetFilePath, project.Id, ruleSetFilePathFunc));
Copy link
Member

Choose a reason for hiding this comment

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

good use of Contract to preserve semantics.

@@ -338,16 +340,14 @@ internal VisualStudioProjectTracker ProjectTracker
// Solution Explorer in the SolutionExplorerShim, where if we just could more directly get to the rule set file it'd simplify this.
internal override string? TryGetRuleSetPathForProject(ProjectId projectId)
{
using (_gate.DisposableWait())
// _projectToRuleSetFilePath is immutable, so can be used outside of locks
Copy link
Member

Choose a reason for hiding this comment

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

this always worries me as i always think there's .net subtleties about all writes being observed. though i think you'r correct about safety.

@@ -18,9 +18,7 @@ internal static class FSharpProjectExternalErrorReporterFactory
{
public static IVsLanguageServiceBuildErrorReporter2 Create(ProjectId projectId, string errorCodePrefix, IServiceProvider serviceProvider)
{
ThreadHelper.ThrowIfNotOnUIThread();
Copy link
Member

Choose a reason for hiding this comment

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

just to check, we're intentionally making this free threaded now?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct: the only reason this had a UI thread affinity was to call SubscribeExternalErrorDiagnosticUpdateSourceToSolutionBuildEvents, but that's been moved earlier when the project itself is being created.

@dotnet dotnet deleted a comment from azure-pipelines bot Mar 1, 2022
@dotnet dotnet deleted a comment from azure-pipelines bot Mar 1, 2022
@dotnet dotnet deleted a comment from azure-pipelines bot Mar 2, 2022
@jinujoseph jinujoseph added this to the 17.2.P2 milestone Mar 2, 2022
@dotnet dotnet deleted a comment from azure-pipelines bot Mar 3, 2022
@genlu
Copy link
Member

genlu commented Mar 3, 2022

If this is for17.2 p2, then I think you need to target release/dev17.2

@jasonmalinowski
Copy link
Member Author

@genlu The plan was since we were stabilizing integration tests, to get a green run here and then retarget once we knew this didn't have regressions. That plan...did not work. 😄

@dotnet dotnet deleted a comment from azure-pipelines bot Mar 5, 2022
These were added for completeness but then not actually used anywhere;
rather than try to maintain them just delete them.
We convert metadata references to project references if the output paths
match the referenced paths. Thus if we have a solution with a bunch of
metadata references, and then we're told a project has a new output
path, we have to go looking to see if we can convert those metadata
references to project references as well. To do that conversion, we
have to acquire the global workspace lock, since any other projects
that are also adding or removing metadata references at the same
time need to be all coordinated.

We also support the concept of "batching" when updating a project,
which means a project system can make a bunch of changes at once but
we'll raise only a single WorkspaceChanged event once those changes are
all done; we also try to ensure that we aren't acquiring the global
workspace lock until the batch is actually being applied; that ensures
we can do as much in parallel at once. We also recently added the
ability for the batch to be applied with an async API, so if there's
another project applying changes at once we aren't going to starve the
thread pool.

There was one bug, however: when you started a batch, and then changed
the output path of the project, that updating of the output path should
have waited until the batch was applied. We'd update the property in the
workspace correctly, but we immediately acquired the global workspace
lock trying to look for other projects to update with project
references. Functionally this was mostly fine (ignoring there's a small
window in time where we converted to project references even if a
project didn't have the updated output path), but it created a huge
performance issue: we would be acquiring the global workspace lock
synchronously, even if the caller was trying to operate in a batch
that they were going to apply asynchronously. If the user got
particularly unlucky, they might have a bunch of projects all being
loaded or reloaded at once, and they might starve off their thread pool.

The fix, conceptually, is simple: defer the conversion to project
references until the global lock is actually held once we are applying
the batch change. This resulted in a bit of churn; when we apply a batch
we have a little helper type called a SolutionChangeAccumulator that we
pass around that tracks the new Solution object, but also tracks what
the resulting "kind" should be for the solution change. Thus this commit
required this set of changes:

1. Convert the methods that did the conversion to/from project
   references to operate on a SolutionChangeAccumulator instead of
   directly reading CurrentSolution and directly setting the output.
2. Update the ChangeProjectOutputPath and ChangeProjectProperty
   functions to now operate with SolutionChangeAccumulators.
3. Update the project removal path to also now create a
   SolutionChangeAccumulator.
4. General refactorings (extracting/merging) of some of our core
   helpers where we apply changes in the end.

Fixes https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1473249
There's still a switch happening in CreateAndAddToWorkspaceAsync,
which is where it should be and can be better limited.
One helper requires you to return a SolutionChangeAccumulator. No reason
we can't just create one and pass in in rather than each caller having
to create it.
…lutionBuildEvents

By calling this once when we already are on the UI thread when a
project is created, we're able to avoid calling it in a bunch of places
which was requiring UI threads in less-intuitive places. It's also
not clear to me why this is even marked as needing the UI thread at all,
but we'll address that independently.
Now there's a consistent set of sync/async/maybe async.
@jasonmalinowski jasonmalinowski merged commit fa8f6bc into dotnet:main Mar 8, 2022
@ghost ghost modified the milestones: 17.2.P2, Next Mar 8, 2022
@jasonmalinowski jasonmalinowski deleted the fix-more-lock-contention branch March 8, 2022 20:08
@allisonchou allisonchou modified the milestones: Next, 17.2.P3 Mar 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants