1. 项目概述:为什么我们需要C++/CLI混合模式编程?
如果你是一个长期在Windows平台上用C++做开发的程序员,最近可能遇到了一个头疼的问题:公司新项目要求开发一个带图形界面的桌面工具,但核心算法部分性能要求极高,用C#写起来力不从心,而用纯C++写UI又仿佛回到了MFC时代,开发效率低下。或者,你维护着一个庞大的历史遗留C++代码库(我们常称之为“祖传代码”),现在需要为它开发一个现代化的.NET配置界面或插件系统。又或者,你正在尝试将一些用C++编写的、经过高度优化的数学库或图像处理库,集成到一个全新的、基于C#的现代化应用程序中。
这些场景,都指向了一个共同的解决方案:C++/CLI。它不是一门全新的语言,而是微软在.NET Framework时代推出的一种语言扩展,允许你在同一个项目、甚至同一个源文件里,同时使用标准C++和托管C++(即运行在.NET公共语言运行时CLR上的代码)。简单来说,它是一座桥,一座连接原生C++世界与托管.NET世界的桥。通过它,你可以在享受C++极致性能的同时,无缝调用.NET Framework丰富的类库(如WPF、WinForms做UI,或者使用LINQ、Entity Framework等高级功能),或者反过来,让C#代码安全、高效地调用那些用C++写的、充斥着指针和内存直接操作的核心模块。
我最初接触C++/CLI,就是因为要在一个C#的医疗影像处理软件中,集成一个用C++和CUDA写的GPU加速三维重建算法库。直接用P/Invoke(平台调用)去调用那个库的C接口,不仅声明复杂,数据封送(Marshaling)效率低下,而且在传递复杂数据结构(如自定义结构体、类对象)时几乎是一场灾难。而C++/CLI允许我创建一个“包装器”项目,在这个项目里,我用类似C++的语法写一个托管类,这个类内部直接调用原生C++的函数和对象,然后将其暴露为标准的.NET类库(DLL)。这样,在C#项目中,我引用这个DLL就像引用任何其他.NET库一样简单直观,性能损耗也微乎其微。
所以,深入理解C++/CLI,对于需要在Windows平台上进行混合编程的开发者而言,是一项能极大提升开发效率、解决实际架构难题的关键技能。它让你不再需要在“性能”和“开发效率”之间做痛苦的二选一。
2. C++/CLI核心概念与语法精要
要玩转C++/CLI,首先得搞清楚几个核心概念,它们是你编写代码的基石。如果你有C++和C#的基础,理解起来会快很多,但要注意,C++/CLI的语法是两者的“混合体”,有一些独特的规则。
2.1 托管类型 vs 原生类型
这是最根本的区别。在C++/CLI中,所有类型都生活在两个“世界”里:
托管类型:生活在.NET CLR的托管堆上,由垃圾回收器(GC)自动管理内存。声明托管引用类型使用
ref class或ref struct关键字。例如,你要定义一个给C#用的窗口类,会这样写:// ManagedWindow.h public ref class ManagedWindow { public: ManagedWindow(); void Show(); property System::String^ Title { System::String^ get(); void set(System::String^ value); } private: System::Windows::Forms::Form^ m_form; // 这是一个托管句柄,指向一个.NET Form对象 };注意
^符号,它被称为“帽子”(hat),是托管堆对象的句柄,类似于C++中的指针*,但更安全,GC会跟踪它。property关键字用于声明.NET属性。原生类型:就是标准C++的类型,生活在原生堆或栈上,内存需要手动管理(
new/delete)或由RAII机制管理。在C++/CLI项目里,你可以像在普通C++项目中一样使用它们。// NativeCalculator.h class NativeCalculator { public: int Add(int a, int b) { return a + b; } std::vector<double> ProcessData(const std::vector<double>& input); };
关键点:托管类型内部可以包含原生类型的成员(通常用指针或智能指针),但原生类型内部不能直接包含托管类型的句柄(^)。这是因为GC需要能够追踪所有托管对象,而原生对象不在GC的管辖范围内。如果必须关联,需要通过System::Runtime::InteropServices::GCHandle来手动管理。
2.2 关键的语法符号:^, %, &
^(托管句柄):指向托管堆对象的引用。声明时用MyRefClass^ obj。使用gcnew关键字来创建实例:obj = gcnew MyRefClass();。它不能被重载(overloaded),也不能进行指针算术。当对象不再被引用时,由GC负责回收。注意:虽然
^看起来像指针,但切记不要对它使用delete。对于实现了IDisposable的托管对象(如文件流、数据库连接),如果需要显式释放非内存资源,应调用delete(在C++/CLI中,delete一个托管句柄会被编译器翻译为调用Dispose方法),但多数情况下交给GC即可。%(跟踪引用):类似于C++中的引用&,但用于托管类型。它是对托管句柄的别名。常用于函数参数传递,以避免不必要的拷贝。void ModifyString(System::String^ % strRef) { // 注意参数类型是 String^ % strRef = "Modified"; } // 调用时,外部的字符串引用会被改变。&(原生引用和取地址):和标准C++中一样,用于原生类型的引用和取地址操作。在C++/CLI中,要分清上下文,避免混淆。
2.3 数据封送(Marshaling):世界之间的数据交换
当数据在托管和原生代码之间传递时,常常需要进行转换,这个过程就是封送。C++/CLI提供了一些内置的机制来简化这个过程。
- 基本类型:如
int,double,bool,它们通常在两个世界中有直接的对应(int对应System::Int32),且内存布局相同,可以直接传递,开销极小。 - 字符串:这是最常见的封送场景。
- 原生
char*/std::string转 托管System::String^:const char* nativeStr = "Hello"; System::String^ managedStr = gcnew System::String(nativeStr); // 或者使用Marshal类(更灵活) #include <msclr/marshal.h> using namespace msclr::interop; marshal_context^ context = gcnew marshal_context(); const char* converted = context->marshal_as<const char*>(managedStr); // 注意:marshal_context 对象生命周期内,converted指针有效。 - 托管
System::String^转 原生char*/std::string:System::String^ managedStr = "World"; std::string nativeStr = marshal_as<std::string>(managedStr); // 使用 marshal_as 模板函数
- 原生
- 数组和集合:封送复杂集合是性能瓶颈之一。应尽量避免在边界上来回传递大型集合。如果必须传递,可以考虑:
- 使用
pin_ptr:固定托管数组在内存中的位置,然后将原生指针指向该内存块。这能提供最高性能,但固定期间会阻碍GC工作,时间要尽可能短。array<byte>^ managedArray = gcnew array<byte>(1024); pin_ptr<byte> pinnedPtr = &managedArray[0]; byte* nativePtr = pinnedPtr; // 此时可以安全地将 nativePtr 传递给原生函数 // pinnedPtr 析构后,数组不再被固定 - 逐元素拷贝:最安全,但性能最差。适用于数据量不大或调用不频繁的场景。
- 设计共享内存缓冲区:对于极高性能要求的场景,可以预先分配一块非托管内存(如使用
malloc或VirtualAlloc),让托管和原生代码通过指针直接读写。这需要精心设计同步机制。
- 使用
实操心得:字符串封送是混合编程中的“性能杀手”之一。我曾在一个高频调用的函数中,错误地在每次调用时都使用marshal_as转换字符串,导致性能急剧下降。后来改为在初始化阶段一次性转换并缓存结果,性能提升了数十倍。记住一个原则:尽量减少跨越边界的调用次数和数据封送量。
3. 混合模式项目实战:从创建到部署
理解了基本概念后,我们通过一个完整的实战项目来串联所有知识点。假设我们要开发一个“图像滤镜处理器”,核心算法(如图像卷积、色彩空间转换)用高性能原生C++实现,而用户界面和图像文件IO用C#(WPF)实现。
3.1 创建与配置C++/CLI类库项目
- 打开Visual Studio,创建新项目。选择“C++”语言,找到“CLR 类库(.NET Framework)”模板。项目名称设为
ImageProcessorBridge。 - 关键配置:创建完成后,右键项目 -> “属性”,进行以下关键设置:
- 常规 -> 公共语言运行时支持:确保是“公共语言运行时支持(/clr)”。这是项目的核心开关。对于需要更新式.NET的项目,可以选择“公共语言运行时支持(/clr:netcore)”,但需要对应版本的VS和.NET SDK。
- C/C++ -> 常规 -> 调试信息格式:对于调试版本,建议选择“程序数据库(/Zi)”。
- 链接器 -> 高级 -> 目标计算机:确保与你的目标平台匹配(如x64)。混合程序集对平台很敏感。
- C/C++ -> 语言:将“符合模式”设置为“否”(/permissive-)。这可以避免一些标准C++与C++/CLI扩展语法的冲突。
3.2 设计桥接层接口
桥接层的设计至关重要,它直接影响了易用性和性能。我们的目标是向C#暴露一个简洁、符合.NET习惯的API,内部则处理所有复杂的原生调用和数据转换。
首先,定义原生C++算法库的头文件(假设已存在):
// NativeImageAlgo.h (纯原生C++头文件) #pragma once #include <vector> #include <cstdint> namespace NativeImage { class Filter { public: virtual ~Filter() {} // 应用滤镜到图像数据,图像数据是连续的RGBA字节数组 virtual bool Apply(const uint8_t* inputData, int width, int height, int stride, uint8_t* outputData) = 0; }; class GaussianBlurFilter : public Filter { /* 实现 */ }; class EdgeDetectionFilter : public Filter { /* 实现 */ }; }然后,创建我们的C++/CLI桥接类:
// ManagedImageProcessor.h #pragma once #include "NativeImageAlgo.h" // 包含原生头文件 #include <msclr/marshal_cppstd.h> // 用于字符串封送 #include <vcclr.h> // 用于pin_ptr namespace ImageProcessorBridge { // 托管枚举,定义滤镜类型,供C#使用 public enum class FilterType { GaussianBlur, EdgeDetection }; // 核心的托管桥接类 public ref class ManagedImageProcessor sealed // sealed 表示不可被继承,常作为桥接类 { public: ManagedImageProcessor(); ~ManagedImageProcessor(); // 析构函数,用于释放原生资源 !ManagedImageProcessor(); // 终结器,析构函数的备份 // 主要方法:处理图像 // 传入托管字节数组、图像尺寸信息,返回处理后的新数组 array<System::Byte>^ ProcessImage( array<System::Byte>^ inputPixels, int width, int height, int stride, FilterType filterType, double filterStrength); // 属性示例 property System::String^ LastError { System::String^ get() { return m_lastError; } } private: // 原生算法对象的智能指针 std::unique_ptr<NativeImage::Filter> m_nativeFilter; // 托管错误信息 System::String^ m_lastError; // 私有辅助方法:根据类型创建原生过滤器 NativeImage::Filter* CreateNativeFilter(FilterType type, double strength); }; }3.3 实现桥接层:处理内存与异常
头文件定义了接口,.cpp文件才是实现魔法的地方,尤其是内存管理和异常处理。
// ManagedImageProcessor.cpp #include "pch.h" // 预编译头 #include "ManagedImageProcessor.h" namespace ImageProcessorBridge { ManagedImageProcessor::ManagedImageProcessor() : m_lastError(nullptr) { // 构造函数里可以初始化一些公共资源 try { // 例如,初始化某个原生库的全局状态 } catch (const std::exception& ex) { m_lastError = gcnew System::String(ex.what()); // 考虑抛出托管异常 throw gcnew System::Exception("Native initialization failed: " + m_lastError); } } ManagedImageProcessor::~ManagedImageProcessor() { this->!ManagedImageProcessor(); // 调用终结器逻辑 } ManagedImageProcessor::!ManagedImageProcessor() { // 释放原生资源 m_nativeFilter.reset(); // unique_ptr 自动释放 // 注意:托管成员(如 m_lastError)不需要也**不能**在这里手动删除,GC会处理。 } NativeImage::Filter* ManagedImageProcessor::CreateNativeFilter(FilterType type, double strength) { switch (type) { case FilterType::GaussianBlur: return new NativeImage::GaussianBlurFilter(strength); case FilterType::EdgeDetection: return new NativeImage::EdgeDetectionFilter(); default: throw gcnew System::ArgumentException("Unsupported filter type"); } } array<System::Byte>^ ManagedImageProcessor::ProcessImage( array<System::Byte>^ inputPixels, int width, int height, int stride, FilterType filterType, double filterStrength) { // 1. 参数验证(托管端) if (inputPixels == nullptr) throw gcnew System::ArgumentNullException("inputPixels"); if (width <= 0 || height <= 0) throw gcnew System::ArgumentException("Invalid image dimensions"); // 简单计算预期数据长度 int expectedLength = height * stride; if (inputPixels->Length < expectedLength) { throw gcnew System::ArgumentException("Input pixel array is too small for given dimensions and stride"); } // 2. 创建或更新原生过滤器 m_nativeFilter.reset(CreateNativeFilter(filterType, filterStrength)); // 3. 准备输出数组(托管) array<System::Byte>^ outputPixels = gcnew array<System::Byte>(expectedLength); // 4. 关键步骤:固定托管数组,获取原生指针 pin_ptr<System::Byte> pinnedInput = &inputPixels[0]; pin_ptr<System::Byte> pinnedOutput = &outputPixels[0]; const uint8_t* nativeInputPtr = pinnedInput; uint8_t* nativeOutputPtr = pinnedOutput; // 5. 调用原生函数 bool success = false; try { // 将可能抛出的原生C++异常转换为托管异常 success = m_nativeFilter->Apply(nativeInputPtr, width, height, stride, nativeOutputPtr); } catch (const std::exception& ex) { // 捕获原生异常,转换为托管异常抛出 throw gcnew System::Exception( gcnew System::String("Native filter operation failed: ") + gcnew System::String(ex.what())); } if (!success) { // 处理原生函数返回的错误 m_lastError = "Filter application failed."; // 可以选择返回null或抛出异常 return nullptr; } // 6. 返回结果 // pin_ptr 离开作用域后自动解除固定,GC可以重新移动数组 return outputPixels; } }实现要点解析:
- 双清理模式:实现了析构函数
~ManagedImageProcessor()和终结器!ManagedImageProcessor()。这是托管包装原生资源的经典模式。析构函数供用户(或using语句)显式调用,立即释放原生资源。终结器是GC在回收对象时的安全网,防止原生资源泄漏。在析构函数中调用终结器是常见做法。 pin_ptr的使用:这是高性能数据传递的核心。它固定了托管数组在内存中的位置,防止GC在原生代码操作期间移动它。务必确保pin_ptr的生命周期尽可能短,只在调用原生函数前后存在。一旦离开作用域,固定即解除。- 异常转换:原生C++代码可能抛出
std::exception或其子类。我们必须捕获它们,并转换为System::Exception或其子类,这样C#调用方才能正常捕获和处理。不要让原生异常“泄漏”到托管世界,这会导致程序崩溃。 - 参数验证:在进入原生代码之前,在托管侧进行充分的参数验证。这比在原生代码中验证更好,因为可以抛出更具体的.NET异常(如
ArgumentNullException),并且错误信息对C#开发者更友好。
3.4 在C#项目中引用与调用
- 编译
ImageProcessorBridge项目,生成ImageProcessorBridge.dll。 - 在你的C# WPF或WinForms项目中,添加对该DLL的引用。就像引用任何其他.NET库一样。
- 在C#代码中调用:
// C# 调用方 using ImageProcessorBridge; public class MainWindowViewModel { public byte[] ApplyFilter(byte[] imageData, int width, int height, int stride) { try { using (var processor = new ManagedImageProcessor()) // using确保及时释放原生资源 { var result = processor.ProcessImage( imageData, width, height, stride, FilterType.GaussianBlur, 2.5); // 滤镜强度 if (result != null) { return result; } else { // 处理错误 Console.WriteLine($"Error: {processor.LastError}"); return null; } } } catch (Exception ex) { // 捕获从C++/CLI层抛出的异常 Console.WriteLine($"Exception from native layer: {ex.Message}"); return null; } } }注意:using语句确保了ManagedImageProcessor的Dispose方法(对应C++/CLI的析构函数)会被调用,从而及时释放原生滤镜对象。这是一个良好的实践。
4. 高级主题与性能优化策略
当基础功能实现后,我们会面临更复杂的场景和性能挑战。以下是几个高级主题和优化技巧。
4.1 混合模式下的内存管理陷阱
内存管理是混合编程中最容易出错的地方。
- 循环引用导致的内存泄漏:这是托管世界的老问题,但在混合模式下更隐蔽。例如,一个托管对象
A持有一个原生对象B的指针(通过某种方式),而原生对象B又通过GCHandle持有了对托管对象A的引用。这样,即使外部没有引用A了,A和B也因为互相引用而无法被GC和delete释放。解决方案:仔细审查这种跨世界的引用关系,尽量设计成单向引用。如果必须双向引用,确保有明确的“清理”方法可以打破循环。 GCHandle的误用:GCHandle用于从原生代码获取和保持对托管对象的引用。你必须确保在不再需要时调用Free(),否则会导致托管对象永远无法被回收。
上面的#include <vcclr.h> void NativeFunction(System::String^ managedStr) { // 将托管对象固定,获取原生指针 pin_ptr<const wchar_t> pinnedStr = PtrToStringChars(managedStr); const wchar_t* nativePtr = pinnedStr; // 使用 nativePtr... // pinnedStr 析构,固定解除 }pin_ptr是GCHandle的一种特殊(更安全)形式。对于更长期的持有,需要使用GCHandle::Alloc和GCHandle::Free。- 静态对象的析构顺序:如果托管静态对象持有原生资源,而原生静态对象又依赖于某些托管运行时环境,在程序关闭时,它们的析构/终结顺序可能是未定义的,可能导致访问违规。建议:尽量减少混合模式下的全局/静态对象,或者使用单例模式并手动控制初始化/销毁顺序。
4.2 回调与事件:从原生到托管的通信
有时,原生算法是长时间运行的,需要向托管UI报告进度或返回中间结果。这就需要回调机制。
最佳实践:使用托管委托(Delegate)作为回调函数
- 在C++/CLI端定义委托类型和方法:
// ManagedImageProcessor.h 增加 public delegate void ProgressCallback(int percentage, System::String^ status); public delegate void ResultReadyCallback(array<System::Byte>^ partialResult); public ref class ManagedImageProcessor { public: // 设置回调的方法 void SetProgressCallback(ProgressCallback^ callback); void SetResultCallback(ResultReadyCallback^ callback); // 一个启动异步处理的方法 void ProcessImageAsync(...); private: ProgressCallback^ m_progressCallback; ResultReadyCallback^ m_resultCallback; // 原生线程函数需要访问这些委托 void NativeAsyncWorker(); }; - 在C++/CLI实现中调用委托:
void ManagedImageProcessor::NativeAsyncWorker() { for (int i = 0; i <= 100; ++i) { // 模拟工作 System::Threading::Thread::Sleep(50); // 从原生线程回调到托管方法! if (m_progressCallback != nullptr) { // 使用 Invoke 或直接调用。注意线程上下文。 // 如果UI更新,需要封送到UI线程,这里简化处理。 m_progressCallback->Invoke(i, "Processing..."); } } } - 在C#端订阅事件:
processor.SetProgressCallback((percent, status) => { // 注意:这个回调可能在非UI线程上触发 Dispatcher.Invoke(() => { ProgressBar.Value = percent; StatusLabel.Text = status; }); }); processor.ProcessImageAsync(...);
关键点:回调是在原生线程上调用的,如果要更新UI控件,必须通过Dispatcher.Invoke或BeginInvoke封送回UI线程,否则会导致跨线程访问异常。
4.3 性能优化关键点
- 减少边界穿越:每一次从托管代码调用C++/CLI函数,再从C++/CLI调用原生函数,都有开销。设计API时,应尽量将多个操作批量化,一次调用完成更多工作,而不是频繁进行小调用。
- 明智选择数据传递方式:
- 对于小数据(基本类型、简单结构),直接传递。
- 对于大型数组或缓冲区,优先使用
pin_ptr进行固定和指针传递。 - 对于复杂对象图,考虑在边界两侧定义对称的数据结构,并通过序列化/反序列化来传递(如使用Google Protocol Buffers、MessagePack等),但这会引入额外开销。
- 使用
interior_ptr和pin_ptr的区别:pin_ptr:固定对象,阻止GC移动它。用于获取指向托管对象内部数据的稳定原生指针,并传递给原生函数。会阻碍GC压缩堆,影响性能,用时越短越好。interior_ptr:不固定对象,只是一个指向托管对象内部的可移动指针。它不能直接传递给期望原生指针的函数,因为GC可能会移动它。主要用于在托管函数内部进行安全的指针算术运算。性能更好,但使用受限。
- 编译优化:确保发布版本开启了所有优化选项(/O2, /Ox)。对于性能关键的C++/CLI模块,可以考虑使用
#pragma managed(push, off)和#pragma managed(pop)指令,将部分纯原生代码块标记为非托管编译,以获得更好的原生代码优化。
5. 调试技巧与常见问题排查
混合模式调试比纯托管或纯原生调试更复杂,但VS提供了强大的支持。
5.1 混合模式调试设置
- 右键C#启动项目 -> “属性” -> “调试”。
- 确保“启用本机代码调试”复选框被勾选。这是最关键的一步,它允许调试器同时附着到托管CLR和原生进程。
- 在解决方案配置管理器中,确保C++/CLI项目和C#项目的平台(如x64)一致。
5.2 典型问题与解决方案
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 运行时崩溃,错误码0xC0000005(访问冲突) | 1.悬空指针:托管对象已被GC回收,但原生代码还在使用其地址。 2.数组越界: pin_ptr后传递的指针,原生代码访问了超出数组边界的内存。3.内存对齐问题:某些原生代码(如使用SSE指令)要求数据内存对齐,而托管数组不一定对齐。 | 1. 检查pin_ptr的生命周期,确保在原生函数调用期间它一直有效。2. 在托管侧和原生侧都添加严格的边界检查。 3. 考虑在原生侧分配对齐的内存,然后将数据拷贝进去。 |
| “System.BadImageFormatException” | 最常见的混合编程错误之一。表示尝试加载格式不正确的程序集。通常是因为平台目标不匹配。 | 1. 检查所有项目(C#主程序、C++/CLI库、任何引用的原生DLL)的生成平台是否一致(全是x86或全是x64)。 2. 检查C++/CLI项目的“目标计算机”设置。 3. 在64位系统上,如果主程序是“Any CPU”,在运行时可能是64位,那么引用的C++/CLI库也必须是x64的。 |
| 调试时无法命中断点(C++/CLI代码) | 1. 调试器类型不对。 2. 代码未编译调试信息。 3. 源代码版本与PDB不匹配。 | 1. 确认已启用“本机代码调试”。 2. 检查C++/CLI项目的“调试信息格式”是否为 /Zi或/ZI。3. 清理解决方案并重新生成。 |
| 性能远低于预期 | 1. 边界调用和封送开销过大。 2. pin_ptr使用不当,导致GC长期受阻。3. 在循环内部进行字符串封送等昂贵操作。 | 1. 使用性能分析工具(如VS Performance Profiler)定位热点。关注“非托管到托管转换”和“封送”相关的开销。 2. 重构代码,减少跨越边界的调用频率,批量处理数据。 3. 缓存封送结果,避免重复转换。 |
| 内存泄漏(托管端或原生端) | 1. 未实现析构/终结器释放原生资源。 2. 循环引用。 3. 未释放 GCHandle。 | 1. 使用_CrtDumpMemoryLeaks()(Debug模式)或Valgrind(Linux)、Visual Leak Detector等工具检测原生泄漏。2. 使用.NET内存分析工具(如dotMemory、VS Diagnostic Tools)查看托管堆,检查 ManagedImageProcessor等包装类实例是否被正确释放。 |
5.3 一个真实的调试案例:神秘的间歇性崩溃
我曾遇到一个棘手的Bug:程序运行一段时间后随机崩溃,崩溃点在一个原生数学库的矩阵乘法函数里。崩溃时栈帧混乱,很难定位。
排查过程:
- 启用全页堆和应用程序验证器:在Windows调试工具中启用这些功能,可以更早地检测到内存损坏。
- 检查所有
pin_ptr:发现有一处代码,在固定一个字节数组后,将指针传递给了一个异步启动的原生工作线程,但pin_ptr在函数返回时就析构了(解除了固定)。然而工作线程还在运行,访问了已被GC移动或回收的内存。这是典型的使用已失效的固定指针。 - 解决方案:不能依赖函数栈上的
pin_ptr来保护异步操作。我们重构了设计:- 方案A:将整个异步操作(包括数据准备和原生调用)封装在同一个托管函数中,确保
pin_ptr生命周期覆盖整个原生调用。 - 方案B:更复杂的场景下,我们改为在原生侧分配内存(
malloc),将托管数据拷贝进去,然后将这块内存的指针交给原生工作线程。工作线程完成后,再通过回调将结果拷贝回托管数组,并释放原生内存。这消除了对GC的依赖,但增加了拷贝开销。
- 方案A:将整个异步操作(包括数据准备和原生调用)封装在同一个托管函数中,确保
这个案例的教训是:对于异步或长时间运行的原生操作,pin_ptr不是安全的解决方案。必须重新设计数据所有权和生命周期管理。