Best practice for async configuration management with RwLock and Serde?

I’ve been working on a configuration system for my application and ran into some design issues regarding RwLock + async/await + Serde.

My requirement:

  • The application loads a Config struct from a file at startup.
  • It may occasionally be updated by the user (write is rare).
  • Most of the time, it’s only read by other async functions.

First attempt: tokio::sync::RwLock

use tokio::sync::RwLock;

pub struct Config {
    pub qb: RwLock<QbConfig>,
    // ...
}

pub struct QbConfig {
    pub host: String,
    // ...
}

This works fine for async reads/writes, but it’s hard to serialize/deserialize because tokio::RwLock<T> doesn’t implement Serialize / Deserialize.


Second attempt: std::sync::RwLock

use std::sync::RwLock;

pub struct Config {
    pub qb: RwLock<QbConfig>,
    // ...
}

pub struct QbConfig {
    pub host: String,
    // ...
}

This is easier to work with in terms of Serde, but I quickly hit another issue:
std::sync::RwLockReadGuard is not Send, so I can’t hold the guard across .await. For example:

let qb_config: RwLockReadGuard<QbConfig> = qb_config(); // from OnceLock<Config>
async_fn(&qb_config.host).await; // error: non-Send across await

Workaround: use Arc<str>

I changed the field type to Arc<str> so I can cheaply clone it out of the lock before calling async code:

pub struct QbConfig {
    pub host: Arc<str>,
    // ...
}

And then:

let host = {
    let qb_config = qb_config();
    qb_config.host.clone()
};
async_fn(host).await;

This works, but it feels clunky.


Question

Are there more idiomatic approaches or best practices for this scenario?

  • A Config struct loaded once, rarely updated, mostly read.
  • Needs to be serializable/deserializable.
  • Should be easy to use inside async functions without awkward Arc<str> tricks.

There might be something in serde_with for this, but writing your own deserialisation function for the Config::qb field is not too complex or much boilerplate:

use serde::Deserialize;
use serde::de::Deserializer;
use tokio::sync::RwLock;

#[derive(Deserialize, Debug)]
pub struct Config {
    #[serde(deserialize_with = "qb_config")]
    pub qb: RwLock<QbConfig>,
    // ...
}

#[derive(Deserialize, Debug)]
pub struct QbConfig {
    pub host: String,
    // ...
}

fn qb_config<'de, D>(d: D) -> Result<RwLock<QbConfig>, D::Error>
where
    D: Deserializer<'de>,
{
    let qb = QbConfig::deserialize(d)?;
    Ok(RwLock::new(qb))
}

fn main() {
    let json = r#"{ "qb": { "host": "localhost" } }"#;

    let config: Config = serde_json::from_str(json).unwrap();

    println!("{config:?}");
}

Playground.

Surely implementing my own deserialisation is not too complex, but serialisation is relatively hard because it requires getting the read lock, and in tokio::sync::RwLock it's asynchronous. How to Serialize/Deserialize an async_std::sync::RwLock where T: Serialize + Deserialize

Tokio's RwLock has a blocking_read method. You'd need to do serialisation from within a synchronous context though, e.g. via spawn_blocking.

1 Like

This is indeed a feasible solution, but my lock contention isn't very high, and I'm not very keen on introducing spawn_blocking, as it would add unnecessary overhead. Therefore, I think this issue is worth further discussion.

I'm relatively new to Rust, so I'm curious about the idiomatic rust solution to the configuration structure design. In other words, this post focus on how to design a nice configuration structure in async context.

Thanks for your reply!

You could use two different structs for your Config, one for runtime and one for serialization, and make the runtime Config give you a copy you can serialise (if you want to make it a view type, you're going to need to lock first and make a view in a second step, because self-referential limitation prevents returning a lock guard and inner references at the same time).

You could serialise the inner part of config separately after locking it.

You could write a manual implementation of Serialize for the Config that locks the lock (in a blocking way) and serializes.

You could use ArcSwap to update the config. It supports serde

3 Likes

I would go with Arc but not for fields, but for the whole configuration struct:

use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Config {
    some_field: String,
}

static CONFIG: Mutex<Option<Arc<Config>>> = Mutex::new(None);

fn get_config() -> Arc<Config> {
    CONFIG.lock().unwrap().clone().unwrap()
}

fn update_config(config: Config) {
    *CONFIG.lock().unwrap() = Some(Arc::new(config));
}

fn example_config_user() {
    let config = get_config();
    println!("Config: {field}", field = config.some_field);
}

fn main() {
    let config = Config { some_field: "Hello, from config".to_string() };
    update_config(config);
    example_config_user();
}

The only downside is that you can't know for sure when all threads and tasks will use an updated configuration. But the world is inconsistent anyway, and perhaps it's better to be prepared for that.

I'd argue you shouldn't be holding the config lock over .await anyway since often those can block practically indefinitely waiting on a network client, which sounds like a good way to have a denial of service vulnerability.

That's a really good method by the way, ideal for rare writes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.