C# 调用 Rust 完全指南: 在 ASP .NET 中解锁极致性能与安全
在现代软件开发中,我们常常追求“为合适的任务选择合适的工具”。C# 和 .NET 生态系统以其强大的生产力、完善的生态和企业级的稳定性而著称,特别是在构建 Web API 和后端服务方面。而 Rust,作为一门新兴的系统编程语言,以其无与伦比的性能、内存安全保证和无畏的并发性(Fearless Concurrency)而备受赞誉。
那么,我们能否将二者结合,打造一个既拥有 C# 的开发效率,又能在关键路径上获得 Rust 极致性能和安全性的“混血”应用呢?
答案是肯定的!本文将简单演示通过 FFI (Foreign Function Interface) 让 C# (特别是 ASP .NET Core) 与 Rust 协同工作,并探讨一些激动人心的应用场景。
1. 为什么选择 C# + Rust?
将 C# 和 Rust 结合,我们可以获得“两全其美”的优势:
- 极致性能: 对于计算密集型任务,如图像/视频处理、数据压缩、复杂算法、加解密等,Rust 的性能可以与 C/C++ 媲美,远超纯 C# 实现。
- 内存安全: Rust 的所有权系统在编译时就消除了空指针、悬垂指针和数据竞争等内存安全问题。这使得我们可以编写高性能代码,而无需担心 C/C++ 中常见的内存错误。
- 生态系统互补: 可以利用 Rust 社区中强大且高效的库(Crates),例如
serde(序列化/反序列化)、image(图像处理)、rayon(数据并行) 等。 - 渐进式优化: 不需要重写整个应用程序。可以只将性能瓶颈部分用 Rust 实现,其他业务逻辑继续使用 C#,实现“外科手术式”的性能优化。
生动的比喻:
把 ASP .NET 应用想象成一辆豪华舒适的轿车(C#),它能胜任绝大多数日常通勤。但有时,需要为它更换一个 F1 赛车级别的引擎(Rust),以便在赛道上(性能瓶颈)飞驰。FFI 就是那个能将 F1 引擎完美安装到轿车底盘上的精密适配器。
2. 核心原理: FFI 与 P/Invoke
C# 和 Rust 如何对话?答案是通过一个“通用语言”—— C ABI (Application Binary Interface) 。
- Rust 端: 将 Rust 函数编译成一个动态链接库(在 Windows 上是
.dll,Linux 上是.so,macOS 上是.dylib),并将其导出为符合 C ABI 的接口。 - C# 端: 使用 .NET 的 P/Invoke (Platform Invocation Services) 技术来加载这个动态链接库,并调用其中的 C ABI 函数。
3. 第一步: 创建一个 Rust 动态链接库 (dll/so/dylib)
3.1 创建 Rust 库项目
打开终端,执行以下命令:
cargo new rust_lib --lib
cd rust_lib
3.2 编写可供 C# 调用的函数
编辑 Cargo.toml 文件,告诉 Rust 编译器我们想生成一个 C 风格的动态链接库 (cdylib):
[package]
name = "rust_lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # <-- 关键配置!
然后,编辑 src/lib.rs 文件。我们将创建一个简单的加法函数作为示例。
// src/lib.rs
/// 一个简单的加法函数,演示了最基本的 FFI 调用
#[no_mangle] // 1. 禁用名称修饰,确保函数名在编译后不变
pub extern "C" fn add(left: i32, right: i32) -> i32 { // 2. 声明为 C ABI
left + right
}
关键点解释:
#[no_mangle]: 默认情况下,Rust 编译器会为了支持泛型和重载等特性而“修饰”(mangle)函数名。例如,my_func可能会变成_ZN7my_lib7my_funcE。#[no_mangle]会阻止这个行为,确保导出的函数名就是add。pub extern "C":pub使函数可见。extern "C"告诉编译器使用 C 语言的调用约定(ABI)。这是 C# P/Invoke 能够正确找到并调用它的基础。
3.3 编译 Rust 库
在 rust_lib 目录下运行编译命令:
# 编译 Release 版本以获得最佳性能
cargo build --release
编译成功后,可以在 target/release 目录下找到对应的库文件:
- Windows:
rust_lib.dll - Linux:
librust_lib.so - macOS:
librust_lib.dylib
4. 第二步: 在 C# 中调用 Rust 函数
4.1 创建 C# 项目
创建一个简单的 C# 控制台应用来测试调用。
dotnet new console -n CSharpCaller
cd CSharpCaller
4.2 使用 P/Invoke 调用 Rust
将刚才生成的 rust_lib.dll (或其他平台的文件) 拷贝到 C# 项目的输出目录(例如 bin/Debug/net8.0)下,这样程序运行时就能找到它。
然后,编辑 Program.cs 文件:
// Program.cs
using System.Runtime.InteropServices;
public class Program
{
// 1. 使用 DllImport 特性指定要加载的库
// 在 Windows 上是 "rust_lib.dll",在 Linux 上是 "librust_lib.so"
// 我们可以省略后缀,.NET 会自动适配
[DllImport("rust_lib", EntryPoint = "add", CallingConvention = CallingConvention.Cdecl)]
public static extern int Add(int left, int right); // 2. 声明一个与 Rust 函数签名匹配的外部方法
public static void Main(string[] args)
{
int a = 10;
int b = 20;
// 3. 像调用普通 C# 方法一样调用 Rust 函数!
int result = Add(a, b);
Console.WriteLine($"调用 Rust 函数: {a} + {b} = {result}"); // 输出: 调用 Rust 函数: 10 + 20 = 30
}
}
关键点解释:
[DllImport("rust_lib", ...)]: 这个特性是 P/Invoke 的核心。它告诉 .NET CLR 去加载名为rust_lib的本地库。EntryPoint = "add": 指定要调用的函数名。CallingConvention = CallingConvention.Cdecl: 指定调用约定,必须与 Rust 端extern "C"匹配。
public static extern int Add(...): 声明一个外部方法。extern关键字表示这个方法的实现在外部。它的签名(参数类型和返回类型)必须与 Rust 函数兼容。- 直接调用: 一旦声明完成,调用
Add方法就和调用任何其他 C# 方法一样简单。
运行 C# 程序,将看到 Rust 代码被成功执行!
dotnet run
# 输出:
# 调用 Rust 函数: 10 + 20 = 30
5. 关键挑战: 安全的内存管理
上面的例子很简单,因为只涉及 i32 这种基本的值类型。当涉及到字符串、数组、结构体等复杂类型时,内存管理就成了最大的挑战。
核心问题: C# 的垃圾回收器(GC)不知道 Rust 的所有权系统,反之亦然。跨越 FFI 边界传递复杂数据时,必须明确由谁分配内存,由谁释放内存。
5.1 场景一: 处理简单类型(值传递)
对于 i8, u8, i32, u32, i64, u64, f32, f64, bool 等基本类型,它们在栈上按值复制。这是最安全、最简单的情况,无需手动管理内存。我们的 add 函数就是这个例子。
5.2 场景二: 处理字符串
字符串比较复杂,因为它们是堆分配的。
模式: 谁创建,谁释放。
示例: C# 传字符串给 Rust,Rust 返回一个新的字符串
Rust 端 (src/lib.rs)
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
// ... add 函数 ...
/// 接收一个 C 风格字符串,返回一个新的、在 Rust 堆上分配的字符串
#[no_mangle]
pub extern "C" fn create_greeting(name: *const c_char) -> *mut c_char {
// 1. 安全地从 C# 传入的指针读取字符串
let c_str = unsafe {
assert!(!name.is_null());
CStr::from_ptr(name)
};
let name_str = c_str.to_str().expect("Invalid UTF-8 string");
// 2. 创建新的字符串
let greeting = format!("Hello, {}! Welcome to Rust!", name_str);
// 3. 将 Rust String 转换为 C 风格字符串,并交出内存所有权
let c_string = CString::new(greeting).unwrap();
c_string.into_raw() // c_string 的内存在这里被“泄露”,等待 C# 来释放
}
/// 释放由 Rust 创建的字符串内存
#[nomangle]
pub extern "C" fn free_greeting(s: *mut c_char) {
if s.is_null() {
return;
}
// 4. 从裸指针重新构建 CString,然后它的析构函数(RAII)会自动释放内存
unsafe {
let _ = CString::from_raw(s);
}
}
C# 端 (Program.cs)
using System;
using System.Runtime.InteropServices;
using System.Text;
// ...
[DllImport("rust_lib", EntryPoint = "create_greeting", CallingConvention = CallingConvention.Cdecl)]
// 注意: 返回的是一个指针,我们用 IntPtr 来表示
public static extern IntPtr CreateGreeting([MarshalAs(UnmanagedType.LPStr)] string name);
[DllImport("rust_lib", EntryPoint = "free_greeting", CallingConvention = CallingConvention.Cdecl)]
public static extern void FreeGreeting(IntPtr ptr);
public static void Main(string[] args)
{
// ... add 调用 ...
Console.WriteLine("\n--- 字符串处理示例 ---");
IntPtr greetingPtr = IntPtr.Zero;
try
{
// 1. 调用 Rust 函数,获取指向 Rust 内存的指针
greetingPtr = CreateGreeting("ASP .NET Core");
// 2. 将指针处的 UTF-8 数据编组为 C# 字符串
string greetingMessage = Marshal.PtrToStringUTF8(greetingPtr);
Console.WriteLine($"从 Rust 收到的消息: {greetingMessage}");
}
finally
{
// 3. 无论成功与否,都必须调用 Rust 的释放函数,防止内存泄漏!
if (greetingPtr != IntPtr.Zero)
{
FreeGreeting(greetingPtr);
Console.WriteLine("Rust 分配的内存已被释放。");
}
}
}
这个模式至关重要: Rust 分配内存并返回一个裸指针,C# 使用完毕后,必须调用 Rust 提供的“释放函数”来归还内存。try...finally 结构是保证释放函数被调用的最佳实践。
5.3 场景三: 处理复杂对象(不透明指针/句柄模式)
如果要传递复杂的结构体或对象,最好的模式是 Opaque Pointer (不透明指针)。
思路:
- Rust 创建一个结构体实例,并将其
Box起来(即在堆上分配)。 - Rust 将
Box<MyStruct>转换为一个裸指针*mut MyStruct,并将其作为“句柄”(Handle)返回给 C#。 - C# 端接收这个指针,但它不关心指针指向的内容,只是将其存为一个
IntPtr。对 C# 来说,这个句柄是“不透明的”。 - C# 每次需要操作这个对象时,都把这个句柄传回给 Rust 的其他函数。
- 最后,C# 调用一个专门的
free函数,将句柄传回,由 Rust 将裸指针转回Box并销毁,从而安全释放内存。
生动的比喻:
你去一家高档餐厅(Rust)点了一份惠灵顿牛排(
MyStruct)。
- 厨师(
create_object)做好牛排,放在一个餐盘(Box)上,然后给你一个取餐牌(IntPtr句柄)。- 你(C#)拿着取餐牌,但你不知道后厨的具体情况,也打不开餐盘盖。
- 如果你想给牛排加点胡椒粉(
do_something),你需要把取餐牌递给服务员,服务员去后厨操作。- 用餐完毕后,你把取餐牌还给前台(
free_object),餐厅就会去回收餐盘并清洗。如果你弄丢了取餐牌,那份牛排就永远留在了后厨,造成了“浪费”(内存泄漏)。
这个模式是处理 FFI 复杂状态的黄金标准,因为它将所有权和生命周期管理完全隔离在了 Rust 内部,C# 只负责持有和传递句柄。
6. ASP .NET + Rust: 实现很棒的功能
现在,让我们将这些技术应用到 ASP .NET Core Web API 中。
首先,确保将编译好的 rust_lib.dll 等文件放在 ASP .NET Core 应用的发布目录下。
6.1 场景: 高性能图像处理 API
假设我们要创建一个 API 端点,它接收一张图片,对其应用高斯模糊效果,然后返回处理后的图片。图像处理是典型的 CPU 密集型任务,非常适合用 Rust 来加速。
Rust 端 (需要添加 image crate: cargo add image)
// src/lib.rs
// ... 其他代码 ...
use image::{ImageFormat, io::Reader};
use std::io::Cursor;
use std::slice;
// 定义一个不透明结构体来持有处理后的图像数据
pub struct ProcessedImage {
data: Vec<u8>,
}
#[no_mangle]
pub extern "C" fn blur_image(
image_data: *const u8,
len: usize,
sigma: f32
) -> *mut ProcessedImage {
// 1. 从 C# 的 byte[] 创建一个 Rust 切片
let image_slice = unsafe { slice::from_raw_parts(image_data, len) };
// 2. 使用 image crate 进行处理
let reader = Reader::new(Cursor::new(image_slice))
.with_guessed_format()
.expect("Failed to guess format");
match reader.decode() {
Ok(img) => {
// 应用高斯模糊
let blurred_img = img.blur(sigma);
// 3. 将处理后的图像编码为 PNG,并存入 Vec<u8>
let mut buffer = Vec::new();
blurred_img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png).unwrap();
// 4. 创建不透明对象,并返回句柄
let processed = Box::new(ProcessedImage { data: buffer });
Box::into_raw(processed)
},
Err(_) => std::ptr::null_mut(),
}
}
// 获取处理后图像的数据指针和长度
#[no_mangle]
pub extern "C" fn get_image_data(handle: *mut ProcessedImage, len: *mut usize) -> *const u8 {
let image = unsafe { &*handle };
unsafe { *len = image.data.len(); }
image.data.as_ptr()
}
// 释放不透明对象
#[no_mangle]
pub extern "C" fn free_image(handle: *mut ProcessedImage) {
if !handle.is_null() {
unsafe { let _ = Box::from_raw(handle); }
}
}
ASP .NET Core Controller 端:
[ApiController]
[Route("api/[controller]")]
public class ImageProcessingController : ControllerBase
{
// 定义句柄类型,增加代码可读性
private class ImageHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private ImageHandle() : base(true) { }
protected override bool ReleaseHandle()
{
// 当 SafeHandle 被 GC 回收时,会自动调用这个方法
NativeMethods.free_image(handle);
return true;
}
}
private static class NativeMethods
{
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern ImageHandle blur_image(byte[] imageData, UIntPtr len, float sigma);
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr get_image_data(ImageHandle handle, out UIntPtr len);
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern void free_image(IntPtr handle);
}
[HttpPost("blur")]
public async Task<IActionResult> BlurImage([FromForm] IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest("No file uploaded.");
}
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var imageBytes = ms.ToArray();
// 使用 SafeHandle 包装句柄,实现自动内存管理
using (ImageHandle imageHandle = NativeMethods.blur_image(imageBytes, (UIntPtr)imageBytes.Length, 5.0f))
{
if (imageHandle.IsInvalid)
{
return StatusCode(500, "Image processing failed in Rust library.");
}
// 从句柄获取处理后的数据
IntPtr dataPtr = NativeMethods.get_image_data(imageHandle, out UIntPtr length);
// 将非托管内存中的数据复制到 C# 托管数组
byte[] resultBytes = new byte[(int)length];
Marshal.Copy(dataPtr, resultBytes, 0, (int)length);
// 返回文件流
return File(resultBytes, "image/png");
} // 在这里,imageHandle 的 Dispose() 会被调用,从而触发 ReleaseHandle,自动调用 free_image
}
}
亮点:
- 我们使用了
SafeHandle,这是一个 .NET 提供的类,专门用于包装本地资源句柄。它能与 GC 完美集成,当SafeHandle对象不再被引用时,GC 会确保其ReleaseHandle方法被调用,从而自动释放 Rust 分配的内存。这比手动try...finally更健壮、更优雅! - 整个复杂的图像处理逻辑都被封装在 Rust 中,C# 端只负责 I/O 和业务流程,职责清晰。
6.2 场景: CPU 密集型的计算任务
- 金融风控: 在 API 中实时运行复杂的风险评估模型。
- 科学计算: 对上传的数据进行矩阵运算、信号处理等。
- 大规模数据解析: 使用 Rust 的
serde和simd-json等库,以极高的速度解析巨大的 JSON 或其他格式的负载。
实现方式与图像处理类似,都是通过不透明指针模式,将计算逻辑封装在 Rust 中,ASP .NET Core 负责调用并返回结果。
6.3 场景: 安全地封装非托管代码
如果后端需要与一个老旧的、不安全的 C/C++ 库交互,直接在 C# 中 P/Invoke 可能会引入各种不稳定因素。
一个更安全的模式是: C# → Rust → C/C++
- 用 Rust 编写一个“安全包装层”,使用 Rust 的
bindgen工具生成对 C/C++ 库的unsafe调用。 - 在 Rust 包装层中,处理所有不安全的指针操作、内存管理和错误处理,并向外暴露一个 100% 内存安全的 Rust API。
- 再将这个安全的 Rust API 通过 FFI 导出给 C#。
这样,所有不安全的操作都被隔离在 Rust 的 unsafe 块中,而 C# 代码面对的是一个由 Rust 保证安全的接口,大大提高了整个系统的健壮性。
7. 自动化与工具
手动编写 C# 的 DllImport 声明可能会很繁琐且容易出错。社区提供了一些工具来简化这个过程:
- cbindgen: 一个强大的 Rust 工具,可以读取 Rust 源代码,并自动生成对应的 C/C++ 头文件 (
.h)。虽然不是直接生成 C# 代码,但这个头文件可以作为编写 P/Invoke 签名的精确参考。 - csbindgen: 一个实验性但非常有前景的项目,旨在直接从 Rust 代码生成 C# P/Invoke 代码,进一步实现自动化。
8. 总结与权衡
| 优点 | 缺点 / 注意事项 |
|---|---|
| 极致性能: 在瓶颈处获得接近 C/C++ 的速度。 | 开发复杂性: 需要维护两种语言的构建系统和工具链。 |
| 内存安全: Rust 从根本上杜绝了内存错误。 | FFI 开销: 跨语言调用有轻微的性能开销,不适合在紧密循环中频繁调用。 |
| 强大的并发: 利用 Rust 的并发模型处理并行任务。 | 调试困难: 跨语言边界调试比单一语言更具挑战性。 |
| 复用生态: 利用 Rust 社区成熟的高性能库。 | 内存管理: 必须非常小心地处理内存,否则会导致泄漏或崩溃。 |
| 渐进式重构: 无需全盘重写,可以逐步替换热点代码。 | 部署: 需要确保本地库文件与主程序一起正确部署到服务器上。 |
结论:
C# 与 Rust 的结合是一种强大而有效的“混合动力”方案。它并不适用于所有场景,但当 ASP .NET 应用面临性能瓶颈,或者需要处理计算密集型、对内存安全有极高要求的任务时,引入 Rust 作为一个“性能加速器”或“安全增强器”,将会带来巨大的收益。
通过遵循本文介绍的内存管理模式(特别是 不透明指针 + SafeHandle ),可以构建出既高效又健壮的现代化后端服务。
9. C# + Rust 混合应用: 从开发到生产的最佳实践
9.1 生产部署与 .so 文件定位 (Linux)
问题: 在生产环境中部署这种混合应用有什么最佳实践吗?特别是在 Linux 服务器上,如何确保
.so文件能被 ASP .NET Core 应用正确找到?
答案: 最佳实践是“随应用部署 (Side-by-side Deployment)”。
这意味着将 Rust 库 (librust_lib.so) 与 ASP .NET Core 应用文件放在同一个目录中。当 .NET 运行时需要加载一个本地库时,它会按顺序搜索几个位置,而 应用程序的基目录是第一优先级 。
为什么这是最佳实践?
- 自包含与可移植性: 应用程序不依赖于系统级的库安装。整个应用(包括其所有依赖)可以被打包、复制、移动到任何具有 .NET 运行时的兼容 Linux 服务器上,并且“开箱即用”。
- 无权限问题: 不需要
sudo或root权限去修改系统的/usr/lib等目录。部署过程可以由普通用户完成。 - 版本控制: 如果应用更新了,它会自带新版本的
.so文件,避免了与其他可能使用旧版本库的应用产生冲突(即“DLL地狱”的 Linux 版本)。 - Docker 友好: 这是构建 Docker 镜像最自然的方式。
部署示例: 使用 Dockerfile
假设项目结构如下:
/my_project
/src
/MyWebApp
MyWebApp.csproj
/rust_lib
Cargo.toml
/src
lib.rs
Dockerfile
一个典型的生产 Dockerfile 会是这样:
# ---- Rust Build Stage ----
# 使用官方 Rust 镜像作为构建环境
FROM rust:1-slim as rust-builder
WORKDIR /usr/src/rust_lib
# 复制 Rust 项目文件
COPY src/rust_lib/ ./
# 编译 Release 版本的 .so 文件
RUN cargo build --release
# ---- .NET Build Stage ----
# 使用 .NET SDK 镜像来发布应用
FROM mcr.microsoft.com/dotnet/sdk:8.0 as dotnet-builder
WORKDIR /src
# 复制所有项目文件
COPY src/ ./
# 发布 .NET 应用
RUN dotnet publish "MyWebApp/MyWebApp.csproj" -c Release -o /app/publish
# ---- Final Stage ----
# 使用最终的 .NET 运行时镜像
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
# 从 .NET 构建阶段复制已发布的应用程序
COPY --from=dotnet-builder /app/publish .
# 从 Rust 构建阶段复制编译好的 .so 文件到应用程序的根目录
COPY --from=rust-builder /usr/src/rust_lib/target/release/librust_lib.so .
# 设置入口点
ENTRYPOINT ["dotnet", "MyWebApp.dll"]
关键点: COPY --from=rust-builder ... . 这一行将 librust_lib.so 文件精确地放在了与 MyWebApp.dll 相同的 /app 目录中。当应用启动时,P/Invoke 会立即找到它。
其他(不推荐的)方法
- 使用
LD_LIBRARY_PATH: 可以设置环境变量LD_LIBRARY_PATH指向包含.so文件的目录。这在开发或临时测试时有用,但在生产中被认为是不健壮的,因为它会影响整个进程的库搜索路径,可能导致意外冲突。 - 安装到系统目录: 将
.so安装到/usr/lib。这通常用于共享库,对于单个应用来说是过度设计,并且破坏了应用的自包含性。
9.2 健壮的错误信息传递
问题: 有没有更健壮的方式将 Rust 中的具体错误信息传递给 C#,而不是简单地返回空指针?
答案: 绝对有!返回空指针只能表示“失败”,但不能说明“为什么失败”。最佳实践是定义一个统一的、跨 FFI 边界的 Result 结构体。
这个模式的核心思想是: Rust 函数总是返回一个包含操作状态、成功时的数据和失败时的错误信息的结构体。
示例: 实现带错误详情的 Result 模式
Rust 端 (src/lib.rs)
我们将定义一个错误码枚举和一个通用的 FFI 结果结构。
use std::ffi::CString;
use std::os::raw::c_char;
#[repr(C)]
pub enum FfiStatus {
Ok = 0,
IoError = 1,
InvalidInput = 2,
Panic = 101, // Rust 恐慌
}
#[repr(C)]
pub struct FfiResult<T> {
pub status: FfiStatus,
pub ok_data: T, // 成功时的数据
pub err_msg: *mut c_char, // 失败时的错误信息字符串
}
// 假设这是我们之前创建的不透明句柄
pub struct MyObject { value: i32 }
// 创建一个可能失败的函数
#[no_mangle]
pub extern "C" fn create_object_safely(value: i32) -> FfiResult<*mut MyObject> {
if value < 0 {
let err_msg = CString::new(format!("Input value cannot be negative, but got {}", value))
.unwrap()
.into_raw();
return FfiResult {
status: FfiStatus::InvalidInput,
ok_data: std::ptr::null_mut(),
err_msg,
};
}
let obj = Box::new(MyObject { value });
FfiResult {
status: FfiStatus::Ok,
ok_data: Box::into_raw(obj),
err_msg: std::ptr::null_mut(),
}
}
// 必须提供一个释放错误字符串的函数
#[no_mangle]
pub extern "C" fn free_error_message(s: *mut c_char) {
if !s.is_null() {
unsafe { let _ = CString::from_raw(s); }
}
}
// ... 释放 MyObject 的函数 ...
#[no_mangle]
pub extern "C" fn free_object(obj: *mut MyObject) {
if !obj.is_null() {
unsafe { let _ = Box::from_raw(obj); }
}
}
C# 端 (Program.cs)
using System;
using System.Runtime.InteropServices;
public enum FfiStatus
{
Ok = 0,
IoError = 1,
InvalidInput = 2,
Panic = 101,
}
[StructLayout(LayoutKind.Sequential)]
public struct FfiResult
{
public FfiStatus status;
public IntPtr ok_data; // 用 IntPtr 接收指针
public IntPtr err_msg; // 用 IntPtr 接收字符串指针
}
public static class NativeMethods
{
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern FfiResult create_object_safely(int value);
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern void free_error_message(IntPtr s);
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern void free_object(IntPtr obj);
}
public static void Main()
{
Console.WriteLine("--- 健壮的错误处理示例 ---");
// 尝试一个会失败的调用
ProcessResult(NativeMethods.create_object_safely(-10));
Console.WriteLine();
// 尝试一个会成功的调用
ProcessResult(NativeMethods.create_object_safely(100));
}
private static void ProcessResult(FfiResult result)
{
IntPtr errorMsgPtr = result.err_msg;
IntPtr objectPtr = result.ok_data;
try
{
if (result.status == FfiStatus.Ok)
{
Console.WriteLine($"成功!获得了对象句柄: {objectPtr}");
// 在这里可以使用 objectPtr 调用其他 Rust 函数...
}
else
{
string errorMessage = Marshal.PtrToStringUTF8(errorMsgPtr) ?? "Unknown error";
Console.WriteLine($"失败!状态: {result.status}, 错误信息: '{errorMessage}'");
}
}
finally
{
// 无论成功失败,都必须释放 Rust 分配的内存
if (errorMsgPtr != IntPtr.Zero)
{
NativeMethods.free_error_message(errorMsgPtr);
}
if (objectPtr != IntPtr.Zero && result.status != FfiStatus.Ok)
{
// 如果创建成功但在处理中出错,也应释放对象
NativeMethods.free_object(objectPtr);
} else if (objectPtr != IntPtr.Zero) {
// 正常使用完毕后释放
Console.WriteLine($"释放对象句柄: {objectPtr}");
NativeMethods.free_object(objectPtr);
}
}
}
这个模式非常清晰、健壮,并且将错误处理的细节完全暴露给了 C# 调用方。
9.3 非阻塞调用长时间运行的 Rust 函数
问题: 如果 Rust 函数执行时间很长,我能在 C# 中用
async/await来非阻塞地调用它吗?
答案: 可以,但不是通过直接 await P/Invoke 调用。正确的方式是使用 Task.Run() 将这个阻塞的 CPU 密集型任务包装成一个可以在后台线程上运行的 Task。
P/Invoke 调用本质上是同步和阻塞的。如果 Rust 函数需要执行 5 秒,调用它的 C# 线程(在 ASP .NET Core 中可能是宝贵的请求处理线程)就会被阻塞 5 秒。这会严重影响服务器的吞吐量。
Task.Run 的作用就是从线程池中取一个后台线程来执行这个阻塞调用,并立即将控制权返回给调用方,从而使 async/await 成为可能。
示例: 在 ASP .NET Core 中非阻塞调用
Rust 端 (假设有一个耗时的函数)
// src/lib.rs
#[no_mangle]
pub extern "C" fn very_long_computation() -> i32 {
// 模拟一个耗时 3 秒的 CPU 密集型任务
std::thread::sleep(std::time::Duration::from_secs(3));
42 // 返回计算结果
}
ASP .NET Core Controller 端:
[ApiController]
[Route("api/[controller]")]
public class ComputationController : ControllerBase
{
private static class NativeMethods
{
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int very_long_computation();
}
[HttpGet("long-running")]
public async Task<IActionResult> GetLongComputationResult()
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Request received. Offloading to background thread...");
// 关键: 使用 Task.Run 将阻塞调用包装成异步任务
int result = await Task.Run(() =>
{
// 这部分代码将在线程池的一个后台线程上执行
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Rust computation started on thread {Thread.CurrentThread.ManagedThreadId}...");
var res = NativeMethods.very_long_computation();
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Rust computation finished.");
return res;
});
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Computation complete. Returning result.");
return Ok(new { Result = result });
}
}
执行流程:
- 一个 HTTP 请求到达
GetLongComputationResult。 Task.Run被调用,它立即返回一个未完成的Task。await关键字将请求处理线程释放回线程池,使其可以处理其他传入的请求。- 同时,线程池中的一个后台线程开始执行
very_long_computation()。这个线程会被阻塞 3 秒。 - 3 秒后,Rust 函数返回,
Task完成。 await后面的代码被安排继续执行(可能在原始线程,也可能在另一个线程上),最终将Ok(result)返回给客户端。
这个模式完美地实现了 非阻塞 I/O (ASP .NET Core) 与 阻塞 CPU 密集型代码 (Rust) 的结合。
9.4 自动化构建与 CI/CD 集成
问题: 手动编译和复制文件在开发时还可以,有没有办法在构建 C# 项目时,自动编译 Rust 库并集成到 CI/CD 流程中?
答案: 当然有!我们可以通过修改 C# 项目的 .csproj 文件,利用 MSBuild 的强大能力来自动化这个过程。
我们将在 C# 项目的构建流程中插入一个自定义目标,该目标负责调用 cargo build。
示例: 修改 .csproj 文件以自动编译 Rust
编辑 MyWebApp.csproj 或 CSharpCaller.csproj 文件,在末尾 </Project> 标签前添加以下内容:
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- ... 其他属性组和项组 ... -->
<!-- 自定义 Rust 构建目标 -->
<Target Name="BuildRustLib" BeforeTargets="Build">
<Message Text="--- Starting Rust Library Build ---" Importance="high" />
<!-- 定义 Rust 项目的路径 -->
<PropertyGroup>
<RustProjectDir>$(MSBuildProjectDirectory)/../rust_lib</RustProjectDir>
<RustTargetDir>$(RustProjectDir)/target</RustTargetDir>
</PropertyGroup>
<!-- 执行 cargo build 命令 -->
<Exec Command="cargo build --release" WorkingDirectory="$(RustProjectDir)" />
<Message Text="--- Rust Library Build Finished ---" Importance="high" />
<!-- 将编译产物包含到项目中,以便发布 -->
<ItemGroup>
<!-- Windows -->
<Content Include="$(RustTargetDir)/release/rust_lib.dll" Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>rust_lib.dll</Link>
</Content>
<!-- Linux -->
<Content Include="$(RustTargetDir)/release/librust_lib.so" Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>librust_lib.so</Link>
</Content>
<!-- macOS -->
<Content Include="$(RustTargetDir)/release/librust_lib.dylib" Condition="$([MSBuild]::IsOSPlatform('OSX'))">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>librust_lib.dylib</Link>
</Content>
</ItemGroup>
</Target>
</Project>
这段代码做了什么:
<Target Name="BuildRustLib" BeforeTargets="Build">: 定义了一个名为BuildRustLib的新构建目标,并让它在标准的Build目标之前运行。<Exec Command="cargo build --release" ...>: 这是核心,它执行了 shell 命令来编译 Rust 库的 Release 版本。<ItemGroup>和<Content Include="...">: 这部分非常巧妙。它根据当前的操作系统 (Condition="$([MSBuild]::IsOSPlatform('...'))"),找到对应的编译产物 (.dll,.so,.dylib)。<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>: 告诉 MSBuild 将这个文件复制到输出目录(例如bin/Release/net8.0)。<Link>...</Link>: 在解决方案资源管理器中创建一个虚拟链接,使文件看起来像是项目的一部分,但实际上它指向的是 Rust 的target目录。
现在,只需要运行 dotnet build 或 dotnet publish,整个流程就会自动完成!
CI/CD 集成 (以 GitHub Actions 为例)
有了上述 .csproj 的修改,CI/CD 的配置变得异常简单。只需要确保构建环境中同时安装了 .NET SDK 和 Rust 工具链即可。
创建一个 .github/workflows/dotnet-rust-ci.yml 文件:
name: .NET and Rust CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest # 使用 Linux 构建环境
steps:
- uses: actions/checkout@v3
# 1. 安装 .NET SDK
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
# 2. 安装 Rust 工具链
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
# 3. 执行构建和发布(MSBuild 会自动处理 Rust 编译)
- name: Build and publish
run: dotnet publish ./src/MyWebApp/MyWebApp.csproj --configuration Release --output ./publish
# 4. (可选) 上传构建产物
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: my-web-app
path: ./publish
这个工作流清晰地展示了如何在一个环境中集成两种技术栈的构建,而真正的自动化魔法,都隐藏在刚刚修改的 .csproj 文件中。
10. 安全实现 Rust 对 C# 的回调
从 Rust 回调 C# 函数是完全可行的,但必须极其小心地处理,因为它横跨了两个完全不同的内存管理和执行模型。安全实现这一功能的核心在于解决三大挑战:
- 垃圾回收 (GC): C# 的委托是托管对象。如果 Rust 持有它的函数指针,我们必须确保 C# 的 GC 不会将其回收或移动。
- 异常处理: 如果 C# 的回调方法抛出异常,这个异常无法被 Rust “理解”,它会穿透 FFI 边界,导致整个进程立即崩溃。
- 线程安全: 回调是在哪个线程上执行的?它是在 Rust 创建的线程上执行的。如果这个回调需要更新 UI 或访问非线程安全的 C# 对象,必须进行正确的线程调度。
我们的策略是: 将一个 C# delegate 转换为函数指针,并传递给 Rust。同时,我们会采取必要的措施来“钉住”这个委托,并捕获任何可能发生的异常。
10.1 C# 端 - 定义委托并传递
在 C# 中,我们需要:
- 定义一个与 Rust 回调签名匹配的
delegate。 - 使用
[UnmanagedFunctionPointer]特性明确其 C 调用约定。 - 在调用 Rust 函数前,使用
GCHandle来“钉住”这个委托,防止 GC 回收它。 - 将委托的函数指针传递给 Rust。
- 在 Rust 函数返回后,释放
GCHandle。
C# 代码 (Program.cs)
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
public class Program
{
// 1. 定义委托,签名必须与 Rust 端期望的函数指针匹配
// 使用 UnmanagedFunctionPointer 特性明确调用约定,这是最佳实践
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ProgressCallback(int percentage, IntPtr messagePtr);
// P/Invoke 声明 Rust 函数,它接收一个委托作为参数
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
private static extern void process_data_with_callback(ProgressCallback callback);
// Rust 端提供的用于释放字符串的函数
[DllImport("rust_lib", CallingConvention = CallingConvention.Cdecl)]
private static extern void free_rust_string(IntPtr s);
// 2. 这是将要被 Rust 调用的 C# 方法
// 注意: 为了安全,通常将回调实现为静态方法
private static void OnProgress(int percentage, IntPtr messagePtr)
{
try
{
// 从 Rust 获取消息字符串
string message = Marshal.PtrToStringUTF8(messagePtr) ?? "";
Console.WriteLine($"[C# Callback Received on Thread {Thread.CurrentThread.ManagedThreadId}]: Progress: {percentage}%, Message: '{message}'");
// 重要: 释放 Rust 传递过来的字符串内存
free_rust_string(messagePtr);
// 模拟回调中可能发生的 C# 异常
if (percentage == 50)
{
// throw new InvalidOperationException("Something went wrong in C# callback!");
}
}
catch (Exception ex)
{
// 在回调内部捕获所有异常,绝不能让异常逃逸到 Rust!
Console.WriteLine($"[C# FATAL ERROR in callback]: {ex.Message}");
}
}
public static async Task Main(string[] args)
{
Console.WriteLine("--- C# to Rust with Callback Example ---");
// 3. 创建委托实例
ProgressCallback progressDelegate = new ProgressCallback(OnProgress);
// 4. (关键安全步骤) "钉住"委托,防止 GC 回收或移动它
// GCHandleType.Normal 即可,因为函数指针本身不会被移动
GCHandle handle = GCHandle.Alloc(progressDelegate);
Console.WriteLine($"C# Main Thread: {Thread.CurrentThread.ManagedThreadId}");
try
{
// 使用 Task.Run 将阻塞的 Rust 调用放到后台线程
await Task.Run(() =>
{
// 5. 调用 Rust 函数,传递委托
process_data_with_callback(progressDelegate);
});
}
finally
{
// 6. (关键安全步骤) 无论成功与否,都必须释放句柄
handle.Free();
Console.WriteLine("GCHandle for delegate released.");
}
Console.WriteLine("--- Task Finished ---");
}
}
10.2 Rust 端 - 接收并调用函数指针
在 Rust 中,我们需要:
- 定义一个与 C# 委托匹配的
extern "C" fn类型。 - 在长时间运行的函数中接收这个函数指针。
- 极其重要: 在调用这个来自 C# 的函数指针时,使用
std::panic::catch_unwind将其包裹起来。这能捕获 C# 抛出的异常(在 Rust 看来是 “panic”),防止进程崩溃。
Rust 代码 (src/lib.rs)
use std::ffi::CString;
use std::os::raw::c_char;
use std::panic;
// 1. 定义函数指针类型,匹配 C# 的 ProgressCallback 委托
type ProgressCallback = extern "C" fn(percentage: i32, message: *mut c_char);
#[no_mangle]
pub extern "C" fn process_data_with_callback(callback: ProgressCallback) {
println!("[Rust]: Starting long process...");
for i in 0..=10 {
let percentage = i * 10;
let msg = format!("Processing chunk {}/10", i);
let c_msg = CString::new(msg).unwrap().into_raw();
// 2. (关键安全步骤) 使用 catch_unwind 包裹对外部代码的调用
let result = panic::catch_unwind(|| {
// 调用从 C# 传入的函数指针
callback(percentage, c_msg);
});
if result.is_err() {
println!("[Rust FATAL ERROR]: The C# callback panicked (threw an exception)!");
// 如果 C# 异常了,我们 Rust 这边就不能再继续回调了,因为状态可能已经损坏
// 此时应该提前退出或进行错误处理
// 由于 c_msg 的所有权已经转移给了 callback,如果 callback 崩溃,
// 可能会导致内存泄漏。更复杂的场景需要更精细的内存管理。
// 但至少进程不会崩溃。
break;
}
// 模拟耗时工作
std::thread::sleep(std::time::Duration::from_millis(300));
}
println!("[Rust]: Long process finished.");
}
// 提供给 C# 的字符串释放函数
#[no_mangle]
pub extern "C" fn free_rust_string(s: *mut c_char) {
if !s.is_null() {
unsafe { let _ = CString::from_raw(s); }
}
}
10.3 安全性分析与总结
a. GCHandle: 防止“悬垂指针”
- 问题: 如果没有
GCHandle,GC 可能会在 Rust 函数执行期间,认为progressDelegate不再被 C# 代码引用,从而将其回收。这会导致 Rust 持有的函数指针变成一个指向无效内存的“悬垂指针”,调用它会立即导致程序崩溃。 - 解决方案:
GCHandle.Alloc()告诉 GC: “嘿,这个对象很重要,即使你看不到任何 C# 代码在用它,也请不要动它!”try...finally...handle.Free()模式确保了无论发生什么,我们都会在最后通知 GC 可以释放这个对象了。
b. catch_unwind: 构建“防火墙”
- 问题: C# 的
throw new Exception()在 FFI 边界的另一边看起来就像是 Rust 的panic!. 如果不加处理,这个 “panic” 会跨越 FFI 边界,破坏 Rust 的栈展开规则,导致整个程序终止。 - 解决方案: Rust 的
panic::catch_unwind就像一个防火墙。它执行一个闭包,如果闭包内部发生了 panic,它会捕获这个 panic 并返回一个Err,而不是让它传播出去。这给了 Rust 代码一个机会去处理这个来自 C# 的致命错误,至少能保证 Rust 端的程序状态是一致的,并且进程不会崩溃。
c. 线程模型
- 观察: 在示例的输出中,会看到 C# 的主线程 ID 和回调被执行的线程 ID 是不同的。这是因为我们用
Task.Run将阻塞调用放到了后台线程池。 - Implication: 这意味着
OnProgress方法是在一个后台线程上执行的。- 如果应用是控制台或 ASP .NET Core: 这通常没问题,只要
OnProgress内部访问的 C# 对象是线程安全的。 - 如果应用是 WinForms/WPF/MAUI 等有 UI 线程的应用: 无论如何 绝不能 在
OnProgress中直接更新 UI 控件。必须使用Control.Invoke/Dispatcher.Invoke等机制将 UI 更新操作封送到 UI 线程执行。
- 如果应用是控制台或 ASP .NET Core: 这通常没问题,只要
10.4 传递上下文: 处理实例方法回调
上面的例子使用了静态方法,但如果想回调一个对象的实例方法怎么办?需要传递一个“上下文指针”。
模式:
- Rust 端: 修改函数签名,多接收一个
context: *mut c_void的不透明指针。回调函数的签名也多接收这个context。 - C# 端:
- 创建一个
ProgressReporter类的实例。 - 使用
GCHandle.Alloc()同时钉住ProgressReporter实例和回调委托。 - 将
GCHandle转换为IntPtr作为context传给 Rust。 - 回调方法依然是
static的,但它的第一个参数是IntPtr context。 - 在回调方法内部,将
IntPtr转回GCHandle,并从handle.Target属性获取ProgressReporter实例,然后调用实例方法。
- 创建一个
这个模式更为复杂,但功能也更强大,是许多原生库与托管代码交互的标准方式。