Search code examples
.netc++-cli

GC collects local object right out from under me


I'm hoping someone can glance at this and explain to me what I'm missing.

The short version is I create a managed object in a function and call a member upon it. Before that member function has even returned, the GC finalizes the object right out from underneath me from another thread.

Here is the C# code that creates the object and calls the C++/CLI function

var integrator = Integrator.Create();
_logger.Debug("Integrating normal map.  Res=" + model.ResolutionMmpp);
var hm = integrator.IntegrateNormalMap(nm, model.ResolutionMmpp);

// *** 'integrator' gets finalized before I even get here.  How? ***
_logger.Debug("Back from integrate");  

Here is the implementation of IntegrateNormalMap (C++/CLI code). It calls through to its unmanaged equivalent C++ code.

HeightMap^ Integrator::IntegrateNormalMap(NormalMap^ nrm, double res)
{
    //    Note:  'm_psi' is a pointer to the valid, unmanaged C++ object
    return gcnew HeightMap(m_psi->integrateNormalMap(nrm->sdkMap(), res));
}

I put a breakpoint on the Integrator class finalizer (i.e. Integrator::!Integrator) and I can see the garbage collector invoking the my C++/CLI object's finalizer from different thread.

Here is the call stack of the finalizer being invoked

Sdk::Integrator::~Integrator() Line 26  C++
Sdk::Integrator::Dispose()  C++
Sdk::Integrator::Dispose()  C++
Sdk::Integrator::!Integrator() Line 38  C++
Sdk::Integrator::Dispose()  C++
[Native to Managed Transition]  
00007ffeee324034()  Unknown
00007ffeee473691()  Unknown

But at the same moment, that IntegrateNormalMap() function is still running on the original thread.

gs::detail::PoissonIntegratorV2014::reconstructNormals(normals={...}) Line 79   C++
gs::detail::PoissonIntegratorV2014::integrateNormalMap(nrm, res) Line 35    C++
[Managed to Native Transition]  
Sdk::Integrator::IntegrateNormalMap(nrm, res) Line 45   C++
Mobile.ViewModels.ScanVm.Generate3d(ffcEnum, token) Line 595    C#
Mobile.ViewModels.ScanVm.Generate3d(token) Line 642 C#
Capture.ViewModels.NormalScanJob.Generate3d() Line 60   C#
Capture.ViewModels.CaptureVm.get_Mesh.AnonymousMethod__27_4(job = {Capture.ViewModels.NormalScanJob}) Line 371  C#
System.Threading.Tasks.Dataflow.TransformBlock<Capture.ViewModels.NormalScanJob, Capture.ViewModels.NormalScanJob>.ProcessMessage(transform, messageWithId = {[{Capture.ViewModels.NormalScanJob}, 0]}) Unknown
System.Threading.Tasks.Dataflow.TransformBlock<System.__Canon, System.__Canon>..ctor.AnonymousMethod__3(messageWithId)  Unknown
System.Threading.Tasks.Dataflow.Internal.TargetCore<Capture.ViewModels.NormalScanJob>.ProcessMessagesLoopCore() Unknown
System.Threading.Tasks.Task.Execute()   Unknown
System.Threading.ExecutionContext.RunInternal(executionContext, callback, state, preserveSyncCtx)   Unknown
System.Threading.ExecutionContext.Run(executionContext, callback, state, preserveSyncCtx)   Unknown
System.Threading.Tasks.Task.ExecuteWithThreadLocal(currentTaskSlot = Id = 2494, Status = Running, Method = Inspecting the state of an object in the debuggee of type System.Delegate is not supported in this context.) Unknown
System.Threading.Tasks.Task.ExecuteEntry(bPreventDoubleExecution)   Unknown
System.Threading.ThreadPoolWorkQueue.Dispatch() Unknown
[Native to Managed Transition]  
00007ffeee324034()  Unknown
00007ffeee473691()  Unknown

Note that the unmanaged C++ code isn't doing anything weird.

  • It's in a loop doing work on unmanaged variables in the the unmanaged object.
  • It's not overwriting/corrupting anything (this code has been working heavily for unmanaged clients for 6-7 years).
  • Nowhere am I doing anything with the GC or weak references or anything like that. I just create a local object, call a function and it gets deleted out from under me.

I did stumble across on strange "fix" but I don't trust it. I don't think it's really a fix and I don't understand why it prevents the problem:

If I put a try/catch frame in the managed C++/CLI IntegrateNormalMap function (to make it translate any unmanaged C++ exceptions into managed ones) the problem goes away. Here it is, rewritten with the try/catch

HeightMap^ Integrator::IntegrateNormalMap(NormalMap^ nrm, double res)
{
    try
    {
      return gcnew HeightMap(m_psi->integrateNormalMap(nrm->sdkMap(), res));
    }
    catch (const std::exception& ex)
    {
        throw gcnew SdkException(ex.what());
    }
}

Note: Although this is obviously good general practice (i.e. preventing unmanaged exceptions from escaping) the underlying unmanaged code is not actually throwing any exceptions in either case. It's still processing.

I have also verified that I am compiling this C++/CLI class with the /clr option. It is managed.

So now I am left confused. Has my exception frame truly "fixed" the problem? If so, what was it and how does that simple step fix it?

(I'm using Visual Studio 2019, and .NET Framework 4.8)

EDIT: Just adding to show the destructor and finalizer of my C++/CLI Integrator class to clarify for Hans Passant's comment below.

// Take ownership of the given unmanaged pointer.
Integrator::Integrator(std::unique_ptr<gs::Integrator> sip) 
    : m_psi(sip.release())
{
}
Integrator::~Integrator()
{
    // Clean up and null out in case we get called twice

    delete m_psi;
    m_psi = nullptr;
}
Integrator::!Integrator()
{
    this->~Integrator();
}

Solution

  • var integrator = Integrator.Create();
    _logger.Debug("Integrating normal map.  Res=" + model.ResolutionMmpp);
    var hm = integrator.IntegrateNormalMap(nm, model.ResolutionMmpp);
    
    // *** 'integrator' gets finalized before I even get here.  How? ***
    _logger.Debug("Back from integrate"); 
    

    integrator' gets finalized before I even get here. How?

    The variable integrator is not used any more after this point so it's free for garbage collection. That's actually already true, right after it's member m_psi is accessed in the call below, because it's the last reference to 'this'.

    HeightMap^ Integrator::IntegrateNormalMap(NormalMap^ nrm, double res)
    {
        //    Note:  'm_psi' is a pointer to the valid, unmanaged C++ object
        return gcnew HeightMap(m_psi->integrateNormalMap(nrm->sdkMap(), res));
    }
    

    And as you statet you delete m_psi in the Finalizer of Integrator. To prevent this you just have to prevent integrator to be garbage collected before your call is finished. For example: make it a field or access it after the call with GC.KeepAlive(integrator);

    Regarding .Net 4.8/4.7.2: [C#] I can confirm, that this is a bug/feature that's new in 4.8. I wrote an example application to reproduce the behavior. In 4.7.2 the Object stayed alive, in 4.8 it was GCed during it's method call. I then reproduced the same behavior with pure C# with the same results, as soon as an Object is not referenced anymore it's garbage collected, even when one of it's methodes is still running. For C# this is ofcause no Problem, because an Object whose data can still be accessed will never be garbage collected.

    https://github.com/c-tair/CSharpAgressiveGC