Repository navigation
Description
Lock statement pattern
- Proposed
- Prototype: Not Started
- Implementation: Add
Lockobject feature roslyn#71716 - Specification: https://github.com/dotnet/csharplang/blob/main/proposals/lock-object.md
(This proposal comes from @kouvel. I've populated this issue primarily with text he wrote in a separate document and augmented it with a few more details.)
Summary
Enable types to define custom behaviors for entering and exiting a lock when an instance of the type is used with the C# “lock” keyword.
Motivation
.NET 9 is likely to introduce a new dedicated System.Threading.Lock type. Along with other custom locks, the presence of the lock keyword in C# might lead developers to think they can use it in conjunction with this new type, but doing so won't actually lock according to the semantics of the lock type and would instead treat it as any arbitrary object for use with Monitor.
Detailed design
Example
A type would expose the following to match the proposed pattern:
class Lock : ILockPattern
{
public Scope EnterLockScope();
public ref struct Scope
{
public void Dispose();
}
}
public interface ILockPattern { }EnterLockScope() would enter the lock and Dispose() would exit the lock. The behaviors of entering and exiting the lock are defined by the type.
The ILockPattern interface is a marker interface that indicates that usage of values of this type with the lock keyword would override the normal code generation for arbitrary objects. Instead, the compiler would lower the lock to use the lock pattern, e.g.:
class MyDataStructure
{
private readonly Lock _lock = new();
void Foo()
{
lock (_lock)
{
// do something
}
}
}would be lowered to the equivalent of:
class MyDataStructure
{
private readonly Lock _lock = new();
void Foo()
{
using (_lock.EnterLockScope())
{
// do something
}
}
}Lock pattern and behavior details
Consider a type L (Lock in this example) that may be used with the lock keyword. If L matches the lock pattern, it would meet all of the following criteria:
Limplements interfaceILockPatternLhas an accessible instance methodS EnterLockScope()that returns a value of typeS(Lock.Scopein this example). The method must be at least as visible asL. Extension methods don't qualify for the pattern.- A value of type
Squalifies for use with theusingkeyword
A marker interface ILockPattern is used to opt into the behaviors below, including through inheritance, and so that S may be defined by the user (for instance, as a ref struct). For a type L that implements interface ILockPattern:
- If
Ldoes not fully match the lock pattern, it would result in an error - If a value of type
Smay not be used in the context, it would result in an error - If a value of type
Lis implicitly or explicitly casted to another type that does not match the lock pattern (including generic types), it would result in a warning- This includes implicit casts of
thisto a base type when calling an inherited method - This is intended to prevent accidental usage of
Monitorwith values of typeLthat may be masked under a different type and used with thelockkeyword, such as with casts to base types, interfaces, etc.
- This includes implicit casts of
- If a value of type
Lis used with thelockkeyword, or a value of typeSis used with theusingkeyword, and the block contains anawait, it would result in an error- The warning would only be issued if in-method analysis can detect it
- This is intended to prevent the enter and exit from occurring on different threads.
MonitorandLockhave thread affinity. SpinLockis optionally thread-affinitized. It can opt into the pattern, but then usage of it with thelockorusingkeywords would still disallow awaits inside the block statically.
SpinLock example
System.Threading.SpinLock (a struct) could expose such a holder:
struct SpinLock : ILockPattern
{
[UnscopedRef]
public Scope EnterLockScope();
public ref struct Scope
{
public void Dispose();
}
}and then similarly be usable with lock whereas today as a struct it's not. When a variable of a struct type that matches the lock pattern is used with the lock keyword, it would be used by reference. Note the reference to SpinLock in the previous section.
Drawbacks
- If an existing reference type opted-in to using the pattern, it would change the meaning of
lock(objectsOfThatType)in existing code, and in ways that might go unnoticed due to the general unobservability of locks (when things are working). This also means if System.Threading.Lock is introduced before the language support, it likely couldn't later participate.- Also if one library
L1that exposes the type is updated to opt into the lock pattern, another libraryL2that referencesL1and was compiled for the previous version ofL1would have different locking behavior until it is recompiled with the updated version ofL1.
- Also if one library
- The same thing is achievable today with only a little bit more code, using
usinginstead oflockand writing out the name of the enter method manually (i.e. writing out the lowered code in the first example). - Having the lock semantics vary based on the static type of the variable is brittle.
ILockPatternitself doesn't match the lock pattern, though an interface could be created to match the lock pattern.
Alternatives
- Use an attribute instead of a marker interface. A marker interface was used because it works more naturally with inheritence. Although
AttributeUsageAttribute.Inherited = truecould be used for an attribute, it doesn't seem to work with interfaces. - Use a new syntax like
using lock(x)or other alternatives instead, which would eliminate any possibility of misusing values of the type withMonitor. - System.Threading.Lock could be special-cased by the compiler rather than having it be a general pattern.
- Various naming options in the pattern itself.
- Instead of having a holder type, instead have the compiler explicitly recognize certain enter/exit methods by naming convention or attribution.
- Potentially allow/require the
refkeyword for certain uses, e.g. in the SpinLock example. - The pattern as suggested doesn't support being hardened against thread aborts (which are themselves obsoleted). It could be extended to now or in the future.
Unresolved questions
Design meetings
https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-05-01.md#lock-statement-improvements
https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-10-16.md#lock-statement-pattern
https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-12-04.md#lock-statement-pattern
Activity
tfenise commentedon Apr 7, 2023
System.Threading.Lock lobecomes typed asObjectat compile-time for whatever reason,lock(lo)would break silently. For example:Everything compiles even though the spelling
EntersLockWithHolderis wrong andlock(lo)does not do what I intended.This may also be problematic if
MyLockis first written against .NET 8 and is later ported to .NET 7 or .NET Framework for whatever reason. In these cases, the codes would also break silently.This problem could be solved by introducing a custom attribute only to be found in .NET 8 BCL that must be present on such lock types. The compiler would then detect any misspelling, and backporting to earlier version would result in a compilation error.
This new design creates inconsistency between newer lock types that should be used with
lock(...)and older lock types likeMutexthat should not be used withlock(...).If this new design is implemented in C#, it should also be implemented in VB.NET, as VB.NET has
SyncLockwhich is basically equivalent tolockof C#.vladd commentedon Apr 8, 2023
If we consider implementing this feature, shouldn’t we consider its async counterpart which is lowered to
await using, too?HaloFour commentedon Apr 8, 2023
@vladd
I believe that should follow a completely different pattern as locks should have to opt-in to being "async"-friendly.
HaloFour commentedon Apr 8, 2023
@tfenise
I have to agree, I think there's enough danger of these newer-style locks being accidentally treated as monitor locks by the compiler in any case where the pattern just happens to not match, with no warning to the developer. I think that needs to be added to the list of "Drawbacks" for this proposal.
Is the plan to also extend this to the myriad of other locks or synchronization primitives that exist in the BCL?
svick commentedon Apr 8, 2023
Would it be feasible to introduce the ability of a type to forbid using
Monitoron it into the runtime? Let's say this feature would be enabled by annotating the type as[ForbidMonitor]. Then the newLockcould look like this:And used like this:
HaloFour commentedon Apr 8, 2023
@svick
How would that be enforced? If you upcast the
Lockit's just a normalobjectand the compiler wouldn't know to not useMonitor.alexrp commentedon Apr 8, 2023
@HaloFour As per the comment, it would be runtime-enforced. Presumably, if
[ForbidMonitor]is applied to the type, some bit in the method table would be set to indicate that anyMonitormethods called on the object should throw.svick commentedon Apr 8, 2023
@HaloFour I meant that the compiler would use
Monitor, but the implementation ofMonitor.Enterwould detect that the type of the object is one of the annotated ones and would throw an exception.But I don't know if that could be implemented without making every existing use of
lockslower.HaloFour commentedon Apr 8, 2023
@alexrp @svick
Sorry, I see what you meant after another re-reading of the comment. It'd prevent a subtle break, but you could still end up with exceptions at runtime if you're not careful.
I'd love to see more language support around locking types. It's always annoying me that none of the lock types in the BCL even offer support for
using, and I've written a bunch of extension methods specifically to enable that. It's nice to see that they're considering it for this newLockclass. I, personally, think that's sufficient. The problem withlockis that it's always going fallback toMonitor, and unless we want to make generic support for these locks a part of the runtime itself it feels a bit too dangerous to me to try to expand on that, at least without some novel guardrails. Honestly, I wish .NET could deprecateMonitorin general.timcassell commentedon Apr 9, 2023
Perhaps
await lock?Also, though I have seen
await usingused for an async lock before, I don't think it makes much sense. The popular AsyncEx library just usesusing. Soawait lock (_lock)would lower tousing (await _lock.EnterLockWithHolderAsync()).TahirAhmadov commentedon Apr 9, 2023
Hmm, my initial reaction is, not only we shouldn't add to
lock() {}in C#, but we should discourage its use, and instead recommend theusing(...) {}approach, even for theMonitor-based locking. This seems to be too much of a specific type of feature to be part of the language. And I also don't like thelocksuddenly doing different things depending on the type's surface area.However, I don't think the
Monitorlocking is bad idea by itself, using something likeusing(Monitor.Lock(obj)) { ... }is perfectly fine with me and I uselockcurrently in many places, it's just that it's really not a language-level feature in my opinion.PS. To clarify, from a clean slate perspective, I wouldn't have made all .NET objects "lockable" with
Monitor; it should be a feature offered by a special type, similar toMutex/etc. But given the legacy, theMonitorjust isn't going anywhere any time soon.TahirAhmadov commentedon Apr 10, 2023
Having thought about it, I would have liked in a "perfect world" to have
lock(andawait lock) statement in C#, which would do something similar to what the OP proposes, as syntactic sugar, and then a few different types could be offered in the BCL to address various common usage scenarios. However, given howlockis now tightly related toMonitor, it's not a good idea to expand it. Perhaps, we can introduce a new keyword here? Likesynchandawait synch? ThenlockandMonitorcan both be obsoleted over time with suggestions/warnings, auto-fixers, etc.jaredpar commentedon Apr 10, 2023
Correct and that is an explicit design choice. This proposal breaks compat for any type which is being locked on today that just happens to fit the new locking pattern. Casting to
objectin alockis the mechanism for restoring the existing behavior. Essentially, it's a key part of the proposal that it be allowed and have this behavior.This particular case is not very realistic to me. In practice, it's very uncommon for the expression inside a
lockto be anything other than a variable (local or field) so the chance of this type of problem feels very low. I think the more realistic case is aLockinstance getting converted toobjectas a part of a more vanilla call and the receiver later decides tolockon it. Even that seems fairly unlikely cause locking on instances you don't own is a dangerous behavior.78 remaining items
KennethHoff commentedon Jul 19, 2024
To be specific, the new
System.Threading.Locktype - and its special-cased interaction with thelockkeyword - is expected in C#13.The actual proposal - a lock pattern - is not coming, at least not in C# 13
julealgon commentedon Jul 19, 2024
Is there any reason why we still use the
lockkeyword now that the language hasusingdisposal blocks? Wouldn't it just be better to encourage developers to use the slightly more explicitusingapproach and mark thelockkeyword as deprecated or something along these lines?I assume when
lockwas introduced it made more sense because there was no "similarly simple" construct that would support the same logic, but now (unless I'm missing something obvious) it feels superfluous.CyrusNajmabadi commentedon Jul 19, 2024
Yes. The lock statement clearly and concisely indicates the intent. It is a region of mutual exclusion. A using block does not indicate that.
We do not think so. The language has supported locking since v1. It is a widely used construct. We see no reason to move away from that
The language supported both
lockandusingSince v1. We have never felt that the latter supplants the former.julealgon commentedon Jul 19, 2024
A
usingblock indicates whatever the call indicates, IMHO.Would be even more explicit and obvious to me than a custom
lock (lock)(inherently confusing and redundant) call.But I guess this aspect is still subjective, so fair enough.
Fair enough.
Just because something is supported since X, doesn't mean it is inherently good.
Just because something is widely used doesn't mean it is good or that it should never be reconsidered.
Again, fair enough... to me it's a shame, as it makes the language unnecessarily more complex/less orthogonal. It introduces a special case for something that clearly didn't need it.
Oh, my bad on this one. I thought the using block was introduced after C# 1. I stand corrected. In that case, looks like we just missed an opportunity to avoid introducing the
lockkeyword altogether. But I can see people used to that word in other languages could be put off at the time.CyrusNajmabadi commentedon Jul 19, 2024
That's more complex and less clear to me than just the
lockstatement. A bread and butter statement with clear and simple meaning since 1.0.I disagree. This is a good part of hte langauge that people have been using for 25 years. Removing support is just adding friction and complexity. Foolish consistencies, hobgoblins, and all that.
We didn't miss an opportunity. The possibility of using
usingwas well understood. After all, these are both about nicer patterns around try/finally. It's an intentional and continued view thatlockhas significant value for the language and moving away from that would be a net negative.CyrusNajmabadi commentedon Jul 19, 2024
We didn't need
usingin the first place either. It was a special case for having a try/finally with a particular null+dispose check. But we still like it.There are lots of special one-off lang features that are just sugar over other things. The vast majority of hte language is actually just that. Yes, we could go the route of other languages and whittle that away to a core kernel that you use for everything. But we actually view that as a negative. We think this features and sugar are the value and niceness that makes our language more pleasant and more desirable for many users.
brantburnett commentedon Jul 19, 2024
I would also point out that
lockdoes have additional semantics that are important, beyond whatusingoffers. For example, inasyncmethods you can't includeawaitcalls within thelockblock because locks are thread-specific, but you can useusingstatements.[-][Proposal]: Lock statement pattern[/-][+][Proposal]: Lock statement pattern (VS 17.10, .NET 9)[/+]marchewek commentedon Oct 8, 2024
Isn't it just a matter of convention? Some will prefer a more technical focus "I want
lockandawait lockto use different verbs, because they will work differently and I want this to be explicit" while others will prefer a more functional focus "I wantlockandawait lockto use the same verb, because I want to protect a critical section of my code regardless of technical differences between sync and async version". Personally I dislike theawait usingused for locking a critical section. Even if the former convention should stay in place, I would prefer someawait lockasynctoawait using, because this is about locking a critical section - using a critical section single-use-lock is not as straightforward.Mr0N commentedon Oct 13, 2024
I don't quite understand the meaning of such a construction. Essentially, the compiler will just replace using with lock, but a lot of additional information is needed.