C# 处理体数据:装、钉、交、取消
今天聊的是一条线:工业检测软件里,C# 要把一大块「体数据」交给 C++ 算法去算。这件事拆成四个动作------装 (用什么装)、钉 (为什么钉)、交 (怎么交地址)、取消(怎么安全喊停)。下面一个个讲,概念用真名,代码脱敏示意。
一、体数据是什么:3D 的「像素」
体素(voxel) 就是 3D 版的「像素(pixel)」。
| 像素 pixel | 体素 voxel | |
|---|---|---|
| 维度 | 2D 平面 | 3D 立体 |
| 样子 | 一个小方格 | 一个小立方块 |
| 组成 | 照片 = 宽 × 高 个像素 | 体数据 = 长 × 宽 × 高 个体素 |
生活类比:体素就是 Minecraft 里的小方块。Minecraft 世界由无数小方块堆成 3D 世界;体数据也是这样,每个小方块(体素)代表那一小块空间里的密度/灰度。
CT 重建干的事,就是把多角度的 2D 投影图,重建成一个由体素堆起来的 3D 体数据。
二、为什么用 byte[] 装:因为它是一大块「裸字节」
体数据本质是一大块连续的、无类型的原始字节。它不是「一堆整数」,也不是「一堆小数」,就是「一堆字节」。
所以用 byte[] 装,理由三条:
byte是内存最小单位(1 字节) ,byte[]就是精确要「这么多字节的连续内存」,一个不多一个不少。- 数组元素在内存里连续存放,首地址能直接交给 C++。
- C# 不贴标签------不解释这些字节是什么,C++ 自己按它的规则解释。
生活类比:体数据像一箱沙 。C++ 要的是「沙子从哪开始、一共多少粒」,每粒沙没有类型标签。
byte就是一粒粒沙,byte[]就是那一整箱连续摆好的沙。你不能用「一盒贴了标签的糖果」去表达它,因为标签(int/double)对这批货毫无意义。
示意代码:
csharp
int totalBytes = sizeX * sizeY * sizeZ; // 算出一共多少字节
byte[] raw = new byte[totalBytes]; // 要这么多个字节的连续内存
三、为什么要「钉」住:GC 会「搬家」
分配完 byte[] 还不够。.NET 的 GC(垃圾回收器)会不定时「搬家」------它把还活着的对象挪到一起(内存压缩),腾出连续空间。
问题来了:如果你已经把这块内存的地址 交给了 C++,GC 突然把这块内存搬走了,C++ 手里那个地址就指向了旧位置(可能是垃圾、可能是别的对象)→ 读到错数据甚至崩溃。
所以交地址之前,必须把这块内存钉住(pinning),告诉 GC「这块别动」。
.NET 公开 API 是 GCHandle:
csharp
// 钉住:告诉 GC 这块内存别动
var handle = GCHandle.Alloc(raw, GCHandleType.Pinned);
// 取固定后的首地址
IntPtr firstAddress = handle.AddrOfPinnedObject();
// 用完解钉:GC 又能管理这块内存了
handle.Free();
三件事,拆开讲语法:
GCHandle.Alloc(数组, GCHandleType.Pinned)------ 第一个参数是你要钉的数组,第二个参数GCHandleType.Pinned是「固定」模式。.AddrOfPinnedObject()------ 返回这块内存的首地址 (IntPtr类型)。钉住之后这个地址就固定不变了。.Free()------ 解钉。用完必须解,否则这块内存一直被钉着,GC 永远搬不动它,等于霸占。
生活类比:GC 像搬家工人 ,会把你家家具重新摆整齐。你在墙上开了个洞(地址)告诉朋友「C++ 从这进」,结果工人把墙搬走了,朋友按旧地址找到的是别家。钉住 = 跟工人说「这面墙别动」 ;
Free()= 说「好了,墙可以动了」。
四、怎么「交」:门牌号 = 共享内存
钉住拿到首地址后,怎么把数据「交」给 C++?
关键认知:C++ 不认识 C# 的 byte[] 对象,它只认「内存地址」。所以 C# 把首地址告诉 C++,两边就共享了同一块内存:
C# 用 byte[](对象)看它 ⟷ 同一块物理内存 ⟷ C++ 用地址(指针)看它
示意代码:
csharp
param.volumeAddress = firstAddress; // 把首地址塞进参数结构体
cppAlgorithm.Run(params); // 结构体传给 C++,C++ 往这个地址写数据
C++ 拿到地址就往里写体数据;因为地址就是那块 byte[] 的首地址,所以 C++ 写进去 = 写进了 C# 的 byte[]。
生活类比:C# 有个空仓库 (byte\[\]),要请 C++ 这个「生产工人」往里放货。但 C++ 只懂「门牌号」(地址),不懂「仓库对象」。于是 C# 把仓库门牌号写在单子上(
param.volumeAddress)递给 C++。C++ 拿着门牌号走到那个地址放货------门牌号就是仓库地址,货自然进了 C# 的仓库。
一句话:首地址就是 C# 和 C++ 之间的「门牌号」,让两个世界共享同一块内存。
五、数据的三本账:原料仓库 → 加工车间 → 发货单
体数据在程序里不是一直待在一个地方,它在「三本账」之间接力:
[原料仓库] ──提取──▶ [加工车间] ──检测──▶ 结果
(重建算法写进来) (检测算法从这里取)
└────────────────────────────────▶ [发货单](只记文件路径)
| 账本 | 角色 | 装什么 |
|---|---|---|
| 原料仓库 | 重建算法「生产」出来的原始体数据堆这里 | 体数据本身 |
| 加工车间 | 检测算法「消费」------从这里取体数据去算 | 体数据本身 |
| 发货单 | 体数据写盘后,只记文件路径 | 路径字符串,不是数据 |
两个容易混的点,记牢:
- 生产 vs 消费:原料仓库是「生产方」(重建算法写进来),加工车间是「消费方」(检测算法取出去)。别搞反。
- 发货单存的是「路径」,不是数据本身。货本身进了磁盘文件,发货单只写「货在 3 号仓 B 区」。
生活类比:这就是一个车间。原料仓库放刚生产出来的货,加工车间来领货加工,发货单登记「货发到哪了」。三本账各司其职,货(体数据)在仓库之间流转。
六、取消机制:源(扳手)vs 令牌(信号线)
一个任务跑到一半要能「安全喊停」。.NET 用两个东西配合,核心是权限分离:
| 能干什么 | 关键成员 | |
|---|---|---|
CancellationTokenSource(源) |
能「写」------主动触发取消 | .Cancel() |
CancellationToken(令牌) |
只能「读」------只能看取消了没 | .IsCancellationRequested |
源是扳手,令牌是信号线。 扳手能按(.Cancel()),信号线只能看(查「取消了没」)。
从源上接出令牌,用 .Token:
csharp
var source = new CancellationTokenSource(); // 造一个源(扳手)
CancellationToken token = source.Token; // 从源上接出令牌(信号线)
source.Cancel(); // 按下扳手,触发取消
token.IsCancellationRequested; // 看:true(取消了)
new CancellationTokenSource()------ 造一个「能按下的取消开关」。.Token------ 从开关上接出一根「信号线」。.Cancel()------ 按下开关。.IsCancellationRequested------ 查「取消了没」。token.ThrowIfCancellationRequested()------ 取消了就抛异常中断(跑任务的代码常用这句「检查哨」)。
为什么要分两个? 取消权(扳手)集中内部,知情权(信号线)发给外面。外面拿着令牌只能「看」,不能「随便取消你」。
生活类比:报警按钮 (源)只有管理员能按;指示灯(令牌)谁都能看。你不能靠「看灯」去「按按钮」------触发取消必须扳扳手。
进阶:联动开关
有时候一个任务要「既听自己的、又听上级的」。用 CreateLinkedTokenSource 把两个令牌「链接」起来:
csharp
// 联动:父令牌 或 自己的令牌,任一取消,这个源都取消
var linked = CancellationTokenSource.CreateLinkedTokenSource(parentToken, selfToken);
生活类比:房间的进线 接了两路------楼里的总闸 (父令牌)和房间的分闸 (自己的令牌)。总闸断或分闸断,房间都断电。
附录:这条线的完整链路
体数据(体素堆)
│ 用 byte[] 装(无类型裸字节)
▼
new byte[totalBytes]
│ 钉住(GCHandle.Alloc + Pinned)------ 防 GC 搬家
▼
拿到首地址(门牌号)
│ 交给 C++(结构体传地址)
▼
C++ 往地址写 = 写进 C# 的 byte[]
│
▼
三本账接力:原料仓库 → 加工车间 → 发货单
│
▼
全程用「源/令牌」管理取消(扳手按、信号线看)
核心就一句话 :C# 用 byte[] 装裸字节 → 钉住保地址 → 把门牌号交给 C++ 共享内存 → 数据在三本账里接力 → 用「源 vs 令牌」安全喊停。