< 返回版块

bnyu 发表于 2023-12-20 15:23

SyncUnsafeCell文档提到需要用户自己提供同步机制,我想问下我这种使用方式会出现不同步的问题吗?

之所以用SyncUnsafeCell是因为:

  1. 我有一个 Vec<SyncUnsafeCell>, 会被并行的修改,但业务保证绝不会出现对同一个SyncUnsafeCell进行修改,只是具体是哪些会被修改是不确定的,但一定不会出现重复,所以实际是不存在并发修改的。这样避免运行时开销,不用加锁甚至没有原子操作。

  2. 然后还有会只读的并行的访问这个 Vec<SyncUnsafeCell> 的业务逻辑

  3. 流程2一定发生在流程1结束之后才开始(两个都是在主线程用rayon创建的并行任务),像1,2,1,2这样的流程。流程(1,2)的并行是用的相同的线程池调度(例如rayon),但是每个SyncUnsafeCell在那个线程上被修改/访问是完全不确定的。

请问我需要额外提供一些同步原语之类的来保证,每次2一定能观察到1的改动吗?会出现因为核心缓存导致脏读结果不一致吗?

评论区

写评论
zylthinking 2023-12-20 21:17

那就看你的具体代码了; 说白了, 你要凑出两个条件

  1. 写任务要先凑出一个写屏障
  2. 读任务要随后凑出一个读屏障

调度确实有可能, 任务调度出去的时候应该会有一个写屏障, 调度进来的时候会有一个读屏障。 因此, 你要是让写任务先调度出去, 然后再发生读任务的调度进来, 问题不大。 但这个是不是如此还真不好说。

比如你怎么通知读任务有数据的? 在通知的时候, 你自然还没有调度出去, 但通知发出去, 读任务可能就已经 调度进来了, 这顺序就是先读屏障, 后写屏障, 没有作用。

但还有可能你这个通知机制说不定也带写屏障, 说不定它能保证一个先写后读的屏障顺序, 这也没问题。 但这些说白了都是依赖外部代码的, 就算成了, 也是凑巧。

本质上, 你应该就是将一个不 Sync 的 T 欺骗成 T: Sync 了。 所以, 你应该做的就是将读写 T 线程安全起来, 自己的事情自己干。

--
👇
bnyu: "一个线程写 A, 另一个线程读 A" 这个不会同时发生,所以不存在并发读写,应该就没有线程安全问题吧

作者 bnyu 2023-12-20 18:08

"一个线程写 A, 另一个线程读 A" 这个不会同时发生,所以不存在并发读写,应该就没有线程安全问题吧

--
👇
zylthinking: 不会重叠但可能并发吧, 就是一个线程写 A, 另一个线程读 A;

你管理流程1 tasks的工具(比如rayon) 可能起不到作用, 因为就算它做了, 也是在调度的时候做, 但你未必能保证写完成之后才调度读; 否则你就不用并发了, 单纯使用一个任务先写再读就好了。

--
👇
bnyu: 感谢大佬解答。不能直接分发Vec的每个元素来并行是因为业务不是针对每个T来进行修改的,而是类似另一组Vec,这里的每个U会计算得知去修改Vec的哪个下标的哪个值(业务保证不会算出相同的index),所以Vec才需要多个可变修改。 1和2不会重叠,那这样我就放心使用这个Unsafe封装了。

--
👇
aj3n: 应该不会存在因为核心脏读导致的不一致,因为你既然说2在1结束之后才会开始,你管理流程1 tasks的工具(比如rayon)应该已经做过内存屏障的工作了;

主要问题还是2和1会不会重叠,你虽然说流程2会在流程1结束后开始,但是下一轮的流程1不会在上一轮流程2没结束的时候开始吧?

其实最理想的做法是直接用rayon scope之类的方法直接分发Vec每个元素的&mut T,然后在下一轮直接用scope只读,这样完全不需要Unsafe也没有心理负担,只是可能被生命周期艹;

zylthinking 2023-12-20 17:13

不会重叠但可能并发吧, 就是一个线程写 A, 另一个线程读 A;

你管理流程1 tasks的工具(比如rayon) 可能起不到作用, 因为就算它做了, 也是在调度的时候做, 但你未必能保证写完成之后才调度读; 否则你就不用并发了, 单纯使用一个任务先写再读就好了。

--
👇
bnyu: 感谢大佬解答。不能直接分发Vec的每个元素来并行是因为业务不是针对每个T来进行修改的,而是类似另一组Vec,这里的每个U会计算得知去修改Vec的哪个下标的哪个值(业务保证不会算出相同的index),所以Vec才需要多个可变修改。 1和2不会重叠,那这样我就放心使用这个Unsafe封装了。

--
👇
aj3n: 应该不会存在因为核心脏读导致的不一致,因为你既然说2在1结束之后才会开始,你管理流程1 tasks的工具(比如rayon)应该已经做过内存屏障的工作了;

主要问题还是2和1会不会重叠,你虽然说流程2会在流程1结束后开始,但是下一轮的流程1不会在上一轮流程2没结束的时候开始吧?

其实最理想的做法是直接用rayon scope之类的方法直接分发Vec每个元素的&mut T,然后在下一轮直接用scope只读,这样完全不需要Unsafe也没有心理负担,只是可能被生命周期艹;

zylthinking 2023-12-20 17:07

应该存在, 并且这还不到缓存一致性问题层次, 而是一个线程安全问题。

从文档可看到,

This is just an UnsafeCell, except it implements Sync if T implements Sync.

Providing proper synchronization is still the task of the user, making this type just as unsafe to use.

因此, 这个安全性应该是 T 本身应该保证的, 若 T 不 sync, 则 UnsafeCell 也不 Sync。 换句话说, 若 T 不 sync, 则根本不可能跨线程还能编译的过去。

既然编译的过去, 那要么是 T 是 sync 的, 要么你伪造了 T:sync。 若有问题, 只有在你伪造的这个情况下才会出现。

因此, 这件事其实就是你正确实现 T 的 sync 语义的问题, 若不正确实现, 则外界不知道, 也未必一定替你做同步。

作者 bnyu 2023-12-20 16:53

感谢大佬解答。不能直接分发Vec的每个元素来并行是因为业务不是针对每个T来进行修改的,而是类似另一组Vec,这里的每个U会计算得知去修改Vec的哪个下标的哪个值(业务保证不会算出相同的index),所以Vec才需要多个可变修改。 1和2不会重叠,那这样我就放心使用这个Unsafe封装了。

--
👇
aj3n: 应该不会存在因为核心脏读导致的不一致,因为你既然说2在1结束之后才会开始,你管理流程1 tasks的工具(比如rayon)应该已经做过内存屏障的工作了;

主要问题还是2和1会不会重叠,你虽然说流程2会在流程1结束后开始,但是下一轮的流程1不会在上一轮流程2没结束的时候开始吧?

其实最理想的做法是直接用rayon scope之类的方法直接分发Vec每个元素的&mut T,然后在下一轮直接用scope只读,这样完全不需要Unsafe也没有心理负担,只是可能被生命周期艹;

aj3n 2023-12-20 16:26

用std::thread::scope写了个demo

fn main() {
    let mut v = (0..100).collect::<Vec<_>>();
	let mut jobs = Vec::new();
    for i in &mut v {
        jobs.push(std::thread::scope(|_s| *i += 1));
    }
	jobs.clear();

    for _ in 0..10 {
        let job = std::thread::scope(|_s| {
			let idx = rand::random::<usize>() % v.len();
			println!("{idx},{}", v[idx]);
		});
		jobs.push(job);
    }
	jobs.clear();
}
aj3n 2023-12-20 16:16

应该不会存在因为核心脏读导致的不一致,因为你既然说2在1结束之后才会开始,你管理流程1 tasks的工具(比如rayon)应该已经做过内存屏障的工作了;

主要问题还是2和1会不会重叠,你虽然说流程2会在流程1结束后开始,但是下一轮的流程1不会在上一轮流程2没结束的时候开始吧?

其实最理想的做法是直接用rayon scope之类的方法直接分发Vec每个元素的&mut T,然后在下一轮直接用scope只读,这样完全不需要Unsafe也没有心理负担,只是可能被生命周期艹;

1 共 7 条评论, 1 页