英文:
What does "uninitialized" mean in the context of FFI?
问题
我正在为 macOS 编写一些 GPU 代码,使用的是 metal
crate。在这个过程中,我通过以下方式分配了一个 Buffer
对象:
let buffer = device.new_buffer(num_bytes, MTLResourceOptions::StorageModeShared)
这会调用到 Apple 的 Metal API,它会分配一块内存区域,CPU 和 GPU 都可以访问,然后 Rust 封装返回一个 Buffer
对象。接着,我可以通过以下方式获取这块内存区域的指针:
let data = buffer.contents() as *mut u32
在口语上,这块内存区域是未初始化的。但从 Rust 的角度来看,这块内存区域是否是 "未初始化" 的呢?
这样做是否合理?
let num_bytes = num_u32 * std::mem::size_of::<u32>();
let buffer = device.new_buffer(num_bytes, MTLResourceOptions::StorageModeShared);
let data = buffer.contents() as *mut u32;
let as_slice = unsafe { slice::from_raw_parts_mut(data, num_u32) };
for i in as_slice {
*i = 42u32;
}
这里我将 u32 值写入了一个通过 FFI 返回的内存区域。根据 nomicon:
...这里需要注意的是,通常情况下,当我们使用 = 为 Rust 类型检查器认为已经初始化的值(如 x[i])赋值时,左侧存储的旧值会被丢弃。这将是一场灾难。但在这种情况下,左侧的类型是 MaybeUninit<Box
>,而放弃它不会有任何影响!下面会有更多关于这个放弃问题的讨论。
没有违反 from_raw_parts
的规则,而且 u32 没有一个 drop 方法。
- 尽管如此,这样做是否合理?
- 在写入之前从该区域(作为
u32
)读取数据是否合理(忽略无意义的值)?该内存区域是有效的,对于所有位模式,u32 都有定义。
最佳实践
现在考虑一个具有 drop 方法的类型 T
(并且你已经完成了 bindgen
和 #[repr(C)]
的操作,以便它可以跨越 FFI 边界)。
在这种情况下,应该如何处理:
- 通过在 Rust 中使用指针扫描该区域并调用
.write()
来初始化缓冲区? - 这样做:
let as_slice = unsafe { slice::from_raw_parts_mut(data as *mut MaybeUninit<T>, num_t) };
for i in as_slice {
*i = unsafe { MaybeUninit::new(T::new()).assume_init() };
}
此外,在初始化该区域之后,Rust 编译器如何在程序的后续调用中记住该区域已初始化?
思维实验
在某些情况下,缓冲区是 GPU 内核的输出,我想要读取结果。所有的写入发生在 Rust 之外的代码中,当我调用 .contents()
时,指向内存区域的指针包含正确的 uint32_t
值。这个思维实验应该传达出我的担忧。
假设我调用 C 的 malloc
,它返回一块未初始化数据的分配缓冲区。从任何类型的角度来看,从该缓冲区读取 u32 值(指针正确对齐且在范围内)都将完全属于未定义行为。
然而,假设我改为调用 calloc
,它在返回之前将缓冲区清零。如果你不喜欢 calloc
,那么假设我有一个 FFI 函数,它在 C 中显式写入 0 个 uint32_t
类型,然后将此缓冲区返回给 Rust。这个缓冲区是用有效的 u32
位模式初始化的。
- 从 Rust 的角度来看,
malloc
返回的数据是 "未初始化" 的,而calloc
返回的数据是已初始化的吗? - 如果这两种情况不同,Rust 编译器如何区分这两种情况以确保合法性?
英文:
I'm writing some GPU code for macOS using the metal
crate. In doing so, I allocate a Buffer
object by calling:
let buffer = device.new_buffer(num_bytes, MTLResourceOptions::StorageModeShared)
This FFIs to Apple's Metal API, which allocates a region of memory that both the CPU and GPU can access and the Rust wrapper returns a Buffer
object. I can then get a pointer to this region of memory by doing:
let data = buffer.contents() as *mut u32
In the colloquial sense, this region of memory is uninitialized. However, is this region of memory "uninitialized" in the Rust sense?
Is this sound?
let num_bytes = num_u32 * std::mem::size_of::<u32>();
let buffer = device.new_buffer(num_bytes, MTLResourceOptions::StorageModeShared);
let data = buffer.contents() as *mut u32;
let as_slice = unsafe { slice::from_raw_parts_mut(data, num_u32) };
for i in as_slice {
*i = 42u32;
}
Here I'm writing u32s to a region of memory returned to me by FFI. From the nomicon:
> ...The subtle aspect of this is that usually, when we use = to assign to a value that the Rust type checker considers to already be initialized (like x[i]), the old value stored on the left-hand side gets dropped. This would be a disaster. However, in this case, the type of the left-hand side is MaybeUninit<Box<u32>>, and dropping that does not do anything! See below for some more discussion of this drop issue.
None of the from_raw_parts
rules are violated and u32 doesn't have a drop method.
- Nonetheless, is this sound?
- Would reading from the region (as
u32
s) before writing to it be sound (nonsense values aside)? The region of memory is valid and u32 is defined for all bit patterns.
Best practices
Now consider a type T
that does have a drop method (and you've done all the bindgen
and #[repr(C)]
nonsense so that it can go across FFI boundaries).
In this situation, should one:
- Initialize the buffer in Rust by scanning the region with pointers and calling
.write()
? - Do:
let as_slice = unsafe { slice::from_raw_parts_mut(data as *mut MaybeUninit<T>, num_t) };
for i in as_slice {
*i = unsafe { MaybeUninit::new(T::new()).assume_init() };
}
Furthermore, after initializing the region, how does the Rust compiler remember this region is initialized on subsequent calls to .contents()
later in the program?
Thought experiment
In some cases, the buffer is the output of a GPU kernel and I want to read the results. All the writes occurred in code outside of Rust's control and when I call .contents()
, the pointer at the region of memory contains the correct uint32_t
values. This thought experiment should relay my concern with this.
Suppose I call C's malloc
, which returns an allocated buffer of uninitialized data. Does reading u32 values from this buffer (pointers are properly aligned and in bounds) as any type should fall squarely into undefined behavior.
However, suppose I instead call calloc
, which zeros the buffer before returning it. If you don't like calloc
, then suppose I have an FFI function that calls malloc, explicitly writes 0 uint32_t
types in C, then returns this buffer to Rust. This buffer is initialized with valid u32
bit patterns.
- From Rust's perspective, does
malloc
return "uninitialized" data whilecalloc
returns initialized data? - If the cases are different, how would the Rust compiler know the difference between the two with respect to soundness?
答案1
得分: 1
以下是代码部分的翻译:
There are multiple parameters to consider when you have an area of memory:
- The size of it is the most obvious.
- Its alignment is still somewhat obvious.
- Whether or not it's initialized -- and notably, for types like
bool
whether it's initialized with valid values as not all bit-patterns are valid. - Whether it's concurrently read/written.
Focusing on the trickier aspects, the recommendation is:
- If the memory is potentially uninitialized, use
MaybeUninit
. - If the memory is potentially concurrently read/written, use a synchronization method -- be it a
Mutex
orAtomicXXX
or ....
And that's it. Doing so will always be sound, no need to look for "excuses" or "exceptions".
Hence, in your case:
let num_bytes = num_u32 * std::mem::size_of::<u32>();
assert!(num_bytes <= isize::MAX as usize);
let buffer = device.new_buffer(num_bytes, MTLResourceOptions::StorageModeShared);
let data = buffer.contents() as *mut MaybeUninit<u32>;
// Safety:
// - `data` is valid for reads and writes.
// - `data` points to `num_u32` elements.
// - Access to `data` is exclusive for the duration.
// - `num_u32 * size_of::<u32>() <= isize::MAX`.
let as_slice = unsafe { slice::from_raw_parts_mut(data, num_u32) };
for i in as_slice {
i.write(42); // Yes you can write `*i = MaybeUninit::new(42);` too,
// but why would you?
}
// OR with nightly:
as_slice.write_slice(some_slice_of_u32s);
希望这对您有所帮助。如果您有任何其他问题,请随时提问。
英文:
There are multiple parameters to consider when you have an area of memory:
- The size of it is the most obvious.
- Its alignment is still somewhat obvious.
- Whether or not it's initialized -- and notably, for types like
bool
whether it's initialized with valid values as not all bit-patterns are valid. - Whether it's concurrently read/written.
Focusing on the trickier aspects, the recommendation is:
- If the memory is potentially uninitialized, use
MaybeUninit
. - If the memory is potentially concurrently read/written, use a synchronization method -- be it a
Mutex
orAtomicXXX
or ....
And that's it. Doing so will always be sound, no need to look for "excuses" or "exceptions".
Hence, in your case:
let num_bytes = num_u32 * std::mem::size_of::<u32>();
assert!(num_bytes <= isize::MAX as usize);
let buffer = device.new_buffer(num_bytes, MTLResourceOptions::StorageModeShared);
let data = buffer.contents() as *mut MaybeUninit<u32>;
// Safety:
// - `data` is valid for reads and writes.
// - `data` points to `num_u32` elements.
// - Access to `data` is exclusive for the duration.
// - `num_u32 * size_of::<u32>() <= isize::MAX`.
let as_slice = unsafe { slice::from_raw_parts_mut(data, num_u32) };
for i in as_slice {
i.write(42); // Yes you can write `*i = MaybeUninit::new(42);` too,
// but why would you?
}
// OR with nightly:
as_slice.write_slice(some_slice_of_u32s);
答案2
得分: 0
这与用户论坛上的这篇帖子非常相似,该帖子在您的问题评论中提到。 (以下是该帖子中的一些链接:2 3)
那里的答案并不是最有条理的,但似乎有四个主要问题与未初始化的内存相关:
- Rust 假设它已经被初始化
- Rust 假设内存是该类型的有效位模式
- 操作系统可能会覆盖它
- 从读取已释放的内存中导致的安全漏洞
对于#1,我认为这对我来说不是一个问题,因为如果有另一个版本的 FFI 函数返回已初始化的内存而不是未初始化的内存,它将与 Rust 看起来完全相同。
我认为大多数人都明白#2,对于 u32
来说这不是一个问题。
#3 可能会成为一个问题,但由于这是为特定操作系统而设计的,如果 macOS 保证不会执行这种操作,您可能可以忽略它。
#4 可能是未定义的行为,但这是非常不希望的。这就是为什么即使 Rust 认为它是有效的 u32
列表,您也应该将其视为未初始化。因此,即使对于 u32
,您也应该使用 MaybeUninit
。
MaybeUninit
将指针转换为 MaybeUninit
切片是正确的。不过,您的示例写得不正确。assume_init
返回 T
,您不能将其赋值给 [MaybeUninit<T>]
中的元素。修正如下:
let as_slice = unsafe { slice::from_raw_parts_mut(data as *mut MaybeUninit<T>, num_t) };
for i in as_slice {
i.write(T::new());
}
然后,将 MaybeUninit
切片转换为 T
切片:
let init_slice = unsafe { &mut *(as_slice as *mut [MaybeUninit<T>] as *mut [T]) };
另一个问题是,&mut
在这里可能根本不正确,因为您说它在 GPU 和 CPU 之间共享。Rust 依赖于您的 Rust 代码是唯一可以访问 &mut
数据的东西,因此您需要确保 GPU 访问内存时消除任何 &mut
。如果您希望交错 Rust 访问和 GPU 访问,您需要以某种方式同步它们,并且只在 GPU 访问内存时存储 *mut
(或从 FFI 重新获取它)。
注释
代码主要来自 MaybeUninit
文档中的逐个初始化数组元素,以及 transmute
的非常有用的替代方法 部分。从 &mut [MaybeUninit<T>]
转换为 &mut [T]
的方法也是 slice_assume_init_mut
写的方式。您不需要像其他示例中那样进行转换,因为它在指针后面。另一个类似的示例在 nomicon 中:未经检查的未初始化内存。该示例通过索引访问元素,但似乎这样做,对每个 &mut MaybeUninit<T>
使用 *
并调用 write
都是有效的。我使用 write
是因为它最短且容易理解。nomicon 还说使用 ptr
方法像 write
也是有效的,应该等同于使用 MaybeUninit::write
。
未来会有一些夜间 [MaybeUninit]
方法,这些方法将非常有帮助,比如 slice_assume_init_mut
。
英文:
This is very similar to this post on the users forum mentioned in the comment on your question. (here's some links from that post: 2 3)
The answers there aren't the most organized, but it seems like there's four main issues with uninitialized memory:
- Rust assumes it is initialized
- Rust assumes the memory is a valid bit pattern for the type
- The OS may overwrite it
- Security vulnerabilities from reading freed memory
For #1, this seems to me to not be an issue, since if there was another version of the FFI function that returned initialized memory instead of uninitialized memory, it would look identical to rust.
I think most people understand #2, and that's not an issue for u32
.
#3 could be a problem, but since this is for a specific OS you may be able to ignore this if MacOS guarantees it does not do this.
#4 may or may not be undefined behavior, but it is highly undesirable. This is why you should treat it as uninitialized even if rust thinks it's a list of valid u32
s. You don't want rust to think it's valid. Therefore, you should use MaybeUninit
even for u32
.
MaybeUninit
It's correct to cast the pointer to a slice of MaybeUninit
. Your example isn't written correctly, though. assume_init
returns T
, and you can't assign that to an element from [MaybeUninit<T>]
. Fixed:
let as_slice = unsafe { slice::from_raw_parts_mut(data as *mut MaybeUninit<T>, num_t) };
for i in as_slice {
i.write(T::new());
}
Then, turning that slice of MaybeUninit
into a slice of T
:
let init_slice = unsafe { &mut *(as_slice as *mut [MaybeUninit<T>] as *mut [T]) };
Another issue is that &mut
may not be correct to have at all here since you say it's shared between GPU and CPU. Rust depends on your rust code being the only thing that can access &mut
data, so you need to ensure any &mut
are gone while the GPU accesses the memory. If you want to interlace rust access and GPU access, you need to synchronize them somehow, and only store *mut
while the GPU has access (or reacquire it from FFI).
Notes
The code is mainly taken from Initializing an array element-by-element in the MaybeUninit
doc, plus the very useful Alternatives section from transmute
. The conversion from &mut [MaybeUninit<T>]
to &mut [T]
is how slice_assume_init_mut
is written as well. You don't need to transmute like in the other examples since it is behind a pointer. Another similar example is in the nomicon: Unchecked Uninitialized Memory. That one accesses the elements by index, but it seems like doing that, using *
on each &mut MaybeUninit<T>
, and calling write
are all valid. I used write
since it's shortest and is easy to understand. The nomicon also says that using ptr
methods like write
is also valid, which should be equivalent to using MaybeUninit::write
.
There's some nightly [MaybeUninit]
methods that will be helpful in the future, like slice_assume_init_mut
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论