Skip to content
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

Concurrency issue in TypeDescriptor.GetConverter(type) #30024

Closed
PawelGerr opened this issue Jun 25, 2019 · 10 comments · Fixed by #96846
Closed

Concurrency issue in TypeDescriptor.GetConverter(type) #30024

PawelGerr opened this issue Jun 25, 2019 · 10 comments · Fixed by #96846

Comments

@PawelGerr
Copy link

TypeDescriptor.GetConverter(type) returns different results when calling on multiple threads with the same type and this type has a custom TypeDescriptionProvider.

This issue is reproducible with target frameworks netcoreapp2.2 and net472 on window 10 x64.

Repro

I'm calling the method on 10 threads and print the fetched type converter at the end.

Expected output

All 10 calls return MyTypeConverter

Actual output

The method return both TypeConverter and MyTypeConverter in random order like the following:

TypeConverter
TypeConverter
TypeConverter
MyTypeConverter
TypeConverter
TypeConverter
TypeConverter
TypeConverter
TypeConverter
TypeConverter

The good thing is that the method is returning MyTypeConverter after the concurrent calls are finished. The bad thing is that there are libs (like newtonsoft.json) that are caching the type descriptors. If the cache of 3rd party lib is initialized with wrong descriptor then the application is broken until restart.

Program.cs

public class Program
{
   public static async Task Main()
   {
      var tasks = new Task<TypeConverter>[10];

      for (var i = 0; i < tasks.Length; i++)
      {
         tasks[i] = new Task<TypeConverter>(() => TypeDescriptor.GetConverter(typeof(MyClass)));
      }

      foreach (var t in tasks)
      {
         t.Start();
      }

      foreach (var conv in await Task.WhenAll(tasks))
      {
         Console.WriteLine(conv.GetType().Name);
      }
   }
}

[TypeDescriptionProvider(typeof(MyClassTypeDescriptionProvider))]
public class MyClass
{
}

public class MyClassTypeDescriptionProvider : TypeDescriptionProvider
{
   public override ICustomTypeDescriptor GetTypeDescriptor(Type objectType, object instance)
   {
      return new MyClassTypeDescriptor();
   }
}

public class MyClassTypeDescriptor : CustomTypeDescriptor
{
   public override TypeConverter GetConverter()
   {
      return new MyTypeConverter();
   }
}

public class MyTypeConverter : TypeConverter
{
}

.csproj-file

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>netcoreapp2.2</TargetFramework>
        <OutputType>EXE</OutputType>
    </PropertyGroup>
</Project>
@stephentoub
Copy link
Member

The bug here is in CheckDefaultProvider. It appears its purpose is to examine a type and add appropriate providers, and it uses a set to indicate whether a type has already been examined... but it marks the type examined at the beginning of its execution rather than at the end, such that any threads that then concurrently look up the provider for that type miss the default provider until it completes. It's not just as simple, however, as moving the marking to the end, as it's also trying to work around a recursive usage.

@stephentoub stephentoub self-assigned this Jun 25, 2019
@safern
Copy link
Member

safern commented Jun 25, 2019

@stephentoub given that you self-assigned this, you plan to fix this before we branch tomorrow? I want to know what is your timeframe for this in order to triage accordingly.

@stephentoub stephentoub removed their assignment Jun 26, 2019
@stephentoub
Copy link
Member

Been staring at it for a bit, and it doesn't look like there's a trivial fix here. This issue has been here basically forever as far as I can tell, so while it'd be good to fix it, I think it's best not to try to rush a fix into 3.0.

@safern
Copy link
Member

safern commented Jun 26, 2019

Thanks for confirming, @stephentoub

@msftgits msftgits transferred this issue from dotnet/corefx Feb 1, 2020
@msftgits msftgits added this to the Future milestone Feb 1, 2020
@maryamariyan maryamariyan added the untriaged New issue has not been triaged by the area owner label Feb 23, 2020
@ericstj ericstj removed the untriaged New issue has not been triaged by the area owner label Jul 1, 2020
@safern safern modified the milestones: Future, 7.0.0 Jun 30, 2021
@eerhardt
Copy link
Member

Moving to Future as this issue has been around for a long time and is not critical to fix in 7.0.

@eerhardt eerhardt modified the milestones: 7.0.0, Future Jul 13, 2022
@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Apr 21, 2023
@ghost ghost removed the in-pr There is an active PR which will close this issue when it is merged label Aug 9, 2023
@ericstj
Copy link
Member

ericstj commented Dec 12, 2023

Adding some links based on @stephentoub's analysis above.

The issue stems from this

lock (s_internalSyncObject)
{
if (s_defaultProviders.ContainsKey(type))
{
return;
}
// Immediately clear this. If we find a default provider
// and it starts messing around with type information,
// this could infinitely recurse.
s_defaultProviders[type] = null;
}

Which allows a second caller to escape this function before the first thread's initialization completes.

My first thought was to hold onto the s_internalSyncObject longer - until the end of this method. That would require us to also acquire that lock before the first check though - which could introduce a performance penalty in the initialized case.

if (s_defaultProviders.ContainsKey(type))
{
return;
}

I don't think we'd want to introduce such a penalty - this code-path seems to have been intentional about avoiding a lock in the read case.

Perhaps a way to solve this, retaining the protection for recursion and avoiding a performance penalty (or another list) is to use a sentinel value during initialization - in addition to holding the initialization lock for the duration of the initialization phase.

So we can have public static object s_initializingProvider = new object();

We'll set this as the value for type instead of null during initialization. When we check for the value outside the lock we'll only return if we get a value that is not the sentinel value. If we find the sentinel value (or no value at all) we'll enter the lock and do the initialization. Under the lock if we find any value (including the sentinel) we'd just return since it means we're either in the recursive case or some other thread completed initialization.

@stephentoub does this sound like it would work? If so I can propose a PR.

@ericstj
Copy link
Member

ericstj commented Dec 12, 2023

I just noticed that #92521 was also trying to fix this by adding an extra hashtable. That works, but I wonder if we can solve it with just a sentinel. @karakasa can you have a read of my suggestion above and see if it makes sense?

@stephentoub
Copy link
Member

@stephentoub does this sound like it would work? If so I can propose a PR.

It's been a while since I looked at this, but at first glance your suggestion sounds viable.

@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Jan 11, 2024
@steveharter
Copy link
Member

The sentinel pattern was applied new PR created: #96846

@ghost ghost removed the in-pr There is an active PR which will close this issue when it is merged label Jan 12, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Feb 12, 2024
@steveharter
Copy link
Member

steveharter commented Feb 26, 2024

Long-standing threading issues in TypeDescriptor have been addressed in .NET 9 Preview 1, so any existing work-arounds can be removed. Workarounds included pre-populating internal caches such as by calling TypeDescriptor.GetProvider(type) for every type that has a TypeProviderAttribute, and by wrapping the access to the affected APIs with a lock statement.

Also see TypeDescriptor.GetProperties(object instance) is not thread-safe.

The threading issues also exist in .NET Framework. If you are affected by this issue in .NET Framework, please add feedback here so we can determine the priority of porting the fix to .NET Framework.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.