可变原始指针(*mut T)的别名是否会导致未定义行为?

14

&mut T&mut T会导致编译错误,这很好,借用可变两次是客观错误。

*mut T*mut T是否未定义行为或者说这是完全有效的事情?也就是说,可变指针别名是有效的吗?

更糟糕的是,&mut T*mut T实际上可以编译并按预期工作,我可以通过引用、指针和再次引用修改值...但我看到有人说这是未定义行为。是的,"有人这么说"是我唯一拥有的信息。

这是我的测试结果:

fn main() {
    let mut value: u8 = 42;

    let r: &mut u8 = &mut value;
    let p: *mut u8 = r as *mut _;

    *r += 1;

    unsafe { *p += 1; }

    *r -= 1;

    unsafe { *p -= 1; }

    println!("{}", value);
}

当然,问题的主要点是:

注意 — 感谢 trentcl 提出 指出这个示例在创建 p2 时实际上会导致复制。可以通过将 u8 替换为非 Copy 类型来确认这一点。编译器会抱怨移动。不幸的是,这并没有让我更接近答案,只是提醒我,由于 Rust 的移动语义,即使没有未定义行为,我也可能会得到意外的行为。

fn main() {
    let mut value: u8 = 42;

    let p1: *mut u8 = &mut value as *mut _;
    // this part was edited, left in so it's easy to spot
    // it's not important how I got this value, what's important is that it points to same variable and allows mutating it
    // I did it this way, hoping that trying to access real value then grab new pointer again, would break something, if it was UB to do this
    //let p2: *mut u8 = &mut unsafe { *p1 } as *mut _;
    let p2: *mut u8 = p1;

    unsafe {
        *p1 += 1;
        *p2 += 1;
        *p1 -= 1;
        *p2 -= 1;
    }

    println!("{}", value);
}

两者都产生:

42

这是否意味着指向相同位置的两个可变指针在不同时间被解引用并不是未定义行为?
我认为在编译器上测试这一点并不是一个好主意,因为未定义的行为可能会发生任何事情,甚至像没有问题一样打印42。无论如何,我还是提到了这一点,因为这是我尝试过的事情之一,希望能得到客观的答案。
我不知道如何编写一个测试,可以强制产生异常行为,以使它明显地不能按预期工作,即使可能做到这一点。
我知道这很可能是未定义的行为,并且无论如何都会在多线程环境中出现故障。然而,我期望得到比这更详细的答案,特别是如果可变指针别名不是未定义行为。 (这实际上很棒,因为虽然我像其他人一样使用Rust,例如内存安全...但我仍然希望保留一支可以指向任何地方的猎枪,而不是锁定在我的脚上。在C中,我可以有别名的“可变指针”,而不会炸掉我的脚。)
这是关于我是否能够做到的问题,而不是关于我是否应该这样做的问题。我想全力以赴地学习不安全的Rust,但感觉没有足够的信息,不像“可怕”的语言C中那样清楚什么是未定义的行为,什么不是。

3
你可以创建别名可变指针而不需要任何“unsafe”操作,因此按定义仅创建它们必须是安全的。当然,使用它们是另一回事...... - rodrigo
3
第一个示例仍然是未定义行为,因为编译器需要对*p取一个&mut引用才能执行+=操作。当然,你不能“仅仅”移动一个(非Copy)类型的值出一个*mut指针,因为这样做比仅仅取消引用这个指针更加不安全 -- 你需要使用ptr::read来完成。 - trent
2
@trentcl 第二个例子的第一个版本中令人惊讶的是,unsafe { &mut *p1 }&mut unsafe { *p1 } 是不同的。unsafe 块将位置表达式转换为值表达式,从而触发移动操作。 - Sven Marnach
我笑了,“意外行为” :) - rsalmei
显示剩余8条评论
1个回答

13
作者注:以下是一种直观的解释,而非严格的定义。我认为 Rust 中“别名”没有严格的定义,但你可能会发现阅读 Rustonomicon 上有关referencesaliasing的章节很有帮助。 references&T&mut T)的规则很简单:
  • 在任何时间,你可以有一个可变引用或任意数量的不可变引用。
  • 引用必须始终有效。
“裸指针”的规则没有任何“规则”。裸指针(*const T*mut T)可以别名任何东西,在任何地方,或者它们可以根本不指向任何东西。
当你解引用一个裸指针时,可能会发生未定义行为,隐式或显式地将其转换为引用。即使源代码中没有&,这个引用仍然必须遵守引用的规则
在你的第一个例子中,
unsafe { *p += 1; }

*p += 1;需要获取&mut引用来使用+=运算符,就像你写的一样。

unsafe { AddAssign::add_assign(&mut *p, 1); }

(编译器实际上不使用AddAssign来实现+= for u8,但语义相同。)
由于&mut *p被另一个引用即r引用,违反了引用的第一条规则,导致未定义的行为。
您的第二个示例(自编辑以来)不同,因为没有别名的引用,只有另一个指针,并且没有管理指针的别名规则。因此,这个
let p1: *mut u8 = &mut value;
let p2: *mut u8 = p1;

unsafe {
    *p1 += 1;
    *p2 += 1;
    *p1 -= 1;
    *p2 -= 1;
}

如果没有其他有关value的参考,这是完全正确的。


谢谢。在最后一部分中,您提到在多线程情况下这种情况不成立(由于数据竞争),我认为值得一提的是,据我所了解,可以通过使用AtomicPtr<T>来避免这种情况,因为该结构,我引用一下,“具有与*mut T相同的内存表示”,并且值得在答案中提及。 - user11877195
1
@Sahsahae AtomicPtr<T> 实际上解决了一个不同的问题:如何同步访问指针本身(它不同步 T)。但也许我传达了错误的印象:在多线程上下文中使用 *mut T 仍然是可以的,只要保留引用规则即可。同步访问需要原子性(最好使用 AtomicU8),或者运行时检查(最好使用 Mutex<u8>RwLock<u8>)。原子和 Mutex/RwLock 都使用原始指针实现。 - trent
2
尽管我很想点赞,但恐怕这有点过于简单了。规则“*在任何时候,您只能拥有一个可变引用或任意数量的不可变引用。”并没有排除在作用域内拥有多个可变引用的可能性。毕竟,当借用时,原始可变引用仍然在作用域内,只是在借用期间不允许使用它。 - Matthieu M.
@trentcl:我同意,我认为我们在这里已经达到了Ralf的工作极限。我不认为*r += 1; unsafe { *p += 1; } *r += 1;有问题,因为可以认为*p在语句执行期间借用了r,然后在第二次增加r之前释放了借用。实际上,这似乎是不切实际的,因为所需的分析过程太复杂,但从时间上看,这是可以争论的。我认为由于缺乏因果关系,它将被Ralf的Stacked Borrows拒绝,但是... - Matthieu M.
@MatthieuM.:我不明白为什么有一个构造说“编译器应该表现得好像进入或离开这个块可能会任意影响任何地址已经逃逸的对象”会有任何实际困难。包含这样一个结构或调用包含这样一个结构的函数的任何循环都可能表现不佳,但如果一个函数按顺序执行三个循环,并且其中一个调用包含这样一个结构的函数,那不应该影响其他两个的性能。如果程序员只在需要时使用这样的结构... - supercat
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接