crossbeam-rs/crossbeam-epoch

Possible memory leak in crossbeam

rolag opened this issue · 9 comments

rolag commented

Unless I'm misunderstanding something in this crate, when I compile and run this simple gist in valgrind, I get the following "possibly lost" and "still reachable" leaks:

==14743== LEAK SUMMARY:
==14743==    definitely lost: 0 bytes in 0 blocks
==14743==    indirectly lost: 0 bytes in 0 blocks
==14743==      possibly lost: 25,440 bytes in 82 blocks
==14743==    still reachable: 848,610 bytes in 1,323 blocks
==14743==         suppressed: 0 bytes in 0 blocks
==14743== 
==14743== For counts of detected and suppressed errors, rerun with: -v
==14743== ERROR SUMMARY: 4 errors from 4 contexts (suppressed: 0 from 0)

These numbers only seem to increase as the number of loop iterations increases.
The full valgrind log is in the gist.

Running rustc 1.26.0-dev (nightly) with the system allocator on Linux.

Threads spawned in main are never joined, which means they'll be forcibly killed at the end of the program. This is a big source of leaks. I suggest doing the following instead:

fn main() {
    crossbeam::scope(|scope| {
        for _ in 0..100 {
            scope.spawn(test);
        }
    });
}

Another problem is that the default collector is never dropped since it is a global variable. If you create a fresh collector in test (with let c = epoch::Collector::new() and replace epoch::pin() with c.handle().pin(), there will be no leaks.

I wonder if we could/should somehow clean up memory from the default collector when all threads exit. Maybe we should clear the garbage queue when all handles to the global collector are gone?

cc @jeehoonkang

Alternatively you could just join() the threads in a second loop after having spawned them. This should eliminate all the "possibly lost" memory. Having some memory as "still reachable" when the program exits is fine.

@stjepang I don't feel too strongly either way about cleaning up the garbage queue at exit. On one hand it would be "cleaner" to do so, but then again nobody cares if you don't free memory when the process is about to be killed anyways. In any case this only affects the global Collector which is initialized with lazy_static!, custom collectors should be properly cleaned up once their reference count reaches zero.

rolag commented

You're right, using scopes and a local collector seems to give no more "possibly lost" leaks.
I'll close this issue then.

Strictly speaking, defer()rered functions are not guaranteed to be called, so it is not the case that the default collector must call the deferred functions.

But users will expect they will be called on best-effort basis, which can also be practically important because some objects can be bound to resources other than memory (e.g. database connections) and should be properly closed, at least on best-effort basis.

So I'm in favor of calling all the remaining deferred functions when the last Handle is dropped. Currently I don't have any good implementation idea, though..

@jeehoonkang What is the reason that their being called isn't guaranteed? I presume it's a performance issue with guards and pinning? If calling them when the Collector itself is dropped, then maybe a better guarantee would be that they're guaranteed to be called before the Collector is dropped, but not necessarily any sooner. It retains the performance because they can still outlive a guard, but give stronger guarantees to reason about.

@joshlf Simply because Rust doesn't guarantee drop() is called. See http://huonw.github.io/blog/2016/04/memory-leaks-are-memory-safe/ for more details. In short, while drop() is not guaranteed to be called, it is called on best-effort basis. My argument is: so should we.

Except that, in Rust, the cases in which drop isn't guaranteed to be called are well-defined: if you have reference cycles or use mem::forget. By knowing the rules of whether drop will be called, you can still guarantee it.

@joshlf Do you have a reference for that? It'll be very useful to know exactly when drop will not be called!

Well drop is guaranteed to be called on any local variable when it goes out of scope. That's really all you need to know - everything else is done using data structures that use unsafe (e.g., Rc and Arc), so it's up to the author to document the behavior of their data structures. Even ManuallyDrop isn't special-cased by the compiler - it's just a union, which is unsafe (mem::forget is implemented using ManuallyDrop).