Search code examples
.netcom

Is it normal for COM RWC's created in STA thread to disconnect from the underlying COM object on thread termination?


First off, I want to make it clear that this is not a question on how to release COM objects under .Net. It is a question about trying to understand an unexpected COM object release that is probably due to normal COM behavior, but I am unable to find a definitive explanation for the observed behavior and would appreciate a confirmation of the inferences that I am making based on some quotations presented later on.

I have noticed that when working with Excel via COM-Interop that the Excel instance will completely terminate, as one would hope, when the interop references are created in a secondary thread with the Apartmentstate set to ApartmentState.STA. This occurs without taking any action to clear the reference count on the runtime callable wrappers (RCW's) either by explicitly calling for their release using Marshal.ReleaseCOMObject or by invoking the garbage collector (GC) to clean up the objects in order for Excel to completely shutdown. For those not familiar with working with Excel Interop, please be advised that it well known for not shutting down after being told to quit until all .Net COM references have been released.

My first thought was that GC had automatically run on thread completion. To see if this was true, I used Visual Studio's "Performance and Diagnostics" tool to monitor memory usage.

enter image description here

The above graphic, I first run the method that interacts with Excel on the UI thread and then on a MTA thread. It can be observed that the Excel process did not terminate until the GC was run to free the COM references. Note that a GC marker is placed on the profiling chart. Then I run the method twice on a STA thread. It can be observed that the Excel process terminates without any additional action required and the Profiling chart indicates that the GC did not run after the thread that Excel was launched from had exited. Also, if I attempt to access a reference created in the STA thread after it has terminated, a "COM object that has been separated from its underlying RCW cannot be used." exception is thrown.

At this point I thought that release of the Excel process was in some way related to the reclamation of the thread used to create the objects. I ran the program executing the Excel method twice on a STA thread and recorded the results shown below. It can be seen that all thread instances as well as the COM objects are listed as being alive throughout the life of the test.

enter image description here

In researching a COM object's lifetime, I found the following statement in Larry Osterman's blog entry "What are these “Threading Models” and why do I care?" that seems to explain why the .Net RCW's are disconnected from the underlying COM object.

A COM object’s lifetime is limited to the lifetime of the apartment that creates the object. So if you create an object in an STA, then destroy the apartment (by calling CoUninitialize), all objects created in this apartment will be destroyed.

This statement implies that the STA COM apartment that is the controlling mechanism. However, the only thing that I have found indicating the impact of Apartment lifetime on .Net objects is the following quotation from Chris Brumme's blog post "Apartments and Pumping in the CLR".

Our COM Interop layer ensures that we almost only ever call COM objects in the correct apartment and context. The one place where we violate COM rules is when the COM object’s apartment or context has been torn down. In that case, we will still call IUnknown::Release on the pUnk to try to recover its resources, even though this is strictly illegal.

So finally to my question: Is what I have observed the result of the STA apartment that was created for thread being destroyed when the thread execution ends and thus allowing the Excel process to terminate because there are no longer any objects holding references to it?

I originally stated that this is not a question on how to release COM objects in .Net. However, I would appreciate any insights into possible negative effects of using this technique to do so. It has worked without fail, but I am hesitant to use it when documented techniques are readily implemented.

The code presented below, is what I used to investigate this behavior.

Imports System
Imports Excel = Microsoft.Office.Interop.Excel
Imports System.Threading
Imports System.Runtime.InteropServices
Imports System.Windows.Forms
Imports System.Diagnostics

Public Class frmComRelease : Inherits Form
    Private launchedExcelProcesses As New System.Collections.Concurrent.ConcurrentDictionary(Of Process, ApartmentState) ' requires Proj Ref: System.ServiceModel.dll
    Private btnRunUI As Button
    Private btnRunMTA As Button
    Private btnRunSTA As Button
    Private btnRunGC As Button
    Private btnTryToAccessExcelReference As Button
    Private excelReference As Object
    Private processStatus As TextBox
    Private chkBxGrabReference As CheckBox
    Private grabReference As Boolean
    Private key As New Object

    Public Sub New()
        MyBase.New()
        Font = New Drawing.Font(Font.FontFamily, 12, Font.Style, Drawing.GraphicsUnit.Pixel)
        Width = 400 : Height = 350

        btnRunUI = AddButton("Run Excel On UI Thead", Nothing, AddressOf btnRunUI_Click)
        btnRunMTA = AddButton("Run Excel On MTA Thead", btnRunUI, AddressOf btnRunMTA_Click)
        btnRunSTA = AddButton("Run Excel On STA Thead", btnRunMTA, AddressOf btnRunSTA_Click)
        btnTryToAccessExcelReference = AddButton("Access Last Excel Reference", btnRunSTA, AddressOf btnTryToAccessExcelReference_Click)
        btnRunGC = AddButton("Run GC to free UI or MTA started Excel Process", btnTryToAccessExcelReference, AddressOf btnRunGC_Click)
        processStatus = New TextBox With {.Multiline = True, .Location = New System.Drawing.Point(5, btnRunGC.Bottom + 10), .Width = Me.ClientSize.Width - 10, .Anchor = AnchorStyles.Bottom Or AnchorStyles.Left Or AnchorStyles.Right Or AnchorStyles.Top, .ReadOnly = True, .ScrollBars = ScrollBars.Vertical}
        processStatus.Height = ClientSize.Height - processStatus.Top - 5
        Controls.Add(processStatus)
        chkBxGrabReference = New CheckBox() With {.Text = "Hold Excel Reference", .AutoCheck = True, .Location = New System.Drawing.Point(10 + btnRunMTA.Width, 5), .TextAlign = System.Drawing.ContentAlignment.MiddleLeft, .AutoSize = True}
        AddHandler chkBxGrabReference.CheckedChanged, AddressOf chkBxGrabReference_CheckedChanged
        Controls.Add(chkBxGrabReference)
        StartPosition = FormStartPosition.Manual
        Location = New Drawing.Point(500, 100)
    End Sub

    Private Sub chkBxGrabReference_CheckedChanged(sender As Object, e As EventArgs)
        SyncLock key
            grabReference = chkBxGrabReference.Checked
        End SyncLock
    End Sub

    Private Function AddButton(text As String, relativeTo As Control, clickHandler As EventHandler) As Button
        Dim btn As New Button() With {.Text = text, .Location = New System.Drawing.Point(5, If(relativeTo Is Nothing, 5, relativeTo.Bottom + 5)), .TextAlign = System.Drawing.ContentAlignment.MiddleLeft, .AutoSize = True}
        AddHandler btn.Click, clickHandler
        Controls.Add(btn)
        Return btn
    End Function

    Protected Overrides Sub OnClosed(e As EventArgs)
        MyBase.OnClosed(e)
        For Each p As Process In Me.launchedExcelProcesses.Keys
            p.Dispose()
        Next
    End Sub

    Private Sub btnTryToAccessExcelReference_Click(sender As Object, e As EventArgs)
        SyncLock key
            If excelReference IsNot Nothing Then
                Dim ptr As IntPtr
                Dim msg As String
                Try
                    ptr = Marshal.GetIUnknownForObject(excelReference)
                    Marshal.Release(ptr)
                    msg = "Sucessfully accessed reference"
                Catch ex As Exception
                    msg = ex.Message
                End Try
                excelReference = Nothing
                MessageBox.Show(msg)
            End If
        End SyncLock
    End Sub

    Private Sub btnRunUI_Click(sender As Object, e As EventArgs)
        ExcelWork()
    End Sub

    Private Sub btnRunMTA_Click(sender As Object, e As EventArgs)
        Dim t As New Thread(AddressOf ExcelWork)
        t.SetApartmentState(ApartmentState.MTA)
        t.Start()
    End Sub

    Private Sub btnRunSTA_Click(sender As Object, e As EventArgs)
        Dim t As New Thread(AddressOf ExcelWork)
        t.SetApartmentState(ApartmentState.STA)
        t.Start()
    End Sub

    Private Sub btnRunGC_Click(sender As Object, e As EventArgs)
        excelReference = Nothing
        Do
            GC.Collect()
            GC.WaitForPendingFinalizers()
        Loop While System.Runtime.InteropServices.Marshal.AreComObjectsAvailableForCleanup
    End Sub

    Private Sub ExcelWork()
        Dim app As Excel.Application = New Excel.Application()
        app.Visible = True
        PositionExcel(app)
        SyncLock key
            If grabReference Then excelReference = app
        End SyncLock

        Dim processId As Int32
        Dim threadID As Int32 = GetWindowThreadProcessId(app.Hwnd, processId)
        Dim proc As Process = Process.GetProcessById(processId)
        proc.EnableRaisingEvents = True
        Dim state As ApartmentState = Thread.CurrentThread.GetApartmentState()
        launchedExcelProcesses.TryAdd(proc, state)
        UpdateStatus(GetProcessStatusMessage(proc))
        AddHandler proc.Exited, AddressOf Process_Exited
        Dim wb As Excel.Workbook = app.Workbooks.Add()
        For Each cell As Excel.Range In DirectCast(wb.Worksheets.Item(1), Excel.Worksheet).Range("A1:H10")
            cell.Value2 = 10
        Next

        wb.Close(False)
        app.Quit()
        UpdateStatus(String.Format("Exiting {0} thread of Excel process [{1}]", state, proc.Id))
    End Sub

    Private Sub PositionExcel(app As Excel.Application)
        Dim r As System.Drawing.Rectangle = Me.Bounds
        ' Excel position/size measured in pts
        Dim pxTopt As Double
        Using g As Drawing.Graphics = CreateGraphics()
            pxTopt = 72.0 / g.DpiX
        End Using
        app.WindowState = Excel.XlWindowState.xlNormal
        app.Top = r.Top * pxTopt
        app.Left = (r.Right) * pxTopt
        app.Width = r.Width * pxTopt
        app.Height = r.Height * pxTopt
    End Sub

    Private Function GetProcessStatusMessage(process As Process) As String
        Dim state As ApartmentState
        launchedExcelProcesses.TryGetValue(process, state)
        Return String.Format("{3} - Excel process [{0}] {1} at {2}", process.Id, If(process.HasExited, "ended", "started"), If(process.HasExited, process.ExitTime, process.StartTime), state)
    End Function

    Private Sub UpdateStatus(msg As String)
        Invoke(New Action(Of String)(AddressOf processStatus.AppendText), msg & Environment.NewLine)
    End Sub

    Private Sub Process_Exited(sender As Object, e As EventArgs)
        Dim proc As Process = DirectCast(sender, Process)
        UpdateStatus(GetProcessStatusMessage(proc))
        Dim state As ApartmentState
        launchedExcelProcesses.TryRemove(proc, state)
        proc.Dispose()
        proc = Nothing
    End Sub

    <DllImport("user32.dll", SetLastError:=True)>
    Private Shared Function GetWindowThreadProcessId(ByVal hwnd As Int32, ByRef lpdwProcessId As Int32) As Int32
    End Function
End Class

Edit: Additional info that may be of relevance:

Don Box; May 1997, Microsoft Systems Journal, Q&A ActiveX/COM

...By their nature, all objects live in a process. For an out-of-process server, this process is created dynamically by the Service Control Manager (SCM), based on the server’s implementation of main/WinMain. For outofproc servers, the server implementor is in complete control of when the process shuts down. The standard implementation of a server’s WinMain is to have the main thread of the process wait around until no objects have outstanding clients to service. This guarantees that the object’s "home" will remain alive as long as it is needed.


Solution

  • I can't find an official source saying that CoUninitialize is called by .NET. However, I have found a few things. Below are some "stack traces" from the .NET Core source code. I was unable to find the corresponding .NET Framework source, but I expect it's not too much different than this. These aren't the only paths through this code, and these aren't all the cases where COM is initialized and uninitialized, but this should be enough to demonstrate that the CLR is designed to manage the COM framework implicitly.

    Here's something interesting to note. Thread:PrepareApartmentAndContext also registers an IInitializeSpy object. That object watches for the apartment to be shut down and calls ReleaseRCWsInCaches. That method is also called from a few other places. Somewhere down these rabbit holes, you'll find all the information you seek.