Background
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).
Question
Understanding Arc
cloning
However, 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
- Get's a write lock on the config Arc struct.
- Checks for changes, and, if changed:
- Calls
Arc::clone()
on the writer (which will automaticallyDeRefMut
the parameter to an Arc before cloning). This doesn't actually 'clone' the struct but increments the reference counter (so should be fast)? - Call
Config::clone
due to step 3 being wrapped in (*...) - is this the right approach? My understanding is this does now clone theConfig
, producing a mutable owned instance, which I can then modify. - Mutates the new config setting the new
debug_mode
. - Creates a new
Arc<Config>
from this ownedConfig
. - Updates the static CURRENT_CONFIG.
- Releases the reference counter to the old
Arc<Config>
(potentially freeing the memory if nothing else is currently using it). - Releases the write lock.
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?
Understanding performance implications
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).
Understanding thread_local!
macro use
If 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 }`
thread_local
does appear to fix my tests, including ensuringConfig
state is shared across threads and updatable safely, full code below (makes use of latestSyncLazy
from nightly builds though: – Perionychium(*Arc::clone(&writer)).clone()
looks like an unnecessary clone of theArc
-writer.as_ref().clone()
should achieve the same purpose without the inner clone. While cloning anArc
is cheap compared to copying an allocated type, it is not free because it involves memory barriers when manipulating the atomic counter. (The counter is updated once when creating the temporary clone of theArc
and again when it is destroyed - and those cannot be optimized away because they can be visible to other threads, so the compiler must generate both adjustments.) – CircumambientArc::_as_ref()
increment the ref count correctly? – Perionychiumas_ref()
doesn't increment the refcount at all. It gives you a&T
that is not allowed to outlive theArc
that handed it out. You can use that&T
, in this case to callT::clone()
without touching the reference count of theArc
. And the fact that the reference can't outlive theArc
guarantees that the object cannot be destructed while you're using the reference. – Circumambient