-
-
Notifications
You must be signed in to change notification settings - Fork 30.7k
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
ThreadPoolExecutor doesn't reuse threads until #threads == max_workers #69070
Comments
https://hg.python.org/cpython/file/3.4/Lib/concurrent/futures/thread.py#l114 ThreadPoolExecutor will keep spawning new threads, even if existing threads are waiting for new work. We should check against the queue length when deciding to spawn a new thread to avoid creating unnecessary threads. |
On further investigation, it appears that we can't just check against the queue length, as it doesn't indicate whether threads are doing work or idle. A change here will need a counter/semaphore to keep track of the number of idle/working threads, which may have negative performance implications. |
For demonstration purposes, here is a small example specifically for Linux which shows how each request starts a new thread even though the client blocks for each result. |
Is there a good reason to worry about overeager worker spawning? ProcessPoolExecutor spawns all workers when the first work item is submitted ( https://hg.python.org/cpython/file/3.4/Lib/concurrent/futures/process.py#l361 ), only ThreadPoolExecutor even makes an effort to limit the number of threads spawned. Threads are typically more lightweight than processes, and with the recent GIL improvements, the CPython specific costs associated with threads (particularly threads that are just sitting around waiting on a lock) are fairly minimal. It just seems like if eager process spawning isn't a problem, neither is (cheaper) eager thread spawning. |
If each worker thread ties up other resources in an application, such as handles to server connections, conserving threads could have a significant impact. That's the situation for an application I am involved with. I've written and tested a patch to make this change, using a second Queue for the worker threads to notify the executor in the main thread by sending a None when they finish a WorkItem and are therefore idle and ready for more work. It's a fairly simple patch. It does add a little more overhead to executing a job, inevitably. I can submit the patch if there's interest. Otherwise, perhaps the TODO comment in thread.py should be rewritten to explain why it's not worth doing. |
This issue seems to overlap with 14119. |
I've submitted a PR that should resolve this - it uses a simple atomic counter to ensure new threads are not created if existing threads are idle. One concern I do have - while writing the patch, I noticed the existing submit method (specifically the adjust_thread_count function) isn't thread safe. I've added more details in the PR. |
I'm not fond of this proposal. The existing behaviour is harmless; especially for a thread pool, since threads are cheap resources. Improving the logic a bit might seem nice, but it also complicates the executor implementation a bit more. Besides, once the N threads are spawned, they remain alive until the executor is shut down. So all it takes is a spike in incoming requests and you don't save resources anymore. |
You may want to implement a pooling mechanism for those connections, independent of the thread pool. It is also probably more flexible (you can implement whichever caching and lifetime logic benefits your application). |
Side note:
True. The executor is obviously thread-safe internally (as it handles multiple worker threads). But the user should not /call/ it from multiple threads. (most primitives exposed by the Python stdlib are not thread-safe, except for the simplest ones such as lists, dicts etc.) |
The existing behavior seems strange (and isn't well documented). The code had a TODO comment from bquinlan to implement idle thread recycling, so that was why I made the change. That said, if threads are cheap, why not just create all the work threads on initialization, and then remove all the logic entirely? Also, regarding the executor and thread-safety, there's an example in the current docs showing a job being added to the executor from a worker thread (it's part of the example on deadlocks, but it focuses on max worker count, not on the executor's thread-safety). |
That would sound reasonable to me. bquinlan has been absent for a long time, so I wouldn't expect an answer from him on this issue.
Actually, looking at the code again, submit() is protected by the shutdown_lock, so it seems it should be thread-safe. That's on git master btw. |
Alright - I'll put together another patch that removes the logic, and spins up all threads during initialization. Do you want me to create a completely new PR, or just update my existing one? |
Creating a new PR would be cleaner IMHO. |
Done - as recommend, I've opened a new PR that changes the behavior to spawn all worker threads when the executor is created. This eliminates all the thread logic from the submit function. |
Why not just remove TODO comment? |
I feel like there are two reasonable options here:
I personally like option 1 because it feels closer to other languages I've worked in, but I'd like a bit more guidance from the reviewers before proceeding. |
When I first wrote and started using ThreadPoolExecutor, I had a lot of code like this: with ThreadPoolExecutor(max_workers=500) as e:
e.map(download, images) I didn't expect that I didn't want to have to explicitly take into account the list size when starting the executor (e.g. max_works=min(500, len(images))) but I also didn't want to create 500 threads up front when I only needed a few. My use case involved transient ThreadPoolExecutors so I didn't have to worry about idle threads. In principle, I'd be OK with trying to avoid unnecessary thread creation if the implementation can be simple and efficient enough. #6375 seems simple enough but I haven't convinced myself that it works yet ;-) |
After playing with it for a while, #6375 seems reasonable to me. It needs tests and some documentation. Antoine, are you still -1 because of the complexity increase? |
We ran into this issue in the context of asyncio which uses an internal ThreadPoolExecutor to provide an asynchronous getaddrinfo / getnameinfo. We observed an async application spawned more and more threads through several reconnects. With a maximum of 5 x CPUs these were dozens of threads which easily looked like a resource leak. At least in this scenario I would strongly prefer to correctly reuse idle threads. Spawning all possible threads on initialization in such a transparent case would be quite bad. Imagine having a process-parallel daemon that running a apparently single-threaded asyncio loop but then getting these executors for doing a single asyncio.getaddrinfo. Now you run 80 instances on an 80 core machine you get 32.000 extra implicit threads. Now you can argue whether the default executor in asyncio is good as is, but if the executors properly reuse threads, it would be quite unlikely to be a practical problem. |
Thomas, I think that's a good argument, so perhaps we should do this (strive to reuse threads) after all. |
Thank you for your contribution iunknwn! |
Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.
Show more details
GitHub fields:
bugs.python.org fields:
The text was updated successfully, but these errors were encountered: