4 ms·
The thing is, you talk about how blocking implicitly is bad, but I didn't say "it's good to block in the destructor" as a blanket rule either. I'm talking about
by dataflow 6y ago
The thing is, you talk about how blocking implicitly is bad, but I didn't say "it's good to block in the destructor" as a blanket rule either. I'm talking about this very particular case of blocking deserves to be in the destructor, for what I thought were fairly decent reasons that I spelled out. You haven't actually addressed my points about threads specifically and are just talking about blocking in a destructor in general, but the discussion/disagreement isn't about other cases. There are definitely times when I think it'd be wrong to block implicitly, but this just isn't one of them.
- gpderetta 6y agoNot the parent, but mine is not a theoretical argument. I work on an highly threaded application. Many components spawn threads, send messages to other components running on other threads and wait for messages or events from other components. When you have base classes and members all blocking in their destructors is very easy to end up with deadlocks or race conditions just by changing the order of members or base classes and things like that. By having an explicit (asynchronous) shutdown phase this can be avoided. There have been some talk in the committee about having async destructors. That might be the solution to the problem.
- dataflow 6y agoSo let me see if I understand this in a simplified scenario. You're saying your code might be something like: class X { void inform_shutdown(); void run(); }; class A { std::jthread t; }; class B { X x; ~B() { x.inform_shutdown(); } }; class C { A a; B b; }; and initially all is fine, because A's destructor only runs after B is destroyed, and thus messages are exchanged before ~A blocks on the thread, but then the code eventually involves to something like: class C { B b; A a; }; and suddenly now ~B can't send shutdown messages and hence the thread won't know to exit in the first place. Is this an example of what you're saying? To me, it sounds like the underlying issue is something else here. It's hard to say without a concrete example, but if the above is accurate, it might be that a hidden dependency that needs to be made explicit. If X::run is running (say) an infinite work loop on its own thread, it is coupled to that thread in some fashion. For example, it might have some kind of message queue to pull from, and that queue belongs to the thread (i.e. you would put the message queue in A). That means X needs a dependency on A just as much as C does, meaning you'd want to hold std::shared_ptr<A> instead of A, and hence you won't have this blocking issue anymore because it will only occur when the last user of A is destroyed. Obviously your scenario might have some differences from this, but it seems quite plausible to me that the problem might have a different solution than non-joining threads.
- gpderetta 6y agoYes, there are complex dataflow (ah!) dependencies between heterogeneous threads, often bidirectional. Using shared pointers would means trading potential deadlocks with potential leaks due to circular dependencies (I'm not saying it is worse, but it is a tradeoff).
- dataflow 6y agoI'd argue there's no tradeoff there. Cyclic references are also avoidable (and should be avoided) in general in my experience. How to achieve that again requires a specific example, as there's no one-size-fits-all-solution. It's great that we got here actually, because it demonstrates how higher level constraints that seem so rigid and impenetrable actually stem from simpler design considerations like these that are often disregarded in the beginning.
- gpderetta 6y agoOh, we solve it all right. We have single blocking point for business level code [1] at the top level of the program. Everything else is asynchronous and it is done by future combining. [1] We also block on each thread executor event loop (at least those that are not spinning) of course, but that's hidden from the business level logic.
- simiones 6y agoI think the arguments I presented do hold for thread joining as well as other cases: it's less clear from the code that this: int main(int argc, char[][]argv) { std::jthread t([](){ volatile int a; while(true) a=1; }); } will block forever, than it is for this: int main(int argc, char[][]argv) { std::jthread t([](){ volatile int a; while(true) a=1; }); t.join(); } //using the volatiles since while(true); is UB, nothing to do with threading This is even more true for a class that has a std::jthread member. I've also asserted that the api of std::jthread::join is not great, that it would have been pereferable to have a mandatory timeout parameter, so that someone calling it would be forced to think about an appropriate timeout, since blocking forever is rarely the right choice (of course, you would have a constant for blocking forever as well - this is not about preventing this behavior, just slightly discouraging it). If you agree that blocking forever waiting for a thread to join is in itself something to be discouraged (not made impossible, but also not the default), then it also follows that having a destructor block at all should also be avoided in the std lib. I do want to emphasize that I'm not claiming that there are no cases where this sort of behavior is useful, just that it is more rarely the best choice. I also believe in the idea that language/library design should nudge users into the right direction, so that the better use case is also the easier one to write.
- dataflow 6y ago> it's less clear from the code that this will block forever Sure! That doesn't imply that's a bad thing. It's not like "clarity" is transitive. Do all your callers know when you're calling lock_guard() for example? No. Should they? No. > than it is for this: You actually sacrifice correctness here for "clarity". What happens if you return control flow returns to the caller before join() is called, whether normally (early return, etc.) or via an exception? Suddenly you have a thread that is out of your control, and the caller has absolutely no guarantees about any aspect of it. At best, your program might terminate in the middle of that thread's work, and wreck whatever it was doing (maybe it was working on some file or communicating with another system). At worst, your code was called in a loop and you actually bring down the whole system, e.g. by launching new threads before the old ones have terminated. It's a massive bug to return with rogue threads. > If you agree that blocking forever waiting for a thread to join is in itself something to be discouraged (not made impossible, but also not the default), then it also follows that having a destructor block at all should also be avoided in the std lib. What I'm saying is this isn't true. Blocking should be discouraged in most cases, but this case is specifically an exception to that. Just like how "calling 'delete' directly should be discouraged" has obvious exceptions like "except in the destructor of a smart pointer". And just like how "calling mutex::unlock() directly should be discourage" has obvious exceptions like "except in the destructor of a lock guard". Your reasoning simply doesn't follow, and for good reason. Moreover, I also beg to differ on the notion that "every blocking operation should have a timeout"; that's true in certain scenarios such as when you're waiting for a component in a distributed system (like an event on another process or machine that might go down without guaranteed notification to you), but quite false in general. Just look at lock_guard... it has no timeout, and it's used all over the place. Moreover, any operation you do that involves a heap allocation or deallocation can block indefinitely with no timeout, and it's perfectly fine. And if you use Windows, look for example at how GetMessage() has no timeout, and note that it's used in pretty much every GUI program for messaging within threads, between threads, and between processes. I could cite more examples but hopefully this illustrates what I mean. The sense I'm getting from your arguments is that you simply don't believe and/or trust in 'scoped' operations. I don't know how to convince you here, but if you scope your operations match destructors to constructors like they're supposed to be, some things that could go wrong simply won't go wrong. Because, more abstractly, this lets you decompose your program into a tree instead of a DAG, and all the niceness that comes with that just comes automatically.