Tcl/Tk 的 F# 接口导致内存损坏

F# interface with Tcl/Tk results in memory corruption

我正在编写一个使用 Tcl/Tk 的 F# 脚本。根据我从 F# 调用 Tcl/Tk 的方式,我遇到了内存损坏。我最好的猜测是发生内存损坏是因为 F# 垃圾收集器在我分配内存时四处移动函数的位置。 Tcl/Tk 显然不知道这个运动,所以发生了不好的事情。我试图理解为什么一种方法会导致内存损坏,而另一种方法不会。

显示问题的最简单代码如下:

open System
open System.Runtime.InteropServices

// -----Tcl-----
// Change this literal to point to appropriate Tcl .dll
[<Literal>]
let TclDll = "c:/Tcl32/bin/tcl86.dll"

// Must have the MarshalAs attribute for objs:ObjPtr[] otherwise the array is assumed to be of size 1.
// The SizeParamIndex parameter specifies the int (the second argument) as being the length of the array.
[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type ObjCmdProc = delegate of nativeint * nativeint * int * [<MarshalAs(UnmanagedType.LPArray, SizeParamIndex=2s)>]objs:nativeint[] -> int

[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type CmdDeleteProc = delegate of nativeint -> unit

// 94 EXTERN Tcl_Interp * Tcl_CreateInterp(void);
[<DllImport(TclDll, EntryPoint="Tcl_CreateInterp", CallingConvention=CallingConvention.Cdecl)>]
extern nativeint tclCreateInterp()

// 180 EXTERN int Tcl_Init(Tcl_Interp *interp);
[<DllImport(TclDll, EntryPoint="Tcl_Init", CallingConvention=CallingConvention.Cdecl)>]
extern int tclInit(nativeint interp)

// 96 EXTERN Tcl_Command Tcl_CreateObjCommand(Tcl_Interp *interp, const char *cmdName, Tcl_ObjCmdProc *proc, ClientData clientData, Tcl_CmdDeleteProc *deleteProc);
[<DllImport(TclDll, EntryPoint="Tcl_CreateObjCommand", CallingConvention=CallingConvention.Cdecl)>]
extern nativeint tclCreateObjCommand(nativeint interp, string cmdName, ObjCmdProc proc, nativeint clientData, CmdDeleteProc deleteProc)

// 291 EXTERN int Tcl_EvalEx(Tcl_Interp *interp, const char *script, int numBytes, int flags);
[<DllImport(TclDll, EntryPoint="Tcl_EvalEx", CallingConvention=CallingConvention.Cdecl)>]
extern int tclEvalEx(nativeint interp, string script, int numBytes, int flags)

// -----Tk-----
// Change this literal to point to appropriate Tk .dll
[<Literal>]
let TkDll = "c:/Tcl32/bin/tk86.dll"

// 0 EXTERN void Tk_MainLoop(void);
[<DllImport(TkDll, EntryPoint="Tk_MainLoop", CallingConvention=CallingConvention.Cdecl)>]
extern void tkMainLoop()

// 118 EXTERN int Tk_Init(Tcl_Interp *interp);
[<DllImport(TkDll, EntryPoint="Tk_Init", CallingConvention=CallingConvention.Cdecl)>]
extern int tkInit(nativeint interp)

// -----Main program-----
// Initialize the Tcl/Tk interpreter
let interp = tclCreateInterp()
tclInit(interp) |> ignore
tkInit(interp) |> ignore

// Direct ObjCmdProc
let testMemDirect = ObjCmdProc (fun clientData interp objCount objs ->
    let mem = seq {for i in 0..1000000 -> i} |> Seq.toList
    printf "%A" mem
    0)

// Indirect ObjCmdProc (via createObjCmd)
let testMemIndirect (objs:nativeint []) (interp:nativeint) = 
    let mem = seq {for i in 0..1000000 -> i} |> Seq.toList
    printf "%A" mem
    0

// Creates an ObjCmdProc given a function
let createObjCmdProc fn = ObjCmdProc (fun clientData interp objCount objs ->
    fn objs interp)

// Ignore delete messages
let ignoreDelete = CmdDeleteProc ignore

// Create Tcl commands
tclCreateObjCommand(interp, "testMemDirect", testMemDirect, IntPtr.Zero, ignoreDelete) |> ignore
tclCreateObjCommand(interp, "testMemIndirect", (createObjCmdProc testMemIndirect), IntPtr.Zero, ignoreDelete) |> ignore

// Set up GUI
let evalStr = """
wm title . {}
ttk::frame .f
ttk::button .f.testMemDirect -text {Test Direct} -command {testMemDirect}
ttk::button .f.testMemIndirect -text {Test Indirect} -command {testMemIndirect}
ttk::button .f.exit -text {Exit} -command {exit}

grid .f
grid .f.testMemDirect
grid .f.testMemIndirect
grid .f.exit
"""
tclEvalEx(interp, evalStr, evalStr.Length, 0) |> ignore

// Start Tk
tkMainLoop()
0 // return an integer exit code

我可以按 "Test Direct" 按钮多次,没有任何问题。我可以毫无问题地按一次 "Test Indirect" 按钮。但是我第二次按下它时,我得到:

Unhandled Exception: System.Reflection.TargetInvocationException: Exception has been thrown by the target of an invocation. ---> System.AccessViolationException: Attempted to read or write protected memory. This is often an indication that other memory is corrupt.

"Test Direct" 按钮和 "Test Indirect" 按钮的区别在于 "Test Direct" 使用 ObjCmdProc 委托调用 tclCreateObjCommand 而 "Test Indirect"使用生成 ObjCmdProc 委托的函数调用 tclCreateObjCommand

我猜测在 "Test Direct" 情况下,F# 固定了 ObjCmdProc 委托,因此没有内存损坏。但是,在 "Test Indirect" 情况下,F# 正在移动委托的位置以响应垃圾回收。 (请注意,只有在分配大量内存时才会出现此问题 - 即,我正在生成一个大序列并将其转换为列表)。

假设我的猜测是正确的(可能不正确),为什么 F# 会这样?更重要的是,有没有办法告诉 F# 不要移动此函数在内存中的位置?我尝试了各种方法,例如 GC.KeepAliveGCHandle.Alloc 但都没有成功。


附录:

要解决这个问题,我所要做的就是保留对 (createObjCmdProc testMemIndirect) 的引用。所以替换:

tclCreateObjCommand(interp, "testMemIndirect", (createObjCmdProc testMemIndirect), IntPtr.Zero, ignoreDelete) |> ignore

与:

let testMemIndirectRef = createObjCmdProc testMemIndirect
tclCreateObjCommand(interp, "testMemIndirect", testMemIndirectRef, IntPtr.Zero, ignoreDelete) |> ignore

否则,F# 认为函数可以被垃圾回收。有可能我还必须更改结束代码:

// Start Tk
tkMainLoop()
0 // return an integer exit code

至:

// Start Tk
tkMainLoop()
GC.KeepAlive(testMemDirect)
GC.KeepAlive(testMemIndirectRef)
0 // return an integer exit code

确保代表们在计划结束前都保持活力。

请注意,此时我并不关心 Tcl 命令的删除,尽管在某些时候我可能会关心(正如这个问题的答案正确指出的那样)。

在 C 级别,clientData 参数(您已将其设置为 IntPtr.Zero)是您如何传递指向某些上下文的指针,您需要正确调用实现命令的函数。没有它,您只是 得到一个指向函数的纯指针。因此,正确的做法是找到某种方法通过该机制传递 fn;您需要保留对它的引用,直到调用已注册的 cmdDeleteProc,并且该回调的主要任务是删除该 (owning!) 引用(它将被调用每当从事物的 Tcl 端删除命令时)。 Tcl 的命令实现 API 就是这样设计的; 强烈 建议您使用它。

我认为它在“直接”调用的情况下有效,因为 F# 中变量的引用使函数保持活动状态。你很幸运,在那种情况下它也没有炸毁你,因为它肯定仍然不安全。


我不知道如何通过原始指针将指针传递给 F# 函数实例。您可能需要将它放在某种普通数据结构中,并传递指向它的指针。我也不知道如何告诉 F# 垃圾收集器该数据引用无法收集,除非您另有说明(即,直到 Tcl 删除回调完成)。但那些是你必须做的事情。您可能不想将此细节直接暴露给大部分 F# 代码;连接器库是您只需要编写一次的东西,然后处理集成 .NET 和 Tcl 内存管理域的细节(这基本上是经典的 C,除了自定义内存分配器以提高速度) .