把设备句柄保存为 nint 很方便,也把所有风险留给调用者:忘记关闭会泄漏资源,并发关闭可能让调用使用失效句柄,异常路径又容易跳过清理。手写终结器并不能可靠解决这些问题。
SafeHandle 把非托管句柄的所有权、有效值和释放动作封装成受运行时支持的资源类型。本文说明怎样为设备 SDK 正确派生它,以及它能保证什么、不能保证什么。
1. 先确认句柄契约
1
2
3
4
5
| typedef void* device_handle;
int32_t device_open(int32_t index, device_handle* out_handle);
int32_t device_get_position(device_handle handle, double* out_mm);
int32_t device_close(device_handle handle);
|
本篇假设 device_close 返回状态码(0 表示成功);第一篇的最小示例曾把它简化为 void,真实 SDK 一律以头文件为准。
接入前必须确认:
- 无效句柄是空指针、
-1,还是另一个哨兵; device_close 能否重复调用;- 关闭是否会阻塞,是否要求特定线程;
- 关闭前是否必须停止采集、注销回调或等待任务;
- 句柄能否被多个线程并发调用;
- 子资源是否必须早于父句柄释放。
SafeHandle 只负责句柄释放,不会自动推导这些会话规则。
2. 为每种释放函数定义独立类型
若空指针和 -1 都表示无效,可继承 SafeHandleZeroOrMinusOneIsInvalid:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
public sealed class SafeDeviceHandle
: SafeHandleZeroOrMinusOneIsInvalid
{
// LibraryImport 通过 out/返回值创建 SafeHandle 时需要 public 无参构造函数。
public SafeDeviceHandle() : base(ownsHandle: true)
{
}
protected override bool ReleaseHandle()
{
return DeviceNative.CloseRaw(handle) == 0;
}
}
internal static partial class DeviceNative
{
[LibraryImport("device_sdk", EntryPoint = "device_close")]
internal static partial int CloseRaw(nint handle);
}
|
若只有零无效,应直接继承 SafeHandle 并实现准确的 IsInvalid。文件句柄、内存映射句柄和设备会话可能分别要求 CloseHandle、UnmapViewOfFile、device_close,不能为了复用而共用一个“万能句柄类”。
3. 让 P/Invoke 直接产生 SafeHandle
1
2
3
4
5
6
7
8
9
10
11
12
| internal static partial class DeviceNative
{
[LibraryImport("device_sdk", EntryPoint = "device_open")]
internal static partial int Open(
int index,
out SafeDeviceHandle handle);
[LibraryImport("device_sdk", EntryPoint = "device_get_position")]
internal static partial int GetPosition(
SafeDeviceHandle handle,
out double millimeters);
}
|
在 .NET 8 及以上,源生成互操作通过返回值、ref 或 out 创建 SafeHandle 派生类型时,该类型必须具有 public 无参构造函数。
封装打开过程时,失败路径也要释放 SDK 可能写出的有效句柄:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public static class DeviceSessionFactory
{
public static DeviceSession Open(int index)
{
int status = DeviceNative.Open(index, out SafeDeviceHandle handle);
if (status != 0)
{
handle?.Dispose();
throw new DeviceSdkException("device_open", status);
}
// 源生成封送下 handle 不会为 null;null 检查是防御式写法。
if (handle is null || handle.IsInvalid)
{
handle?.Dispose();
throw new InvalidOperationException("SDK returned an invalid handle");
}
return new DeviceSession(handle);
}
}
|
4. SafeHandle 保护单次原生调用
当 P/Invoke 参数直接声明为 SafeDeviceHandle 时,运行时会在调用期间保持安全句柄存活,避免另一个线程的 Dispose 在原生调用尚未返回时立即释放底层句柄。这比先调用 DangerousGetHandle() 再传 nint 更可靠。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public sealed class DeviceSession(SafeDeviceHandle handle) : IDisposable
{
private readonly SafeDeviceHandle _handle = handle;
public double GetPosition()
{
int status = DeviceNative.GetPosition(_handle, out double millimeters);
if (status != 0)
{
throw new DeviceSdkException("device_get_position", status);
}
return millimeters;
}
public void Dispose() => _handle.Dispose();
}
|
这项保证只覆盖 P/Invoke 调用期间。它不代表设备允许并发命令,也不阻止业务层在“准备动作”和“真正调用”之间发生关闭。会话仍需用状态机、命令队列或锁定义并发语义。
5. ReleaseHandle 必须短小且不抛异常
ReleaseHandle 可能从显式 Dispose 或运行时清理路径进入。实现应:
- 只调用可靠的原生释放函数;
- 不抛异常;
- 不依赖普通业务对象仍然存活;
- 不做 UI、网络、复杂日志或长时间等待;
- 以返回值报告是否成功,但不能指望调用者在终结阶段恢复。
如果关闭设备必须先执行异步停止、保存或注销回调,应在上层 DisposeAsync/显式 StopAsync 中完成受控关闭,最后再释放 SafeHandle。ReleaseHandle 只作为底层资源兜底,不能承担完整停机流程。
6. Danger 前缀的方法确实危险
DangerousGetHandle 暴露原始值,却不自动延长句柄寿命。只有第三方 API 无法直接接受 SafeHandle 时,才考虑配合 DangerousAddRef/DangerousRelease 建立严格的 try/finally:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| bool addedRef = false;
try
{
handle.DangerousAddRef(ref addedRef);
nint raw = handle.DangerousGetHandle();
LegacyCall(raw);
}
finally
{
if (addedRef)
{
handle.DangerousRelease();
}
}
|
优先修改 P/Invoke 声明直接接收 SafeHandle。手工引用计数代码越多,越容易在异常和并发路径上失衡。
7. 句柄所有权不能重复
同一个原始句柄不能由两个 ownsHandle: true 的对象分别拥有,否则会重复关闭。包装已有句柄时要明确:
- 所有权转移:新
SafeHandle 负责释放,旧所有者不再关闭; - 借用:包装对象不拥有句柄,但必须保证真实所有者覆盖全部使用期;
- 共享:需要 SDK 明确支持的引用计数或复制句柄机制,不能自行假设。
若原生 API 接管了句柄所有权,可在成功后调用 SetHandleAsInvalid(),防止托管侧再次释放;只有文档明确说明“成功后接管”时才能这样做。
8. 关闭顺序与回调协同
典型设备会话的关闭顺序是:
- 阻止新命令进入;
- 请求采集/运动停止并确认物理状态;
- 注销回调并等待在途回调退出;
- 释放子资源;
Dispose 主设备 SafeHandle;- 发布已关闭状态。
顺序必须以 SDK 契约为准。若先关闭主句柄再注销回调,原生线程可能访问已释放设备;若先释放回调 context,而注销仍有在途调用,则会形成 use-after-free。
总结
SafeHandle 是原生句柄的默认托管表示:它集中定义无效值和释放函数,并保护单次 P/Invoke 调用期间的句柄寿命。但它不是设备状态机,也不会自动处理异步停机、回调屏障和线程安全。把底层释放交给 SafeHandle,把完整关闭协议留在会话层。
下一篇把系列收束到 SDK 设计端:如何用稳定 C ABI 桥接 C++ 实现,并同时交付 Windows DLL 与 Linux .so。
参考资料