在 STA 线程中创建的 COM RWC 在线程终止时与底层 COM 对象断开连接是否正常?
Is it normal for COM RWC's created in STA thread to disconnect from the underlying COM object on thread termination?
首先,我想说明一下,这不是关于如何在.Net 下释放COM 对象的问题。这是一个关于试图理解可能是由于正常 COM 行为导致的意外 COM 对象释放的问题,但我无法找到对观察到的行为的明确解释,并且希望能确认我基于某些原因所做的推论报价稍后提供。
我注意到,当通过 COM-Interop 使用 Excel 时,Excel 实例将完全终止,正如人们希望的那样,当在辅助线程中创建互操作引用时Apartmentstate 设置为 ApartmentState.STA。发生这种情况时没有采取任何措施来清除 运行time 可调用包装器 (RCW) 上的引用计数,方法是使用 Marshal.ReleaseCOMObject 显式调用它们的释放或调用垃圾收集器 (GC) 来清理对象,以便 Excel 完全关闭。对于那些不熟悉使用 Excel Interop 的人,请注意,众所周知,它在被告知退出后不会关闭,直到所有 .Net COM 引用都已发布。
我的第一个想法是 GC 在线程完成时自动 运行。为了查看这是否属实,我使用了 Visual Studio 的 "Performance and Diagnostics" 工具来监控内存使用情况。
上图,我先在UI线程上运行与Excel交互的方法,然后在MTA线程上。可以观察到 Excel 进程直到 GC 运行 释放 COM 引用后才终止。请注意,GC 标记位于分析图表上。然后我 运行 在 STA 线程上执行两次该方法。可以观察到 Excel 进程在不需要任何额外操作的情况下终止,并且分析图表表明 GC 在 Excel 从中启动的线程退出后没有 运行。此外,如果我在 STA 线程终止后尝试访问在 STA 线程中创建的引用,则会抛出 "COM object that has been separated from its underlying RCW cannot be used." 异常。
此时我认为 Excel 进程的释放在某种程度上与用于创建对象的线程的回收有关。我运行程序在一个STA线程上执行了两次Excel方法,并记录下如下所示的结果。可以看出,所有线程实例以及 COM 对象都被列为在整个测试生命周期中都处于活动状态。
在研究 COM 对象的生命周期时,我在 Larry Osterman 的博客条目“这些是什么 “Threading Models” and why do I care?" 中发现了以下陈述,这似乎解释了为什么 .Net RCW 与底层 COM 对象断开连接。
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.
此语句暗示 STA COM 单元是控制机制。然而,我发现唯一表明公寓生命周期对 .Net 对象的影响的是 Chris Brumme 博客 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.
所以最后我的问题是:我观察到的结果是,为线程创建的 STA 单元在线程执行结束时被销毁,从而允许 Excel 进程因为不再有任何对象持有对它的引用而终止?
我最初声明这不是关于如何在.Net 中释放COM 对象的问题。但是,如果您对使用此技术可能产生的负面影响有任何见解,我将不胜感激。它一直没有失败,但当记录的技术很容易实现时,我对使用它犹豫不决。
下面显示的代码是我用来调查此行为的。
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
编辑:可能相关的附加信息:
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.
我找不到官方消息来源说 CoUninitialize 是由 .NET 调用的。但是,我发现了一些事情。以下是 .NET Core 源代码中的一些 "stack traces"。我无法找到相应的 .NET Framework 源代码,但我希望它与此差别不大。这些不是通过此代码的唯一路径,也不是 COM 初始化和未初始化的所有情况,但这应该足以证明 CLR 旨在隐式管理 COM 框架。
- CLR 启动和关闭
- 线程池线程数
- IOCP 线程
这里有一些有趣的事情需要注意。 Thread:PrepareApartmentAndContext also registers an IInitializeSpy object. That object watches for the apartment to be shut down and calls ReleaseRCWsInCaches。该方法也从其他几个地方调用。在这些兔子洞的某个地方,您会找到所有想要的信息。
首先,我想说明一下,这不是关于如何在.Net 下释放COM 对象的问题。这是一个关于试图理解可能是由于正常 COM 行为导致的意外 COM 对象释放的问题,但我无法找到对观察到的行为的明确解释,并且希望能确认我基于某些原因所做的推论报价稍后提供。
我注意到,当通过 COM-Interop 使用 Excel 时,Excel 实例将完全终止,正如人们希望的那样,当在辅助线程中创建互操作引用时Apartmentstate 设置为 ApartmentState.STA。发生这种情况时没有采取任何措施来清除 运行time 可调用包装器 (RCW) 上的引用计数,方法是使用 Marshal.ReleaseCOMObject 显式调用它们的释放或调用垃圾收集器 (GC) 来清理对象,以便 Excel 完全关闭。对于那些不熟悉使用 Excel Interop 的人,请注意,众所周知,它在被告知退出后不会关闭,直到所有 .Net COM 引用都已发布。
我的第一个想法是 GC 在线程完成时自动 运行。为了查看这是否属实,我使用了 Visual Studio 的 "Performance and Diagnostics" 工具来监控内存使用情况。
上图,我先在UI线程上运行与Excel交互的方法,然后在MTA线程上。可以观察到 Excel 进程直到 GC 运行 释放 COM 引用后才终止。请注意,GC 标记位于分析图表上。然后我 运行 在 STA 线程上执行两次该方法。可以观察到 Excel 进程在不需要任何额外操作的情况下终止,并且分析图表表明 GC 在 Excel 从中启动的线程退出后没有 运行。此外,如果我在 STA 线程终止后尝试访问在 STA 线程中创建的引用,则会抛出 "COM object that has been separated from its underlying RCW cannot be used." 异常。
此时我认为 Excel 进程的释放在某种程度上与用于创建对象的线程的回收有关。我运行程序在一个STA线程上执行了两次Excel方法,并记录下如下所示的结果。可以看出,所有线程实例以及 COM 对象都被列为在整个测试生命周期中都处于活动状态。
在研究 COM 对象的生命周期时,我在 Larry Osterman 的博客条目“这些是什么 “Threading Models” and why do I care?" 中发现了以下陈述,这似乎解释了为什么 .Net RCW 与底层 COM 对象断开连接。
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.
此语句暗示 STA COM 单元是控制机制。然而,我发现唯一表明公寓生命周期对 .Net 对象的影响的是 Chris Brumme 博客 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.
所以最后我的问题是:我观察到的结果是,为线程创建的 STA 单元在线程执行结束时被销毁,从而允许 Excel 进程因为不再有任何对象持有对它的引用而终止?
我最初声明这不是关于如何在.Net 中释放COM 对象的问题。但是,如果您对使用此技术可能产生的负面影响有任何见解,我将不胜感激。它一直没有失败,但当记录的技术很容易实现时,我对使用它犹豫不决。
下面显示的代码是我用来调查此行为的。
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
编辑:可能相关的附加信息:
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.
我找不到官方消息来源说 CoUninitialize 是由 .NET 调用的。但是,我发现了一些事情。以下是 .NET Core 源代码中的一些 "stack traces"。我无法找到相应的 .NET Framework 源代码,但我希望它与此差别不大。这些不是通过此代码的唯一路径,也不是 COM 初始化和未初始化的所有情况,但这应该足以证明 CLR 旨在隐式管理 COM 框架。
- CLR 启动和关闭
- 线程池线程数
- IOCP 线程
这里有一些有趣的事情需要注意。 Thread:PrepareApartmentAndContext also registers an IInitializeSpy object. That object watches for the apartment to be shut down and calls ReleaseRCWsInCaches。该方法也从其他几个地方调用。在这些兔子洞的某个地方,您会找到所有想要的信息。