Skip to content

[Proposal]: Lock statement pattern (VS 17.10, .NET 9) #7104

@stephentoub

Description

@stephentoub

Lock statement pattern

(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:

  • L implements interface ILockPattern
  • L has an accessible instance method S EnterLockScope() that returns a value of type S (Lock.Scope in this example). The method must be at least as visible as L. Extension methods don't qualify for the pattern.
  • A value of type S qualifies for use with the using keyword

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 L does not fully match the lock pattern, it would result in an error
  • If a value of type S may not be used in the context, it would result in an error
  • If a value of type L is 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 this to a base type when calling an inherited method
    • This is intended to prevent accidental usage of Monitor with values of type L that may be masked under a different type and used with the lock keyword, such as with casts to base types, interfaces, etc.
  • If a value of type L is used with the lock keyword, or a value of type S is used with the using keyword, and the block contains an await, 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. Monitor and Lock have thread affinity.
    • SpinLock is optionally thread-affinitized. It can opt into the pattern, but then usage of it with the lock or using keywords 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 L1 that exposes the type is updated to opt into the lock pattern, another library L2 that references L1 and was compiled for the previous version of L1 would have different locking behavior until it is recompiled with the updated version of L1.
  • The same thing is achievable today with only a little bit more code, using using instead of lock and 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.
  • ILockPattern itself 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 = true could 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 with Monitor.
  • 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 ref keyword 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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions