You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
concurrent.futures.ThreadPoolExecutor does not free memory when shutdown #98467
Memory allocated in threads is never freed when using a ThreadPoolExecutor. The expected behavior is that when the executor is shut down, all memory allocated in its threads should be freed. The below code demonstrates this leak in that the the memory usage before allocating memory in threads is significantly less than after.
Correct, it should be available in the main thread. The primary issue is that allocated memory is never freed though. It should be freed once futures goes out of scope or when the executor is shutdown which should occur when exciting the with block but that doesn't happen.
I believe the objects are garbage collected, but the memory isn't necessarily returned to the OS because Python uses a layer of abstraction around the system allocator. So you won't see process-level memory usage going back to the previous level.
However, if you keep doing the same thing, memory usage should stabilize and not keep growing.
@eMeena Your benchmark doesn't make sense: You're using ru_maxrss, which is the maximum memory usage (maximum over the lifetime of the program). It thus can never go down, as it does not measure the current memory usage.
makes no sense -- that's expected to be, and stay, high.
To get the current memory usage, you need to measure rss, not maxrss. For example this way with psutil.Process().memory_info().rss.
(Note I'm also coming here because my ThreadPoolExecutor.map() seems to somehow memory leak, but the method from the issue description surely isn't the way to measure that.)
I got curious about this because of my work through Parsl's ThreadPoolExecutor, which from my understanding takes advantage of CPython's ThreadPoolExecutor (Parsl/parsl#2876, cytomining/CytoTable#75). Below, I borrowed @eMeena's example and implemented @nh2's suggestion with the following to try and demonstrate what @JelleZijlstra mentioned.
"""Show how ThreadPoolExecutor might cause memory challenges"""importosimportpsutilimportgcimportrandomfromconcurrent.futuresimportThreadPoolExecutor, as_completedif__name__=="__main__":
# one-time process handle_proc=psutil.Process(os.getpid())
defcurrent_rss_mb() ->float:
# show resident memory in megabytesreturn_proc.memory_info().rss/ (1024**2)
defdo_something(x):
# generate random datareturn [random.randint(0, 2**31-1) for_inrange(5_000)]
defpool_stuff():
withThreadPoolExecutor(max_workers=20) asexecutor:
futures= [executor.submit(do_something, i) foriinrange(100)]
fori, futureinenumerate(as_completed(futures), 1):
# force retrieval to ensure result doesn’t stick around_=future.result()
print("Before:", current_rss_mb(), "MB")
foriinrange(10):
# run the exectutor taskspool_stuff()
# show memory usageprint(f"result {i}: ", current_rss_mb(), "MB")
# collect garbagegc.collect()
print("Finally: ", current_rss_mb(), "MB")
When I run this on MacOS with Python 3.12 I see something like the following (varies each time):
Before: 15.75 MB
result 0: 30.75 MB
result 1: 31.734375 MB
result 2: 32.71875 MB
result 3: 33.015625 MB
result 4: 33.765625 MB
result 5: 33.953125 MB
result 6: 34.421875 MB
result 7: 34.421875 MB
result 8: 34.4375 MB
result 9: 34.484375 MB
Finally: 26.484375 MB
It still seems like there's a gradual increase each time we run ThreadPoolExecutor, most of the time. Afterwards, we seem to regain about a quarter of our memory used during processing if we suggest garbage collection. Tinkering around with this we seem to regain roughly the same amount of memory each time from garbage collection. It still has me wondering about how to get back to the "before" state.
Activity
iritkatriel commentedon Oct 20, 2022
The process_user function returns the allocated memory. Don’t you want the return value to be available in the main thread?
eMeena commentedon Oct 20, 2022
Correct, it should be available in the main thread. The primary issue is that allocated memory is never freed though. It should be freed once futures goes out of scope or when the executor is shutdown which should occur when exciting the with block but that doesn't happen.
thomasyang18 commentedon Oct 31, 2022
This isn't even because of threads, this is normal python behavior:
For example, this fib program "leaks"
Editing because I thought that python doesn't destroy on destructor scope. It does (both the threaded example and my example) if you write:
JelleZijlstra commentedon Oct 31, 2022
I believe the objects are garbage collected, but the memory isn't necessarily returned to the OS because Python uses a layer of abstraction around the system allocator. So you won't see process-level memory usage going back to the previous level.
However, if you keep doing the same thing, memory usage should stabilize and not keep growing.
nh2 commentedon Mar 26, 2025
@eMeena Your benchmark doesn't make sense: You're using
ru_maxrss, which is the maximum memory usage (maximum over the lifetime of the program). It thus can never go down, as it does not measure the current memory usage.Thus the print
makes no sense -- that's expected to be, and stay, high.
To get the current memory usage, you need to measure
rss, notmaxrss. For example this way withpsutil.Process().memory_info().rss.(Note I'm also coming here because my
ThreadPoolExecutor.map()seems to somehow memory leak, but the method from the issue description surely isn't the way to measure that.)d33bs commentedon May 20, 2025
I got curious about this because of my work through Parsl's ThreadPoolExecutor, which from my understanding takes advantage of CPython's ThreadPoolExecutor (Parsl/parsl#2876, cytomining/CytoTable#75). Below, I borrowed @eMeena's example and implemented @nh2's suggestion with the following to try and demonstrate what @JelleZijlstra mentioned.
When I run this on MacOS with Python 3.12 I see something like the following (varies each time):
It still seems like there's a gradual increase each time we run ThreadPoolExecutor, most of the time. Afterwards, we seem to regain about a quarter of our memory used during processing if we suggest garbage collection. Tinkering around with this we seem to regain roughly the same amount of memory each time from garbage collection. It still has me wondering about how to get back to the "before" state.