I am completely new to Rust (started yesterday) and I'm trying to ensure I've understood correctly. I am looking to write a configuration system for a 'game', and want it to be fast access but occasionally mutable. To start, I wanted to investigate localization which seemed a reasonable use case for static configuration (as I appreciate such things are generally not 'Rusty' otherwise). I came up with the following (working) code, based in part on this blog post (found via this question). I've included here for reference, but feel free to skip it over for now...
#[macro_export]
macro_rules! localize {
(@single $($x:tt)*) => (());
(@count $($rest:expr),*) => (<[()]>::len(&[$(localize!(@single $rest)),*]));
($name:expr $(,)?) => { LOCALES.lookup(&Config::current().language, $name) };
($name:expr, $($key:expr => $value:expr,)+) => { localize!(&Config::current().language, $name, $($key => $value),+) };
($name:expr, $($key:expr => $value:expr),*) => ( localize!(&Config::current().language, $name, $($key => $value),+) );
($lang:expr, $name:expr $(,)?) => { LOCALES.lookup($lang, $name) };
($lang:expr, $name:expr, $($key:expr => $value:expr,)+) => { localize!($lang, $name, $($key => $value),+) };
($lang:expr, $name:expr, $($key:expr => $value:expr),*) => ({
let _cap = localize!(@count $($key),*);
let mut _map : ::std::collections::HashMap<String, _> = ::std::collections::HashMap::with_capacity(_cap);
$(
let _ = _map.insert($key.into(), $value.into());
)*
LOCALES.lookup_with_args($lang, $name, &_map)
});
}
use fluent_templates::{static_loader, Loader};
use std::sync::{Arc, RwLock};
use unic_langid::{langid, LanguageIdentifier};
static_loader! {
static LOCALES = {
locales: "./resources",
fallback_language: "en-US",
core_locales: "./resources/core.ftl",
// Removes unicode isolating marks around arguments, you typically
// should only set to false when testing.
customise: |bundle| bundle.set_use_isolating(false)
};
}
#[derive(Debug, Clone)]
struct Config {
#[allow(dead_code)]
debug_mode: bool,
language: LanguageIdentifier,
}
#[allow(dead_code)]
impl Config {
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.read().unwrap().clone())
}
pub fn make_current(self) {
CURRENT_CONFIG.with(|c| *c.write().unwrap() = Arc::new(self))
}
pub fn set_debug(debug_mode: bool) {
CURRENT_CONFIG.with(|c| {
let mut writer = c.write().unwrap();
if writer.debug_mode != debug_mode {
let mut config = (*Arc::clone(&writer)).clone();
config.debug_mode = debug_mode;
*writer = Arc::new(config);
}
})
}
pub fn set_language(language: &str) {
CURRENT_CONFIG.with(|c| {
let l: LanguageIdentifier = language.parse().expect("Could not set language.");
let mut writer = c.write().unwrap();
if writer.language != l {
let mut config = (*Arc::clone(&writer)).clone();
config.language = l;
*writer = Arc::new(config);
}
})
}
}
impl Default for Config {
fn default() -> Self {
Config {
debug_mode: false,
language: langid!("en-US"),
}
}
}
thread_local! {
static CURRENT_CONFIG: RwLock<Arc<Config>> = RwLock::new(Default::default());
}
fn main() {
Config::set_language("en-GB");
println!("{}", localize!("apologize"));
}
I've not included the tests for brevity. I would welcome feedback on the localize
macro too (as I'm not sure whether I've done that right).
Arc
cloningHowever, my main question is on this bit of code in particular (there is a similar example in set_language
too):
pub fn set_debug(debug_mode: bool) {
CURRENT_CONFIG.with(|c| {
let mut writer = c.write().unwrap();
if writer.debug_mode != debug_mode {
let mut config = (*Arc::clone(&writer)).clone();
config.debug_mode = debug_mode;
*writer = Arc::new(config);
}
})
}
Although this works, I want to ensure it is the right approach. From my understanding it
Arc::clone()
on the writer (which will automatically DeRefMut
the parameter to an Arc before cloning). This doesn't actually 'clone' the struct but increments the reference counter (so should be fast)?Config::clone
due to step 3 being wrapped in (*...) - is this the right approach? My understanding is this does now clone the Config
, producing a mutable owned instance, which I can then modify.debug_mode
.Arc<Config>
from this owned Config
.Arc<Config>
(potentially freeing the memory if nothing else is currently using it).If I understand this correctly, then only one memory alloc will occur in step 4. Is that right? Is step 4 the right way to go about this?
Similarly, this code:
LOCALES.lookup(&Config::current().language, $name)
Should be quick under normal use as it uses this function:
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.read().unwrap().clone())
}
Which gets a ref-counted pointer to the current config, without actually copying it (the clone()
should call Arc::clone()
as above), using a read lock (fast unless a write is occurring).
thread_local!
macro useIf all that is good, then great! However, I'm then stuck on this last bit of code:
thread_local! {
static CURRENT_CONFIG: RwLock<Arc<Config>> = RwLock::new(Default::default());
}
Surely this is wrong? Why are we creating the CURRENT_CONFIG as a thread_local
. My understanding (admittedly from other languages, combined with the limited docs) means that there will be a unique version to the currently executing thread, which is pointless as a thread cannot interrupt itself? Normally I would expect a truly static RwLock
shared across multiple thread? Am I misunderstanding something or is this a bug in the original blog post?
Indeed, the following test seems to confirm my suspicions:
#[test]
fn config_thread() {
Config::set_language("en-GB");
assert_eq!(langid!("en-GB"), Config::current().language);
let tid = thread::current().id();
let new_thread =thread::spawn(move || {
assert_ne!(tid, thread::current().id());
assert_eq!(langid!("en-GB"), Config::current().language);
});
new_thread.join().unwrap();
}
Produces (demonstrating that the config is not shared across thread):
thread '<unnamed>' panicked at 'assertion failed: `(left == right)`
left: `LanguageIdentifier { language: Language(Some("en")), script: None, region: Some(Region("GB")), variants: None }`,
right: `LanguageIdentifier { language: Language(Some("en")), script: None, region: Some(Region("US")), variants: None }`
The section of the blog post you are referring to is, in my opinion, not very good.
You are correct that RwLock
here is bogus - it can be replaced with a RefCell
as it is thread local.
The justification for the approach in the blog post is flimsy:
However, in the previous example we introduced interior mutability. Imagine we have multiple threads running, all referencing the same config, but one flips a flag. What happens to concurrently running code that now is not expecting the flag to randomly flip?
The entire point of a RwLock
is that modifications cannot be made while the object is locked for reading (i.e. the RwLockReadGuard
returned from RwLock::read()
is alive). So an Arc<RwLock<Config>>
won't have your flags "randomly flipped" while a read lock is taken out. (Granted, it can be an issue, if you release the lock and take it again, and assume the flag has not changed in the meantime.)
The section also doesn't specify how an update to a configuration would actually take place. You'd need a mechanism to signal the other threads that a config change had taken place (ex. a channel) and the threads themselves would have to update their own thread-local variable with the new configuration.
Ultimately, I'd just consider that section as bad advice, and certainly not tailored to a beginner.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With