-
Notifications
You must be signed in to change notification settings - Fork 8.3k
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
Introduce til/latch.h, til/mutex.h and til/throttled_func.h #10403
Conversation
This comment has been minimized.
This comment has been minimized.
1309b72
to
211c67b
Compare
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.
make sure you're adding tests for the til things you're adding 😄 wherever easily possible of course
211c67b
to
9ff7fc9
Compare
I've rebased this PR to resolve:
No further changes were made. |
This comment has been minimized.
This comment has been minimized.
} | ||
|
||
private: | ||
mutable T _data{}; |
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 didn't expect _data to be mutable. Now there's no difference between const and non-const shared_mutex because all data members are mutable and all member functions are const.
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.
That's exactly right! This is because this mutex class encapsulates not just the data, but also const-correctness! In Rust this concept is called "interior mutability".
The mutex ensures const-correctness, by enforcing that only a single writer at a time can acquire a non-const reference to the inner (protected) data using a call to .lock()
. If no routine has currently .lock()
ed the mutex, multiple const references to the inner data can be created, by calling .lock_shared()
. That's why _data
is mutable
: The mutex itself controls const-correctness.
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.
BTW I also tested today whether there's any difference in the generated assembly between using mutable
and not and there doesn't seem to be any.
But this isn't really surprising anyways: As usual C++ continues to not have any rules forbidding aliasing non-const references, which sucks 🍑. Meaning: Every time (if on MSVC without strict aliasing support) or almost every time (with strict aliasing) you dereference a value in C++ you fetch it's value from memory, because the compiler can't prove you didn't change it as a side-effect somewhere else. In C++ every pointer is basically something of a 50%-volatile
pointer. So yeah... mutable
doesn't make it worse, because it's already suboptimal by default lol.
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.
Oh, I wasn't thinking about aliasing and code generation. Rather, I was thinking about having a til::shared_mutex mutex
data member in some type X. Even const
member functions of X can then exclusively lock and mutate the data within X::mutex
. It seems to me that, if the const
member functions of X
need to do that, then having X::mutex
itself be mutable would make the code easier to understand. I am not familiar with Rust, though.
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.
Edit: This is a long answer, the short one is below
My first comment explains the reasoning why this mutex works even if in const member functions of X.The gist of it was:
Imagine a programmer accidentally uses .lock_shared()
in a non-const method and modify the data protected by the mutex! All hell would break loose, because you just created a race condition.
By tying const-ness to the way you locked (exclusive/shared) you can get much better safety instead.
When you use .lock_shared()
with this mutex you can never get a non-const reference and accidentally modify the data.
But let's talk about your concern:
- If you use a class instance across threads in, the sharing must occur using constant references or a
std::shared_ptr<const T>
. Why? Well if your class has any methods that aren't thread-safe, all such methods must be marked const, otherwise you risk a race condition. To ensure this safety, cross-thread sharing must always use const-ness.... technically. I know the real world works differently, but that's really just an unfortunate of C++ age, isn't it? - One solution would be to make the
til::shared_mutex
member of the class itselfmutable
, allowing you to call.lock()
, like you said. But this would run counter to the very idea this class represents: It encapsulates cross-thread shared state and the const-ness of its data, by the way you locked it, and not by whether the caller is const or not. In fact it doesn't matter whether the caller isconst
, because the caller should always beconst
when this class is used (due to the prior point). But yeah, I could add themutable
keyword anywhere this class is used instead for sure, but it would certainly be against the concept and ideals this class represents. Especially since many probably agree that themutable
keyword is almost as bad asconst_cast
itself. I mean it does the same thing after all. Personally I'd rather have this anti-pattern encapsulated just once, which is why I did it this way...
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.
Damn.... Now that I wrote all this I realize a much much shorter answer:
You cannot have any mutable references to a class instance once it's potentially shared across threads.
So why bother making this mutex work with the old C++ concept of "the caller's constness determines the callee's constness", when the former must always be const anyways?
@DHowett Unit tests added! ✔️ |
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.
Just a couple questions!
winrt::Windows::UI::Core::CoreDispatcher _dispatcher; | ||
function _func; | ||
wil::unique_threadpool_timer _timer; | ||
til::details::throttled_func_storage<Args...> _storage; |
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 is technically a violation of til
's private space by an external library. Is there a benefit to replacing the currently implementation of ThrottledFunc with this new one? Or should we just leave the current one alone (move it to WinRTUtils)?
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 see that it cleverly splits the implementation into leading/trailing edge, and I do appreciate that it is cleaner.
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 added some improvements to til::details::throttled_func_storage
, which weren't in ThrottledFuncStorage
. As I consider ThrottledFunc
a bit of a hack, I also considered it fine to use the private namespace.
I can definitely add ThrottledFuncStorage
back!
throw std::invalid_argument("non-positive delay specified"); | ||
} | ||
|
||
memcpy(&_delay, &d, sizeof(d)); |
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 seems like an overly clever trick. Does the compiler not generate a sufficient copy when you use _delay = d
?
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.
(Tricks like this seem like a micro-optimization, and micro-optimizations aren't bad but they do deserve some scrutiny and discussion about their specific purpose 😄)
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.
Actually _delay
is a FILETIME
and d
is a int64_t
.
I considered making _delay
an int64_t
as well, but this would require a reinterpret_cast
between two unrelated types, which is technically undefined behavior and could break with any VS upgrade.
C++20 introduced std::bit_cast
which would allow me to write _delay = std::bit_cast<FILETIME>(-delay.count())
, but we don't have C++20 enabled yet (std::bit_cast
is basically memcpy
, but from the compiler and constexpr
).
// Makes sure that the currently pending timer is executed | ||
// as soon as possible and in that case waits for its completion. | ||
// You can use this function in your destructor to ensure that any | ||
// pending callback invocation is completed as soon as possible. |
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.
In the original implementation:
- If the callback was mid-firing while the caller gave up on it, it would keep itself alive and finish.
- If the callback had been scheduled, but not called, while the caller gave up on it, it would not fire.
- If the callback had not been scheduled, and the caller gave up on it, it would not fire.
This seems like an implementation that's different in an important way:
In cases 1 and 2, the callback will always complete. You must wait for it during destruction, and the throttled_func must remain alive for the callback's complete lifetime (scheduling + execution)...?
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.
Maybe I'm misunderstanding you, but the difference is:
When the new throttled_func
is dropped/destroyed it'll cancel any timers automatically. If a callback is currently being executed in a background thread, the throttled_func
will synchronously block in the destructor until the callback finished. It would be quite bad if you hold a mutex, while you destroy a throttled_func
...
|
||
FILETIME _delay; | ||
function _func; | ||
wil::unique_threadpool_timer _timer; |
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.
Does the destruction of a unique_threadpool_timer deschedule it?
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.
Yep! The destructor of wil::unique_threadpool_timer
calls WaitForThreadpoolTimerCallbacks(_timer, true)
and thus cancels any pending timers.
mutable std::shared_mutex _lock; | ||
std::optional<std::tuple<Args...>> _pendingRunArgs; |
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.
Did you consider making this use til::mutex?
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 did, but I wanted to keep both header files independent of each other and keep their template complexity minimal. Would you suggest that I use til::shared_mutex
here?
constexpr bool ends_with(const std::basic_string_view<T, Traits> str, const std::basic_string_view<T, Traits> prefix) noexcept | ||
{ | ||
#ifdef __cpp_lib_ends_ends_with | ||
#error This code can be replaced in C++20, which natively supports .ends_with(). |
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 it more meaningful to smack developers who try to switch to C++20, or simply call ends_with
ourselves? 😉
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.
SMACK 'EM! 👊
Jokes aside: Maybe a #warning
? The idea behind the #error
is that if anyone ever upgrades to C++20, they should be able to remove the #error
line, if they believe that's appropriate.
What's your opinion about this?
Hello @DHowett! Because this pull request has the p.s. you can customize the way I help with merging this pull request, such as holding this pull request until a specific person approves. Simply @mention me (
|
This commit introduce three new `til` features: * "til/latch.h": A std::latch clone, until we're on C++20. * "til/mutex.h": A safe mutex wrapper, which only allows you access to the protected data after locking it. No more forgetting to lock mutexes! * "til/throttled_func.h": Function invocation throttling used to be available as the `ThrottledFunc` class already. But this class is vastly more efficient and doesn't rely on any WinRT types. This PR also adds a `til::ends_with` string helper which is `til::starts_with` counterpart. * Scrollbar throttling still works as it used to ✔️ * No performance regressions when printing big.txt ✔️ Closes #10393 (cherry picked from commit 0d9a357)
🎉 Handy links: |
This commit introduce three new
til
features:ThrottledFunc
class already. But this class is vastly more efficient and doesn't rely on any WinRT types.This PR also adds a
til::ends_with
string helper which istil::starts_with
counterpart.Validation Steps Performed
Closes #10393