Pining for Arc Downcasting in Rust

Pining for Arc Downcasting in Rust

渴望 Rust 中的 Arc 向下转型 (Downcasting)

In the course of writing my build driver, I came across a bit of an unusual problem, for which I made a bit of an usual solution. I think the solution is interesting and would like to talk about it, but to understand anything we must first understand the problem at hand. 在编写构建驱动程序的过程中,我遇到了一个有点不寻常的问题,并为此提出了一个有点不寻常的解决方案。我认为这个方案很有趣,想和大家聊聊,但要理解这一切,我们必须先了解手头的问题。

Consider The Case Of The Humble Concurrent Cache

考虑一下简单的并发缓存案例

Suppose we have some expensive function we’d like to put a cache in front of. Furthermore, suppose we’d like to access this cache from multiple threads. A simple example follows: 假设我们有一个开销很大的函数,想在它前面加一个缓存。此外,假设我们希望从多个线程访问此缓存。下面是一个简单的示例:

enum JSON {
    F64(f64),
    String(String),
    Vec(Vec<JSON>),
    Object(HashMap<String, JSON>),
}

struct Proxy {
    client: HTTPClient,
    cache: RWLock<HashMap<String, JSON>>,
}

impl Proxy {
    fn get(&self, key: &str) -> JSON {
        // 1.
        {
            let cache = self.cache.read().unwrap();
            if let Some(value) = cache.get(key) {
                return value.clone();
            }
        }
        // 2.
        let value = self.client.get(key);
        {
            let mut cache = self.cache.write().unwrap();
            cache.insert(key.to_string(), value.clone());
        }
        value
    }
}

This code has an “early exit” path (1) where it returns a value from the cache if it’s present, and a “late exit” path (2) where it calls the expensive function, then inserts the resulting value into the cache. Please ignore the many, many obvious problems with this implementation. Instead, let’s focus on the one problem that bothers me the most: there’s fair bit of .clone() action going on here! 这段代码有一个“提前退出”路径 (1),如果缓存中存在值,则直接返回;还有一个“延迟退出”路径 (2),它会调用昂贵的函数,然后将结果插入缓存。请忽略此实现中许多显而易见的问题。相反,让我们关注最让我困扰的一个问题:这里有相当多的 .clone() 操作!

Technically, it’s just one .clone() per call: one on early exit to take value out of the cache, and one on late exit to put value into the cache. But if those values are big/tree-shaped/otherwise expensive to clone, this cost can dominate, minimizing the savings conferred by a cache. In my code, I found this to be the case, so we gotta do something about it. 从技术上讲,每次调用只有一次 .clone():提前退出时从缓存中取值需要一次,延迟退出时将值放入缓存也需要一次。但如果这些值很大、呈树状结构或以其他方式难以克隆,这种开销可能会占据主导地位,从而抵消缓存带来的收益。在我的代码中,我发现了这种情况,所以我们必须设法解决它。

Let’s Get Rid Of The Clones?

摆脱克隆?

Assume that, with the way we use this data, read-only access is more than enough. Shared references are read-only & cheap to Copy, so using those instead of .clone()-ing the entire value seems good. If some later part of the code really needs to take ownership, we can just .clone() there, saving time in the average case. So, we’d like to change the signature for get() to be: 假设按照我们使用这些数据的方式,只读访问就足够了。共享引用是只读的且易于 Copy,因此使用它们而不是对整个值进行 .clone() 似乎是个好主意。如果后续代码确实需要获取所有权,我们可以在那里进行 .clone(),从而在一般情况下节省时间。因此,我们希望将 get() 的签名更改为:

impl Proxy {
    fn get<'a, 'b>(&'a self, url: &'b str) -> &'a JSON { ... }
}

But we can’t do this!! Because our cache is behind a mutex, the only way we can get references to its contents is thru temporary handles. Those handles, while live, hold a lock on the cache, plus they only live for the body of the function, and not for all of 'a. Even if we could return one of those handles, that’d be equivalent to holding the lock outside the function, which is very bad. Locks should only be held for VERY SHORT amounts of time. 但我们做不到!!因为我们的缓存位于互斥锁(mutex)之后,获取其内容引用的唯一方法是通过临时句柄。这些句柄在存活期间会持有缓存锁,而且它们只在函数体内有效,而不是在整个 'a 生命周期内有效。即使我们能返回其中一个句柄,也相当于在函数外部持有锁,这是非常糟糕的。除非你下个月想处理相关问题,否则锁应该只持有极短的时间。

Let’s Make The Clones Cheaper

让克隆更廉价

So, no references. What other types can we use? What we want is something with all the following properties: 所以,不能用引用。我们还能用什么类型?我们需要的是具备以下所有属性的东西:

  1. It allows for read access to our data. That is, it allows us to get an &JSON somehow.

  2. It has no lifetime parameters ('a, the only lifetime we have access to, is too long).

  3. It is cheap to .clone(), even if the underlying value is not cheap to .clone().

  4. 它允许对我们的数据进行只读访问。也就是说,它允许我们以某种方式获取 &JSON。

  5. 它没有生命周期参数(我们唯一能访问的生命周期 'a 太长了)。

  6. 它的 .clone() 操作很廉价,即使底层值本身并不廉价。

These requirements hint we should probably still be looking for some sort of pointer… Among standard library types, we have the following options: 这些要求暗示我们可能仍然需要寻找某种指针……在标准库类型中,我们有以下选择:

  • Raw pointers: *const JSON

  • Reference-counted pointers: Rc<JSON>

  • Atomically-reference-counted pointers: Arc<JSON>

  • 裸指针:*const JSON

  • 引用计数指针:Rc<JSON>

  • 原子引用计数指针:Arc<JSON>

Like any good Rustacean, we care a lot about safety & concurrency, so Arc is the obvious pick here. Modifying the example to use it is straightforward: 像任何优秀的 Rust 开发者一样,我们非常关心安全性和并发性,所以 Arc 是这里的显而易见的选择。修改示例以使用它非常简单:

struct Proxy {
    client: HTTPClient,
    cache: RWLock<HashMap<String, Arc<JSON>>>, // new!
}

impl Proxy {
    fn get(&self, url: &str) -> Arc<JSON> { // new!
        {
            let cache = self.cache.read().unwrap();
            if let Some(value) = cache.get(url) {
                return value.clone();
            }
        }
        let value = Arc::new(self.client.get(url)); // new!
        {
            let mut cache = self.cache.write().unwrap();
            cache.insert(url.to_string(), value.clone());
        }
        value
    }
}

Other than the three lines with Arc added to them, this implementation looks the exact same as before. But now our clones are cheaper, so we’re happy, yay!! 除了添加了 Arc 的三行代码外,此实现看起来与之前完全相同。但现在我们的克隆更廉价了,所以我们很开心,耶!!

So What’s This About Downcasting?

那么,关于向下转型是怎么回事?

I hope the above section convinced you having an Arc “owned value that acts like a reference” is both normal to want & possible to achieve. Switching gears a bit, I’d like to discuss an interesting shortcoming with them: they don’t fit into Rust’s type system very well. 我希望以上部分已经让你相信,拥有一个“表现得像引用的所有权值”的 Arc 既是正常的需求,也是可以实现的。转换一下话题,我想讨论它们的一个有趣缺陷:它们不能很好地融入 Rust 的类型系统。

Suppose we know for a fact that certain JSON values are strings, and we’re only interested in the JSON::String variant of them. With an owned value or a shared reference, we can just pattern-match to “downcast” from a JSON to a String, or a &JSON to a &String. 假设我们确定某些 JSON 值是字符串,并且我们只对它们的 JSON::String 变体感兴趣。对于所有权值或共享引用,我们可以通过模式匹配来从 JSON “向下转型”为 String,或者从 &JSON 转型为 &String。

impl JSON {
    fn to_str(self) -> Option<String> {
        match self {
            Self::String(s) => Some(s),
            _ => None,
        }
    }
    fn as_str(&self) -> Option<&String> {
        match self {
            Self::String(s) => Some(s),
            _ => None,
        }
    }
}

But if we have an Arc<JSON>, we can’t get another Arc<String> the same way! 但如果我们有一个 Arc<JSON>,我们就无法以同样的方式获得另一个 Arc<String>!

impl JSON {
    fn doesnt_exist(value: Arc<JSON>) -> Option<Arc<String>> {
        match value.deref() {
            Self::String(s) => Some(s), // compile error!
            _ => None,
        }
    }
}

This is because value.deref() creates a reference to value, whose lifetime will end as soon as the function is over, because we don’t return it, only a pointer somewhere inside it. The machinery for Arc only works if it has access to the original pointer, not any derived pointers. So if we wanted to return an Arc<String>, we’d need to .clone() out of the Arc<JSON>, which is what we’ve been trying to avoid this whole time. 这是因为 value.deref() 创建了一个指向 value 的引用,其生命周期在函数结束时就会终结,因为我们没有返回它,只返回了它内部某个位置的指针。Arc 的机制只有在能够访问原始指针时才能工作,而不是派生指针。因此,如果我们想返回一个 Arc<String>,我们就需要从 Arc<JSON> 中进行 .clone(),而这正是我们一直试图避免的。

However! We don’t necessarily need a full Arc<String>! We’d be perfectly happy returning some other type, perhaps implementing Deref<Target = String>, so long as it still gives us those “owned value that acts like a reference” properties. If only we could extend the lifetime of value, perhaps by returning it alongside a… 然而!我们不一定需要一个完整的 Arc<String>!如果我们能返回其他类型(也许是实现了 Deref<Target = String> 的类型),只要它仍然能提供那些“表现得像引用的所有权值”的属性,我们就非常满意了。要是我们能延长值的生命周期就好了,也许可以通过将它与……一起返回来实现。