I’m trying to manage some async tasks (usually timers) that correspond to structs I create. Sometimes I want to delete all my data and start over, which is when I want the timers to close. I thought it would be a good idea to create a finalizing function that closes the timers. In my program I’m using doubly linked objects since the ordering of my program is hierarchical. It seems that this somehow prevents the structs from getting garbage collected? This is an example:
abstract type AFoo end
mutable struct Foo <: AFoo
i::Int
t::Timer
ref::ABar
function Foo(i, t, ref)
f = new(i, t, ref)
finalizer(destructor, f)
return f
end
end
mutable struct Bar
i::Int
t::Timer
ref::AFoo
function Bar(i)
tfoo = Timer((t) -> println("foo"), 0, interval = 2)
tbar = Timer((t) -> println("bar"), 1, interval = 2)
b = new(i)
b.t = tbar
b.ref = Foo(i, b, tfoo)
finalizer(destructor, b)
return b
end
end
function destructor(f::Foo)
close(f.t)
end
function destructor(b::Bar)
close(b.t)
end
b = Bar(1)
b = nothing
GC.gc()
Your example currently does not execute as is. Here’s my attempted fix.
abstract type AFoo end
abstract type ABar end
mutable struct Foo <: AFoo
i::Int
t::Timer
ref::ABar
function Foo(i, t, ref)
f = new(i, t, ref)
finalizer(destructor, f)
return f
end
end
mutable struct Bar <: ABar
i::Int
t::Timer
ref::AFoo
function Bar(i)
tfoo = Timer((t) -> println("foo"), 0, interval = 2)
tbar = Timer((t) -> println("bar"), 1, interval = 2)
b = new(i)
b.t = tbar
b.ref = Foo(i, tfoo, b)
finalizer(destructor, b)
return b
end
end
function destructor(f::Foo)
@async println("Destructing Foo")
close(f.t)
end
function destructor(b::Bar)
@async println("Destructing Bar")
close(b.t)
end
b = Bar(1)
sleep(4)
b = nothing
GC.gc()
sleep(4)
If I put this into a file test.jl and then run julia test.jl I get the following output:
$ julia test.jl
foo
bar
foo
bar
foo
Destructing Bar
Destructing Foo
Sorry I indeed changed some things before posting to make the code shorter and more readable, I should’ve tested it. Now testing it myself I cannot reproduce the problem anymore. Perhaps in my own program the problem lies somewhere else and I’m keeping a reference somewhere that I’m forgetting. Is there a way to check what objects hold references to some given object?
Ah I see what happened. I first had the timer hold some reference to a field in Bar. This prevents the finalizer from being run and the task being closed, even if there are no available references to b anywhere else than in the task. The timers I’m using hold references to their structs, so I guess trying to close them using finalizers is just not the right way.
abstract type AFoo end
abstract type ABar end
mutable struct Foo <: AFoo
i::Int
t::Timer
ref::ABar
function Foo(i, t, ref)
f = new(i, t, ref)
finalizer(destructor, f)
return f
end
end
mutable struct Bar <: ABar
i::Int
t::Timer
ref::AFoo
function Bar(i)
tfoo = Timer((t) -> println("foo"), 0, interval = 2)
b = new(i)
tbar = Timer((t) -> println("bar $(b.i)"), 1, interval = 2)
b.t = tbar
b.ref = Foo(i, tfoo, b)
finalizer(destructor, b)
return b
end
end
function destructor(f::Foo)
@async println("Destructing Foo")
close(f.t)
end
function destructor(b::Bar)
@async println("Destructing Bar")
close(b.t)
end
b = Bar(1)
sleep(4)
b = nothing
GC.gc()
sleep(4)
You can try again with GC.gc(true) to force a full garbage collection. But generally you should never rely on finalizers to run. Recent thread on a similar topic:
Memory issue can also arise from different things. Julia 1.9 changed GC heuristics and as a result many people experience memory issues. If you haven’t already you should give Julia a hint with --heap-size-hint and see if things improve.
GC.gc() by default does GC.gc(true), so OP is doing that already. You’re right it’s probably a bad idea to rely on finalizers to wrap anything up besides freeing the parts of memory the GC can’t track directly. In fact, the more efficiently you allocate, the more delayed the automatic GC runs and finalizers. Manually running GC.gc() often enough to undo that delay will ruin performance.
I’m a little puzzled actually, if b=nothing removes all live references to the mutable Foo, Bar, and Timer instances, shouldn’t those be collected? GC is supposed to handle circular references too. Is this some tasks rule that I should know? Never mind I just checked and reread the post, the scheduled Timers kept running even in the first MWE where the issue doesn’t occur. It’s simply that scheduled tasks don’t need a variable in the main thread to keep them live; for example, @async while true println("foo"); sleep(1) end runs forever, and it’s not assigned to a variable to begin with. Circular references are only collected if none of them are live, so it’s bad to make a task one of them.