bUnit-dev / bUnit

bUnit is a testing library for Blazor components that make tests look, feel, and runs like regular unit tests. bUnit makes it easy to render and control a component under test’s life-cycle, pass parameter and inject services into it, trigger event handlers, and verify the rendered markup from the component using a built-in semantic HTML comparer.
https://bunit.dev
MIT License
1.14k stars 107 forks source link

Improved locking performance on .NET 9.0+ #1532

Closed MarkCiliaVincenti closed 1 month ago

MarkCiliaVincenti commented 2 months ago

The new System.Threading.Lock offers greater performance, as independent benchmarks show (eg https://steven-giesel.com/blogPost/4cf6d48a-ec9d-4c68-961c-31fd8d8c1340)

linkdotnet commented 2 months ago

Hey @MarkCiliaVincenti

Thanks for filing the PR. Good to know that System.Threading.Lock isn't experimental anymore. While I am totally on board with using System.Threading.Lock for net9.0,, I am hesitant to use a backport that doesn't bring new features or crucial "polyfills."

We could do the same via:

#if NET_9_0_OR_GREATER
private readonly Lock syncLock = new();
#else
private readonly object syncLock = new();
#endif

Maybe @egil, and I can discuss this next week, but I would drop the package in favor of the more simple approach - given that we don't have many places where we are using locks.

MarkCiliaVincenti commented 2 months ago

Hi @linkdotnet, I didn't quite expect a comment from the guy whose blog articles helped inspire this micro-library. Thanks for that!

Yes, such an approach as you mentioned is possible, however as you are well aware of, System.Threading.Lock has some extras in it, such as the EnterScope. You will also have an issue if you try to use Monitor.Enter or the like. You should never Monitor.Enter on a Lock object, but instead call the Enter method. Essentially, not using a backport means that moving forward you will be limited in use.

linkdotnet commented 2 months ago

Hi @linkdotnet, I didn't quite expect a comment from the guy whose blog articles helped inspire this micro-library. Thanks for that!

Thanks for the mention and also linking the blog post as motivation for the PR.

Yes, such an approach as you mentioned is possible, however as you are well aware of, System.Threading.Lock has some extras in it, such as the EnterScope. You will also have an issue if you try to use Monitor.Enter or the like. You should never Monitor.Enter on a Lock object, but instead call the Enter method. Essentially, not using a backport means that moving forward you will be limited in use.

I am not sure if I understand 100% here. Let's dissect this a bit. We basically have two scenarios:

  1. net9.0 and beyond
  2. net8.0 and earlier

For the first one, sure - System.Threading.Lock will be the default lock type. So the question evolves (for me) around the latter on: net8.0 and earlier. As I understand using the backport will result in the same lowered code as with net9.0- is my assumption correct? The "only" downside is, you don't get the performance improvement (which I don't mind). Furthermore, are there any other implications for <= net8.0 given that the semantics are different.

Essentially, not using a backport means that moving forward you will be limited in use.

Why is that? Until net8.0 locking a readonly object worked just fine? I am not sure, why we are limited if we aren't using a backport. Can you elaborate on this one?

MarkCiliaVincenti commented 2 months ago

OK let's say you have a Lock instance and you have one method that's doing a lock (myObj) on it.

That will work with the trick you posted above. So far so good.

Now let's say you add a second method but want to have a timeout on it. Now you can neither use myObj.Enter(5) because on .NET 8.0 that won't work, nor can you use Monitor.Enter(myObj, 5) because on .NET 9.0 that has different behaviour. Remember the new locking class doesn't use Monitor, so if you try this your lock for the timeout and the normal lock (myObj) will behave as if they were two different locks, apart from not getting the performance improvement on .NET 9.0.

MarkCiliaVincenti commented 2 months ago

Basically:

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
}
MarkCiliaVincenti commented 2 months ago

Thanks for the mention and also linking the blog post as motivation for the PR.

Doesn't even stop here :) https://www.reddit.com/r/csharp/comments/1f6ari2/locking_with_net_90s_systemthreadinglock_even_on/

MarkCiliaVincenti commented 2 months ago

Furthermore, are there any other implications for <= net8.0 given that the semantics are different.

The only limitation is that on .NET Framework 3.5 and .NET Framework 4.0, you cannot use the IsHeldByCurrentThread property since the IsEntered() method on Monitor was only introduced in .NET Framework 4.5.

linkdotnet commented 2 months ago

Okay now I understand a bit better - thanks for clarifying @MarkCiliaVincenti !

Now let's say you add a second method but want to have a timeout on it. Now you can neither use myObj.Enter(5) because on .NET 8.0 that won't work, nor can you use Monitor.Enter(myObj, 5) because on .NET 9.0 that has different behaviour.

True that - and here is what I meant initially. If we have such a situation, I totally agree, that some kind of backport is useful! The way bUnit is using locking is very simple and we don't have such cases. So my argument would still be: For now, I would postpone the usage of a micro-library for a case we don't have and use it once we hit this barrier.

Don't get me wrong: You did an amazing job, and lots of folks have such use cases, but bUnit doesn't (at the moment). That said, @egil and I have our weekly sync/stream where we can discuss this. Everything you read so far is just my opinion.

linkdotnet commented 2 months ago

Furthermore, are there any other implications for <= net8.0 given that the semantics are different.

The only limitation is that on .NET Framework 3.5 and .NET Framework 4.0, you cannot use the IsHeldByCurrentThread property since the IsEntered() method on Monitor was only introduced in .NET Framework 4.5.

Fair enough. .NET FX 4.0 isn't supported anymore anyway. So targeting >netstandard2.0 and beyond is reasonable!

MarkCiliaVincenti commented 2 months ago
#if NET_9_0_OR_GREATER
private readonly Lock syncLock = new();
#else
private readonly object syncLock = new();
#endif

That is correct. It will work as per the code you wrote. There's also a neater way of doing it via:

<Using Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net9.0'))"
           Alias="Lock"
           Include="System.Object" />

And if you do try to Monitor.Enter on it, it will still give you the warning CS9216: A value of type System.Threading.Lock converted to a different type will use likely unintended monitor-based locking in lock statement.

That said, I understand your argument with regards to crossing the bridge when you come to it, but it just feels a bit like procrastinating to me and I'd personally prefer to remove the limitation. If your biggest concern is adding in a new dependency, you may want to consider importing the class directly into your project, just giving some credit.

linkdotnet commented 2 months ago

That said, I understand your argument with regards to crossing the bridge when you come to it, but it just feels a bit like procrastinating to me and I'd personally prefer to remove the limitation.

It isn't about procrastinating or not - sorry if it felt that way. It is more about the choice we have to make to onboard something we have to maintain for years to come. Given that we support net8.0, we will support this at least until November 2026 (and as v1 shows we support even outdated versions).

We will add a dependency for users, so we have to think through it and not just rush into something. I do understand that you have an awesome library that solves specific issues for users.

And if you do try to Monitor.Enter on it, it will still give you the warning CS9216: A value of type System.Threading.Lock converted to a different type will use likely unintended monitor-based locking in lock statement.

I do understand and also agree that your library might solve that for many folks. My whole argumentation is based on the fact, that we don't have such code.

Given the pros and cons, what is the downside of delaying this to the time we are sure we will run into this situation? Or at least think of all the scenarios.

linkdotnet commented 1 month ago

@MarkCiliaVincenti

After discussing (link)the pros and cons, we decided that for bUnit that we keep the current object lock version even for .NET 9. If we use the new type, we will use the #ifdef NET_9_0_OR_GREATER approach as discussed earlier.

Thanks for filing the PR and discussing it!