-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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 first class System.Threading.Lock type #34812
Comments
Should this type not be
|
Yep added, thanks |
I'm not clear on the extent of this proposal. You're proposing that Monitor would special-case this with its existing object-based methods? But only some of Monitor's methods? |
I'm proposing a type that doesn't need to concern itself with the state of the syncblock so it will only be a thinlock and not worry about handling the flags |
So no special treatment anywhere in the runtime it's just setup so that it'll never have a stored hashcode and the syncblock will only ever be used for locking? Though it'd be a pretty niche case where you have a lock on something that has been hashed i'd have thought. |
I understand. But how are you proposing interacting with it? Still via Monitor? |
Ah, get you point. Have added other methods. Would still need to go via monitor currently to work with the |
Yes; but the handling for the niche case isn't without overhead. So the idea is to introduce a type that drops this overhead by being explicit in only being a lock. |
Today, yes, though likely C# could be augmented to recognize the type in the future.
A potentially much more impactful (and experimental) variation of this would be to actually only support locking on such a type and eliminate the memory overhead incurred for every other object in the system. Until code fully transitioned, locking on other objects would obviously be more expensive as the runtime would need to employ some kind of mapping to a Lock, e.g. https://github.com/dotnet/corert/blob/d0683071605aed956497506ed1aae4d366be0190/src/System.Private.CoreLib/src/System/Threading/Monitor.cs#L28 |
/cc @davidwrighton as FYI since we were coincidentally talking about this just the other day. |
:) This brings back memories of the early days on the .NET Native project, when we actually implemented this type, and tied it into Monitor (so uses of this If we want to have a lock object type not integrated into |
Why? What prevents us from just removing that? |
Is this due to the min size of 12 bytes? runtime/src/coreclr/src/vm/object.h Lines 108 to 111 in c06810b
What would be performance the drawback? Due to an extra |
This implementation is still there . In CoreCLR, the lock can take two paths: The thinlock path that just flips the bit in the object header is used as long as there is no contention that requires waiting or other special situations. Once the thin lock hits the contention, the syncblock (a piece of unmanaged memory) is allocated for the object by the VM, and all lock operations go through the syncblock. Going through syncblock has extra overhead - there are several indirections required to get to syncblock. In CoreRT, the thinlock path is same as in CoreCLR (the thin lock path was not there in the early .NET Native days). Once the lock hits contention, one of these
Here is where the min size comes into play: The current GC implementation needs to be able replace any chunk of free space with a special fake array to keep the heap walk-able. The object minimum size is a minimum size of this fake array. If we were to get rid of the free space on System.Object, we would need to introduce a special fake object that would be used for free space where the fake array does not fit, and all places in the GC that check for the free space would need to check for both these. Some of these checks are in hot loops that would take a perf hit. The technically most challenging part for getting rid of object header is dealing with object hashcodes (ie |
What about making the |
The current implementation of NativeAOT locking is here:
NativeAOT managed runtime often bypasses the thin lock stage and just uses I think the NativeAOT |
I have low expectations and am happy with a Currently every class is sorta a lock, but there is no type that expresses the intention, so when you want a vanilla lock you do object _lock = new object(); Which seems weird; and incongruous with .NET where every api is spelled out very clearly and expresses its intent in naming. That clarity in apis is one of the things .NET is very good at, however its missing for the fundamental lock type, perhaps because everything is a lock? i.e. to a new .NET developer wanting a lock; the answer isn't "use a lock" but "use any object" which isn't an obvious answer |
Tbf, I think as part of the early early design of the CLR, it was thought that locking on |
Sure and I'm not saying remove the standard locking mechanism; it is handy when you don't want an additional allocation, just introduce a type that conveys intention rather than using |
I think that the name private readonly Lock _lock = new(); But declaring a local variable is a compile-time error: Lock lock = new(); // Syntax error The |
Would be uncommon? When would you define a local lock? Would only serve a purpose if you where also passing it as a parameter to methods that would execute in parallel otherwise it would have no function, as what is there to lock? |
@MarkCiliaVincenti, the problem could occur on .NET Framework if your code uses an API-compatible reimplementation of System.Threading.Lock but someone else's code (e.g. part of ASP.NET infrastructure) aborts the thread. |
@weltkante then I'm even understanding less @KalleOlaviNiemitalo then it would work just the same as lock (myObj) where myObj is of type system.object on .NET Framework. Imagine I made a class called HelloWorld, created an instance of it and locked on that instance, the behaviour on all framework versions is expected to be just the same as an instance of system.object, no? |
@MarkCiliaVincenti, yes, just the System.Threading.Lock name is special-cased in the compiler and causes |
Whats not to understand in the explanation of the example? You'll have to be more specific if you have questions.
If your abort happens between the lock request but before the try block then the finally won't be executed and no unlock happens. |
I see your edit now. This has nothing to do with backporting of System.Threading.Lock though, if I understand you correctly. That is if you have code that is multi-targeting .NET Framework up to .NET 8.0 and locking on a readonly object, then you ALREADY have this problem, no? Or am I still not understanding you? |
If you are locking using the old API a different pattern is used, with the Monitor.Enter inside the try block (and reporting lock state via boolean variable), so you don't have the problem of not executing the finally. Its all in the example/explanation above. So yes, this is a problem with backporting the new pattern, since it compiles differently (since it wasn't designed for Desktop Framework and thread aborts) |
But the backport is still locking on an object. Imagine you implement #34812 (comment) And then you have code like this on .NET Framework:
These methods should work exactly the same with .NET Framework and the class from #34812 (comment) compiled in the same assembly, no? |
No, they won't work exactly the same, if you build with a compiler that recognizes the System.Threading.Lock name. |
Which .NET Framework version "recognizes the System.Threading.Lock name"? For .NET Framework, System.Threading.Lock would simply be a class in the same assembly that inherits from System.Object and has no variables, just methods and properties. Why would it work differently? For .NET Framework, System.Threading.Lock might well have been MyNamespace.MyClass |
None, its a compiler thing, and the compiler isn't designed to generate code for Desktop Framework in presence of thread aborts (for the new-style locking API). So if you use the new Compiler Feature together with Desktop Framework and Thread Aborts the code the compiler generates will be buggy and lead to deadlocks. I think you're overlooking that the IL is generated by the compiler, and depends on whether you are using the old or new feature, it is not determined by the framework which will run it later. |
So if I'm understanding correctly, my examples of LockLock and LockObject, if the .NET 8.0 compiler is used, it's not a problem, but if the .NET 9.0 compiler is used it would have different behaviour, even though ultimately the product is a .NET Framework 4.8 executable? |
The C# compiler in .NET SDK 8.0.400 already recognizes the
But if you instead do |
I still don't get it. Are we saying that if you were to do this:
and
This wouldn't work on .NET 8 because somehow it refuses to use System.Threading.Lock from your assembly but System.Threading.Lock from somewhere else, where it won't have the Hello method? |
On .NET SDK 8.0.400, if you try to build <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project> namespace System.Threading
{
public class Lock
{
public void Hello()
{
// do nothing
}
}
}
public class LockDemo
{
private readonly System.Threading.Lock myLock = new System.Threading.Lock();
public void Foo()
{
lock (myLock)
{
myLock.Hello();
}
}
} then it fails outright:
because the compiler recognizes the The same error occurs also if you change the project to target .NET Framework 4.8: <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
</Project> |
Hmm, I managed to replicate the above @KalleOlaviNiemitalo. So there is no way to 'override'? |
I'd consider the |
So how do I test this? I tried this on net462 in a project that multi-targets even net9.0, and where Lock is https://github.com/MarkCiliaVincenti/Backport.System.Threading.Lock/blob/master/Backport.System.Threading.Lock/Lock.cs but both tests passed. What am I doing wrong pls?
|
Those Thread.Sleep calls are within the The problem would occur if you abort the thread just as it is about to enter the |
I see, that helps a lot, thanks. I will try to reproduce it. But what I still don't quite get is this: if you're multi-targeting net462 and net9.0, you will not be using thread.Abort() since net9.0 doesn't support it, so is there an actual problem? Maybe if you pass on a thread to a net462 library that would do the abort itself? In such a case, then doing the following instead of the lock would the below solve it?
|
That would not solve it, because the thread could be aborted after |
This sucks because .Enter() on System.Threading.Lock does not accept the It's a very unlikely scenario but if we're talking about correctness... I asked you another question as well and I'd like to have some input on that, I'd appreciate it. "But what I still don't quite get is this: if you're multi-targeting net462 and net9.0, you will not be using thread.Abort() since net9.0 doesn't support it, so is there an actual problem? Maybe if you pass on a thread to a net462 library that would do the abort itself?" I'm basically trying to understand the exact chain of events that need to happen for the Backport.System.Threading.Lock micro-library to fail, so I can explain it, because I couldn't find another way to fix this, not even using giving the class a different namespace than |
How about this? var backportedLock = new System.Threading.Lock();
try
{
backportedLock.Enter();
... // stuff that's inside the lock
}
catch (ThreadAbortException)
{
backportedLock.Enter(); // thanks to lock reentrancy will not stay waiting even if lock was obtained
}
finally
{
backportedLock.Exit();
} |
At https://referencesource.microsoft.com/#q=Thread.Abort, you can search for .NET Framework code that calls Thread.Abort() or Thread.Abort(object stateInfo). Most of those calls look like the thread being aborted is likely to be running only code that is part of .NET Framework. However, the ASP.NET execution timeout will abort a thread on which user code has spent too much time handling an HTTP request. See RequestTimeoutManager.cs and docs for HttpContext.ThreadAbortOnTimeout and HttpRuntimeSection.ExecutionTimeout. So if you make a library that uses a backported System.Threading.Lock, and someone's ASP.NET application calls your library on request-handler thread without disabling ThreadAbortOnTimeout, then that's not safe. I think CLR hosting in SQL Server may also abort threads running user code, but I'm not sure about that. |
I see, and if that Lock instance is set to static, then one aborted ASP.NET thread might leave it in a locked state in a very rare but still possible scenario. It seems like if you want to target both .NET Framework 4.8 and .NET 9.0 and want to use the new System.Threading.Lock and enjoy its speed advantages, you can't get all of these 3:
The library does a best effort but it is not perfect. |
Perhaps the solution might lie in the backport library users avoiding use of public Scope EnterScope()
{
bool lockTaken = false;
try
{
Monitor.Enter(this, ref lockTaken);
return new Scope(this);
}
catch (ThreadAbortException)
{
if (lockTaken) Monitor.Exit(this);
}
} and thus instead of doing: lock (myLock)
{
...
} they would do: using (myLock.EnterScope())
{
...
} and if I understand correctly then this pattern would be hardened against thread aborts. |
No, that would still not be fully hardened. In A lock class with a different API could be hardened against thread aborts, but would be too tedious to use without compiler support in |
Argh!
That solution is not very good. It limits you to using just the lock keyword, nothing else. Here's how: #if NET9_0_OR_GREATER
global using Lock = System.Threading.Lock;
#else
global using Lock = System.Object;
#endif
private readonly Lock myObj = new();
void DoThis()
{
lock (myObj)
{
// do something
}
}
void DoThat()
{
myObj.Enter(5); // this will not compile on .NET 8.0
Monitor.Enter(myObj, 5); // this will immediately enter the lock on .NET 9.0 even if another thread is locking on DoThis()
// do something else
} I'm thinking that a potential solution is to change https://github.com/MarkCiliaVincenti/Backport.System.Threading.Lock/blob/master/Backport.System.Threading.Lock/Lock.cs such that the namespace is BackportSystem.Threading and then make a factory for it. public static class LockFactory
{
#if NET9_0_OR_GREATER
public static System.Threading.Lock Create() => new();
#else
public static BackportSystem.Threading.Lock Create() => new();
#endif
} |
Background and Motivation
Locking on any class has overhead from the dual role of the syncblock as both lock field and hashcode et al. (e.g. #34800)
Adding a first class lock type that didn't allow alternative uses and only acted as a lock would allow for a simpler and faster lock as well as be less ambiguous on type and purpose in source code.
API proposal
[Edit] by @kouvel based on #34812 (comment) and dotnet/csharplang#7104
API Usage
Change in usage
Becomes the clearer
[Edit] by @kouvel
Usage example 1
Usage example 2
Usage example 3
After the
lock
statement integration described in #34812 (comment), the following would useLock.EnterScope
andScope.Dispose
instead of usingMonitor
.TryEnterScope usage example
The following:
Could be slightly simplified to:
The latter could also help to make the exit code path less expensive by avoiding a second thread-static lookup for the current thread ID.
Alternative Designs
Scope
could be a regular struct instead of aref struct
. Aref struct
helps to make the exit code path less expensive and can improve performance in some cases, as it guarantees thatScope.Dispose
is called on the same thread asEnterScope
, and the path that exits the lock would be able to avoid an extra thread-static lookup for the current thread ID. Similarly forTryScope
.TryEnterScope
methods, an alternative is to return a nullable struct. It seems more readable to check forWasEntered
than to check for null or theHasValue
property to see if the lock was entered. Also aref struct
can't be nullable currently, and a regular struct would not benefit from a potentially less expensive exit path.IsHeld
instead ofIsHeldByCurrentThread
. The former is simpler, though may be a bit ambiguous, and may be confusing againstSpinLock.IsHeld
, where it meansIsHeldByAnyThread
.Lock
type a struct instead. This may save some allocations in favor of increasing the size of the containing type. Structs can be a bit more cumbersome to work with, and the benefits may not be large enough to be worthwhile.Risks
The text was updated successfully, but these errors were encountered: