-
Notifications
You must be signed in to change notification settings - Fork 225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sync ReadLimiter #201
Sync ReadLimiter #201
Conversation
Thanks! Do you know which platforms support |
capnp/src/private/arena.rs
Outdated
} | ||
|
||
#[inline] | ||
pub fn can_read(&self, amount: u64) -> Result<()> { | ||
let current = self.limit.get(); | ||
if amount > current { | ||
let read = self.read.fetch_add(amount, Ordering::Relaxed) + amount; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This potentially allows self.read
to overflow and wrap around.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought about it, but it also return an error at first if it reaches the limit, so I would expect this to be an undefined behaviour to call it afterward.
My first implementation was a load + cas, but that would be definitely less performant.
We could also have a second AtomicBool that indicates that we reached the limit and return right away. That could potentially be over the limit, but decreases odds of overflow.
Makes sense. I couldn't find the list of targets that this crate was tested against. Could be a good idea to add tests, or even just check compilation against them (ex: using cross) in CI. How you would go about it ? We could have a different implementation for platforms that don't support it. It could also be a feature to support Sync that would need to be explicitly enabled, or fallback to non-sync implementation if it's not a supported platform. Let me know ! |
Some options:
|
Another option would be to use In both In the 3rd option, if I understand correctly, you'd move the read limiting responsibility to the |
@dwrensha Pushed an updated version that uses |
Thanks! Having thought about this some more, I think I prefer option 2 above, i.e. switching to AtomicU32. You are correct that it would be a breaking change, so it would require a version bump. We would probably want to wait until we have other pending breaking changes, so things can be batched together and we minimize version churn. I'm not sure when that would be, but historically it has been something like every six months. If we want to quickly land a fix/workaround, then maybe your feature flag is the way to go. I hesitate because I don't like adding this kind of complexity, but maybe it's worth it. Are you able to workaround the problem on your end by sharing an |
Unfortunately, sharing segments and creating a |
I was curious about which targets support which atomics, so I tried cross compiling the following program on a sampling of targets: #![no_std]
pub fn foo() {
let x = core::sync::atomic::AtomicUsize::new(0);
let x = core::sync::atomic::AtomicU64::new(0);
let x = core::sync::atomic::AtomicU32::new(0);
} targets that support
|
I think Looks like That's why I think having a feature flag to disable the sync reader is the best option to support all cases correctly, at the expense of conditional code. Let me know if you want me to switch to |
@dwrensha What's your thoughts on this? Do you think we could get this done? Otherwise I need to plan a refactor or an alternative on my side since I can't publish without this. |
My current feeling is that the best solution to this and a bunch of other problems will be to expose inner The main obstacle is that getting this to work (particularly with In the meantime, it could make sense to land a targeted temporary fix, like what you've proposed here. I don't like adding complexity when it seems like there is a simplifying solution within reach, but maybe it's worth it in this case. |
Sounds good for the arena as a type parameter. Eager to see GATs landing too at some point, as it will also help simplify some code on my side too. As for this temporary fix, from my opinion, since it's pretty constrained and isn't exposed to the users (other than the new feature), I think it's a good trade-off to get the reader |
capnp/src/private/read_limiter.rs
Outdated
|
||
#[inline] | ||
pub fn can_read(&self, amount: usize) -> Result<()> { | ||
let read = self.read.load(Ordering::Relaxed) + amount; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks to me like this can overflow if self.read.load(Ordering::Relaxed) + amount > core::usize::MAX
.
Why not write this is the same way as the non-Sync
version, with a single limit: AtomicUsize
field that decreases on each call? I think overflows and underflows are easier to avoid in that case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, unless I don't see an obvious solution here, the way it's done in the non-sync version is possible because it's single threaded. In a multi-threaded version, I need to make sure that the limit underflowing is handled correctly. If I was to get then sub without checking underflow, the limit would wrap around and allows the next readers to read usize::MAX
.
I think the version I pushed is better and handle this better than my previous version. I use an extra flag to indicate that the limit has been reached when the limit got to 0 or when it underflows.
capnp/Cargo.toml
Outdated
@@ -25,7 +25,7 @@ quickcheck = { version = "0.9", optional = true } | |||
quickcheck = "0.9" | |||
|
|||
[features] | |||
default = ["std"] | |||
default = ["std", "sync_reader"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer to make the new feature not a default feature, so that we minimize the chance of breaking anyone's code.
capnp/src/private/read_limiter.rs
Outdated
|
||
pub struct ReadLimiter { | ||
pub limit: usize, | ||
pub read: AtomicUsize, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Making this an AtomicU64
would more closely match the current implementation, which uses a u64
. I'm worried that someone might be setting the current limit to a large u64
value, and when this new feature gets enabled their code will break.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue with AtomicU64
, though, is that it is supported on fewer platforms. I don't see a nice solution here...
capnp/src/private/read_limiter.rs
Outdated
} | ||
|
||
let prev_limit = self.limit.fetch_sub(amount, Ordering::Relaxed); | ||
if prev_limit == amount { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this branch necessary? When prev_limit == amount
, the current limit will be 0, and therefore we'll get an error on the next attempt to read.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, it's not necessary
capnp/src/private/read_limiter.rs
Outdated
if prev_limit == amount { | ||
self.limit_reached.store(true, Ordering::Relaxed); | ||
} else if prev_limit < amount { | ||
self.limit_reached.store(true, Ordering::Relaxed); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the goal is to cause future reads to fail too, then I think we could do
set.fetch_add(amount, Ordering::Relaxed)
here and not need to worry about adding a limit_reached
flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't this just resets the value to what it was right before the fetch_sub
?
If we want to use a single variable, we could set the value to 0 here when fully consumed (since the new value after fetch_sub
may have underflow) and check if we have enough remaining limit before reading on each call.
Friendly ping @dwrensha. Let me know if there is anything I can do to land this faster :) |
I would like get this pull request landed, but I think we first need to resolve the I think a satisfactory path forward would be:
How does that sound? Relatedly, I've been working on the arena type parameter approach (work in progress in #210), and while I've made some progress and learned some things, I would no longer describe a solution as "within reach" using that approach, because there are a lot of details to be worked out, and things generally look complicated. |
I agree for the Steps 1-2-3 makes sense. Step 4-5 are confusing me here. Which limit are we comparing against if the Quite massive refactor you have in #210. I like the removal of the higher-ranked trait bounds. I also don't think it is within reach because there is no clear roadmap for when GATs will actually be stabilized. There are still a few issues to be tackled according to the tracking issue. |
To an outside observer, yes. What I'm trying to describe is an optimization. Sorry, pub fn can_read(&self, amount: usize) -> Result<()> {
let current = self.limit.get();
if amount > current && self.error_on_limit_exceeded {
Err(Error::failed(format!("read limit exceeded")))
} else {
self.limit.set(current.wrapping_sub(amount));
Ok(())
}
} So when no limit is enabled, the Compare to this naive version, where pub fn can_read(&self, amount: usize) -> Result<()> {
if let Some(current) = self.limit.get() {
if amount > current {
Err(Error::failed(format!("read limit exceeded")))
} else {
self.limit.set(current - amount);
Ok(())
}
}
} (Of course, it's possible that this optimization won't have a noticeable effect. It seems easy enough, though, to be worth doing.) |
I'm wondering if the sync version should do the same though. The atomic operations are probably most costly than having the extra branching. So disabling the limit should probably try to avoid the atomic Which benchmark do you think would be impact the most here? I could probably bench the 2 alternatives. |
I suppose I mostly care about avoiding performance regression on the non-sync version. You could try |
(also, fyi, I've bumped the version numbers in preparation for landing this and other breaking changes: f40b6dc) |
Thanks for the pull request and discussion! I am going to merge this and then add a few changes. |
Sorry for the radio silence. Was planning to do the changes over the weekend. But I'm happy that this got merged! Saw the other changes you merged on master too. Can't wait for the release! |
I looked at a few benchmarks myself. My findings were:
You are welcome to look into benchmarks too and to propose changes. My plan is to release 0.14 later today or tomorrow. |
As mentioned in #191 and #121,
capnp::message::Reader<capnp::serialize::OwnedSegments>
isn'tSync
because of theCell<u64>
in theReadLimiter
.This solution will only work with platforms that have
AtomicU64
and only works from 1.34 onward. This modification could be put behind a feature if this affects any user of the crate.Let me know !