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.
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.
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.
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.