Personally, my preferred way of dealing with this is to use lambdas:
t = threading.Thread(target=lambda: func(3, 1.0))
Which I find more readable than the proposed run method:
threading.run(func, 3, 1.0)
Another advantage of the lambda-based idiom is that type checkers already work well with it, whereas the usual Thread(target=func, args=(3, 1.0)) or the proposed threading.run(func, 3, 1.0) don’t work well with static type checkers.
It goes without saying, but an additional obvious advantage is that it works with all current Python versions without requiring any changes to CPython.
One disadvantage is that there are some surprises around lazily-bound names (notice the i=i required to make this work):
for i in range(10):
Thread(target=lambda i=i: spam(i))
It would be nice to see the proposed reference implementation.
I’m looking at the code for subprocess.call and subprocess.run and I notice that they’re geared around:
context managers to avoid resource leaks
timeouts
subprocess.run communicating with stdio to avoid EWOULDBLOCK
wait until the subprocess has finished, handling exceptions properly
threading.run wouldn’t have any of these right? I’m thinking of it as more comparable to a raw os.fork() which returns immediately, and it’s up to the user to handle resource collection later (threading.Thread.join and os.waitpid).
subprocess.call is short but looks worth it for exception handling. Seeing the reference implementation for threading.run would show whether it’s:
short but with side effects and repeated so often it’s worthy of its own function
just a convenient wrapper to save one or two lines, for the most common case of immediately starting a thread once its created (which doesn’t necessarily rule out the admission of threading.run; people may still really want this shortcut )
As a side note for resource cleanup, it makes me think of socketserver, and how the ForkingMixIn cleans up its children when __exit__ing a server context. Is there an equivalent ‘threadingserver’ module for creating threads and having them cleaned up when exiting a ‘ThreadServer’? *Googling*. Is that what concurrent.futures.ThreadPoolExecutor is for? I only occasionally use threads. I prefer forking, giving each subprocess memory autonomy. That’s why I pushed for ForkingUnix{Stream,Datagram}Server to be added. Those were a oneliner each
# socketserver.py
class ForkingUnixStreamServer(ForkingMixIn, UnixStreamServer): pass
class ForkingUnixDatagramServer(ForkingMixIn, UnixDatagramServer): pass
but were a healthy addition, and the server I actually wanted
If you want more functionality, I’m thinking of something:
# (remake of threading documentation example)
links = [
"https://python.org",
"https://docs.python.org",
"https://peps.python.org",
]
pages = threading.join(threading.run(fetch, link) for link in links)
# or plainly
reqs = [threading.run(fetch, link) for link in links]
pages = [req.join() for req in reqs]
threading.run would not return a thread but another thing capturing the function return value. The implementation would be less straighforward though.
Right, your main concern was removing the start() call then, rather than the parameter passing? Personally I prefer the explicit version, but I’m in favour of having an additional choice.
What I found myself wanting more is to call start and join over a set of threads, which is why I implemented ThreadSet. You can find several usages in that package’s test suite. In general, I like this pattern:
I like the convenience of the proposed decorator form, but also agree that there should be a way to optionally configure a thread. We can do this by making threading.run an optional decorator factory based on the presence of a positional argument to support both of the following decorator usages:
while also supporting non-decorator usage both with and without thread configuration (args and kwargs are supported in this form):
t = threading.run(task, 1, option=True)
t = threading.run(name="foo")(task, 1, option=True)
The decorator form doesn’t need to support passing arguments because such usage is meant for one-time-use target functions that typically don’t need to be parameterized.
import threading
LOCK = threading.Lock()
def print_stuff(n: int, name: str | None = None):
s = f"{n}" if name is None else f"{name} is {n}"
with LOCK:
print(s)
threads = []
for idx in range(8):
t = threading.run(print_stuff, idx, name="xyz")
threads.append(t)
for thread in threads:
thread.join()
Okay that’s fine, and so my only major remaining concern is:
In the past in small codebases I have created threads via
threads = [threading.Thread(target = download, args = (idx, i, j, k)) for idx in range(number_of_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
in which the separation of ‘create’ and ‘start’ was just an artefact of using a list comprehension. In bigger codebases maybe people want this as intended behavior to produce a separation of powers in (create-create-create)-(start-start-start)-(join-join-join); one block of code doing the creating, another doing the starting when ready, another joining to finish up.
With create-start-create-start-create-start-join-join-join each ‘create’ stage might have delays leading to really staggered 'start’s. I don’t think it’s too much of an issue, and code shouldn’t be relying on side effects of thread start times, but it does make me think of how bind(2) and listen(2) are separate partially to allow lots of binding, then any final cleanup, then fast listen calls, ie (bind-bind)-cleanup-(listen-listen)-poll, as otherwise with bind-cleanup-listen-bind-cleanup-listen-poll connections to the first sockets would be accepted by kernelspace early on but not accept(3)ed by userspace for a while as the userspace code takes time with cleanup.
Overall I’m +0 now, swayed by the simple func, *args, **kwargs syntax.
It’s also notable that bind can require elevated permissions, which can be relevant.
TBH if I could steal the time machine for a bit, I’d make it that threading.Thread(func) created and started a thread (meaning that the target would also be the first argument), and if you DON’T want it to immediately start, threading.Thread(func, start=False). It’s far more common to want it to start.
I disagree that my proposed API is overcomplicating this, when it supports the common usage of run(func, 1, option=True) just as well with about the same number of lines as your sample code above. It does incur one more call in an attempt to keep the code DRY but I wouldn’t say the extra call makes it overcomplicated, and the extra call can be avoided by providing the common unconfigured form a direct path:
I personally find naming threads important in larger projects. Popular projects routinely name threads after their operational role, owner, or instance, not merely the callable. If threading.run is released without the ability to name a thread, it may actually lead to people giving up on a more readable debugging output for the luring convenience of the more ergonomic API.
As a reminder (I’m sure most of you are well aware though), here are a few quick examples from popular projects that illustrate how meaningfully named threads can be helpful:
Uniquely identify a worker:
Distinguish multiple threads running the same target for different resources:
Replace a nearly meaningless target name with the thread’s actual purpose:
Identify which component owns an otherwise generic event loop:
If a project really wants to name their thread, they can continue to use the Thread constructor, or set the name attribute on the returned Thread object. In most cases, the callable name will be fine anyway; do you really find asyncio-waitpid that much better than _do_waitpid?
The proposed solution makes this API’s behavior much harder to describe, and will likely lead to some interesting gotchas. I’m inclined to quote the Zen here: “If the implementation is hard to explain, it’s a bad idea.”
All of these kinds of things are still possible. If they make sense for your project, you are very welcome to create them as local utility functions.
Let’s get a simple convenience function. It doesn’t have to do everything, it just has to do what’s actually common and useful. For everything else, the underlying Thread constructor is still available.