Rust in Everything
基于 rayon 1.12.0

两个 trait

主线位置:主线 2 的入口。(0..n).into_par_iter().sum() 里,into_par_iter 来自 IntoParallelIterator,sum 是 ParallelIterator 的一个默认方法,它造一个消费者交给 drive_unindexed;Range<usize> 是 indexed 的,于是 drive_unindexed 转身调 bridge,再经 with_producer 拿到生产者。下一步见 plumbing。

flowchart TB
  into["IntoParallelIterator::into_par_iter"] --> pi["ParallelIterator(61 个方法)"]
  ref["IntoParallelRefIterator::par_iter = (&self).into_par_iter()"] --> into
  pi --> du["必须实现:drive_unindexed(UnindexedConsumer)"]
  pi --> ol["可选:opt_len()"]
  ipi["IndexedParallelIterator(34 个方法)"] -->|继承| pi
  ipi --> len["必须实现:len()"]
  ipi --> drv["必须实现:drive(Consumer)"]
  ipi --> wp["必须实现:with_producer(ProducerCallback)"]
  du -->|"消费者往下推"| leafc["Folder 在叶子上顺序消费"]
  wp -->|"生产者往上拉"| split["bridge 切分 Producer"]

三个入口 trait

IntoParallelIterator 对应 std 的 IntoIterator:

pub trait IntoParallelIterator {
    type Iter: ParallelIterator<Item = Self::Item>;
    type Item: Send;
    fn into_par_iter(self) -> Self::Iter;
}

par_iter() 和 par_iter_mut() 不是另外实现的,而是两个 blanket impl(mod.rs:288、332):只要 &'data I: IntoParallelIterator,I 就有 par_iter(),函数体就一行 self.into_par_iter()。所以 v.par_iter() 等于 (&v).into_par_iter(),Vec<T>、[T]、HashMap 们只需要给自己的引用类型实现 IntoParallelIterator。

Item: Send 是第一道约束:元素要被送到别的线程上处理。ParallelIterator 本身还要求 Sized + Send(mod.rs:357),迭代器整体会被拆开分给不同线程。闭包参数普遍是 Fn + Sync + Send:同一个 map_op 被所有线程共享引用着调用,不能是 FnMut。

ParallelIterator:61 个方法,必须实现 1 个

trait ParallelIterator 从 357 行开始,到 2422 行结束,用 ^ fn 数是 61 个方法。去掉文档以后,必须实现的只有一个:

fn drive_unindexed<C>(self, consumer: C) -> C::Result
where
    C: UnindexedConsumer<Self::Item>;

fn opt_len(&self) -> Option<usize> {
    None
}

(mod.rs:2401、2419。)其余 60 个都有默认实现,形状只有两种:

  • 适配器返回一个新结构体,什么都不做:map 就是 Map::new(self, map_op),filter、fold、flat_map 同理。和 std 一样是惰性的,结构体上都标着 #[must_use = "iterator adaptors are lazy ..."]。
  • 终结方法造一个消费者,调 self.drive_unindexed(consumer):sum 是 sum::sum(self),里面就一行 pi.drive_unindexed(SumConsumer::new());count 是 self.map(one).sum()(mod.rs:571);find_any、reduce、for_each 各有一个消费者。

opt_len 是给 collect 用的:返回 Some(len) 表示「我保证恰好产出 len 个元素」,Vec 的 par_extend 据此走就地写入的快路径(见 collect 到 Vec)。默认 None。indexed 的迭代器都返回 Some(self.len());filter、flat_map 不覆盖,就是 None。

IndexedParallelIterator:再加 3 个必须实现的

trait IndexedParallelIterator: ParallelIterator 有 34 个方法,必须实现三个:

fn len(&self) -> usize;
fn drive<C: Consumer<Self::Item>>(self, consumer: C) -> C::Result;
fn with_producer<CB: ProducerCallback<Self::Item>>(self, callback: CB) -> CB::Output;

(mod.rs:3211、3227、3244。)

「indexed」的意思是:长度事先确定,而且能在任意下标处切成两半。切片、Vec、Range<usize>、map / zip / enumerate 之后的链都是 indexed;filter 之后长度未知,就退化成只有 ParallelIterator。只有 indexed 的迭代器才有 zip、enumerate、rev、skip、take、chunks、collect_into_vec、with_min_len / with_max_len 这些方法,因为它们都要「按下标」做事。

drive 与 drive_unindexed 的区别只在消费者的类型:Consumer 能 split_at(index) 按下标切,UnindexedConsumer 只能 split_off_left() 不带下标地切。Range<usize> 的两个都直接调 bridge(range.rs:158-177)。

Range<u64> 在 rayon 里不是 indexed:u64、i64、u128、i128 的区间用的是 unindexed_range_impl!,因为长度可能超过 usize。但它的 drive_unindexed 先看 opt_len():长度放得进 usize,就改成 (0..len).into_par_iter().map(offset(start)).drive(consumer),注释写的是「Drive this in indexed mode for better collect」。所以 (0..n as u64).into_par_iter().collect() 也能就地写入,只是 zip 之类的方法用不了。

两种驱动:推和拉

一条链 a.map(f).filter(p).sum() 被驱动时,信息朝两个方向走:

  • 消费者往下推:sum 造 SumConsumer,交给 Filter::drive_unindexed;Filter 把它包成 FilterConsumer,交给 Map::drive_unindexed;Map 再包一层 MapConsumer 交给源头。适配器拿到的是「下游要什么」,它只管在下游外面套一层。大部分适配器(map、filter、inspect、flat_map、fold……)都只用这一种方式,见 Map::drive_unindexed:两行。
  • 生产者往上拉:源头(切片、区间)收到消费者以后,调 bridge(self, consumer),bridge 再调 self.with_producer(callback) 把自己变成一个 Producer。如果中间有 zip、enumerate 这类适配器,它们的 with_producer 先向上游要生产者,包一层再交给回调。

为什么要两种?只用消费者,zip 写不出来:zip 需要把两个上游在同一个下标切开,消费者只代表「下游怎么接」,不知道上游的数据怎么切。enumerate 需要知道每一片的起始下标,rev 需要从后往前切,同样要直接操作生产者。所以 Zip::drive_unindexed 不是把消费者往下传,而是直接 bridge(self, consumer),在自己这一层把两个上游的生产者合成一个 ZipProducer,见 适配器选讲。

消费者(推)生产者(拉)
代表「什么」下游怎么接收、怎么合并结果上游的数据、怎么切
切分方法Consumer::split_at(index) / UnindexedConsumer::split_off_left()Producer::split_at(index) / UnindexedProducer::split()
叶子上into_folder() 变成 Folder,逐个 consumefold_with(folder) 把自己的元素喂给 folder
回溯时Reducer::reduce(left, right)无
谁实现终结方法和大部分适配器源头和 zip / enumerate / rev / chain / skip / take 等

with_producer 为什么是回调

自然的写法是给 IndexedParallelIterator 加一个关联类型 type Producer 和 fn into_producer(self) -> Self::Producer。rayon 没这么写,而是:

pub trait ProducerCallback<T> {
    type Output;
    fn callback<P>(self, producer: P) -> Self::Output
    where
        P: Producer<Item = T>;
}

(plumbing/mod.rs:17。)原因在 Map::with_producer 里:

fn with_producer<CB>(self, callback: CB) -> CB::Output
where
    CB: ProducerCallback<Self::Item>,
{
    return self.base.with_producer(Callback {
        callback,
        map_op: self.map_op,
    });

    struct Callback<CB, F> {
        callback: CB,
        map_op: F,
    }

    impl<T, F, R, CB> ProducerCallback<T> for Callback<CB, F>
    where
        CB: ProducerCallback<R>,
        F: Fn(T) -> R + Sync,
        R: Send,
    {
        type Output = CB::Output;

        fn callback<P>(self, base: P) -> CB::Output
        where
            P: Producer<Item = T>,
        {
            let producer = MapProducer {
                base,
                map_op: &self.map_op,
            };
            self.callback.callback(producer)
        }
    }
}

MapProducer<'f, P, F> 里存的是 &'f F,借的是 callback() 这一帧栈上的 self.map_op。生产者要被切成成百上千份分给各个线程,每份都带一个 &F 最便宜;但这个 'f 是 callback 函数体里的生命周期,在 impl 层面写不出来,所以没法写 type Producer = MapProducer<'?, ...>。改成「我把生产者交给你」的回调,生产者的类型和生命周期都只在回调内部出现,trait 签名里不用出现生产者类型。

为什么不用 FnOnce(P)?因为回调必须对所有 P: Producer 泛型,而闭包不能是泛型的。所以每个适配器都要手写一个 struct Callback 加一个 impl ProducerCallback,上面那段代码有一半是样板。上游的 plumbing/README.md 有一节「What on earth is ProducerCallback?」讲同一件事(README 是资料,上面的说法已对照 map.rs 的代码核实)。

回调还有一个好处:with_producer 可以在自己的栈帧上准备并行期间要用的资源,把引用放进生产者,不用返回它。par_bridge 的 Mutex 和 threads_started 数组就是这样放在 drive_unindexed 的栈上、以引用交给生产者的。

和 std Iterator 对照

std::iter::IteratorParallelIterator
核心方法next():调用方一个一个拉drive_unindexed(consumer):迭代器把元素推给消费者
能否外部逐个取能不能,没有 next()
闭包FnMutFn + Sync + Send
长度size_hint 是提示opt_len / len 是承诺,collect 靠它写裸指针(写错会触发断言,见 collect 到 Vec)
顺序天然有序collect 保序(filter 之后的链也保序,par_bridge 除外);for_each 的执行顺序不确定;find_any 返回哪一个不确定
短路break / ?消费者的 full() 返回 true,各线程在下一次检查时停下

没有 next() 是设计上的选择:「下一个」在并行里没有意义。代价是想把一个只有 next() 的东西并行化,只能经过 par_bridge 加锁地一个个取。

小结

  • par_iter() = (&self).into_par_iter(),靠两个 blanket impl;集合类型只需给引用实现 IntoParallelIterator。
  • ParallelIterator 61 个方法,必须实现 drive_unindexed,可选 opt_len;IndexedParallelIterator 34 个方法,必须实现 len / drive / with_producer。
  • 适配器返回结构体,终结方法造消费者调 drive_unindexed。大部分适配器只包消费者;zip / enumerate / rev 这类必须拿到生产者。
  • with_producer 写成 ProducerCallback 回调,是因为生产者借用了回调栈帧上的数据,类型里的生命周期在 impl 层写不出来,闭包又不能对生产者类型泛型。
  • Range<u64> 不是 indexed,但长度放得进 usize 时内部按 indexed 驱动,collect 仍能就地写入。

💬 关联讨论

发起讨论
加载中...