C# 原生互操作与设备 SDK(六):回调、原生线程与事件

设备 SDK 常通过回调上报图像到达、运动完成、报警和连接变化。注册成功只完成了第一步:原生库可能在任意线程、任意时刻调用函数指针,并继续使用注册时传入的上下文。委托被 GC 回收、设备句柄已关闭、缓冲区已失效或异常穿过 ABI 边界,都可能导致进程级崩溃。

本文建立回调的完整生命周期,并用非托管函数指针(C# 9/.NET 5 起可用)在 .NET 10 环境下演示一条可验证的实现路径。

1. 先读清原生回调契约

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stddef.h>
#include <stdint.h>

typedef void (*device_event_callback)(
    void* context,
    int32_t event_code,
    const uint8_t* payload,
    size_t payload_length);

int32_t device_register_callback(
    device_handle handle,
    device_event_callback callback,
    void* context);

int32_t device_unregister_callback(device_handle handle);

头文件之外还要确认:

  • 回调使用哪种调用约定;
  • SDK 是同步回调还是由内部线程异步回调;
  • 是否可能并发、重入或在注销期间继续进入;
  • payload 只在本次回调有效,还是可由调用者释放;
  • 注销返回时是否保证所有在途回调结束;
  • 关闭设备句柄前必须执行什么顺序。

2. 使用函数指针表达精确 ABI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

internal static partial class DeviceNative
{
    [LibraryImport("device_sdk", EntryPoint = "device_register_callback")]
    internal static unsafe partial int RegisterCallback(
        nint handle,
        delegate* unmanaged[Cdecl]<nint, int, byte*, nuint, void> callback,
        nint context);

    [LibraryImport("device_sdk", EntryPoint = "device_unregister_callback")]
    internal static partial int UnregisterCallback(nint handle);
}

delegate* unmanaged[Cdecl]<...> 表达的是非托管函数指针,参数顺序与原生 typedef 一一对应。若 SDK 不是 cdecl,两侧必须同时改为真实调用约定。

3. 用 UnmanagedCallersOnly 暴露静态入口

实例方法带有隐含的 this,不能直接作为普通 C 回调。可以暴露一个静态入口,把实例放在 context 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.IO;
using System.Threading.Channels;

internal static class DeviceCallbackBridge
{
    [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
    public static unsafe void OnEvent(
        nint context,
        int eventCode,
        byte* payload,
        nuint payloadLength)
    {
        try
        {
            var handle = GCHandle.FromIntPtr(context);
            if (handle.Target is not DeviceEventSink sink)
            {
                return;
            }

            if (payloadLength > sink.MaxPayloadLength ||
                (payload == null && payloadLength != 0))
            {
                CallbackFailureLog.Write(
                    new InvalidDataException("Invalid callback payload"));
                return;
            }

            int length = checked((int)payloadLength);
            byte[] copy = new ReadOnlySpan<byte>(payload, length).ToArray();
            sink.TryPublish(new NativeDeviceEvent(eventCode, copy));
        }
        catch (Exception exception)
        {
            CallbackFailureLog.Write(exception);
        }
    }
}

public sealed record NativeDeviceEvent(int Code, byte[] Payload);

public sealed class DeviceEventSink
{
    private readonly Channel<NativeDeviceEvent> _events =
        Channel.CreateBounded<NativeDeviceEvent>(64);

    public DeviceEventSink(nuint maxPayloadLength)
    {
        if (maxPayloadLength == 0 || maxPayloadLength > int.MaxValue)
        {
            throw new ArgumentOutOfRangeException(nameof(maxPayloadLength));
        }

        MaxPayloadLength = maxPayloadLength;
    }

    public nuint MaxPayloadLength { get; }

    public ChannelReader<NativeDeviceEvent> Events => _events.Reader;

    public bool TryPublish(NativeDeviceEvent deviceEvent) =>
        _events.Writer.TryWrite(deviceEvent);
}

internal static class CallbackFailureLog
{
    public static void Write(Exception exception)
    {
        try
        {
            Console.Error.WriteLine(exception);
        }
        catch
        {
            // 回调边界不能因诊断失败再次抛出。
        }
    }
}

这里使用有界通道,队列已满时 TryWrite 会失败;生产代码应记录丢弃计数或根据事件类型采用受控降载策略,不能静默假装事件已经处理。

UnmanagedCallersOnly 方法必须是静态方法,签名只能使用本系列(三)定义的 blittable 类型。异常绝不能穿过原生边界;回调入口要捕获全部异常,并把故障写入不再抛出的最小日志通道。

示例立即复制 payload,因为没有契约允许回调返回后继续读取该指针。最大长度由创建 DeviceEventSink 时结合设备数据模型配置,避免损坏的 SDK 值触发超大分配,又不武断限制合法图像或波形大小。

4. 上下文句柄必须活到最后一次回调结束

注册时用普通 GCHandle 保持托管对象可达。这里固定的是对象的生命周期,不是内存地址;默认 GCHandleType.Normal 仍允许 GC 移动对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public sealed unsafe class CallbackRegistration : IDisposable
{
    private readonly nint _device;
    private GCHandle _context;
    private bool _registered;

    public CallbackRegistration(nint device, DeviceEventSink sink)
    {
        _device = device;
        _context = GCHandle.Alloc(sink);

        int status = DeviceNative.RegisterCallback(
            device,
            &DeviceCallbackBridge.OnEvent,
            GCHandle.ToIntPtr(_context));

        if (status != 0)
        {
            _context.Free();
            throw new DeviceSdkException("device_register_callback", status);
        }

        _registered = true;
    }

    public void Dispose()
    {
        if (!_registered)
        {
            return;
        }

        int status = DeviceNative.UnregisterCallback(_device);
        if (status != 0)
        {
            throw new DeviceSdkException("device_unregister_callback", status);
        }

        // 只有 SDK 保证注销返回后无在途回调,才能在这里释放。
        _context.Free();
        _registered = false;
    }
}

如果注销不等待在途回调,这个简化版本不安全。需要增加计数/屏障,或调用 SDK 提供的同步停止函数,按“停止产生新回调 → 等待已有回调退出 → 释放 context → 关闭设备”的顺序执行。

异常路径也要设计:如果注销失败,立即释放 GCHandle 可能形成悬空上下文;无限保留则泄漏。应根据厂商契约决定重试、强制停止或把会话转入不可恢复状态,而不是在 finally 中盲目释放。

5. 回调线程不等于 UI 线程

相机或运动控制 SDK 常从内部原生线程调用。回调中应只做有界、非阻塞工作:

  • 复制必要数据和时间戳;
  • 写入 Channel<T>、无界风险受控的队列或专用调度器;
  • 立即返回,让 SDK 线程继续工作;
  • 在消费者侧解析、记录、更新状态并切换到 UI Dispatcher。

不要在回调里等待 UI、获取长时间持有的业务锁、同步调用关闭设备,或执行不可控的磁盘/网络 I/O。否则不仅丢帧,还可能与 SDK 内部锁形成死锁。锁与死锁的通用机制见《线程安全的本质:从 CPU 缓存到内存模型,锁到底在保证什么》。

6. 委托回调仍然可用,但必须扎根

旧 API 或不适合函数指针的场景可以声明委托:

1
2
3
4
5
6
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal unsafe delegate void DeviceEventCallback(
    nint context,
    int eventCode,
    byte* payload,
    nuint payloadLength);

如果原生代码只在一次同步调用期间使用委托,可在调用后用 GC.KeepAlive(callback) 保证其活到调用结束。如果原生库保存函数指针,就必须把委托存入与注册同寿命的字段,不能只依赖局部变量、GC.KeepAlive 或“目前 GC 还没发生”。

7. 回调测试要覆盖时间窗口

至少验证:

  • 注册失败时 context 不泄漏;
  • 回调在工作线程、并发和重入时仍正确;
  • 空载荷、最大载荷和非法长度受到保护;
  • 回调处理异常不会越过 ABI;
  • 注销与回调竞争时没有 use-after-free;
  • 关闭设备后不会再向已释放队列或 UI 发布;
  • 多次启动/停止不会重复注册。

模拟原生线程的测试应主动随机化回调时序,而不是只在单线程中顺序调用入口。

总结

回调是一段跨线程、跨 GC、跨 ABI 的长期租约。函数签名正确只是起点;托管目标、委托或 GCHandle 必须保持到最后一次回调结束,载荷要在有效期内复制,异常不能穿过边界,耗时工作应转移到托管队列。

下一篇用 SafeHandle 管理设备句柄,让关闭时机和调用期间的存活保证进入类型系统。

参考资料

Licensed under CC BY-NC-SA 4.0