-
Notifications
You must be signed in to change notification settings - Fork 4.9k
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
Reconsider version checks in collections? #81523
Comments
Tagging subscribers to this area: @dotnet/area-system-collections Issue DetailsMany of our mutable collection types maintain version numbers. These version numbers are incremented for many (not necessarily all) of the mutating operations performed on the type, e.g. runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs Lines 196 to 198 in 2ba2396
The version number is then snapshot as part of calling GetEnumerator, and every MoveNext on the resulting enumerator compares the version number that was snapshot against the current version number. runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs Lines 1166 to 1170 in 2ba2396
This serves as a way to flag some misuse, where mutation is performed during enumeration. The motivation here is good: help developers find misuse. There are a few problems, though:
I'm opening this issue to gauge folks' appetite for removing these versions. Do they still provide good enough value that they're worth keeping? Have they outlived their usefulness?
|
The main examples of what it protects against are multithreaded access (which is often dangerous for these types anyways, without explicit synchronization) and various cases of sharing where some method in the enumerator ends up mutating the collection itself, right? I'd guess handling these via analyzers is fairly complex and not feasible, so it wouldn't be "trivial" to replace the functionality for devs that still want it. We could have some compat or config switch, but in general I've not personally found it useful to have and have often been annoyed at the inconsistency around its tracking/enforcement (within a singular type and also across different collection types) and so would be personally happy to see it go. -- IIRC, LINQ is another example where our |
A concrete example is the places, like |
In an older version of Paint.NET, I actually relied on this behavior for a background cleanup thread that scanned through a That said, I think the version checks are an outdated relic of an overly-cautious design era (it's not bad design, mind you, it just hasn't proven its value IMO). It's an easy thing to forget about when implementing your own collection classes, leading to inconsistency between framework-provided and custom collection classes. It's also kind of hard to implement in some cases, and can add a false assurance of safety when it is implemented. And, as you mention, it prevents certain optimizations -- like replacing I think I can count on one hand the number of times I've hit an Ultimately I think the version checks add too much burden on new collection class implementations. It adds a good amount of risk to the implementation cost of new collection classes. I would much prefer collection classes that support immutability (which we have), freezing (which we'll have in 8, and which you also see in WPF), and snapshotting (which I have my own implementations of), all of which provide better and more visible safety guarantees in this area. That said, I'm a low-level coder at heart and while I'm happy to gain some performance by removing the version checks, I do not have data -- scientific nor anecdotal -- about how helpful (or not) this is for all the other .NET developers out there. So for me, yes please remove them. But I'm not willing to gain some cycles for my app in exchange for a large ecosystem wide reduction in safety. I can always fork a collection class and remove the version checks if I really need those few extra cycles. (Paint.NET has many custom collection classes for all sorts of scenarios) |
Also, wouldn't that lowering optimization make it basically pointless (in a good way) to implement custom |
Not sure if this is possible, but it could be good to enable the checks on debug mode only, that way you'd get the benefit of it when debugging, but also no performance harm in release mode. I suspect this may not be possible though as I certainly don't know how to do this :)
I'm sure you already know this, but I would like to add that if someone is doing it by index, then they definitely should be able to mutate it, it's been useful to me a number of times, they just have to make sure they keep the index up-to-date. In I don't really care if they're removed, I've not seen the exceptions that many times (it was mostly when I was learning basic programming, they were quite useful to see then though as it let me know what the issue was, and if it had looked like it worked, it likely would have been incorrect behaviour), and when I did it was usually because I wanted to mutate it anyway. But if they wanted to lower to the index lookup form, then I think you might as well remove the versioning, since they'd rarely be called, but you certainly shouldn't add the version check in explicitly during lowering imo. |
I've often seen beginners (and sometimes even myself) be "saved" by the current behavior. Note that it's not just unprotected multi-threading but also eg. the "obvious" // THIS IS WRONG
foreach (var item in list)
if (item.ShouldBeDeletedForSomeReason)
list.Remove(item); or // THIS IS WRONG
foreach (var item in list)
list.Add(item); Which could manifest in far harder to track down problems. As
If lowering, I imagine the compiler could detect any references to the collection in the body and pessimise in that case. If it's really showing up in profiles as performance sensitive, converting to a
Changing class layout during debugger attach sounds hairy. I don't think there's any precedent in the BCL for adding extra checks in |
This is a good case of why versioning should be removed. It works fine when you use for loop: for (int i = 0; i < list.Count; i++)
{
if (list[i].ShouldBeDeletedForSomeReason)
list.RemoveAt(i--);
} This versioning restriction only makes such cases more complicated then it needs to be. If I remove such an item while iterating, it should just continue with the other items. |
@Nuklon if you |
Being slightly pedantic here, but To your broader point, it seems it can be split into two potentially independent proposals:
It seems to me that 1) offers limited performance benefits and risks regressing the small number of components that do depend on the current behavior in production (as reported above in #81523 (comment)) whereas 2) seems like something users can opt into at compile time. |
This depends on whether public float[] GetFirstElement(List<Vector3> list)
{
return list.Select(Transform).ToArray();
float Transform(Vector3 value, int index)
{
if (value == Vector3.Zero)
{
_ = list.RemoveAt(index);
}
return value.X;
}
} The same could be true for an instance function |
Such scenarios are not super likely, but they certainly are possible to encounter. LINQ being inconsistent with whether or not it surfaces version changes means that users get an inconsistent experience. Likewise, we don't strictly update If we were to keep I think the overall state, inconsistency around its usage/handling/visibility to users, and more all point towards it just being more painful and less useful than it was designed/intended and it'd likely be better (both for perf and general consistency/usability) to remove it. |
It's also worth pointing out that I don't believe the performance benefits are limited. The difference between We've already seen such improvements when utilizing the internal |
Definitely, but we could pursue these without stripping version checks from the enumerator implementations themselves (which LINQ is already doing in most of its optimized methods). The obvious downside is that the less the enumerator is being used by other components or the compilers, the less likely it is for concurrency bugs to be discovered by the version checker, so perhaps that is reason enough for us to consider removing it altogether. |
Note that |
I don't think I can remember ever discovering a bug through the version check, and as you point out it's incomplete and adds size, so I'm supportive |
That's a good example, Dictionary has had enumerator version checks forever; however the the number of concurrent corruptions revealed from the loop guard was high and the enumerator check wasn't serving a purpose to detect those. In addition with regards to inconsistencies; the pattern above when applied to Dictionary rather than List foreach (var kv in dict)
if (kv.Value.ShouldBeDeletedForSomeReason)
dict.Remove(kv.Key); Intentionally doesn't increase the version, so that you can use this pattern for performance and allocations; as otherwise you need to collate to a second structure (e.g. List) then do a second loop for the removals. I'd prefer the version to be removed; with a stretch goal of a analyser to pick up the foreach+modify for list (which is better anyway as is compile time prevention rather than runtime failure) For unsynchronised concurrent mutations you are already in trouble, and not sure the version adds any value 😅 |
conversely, here's an example of the version check helping spot a bug. @ladipro would it have been found if the list was merely corrupted/overwritten/inconsistent? |
Indeed, that is safe (and thus doesn't have a version check, in fact I believe we removed it) -- if we remove version checks, I guess we need to document which patterns are safe and which are not for each type. |
We need that, regardless :-) |
Indeed, but I meant in the code, whereas (in theory!) that's implicitly documented with the version check. |
One issue is that the version check doesn't necessarily catch all mutations. There is a bit of a race condition in that since its often the last updated field (rather than the first) an enumerator on another thread can see and potentially miss that a mutation happened. I'm sure they help in some cases, but overall the inconsistency in handling and other aspects just seems like overhead that could potentially be handled via analyzers or other considerations. |
Yes and no. If I enumerate a list using the enumerator: foreach (var item in list)
{
...
} sure, synchronous mutations can be caught and flagged. But if I perform the exact same iteration except using the indexer (which is more efficient): for (int i = 0; i < list.Count; i++)
{
var item = list[i];
...
} then there's no version check, and it's not "implicitly documented". The benefits are incredibly inconsistent. |
Thinking about it another way -- if we didn't have these checks already I'm confident we wouldn't add them today. It would be nice to have another small size win just like when we removed the SyncRoot objects, etc. |
@jkotas, do you have an opinion here, whether these versions are providing enough value to keep them around? |
That's a race condition between multiple threads; bigger issues at play than this check will discover? And the data structures are called out as not being threadsafe 90% of the data structures aren't threadsafe and have no protections. (Dictionary is special case with the recent addition, however that's to prevent infinite looping, still isn't threadsafe) |
These version checks prevent common source of bugs. As a data point, we should consider what the other programming environments do in similar situations. I just tried the following in C++:
I got A different line of thought: If we were to remove these version checks, the exception should be replaced by easy to explain, intuitive, well-defined behavior. We would not want to leave the behavior undefined. Undefined behaviors are bad, they always take you by surprise and cause dissatisfaction. What would that well-defined behavior be (ie what would the documentation for collection enumerators say)? Ideally, this well-defined behavior would be same across collections. |
I think the fact that they are debug only (or opt-in for other builds) is an interesting point. For a typical production app they normally do not exist and have no impact on the size or code execution of the app in total. We don't quite have the same capability of exposing such things in .NET. We could have the checks compiled in/out based on a static readonly field (which is populated from an AppContext switch), but the backing fields could not be removed and the general higher level checks have some impact on inlining and other factors. -- I've done this to enable opt-in release mode "assert" support for a couple of my lowlevel libraries, like TerraFX.Interop.Mimalloc which is a port of I think it's also worth noting that the simple cases like the above are the kind of thing that could be handled by an analyzer. I think the complex cases are more interesting from that perspective, but we ultimately still end up with missing things in In the case of LINQ, we've seen some pretty significant perf wins where the underlying implementation uses functionality like |
Aside an aside; the error message is a warning about a risk:
But since it throws an exception; well, it definitely won't execute 😅 |
This (paragraph) is so unrelated, but, I personally think we should at least remove the version increment from I think having the local variable not necessarily be the same as what's in the list is not an error, and actually quite useful (e.g. if you want to replace an item in the list, and then do some cleanup on the one you replaced, or read some values from it afterwards, etc), and I think that someone who understands that you have a copy of a reference (or value for value types) on the stack when enumerating probably wouldn't make this particular mistake unless it's very non-obvious that the list has an item set elsewhere (which is probably a documentation error imo if it's not obvious). |
FWIW I went through some of the crash reports I referred to in #81523 (comment). For a significant number of the cases I think we would get similar diagnostic if |
Note that count checks instead of version checks would also result in a loss of an exception when |
It also would have limited benefit, at least on |
Maybe you can, just only in release builds? Based on this discussion my opinion leans towards keeping the full version checks and lower to |
I was talking about replacing a version check with a count check. If you still need the check in a build, then you can't change how it's lowered. If you don't need the check in some build, then it doesn't matter whether other builds check version or count. |
@jkotas is there prior thinking on the idea of having an "opt in, minimal overhead debug mode for release assemblies". something like an appcontext or similar that would enable checks that otherwise get elided. Maybe the field gets left behind, or some ghastly trick with a shared dictionary or something (given it's diagnostic only). I could imagine that facility being handy in other cases. I appreciate though it wouldn't help in @leculver case as it would mean it switched off for customers in general. |
We effectively do this for some “power scenarios” like hardware intrinsics today. API review opted to have LoadAligned only require alignment validation in unoptimized code over introducing a new api. This allowed the single api to be used which does the optimal thing for the hot case while still providing the necessary validation for the cold and startup case (which also covers most unit tests/etc) |
In .NET Framework, we used to have managed debugging assistants that were basically a way to enable additional debug-only checks in release runtime and CoreLib. It just made things complicated - we struggled with deciding which one of these should be enabled by default and when. The learning was that the on-by-default diagnosics has to be very cheap. Otherwise, it will start impacting F5 performance and people complain. We had to scale back on which one of these debugging assistants are on by default in .NET Framework for this reason. I am happy that we did not bring them over to .NET Core. If there is cheap high-value diagnostic, we just make it on-by-default to keep things simple. |
This sort of undefined behavior is not specific to LoadAligned intrinsic. Optimizations can remove memory accesses in unsafe low-level code, and in turn eliminate exceptions that can be caused by invalid memory addresses. |
I've used a static The more I think about it, I'm not sure that defaulting to lowering |
@jkotas point taken, but I'm hypothesizing off by default diagnostics. |
Managed debugging assistants were off by default. The debugger enabled them selectively, and you can also enable them using environment variables outside debugger. We have established patterns to control diagnostic features, for example |
Are you referring to concurrency scenarios here? I don't see what the difference is in single-threaded access, and for multithreaded access wouldn't that just change when the concurrency problem is detected in equally unpredictable ways? For example, Or is there some other situation where incrementing version before changing the underlying data is more reliable? |
@mikernet, the general issue is that This means there are lots of scenarios where changing it last means that concurrency can otherwise "miss" the change or cause other issues. Ensuring it is consistent/deterministic would imply that |
There's also a lot of inconsistency in "what" counts as a version change Including between |
How about making version checks a Debug-only things. In the Release config those checks will be eliminated. |
I think this was discussed above - bear in mind that almost no developers will have debug versions of List, etc. They're not distributed. So it would need some mechanism to have code in the release build of List etc that the jit can choose to emit or not based on some kind of flag. And wiring that flag to the app's build configuration. That would be a rather useful thing to have in general. |
For many of these types we can probably eliminate the size of version field while still retaining a reasonably sensitive check. Our de-facto principal is we want to avoid enumerating garbage (by being stuck on an old copy of the backing data) or getting in a loop (by following next pointers in the hashing types). We're OK with seeing a torn collection, apparently, because IIRC that was our reasoning for removing the version++ from Remove() on Dictionaries: we determined it would not lead to those bad effects. So if that is our principles, it seems to me we can get fairly close without the version field. For Dictionary would likely be much the same, unlike Hashset you can replace in place, but that doesn't affect the backing store or the next pointers. For And so forth. These changes would shrink the objects 4 bytes and remove the (tiny) cost of the version increment from the hot remove/set/update paths, at the expense of a (equally tiny) cost to the enumeration and a (slightly) weaker guarantee. If the faulty access is from another thread, rather than from modification during enumeration on the same thread, the increased indeterminism could further increase your chance of finding any bug during testing/production. You still lose any checks on the lowering to for's, which was the primary reason this issue was created. However the benefit of shrinking the objects at no incremental cost would still be interesting, IMO, as there's scenarios (like MSBuild) where there can be huge numbers of collections and the 4 bytes adds up. Also, it would benefit any cases where the foreach's were not lowered, such as perhaps an older compiler or cases the compiler can't lower. Does this reasoning seem reasonable? |
If the cost of enumeration is overall increasing, how do you evaluate whether the decrease in modification cost (and memory space) offsets the cost enough? I would expect enumeration to be at least slightly more frequent (hot?) than modification operations, if not significantly more. |
You're probably right. Still, it's hard to imagine the cost is visible either way. The only reason to do this would be to eliminate the field |
Not that I have any strong opinion one way or the other, but this in particular is not a good reason. It would still only keep working when you use a |
If the version checks were removed, why does the compiler still have to optimize |
Many of our mutable collection types maintain version numbers. These version numbers are incremented for many (not necessarily all) of the mutating operations performed on the type, e.g.
runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs
Lines 196 to 198 in 2ba2396
The version number is then snapshot as part of calling GetEnumerator, and every MoveNext on the resulting enumerator compares the version number that was snapshot against the current version number.
runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs
Lines 1166 to 1170 in 2ba2396
This serves as a way to flag some misuse, where mutation is performed during enumeration.
The motivation here is good: help developers find misuse. There are a few problems, though:
List<T>
viaforeach (var item in list) Use(item);
, then it applies, but if you change to iterating viafor (int i = 0; i < list.Count; i++) Use(list[i]);
, then it doesn't.IList<T>
implementations to use indexing in some situations... usinglist.Select(Transform).ToArray()
will not use the version checks even though the Transform could mutate the list. We also end up avoiding optimizing in this way in other places because it'd be visible.I'm opening this issue to gauge folks' appetite for removing these versions. Do they still provide good enough value that they're worth keeping? Have they outlived their usefulness?
The text was updated successfully, but these errors were encountered: