Add a threading.run convenience function

When writing code, if one want to use some threads it’s often the same recipe:

import threading

def func(n, t):
	time.sleep(n * t)
	print('done')

t = threading.Thread(target=func, args=(3, 1.0))
t.start()

# or

t = threading.Thread(target=func, args=(3, 1.0))
t.daemon = True
t.start()

It’s rather verbose, and need a pause to write it correctly or to read it. What about adding a convenience function for this common use case, like

threading.run(func, 3, 1.0)
threading.run_daemon(func, 3, 1.0)

or named start() maybe?

For instance, the introduction example in the threading documentation

threads = []
for link in links:
    t = threading.Thread(target=crawl, args=(link,), kwargs={"delay": 2})
    threads.append(t)

for t in threads:
    t.start()

would become

threads = []
for link in links:
    t = threading.run(crawl, link, delay=2)
    threads.append(t)

It also open the possibilty to rapid prototyping when using functions without argument:

import threading

@threading.run
def work():
	time.sleep(2)
	print('work done')

...

work.join()

What do you think ?

7 Likes

Using a decorator that returns a non-callable seems pretty unconventional.

Think about @property, which turns a method into a kind of attribute (I know it’s not really an attribute, but a descriptor, but it behaves like an attribute). I think it’s maybe not the most common thing there is, but definitely not rare either.

Creating this `run` is literally 3 LoC in your own projects - inside a project there are no hard decisions (should they be `daemon` by default?) - I don’t see edge cases that would be hard to implement there.

TLDR: I believe this is too trivial, and nothing enough of a helper.

Unlike the proposal of `math.clamp` recently “reawakened”: in that case there is a mental burden on what should be the most efficient way of clamping, and there are edge-cases with NaNs, etc…

As an anecdote, and my personal pattern, some of the times I was doing something quick and dirty to start a couple threads, I tend to use the walrus decorator, and just chain start it.

And to finish with a little bit of bike-shedding, we might as well just add an extra parameter to have the thread started as it is created - say `threading.Thread(target=worker, start_now=True)` instead:
no need to duplicate docs and semantics for passing arguments, separate call for a daemon thread, etc.

1 Like

I’m -1 on this proposal for two reasons:

  1. We like the fine precision over when we create, start, and join threads, which the current threading library provides. This proposal merges them together, which we don’t always want, and therefore only serves to occasionally save 3 lines.
  2. If threading.run is meant to parallel asyncio.run I don’t think they’re really comparable.

1.

Often one wants to get all the threads ready before starting any of them. Instead of

for _ in range(N_THREADS):
    t = threading.Thread(target = do_stuff)
    t.start()
    threads.append(t)

which is like what you’re suggesting with threading.run(), one wants

for _ in range(N_THREADS):
    t = threading.Thread(target = do_stuff)
    threads.append(t)

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

especially true for the thread.join() loop being separated from the thread.start() loop.

2.

If this proposal is trying to parallel asyncio.run, the reason (to me) that asyncio.run exists is because we need to create a synchronous runner which can handle asyncio coroutines, that can wait for one to complete without having to use the await keyword. ie we need

import asyncio

async def amain() -> None:
    await asyncio.sleep(1)

asyncio.run(amain())

because we can’t do

import asyncio

async def amain() -> None:
    await asyncio.sleep(1)

await amain() # SyntaxError: 'await' outside function

asyncio.run is nothing special with respect to “does it import special _asyncio C code” (no). It’s just a very convenient wrapper around creating an asyncio event loop. But it does functionally serve a special purpose as the ‘head’ of one’s asyncio code. It’s the same as if one were to use yield from to create incremental functions. One wants a wrapper around the top layer:

PEP 380 `yield from` inspired PEP 3156 `asyncio`
from typing import Generator
from contextlib import closing

class IncrementalDecoder:
    def __init__(self) -> None:
        self._bytes_buffer: bytes     = b""
        self._results:      list[int] = []

        self._generator = self._parse_forever()
        self._wakeup()

    def decode(self, input: bytes, final: bool = False) -> tuple[int]:
        self._bytes_buffer += input
        self._wakeup()
        if final:
            self._shutdown()

        ret = tuple(self._results)
        self._results.clear()
        return ret

    def close(self) -> None:
        self.decode(b"", final = True)

    def _wakeup(self) -> None:
        self._generator.send(None)

    def _shutdown(self) -> None:
        self._generator.close()

    def _await_should_parse_again(self) -> Generator[None, None, None]:
        while not self._bytes_buffer:
            yield

    def _await_bytes(self, n_bytes: int) -> Generator[None, None, bytes]:
        while len(self._bytes_buffer) < n_bytes:
            yield

        ret                = self._bytes_buffer[:n_bytes]
        self._bytes_buffer = self._bytes_buffer[n_bytes:]

        return ret

    def _await_byte(self) -> Generator[None, None, int]:
        return (yield from self._await_bytes(1))[0]

    def _parse_single(self) -> Generator[None, None, int]:
        b1 = yield from self._await_byte()
        b2 = yield from self._await_byte()
        b3 = yield from self._await_byte()
        b4 = yield from self._await_byte()

        return (b1 << 24) + (b2 << 16) + (b3 << 8) + (b4 << 0)

    def _parse_forever(self) -> Generator[None, None, None]:
        while True:
            try:
                yield from self._await_should_parse_again()
                # Park the generator in this 'parking lot'
                # Safe to close() here
            except GeneratorExit:
                return
            try:
                x = yield from self._parse_single()
            except GeneratorExit as e:
                raise EOFError("Partially decoded unit didn't finish") from e
            else:
                self._results.append(x)

decoder = IncrementalDecoder()
with closing(decoder):
    print(decoder.decode(b"H"))      # ()
    print(decoder.decode(b"ello "))  # (1214606444,)
    print(decoder.decode(b"World!")) # (1864390511, 1919706145)

Threads however just come across as a normal resource like a file descriptor that we can close() etc, or a process that we can waitpid() etc. I don’t think they need a higher-level runner-wrapper like asyncio.run() creates for us.

We like the fine precision over when we create, start, and join threads, which the current threading library provides.

Indeed. In fact, the threading library already provides a high level of abstraction compared to _thread, where you must manipulate raw thread identifiers. Abstracting less would make the module unusable for less seasoned developers and abstracting more would make some use cases inexpressible.

One wants a wrapper around the top layer

Agreed, especially because the setup and teardown logic involved is non-trivial, involving the proper closing of async generators and execution of finally: blocks in orphaned tasks. The same cannot be said for the function proposed.

Think about @property

Yes, the descriptor case went over my head. Thank you for the correction.

+1 from me, but I guess it doesn’t matter at this point, judging by the negative feedback here. I think this would have been nice to have. The ergonomics of threading.Thread are pretty bad, and we could have fixed that with threading.run:

  1. There’s the group parameter as the first argument, meaning threading.Thread(my_func) doesn’t work. (I bet we’ve all been stung by that at least once!)
  2. The args and kwargs parameters feel unnatural and are often very unreadable in practice. They’re also not type-safe, because our type system doesn’t let you use ParamSpec in that manner.
6 Likes

I’ll weigh in with some minor positive feedback. Not enough to outweigh the “not every three line function needs to be a builtin”, but an acknowledgement of the clunky ergonomics of threading.Thread. If you spawn a subprocess, you don’t then have to start it; it is already running. If you call a function, you don’t then have to ask for it to start; it runs. Async functions are generally called by awaiting them, so they just happen (though if you don’t, then that’s its own clunky ergonomics and I would love to improve on that too). Why, then, does a spawned thread NOT start?

But I get it. Python’s threading module was largely built to imitate Java’s, and AIUI the same is true there. And of course, we can’t change the semantics or signature of threading.Thread() without major backward compatibility issues.

So maybe the right choice IS, in fact, to add a new API. We got subprocess.run() in Python 3.5; prior to that, a lot of simple operations required two or three lines. But subprocess.run() isn’t just a “three line function”, even if the net result of having it is actually to just save you a line or two. Perhaps there’s something more that a threading.run() API can do for us, making it more useful.

I’m not sure what, though, which is why I hadn’t responded in this thread so far :slight_smile:

4 Likes

I created an issue and a pull request to add start parameter to threading.Thread.

For example, the code:

threads = []
for link in links:
    t = threading.Thread(target=crawl, args=(link,), kwargs={"delay": 2})
    threads.append(t)

for t in threads:
    t.start()

becomes:

threads = [
    threading.Thread(target=crawl, args=(link,), kwargs={"delay": 2},
                     start=True)
    for link in links]
2 Likes

I like the threading.run(func, *args, **kwargs) API, it’s easier to use than threading.Thread(target=func, args=args, kwargs=kwargs). The problem is that it lacks multiple Thread constructor parameters:

  • group
  • name
  • daemon: Romain proposes adding run_daemon() for that.
  • context

The run() function can be implemented as:

import threading
import functools

def hello(name, end=''):
    print(f"Hello {name}{end}")

thread = threading.Thread(target=hello, args=("Python",), kwargs={'end': '.'})
thread.start()
thread.join()

def run(func, *args, **kwargs):
    # not supported parameters: group, name, daemon, context
    thread = threading.Thread(target=func, args=args, kwargs=kwargs)
    thread.start()
    return thread

thread = run(hello, "Python", end='.')
thread.join()

thread = threading.Thread(target=functools.partial(hello, "Python", end='.'))
thread.start()
thread.join()

An alternative is to use functools.partial():

import functools

thread = threading.Thread(target=functools.partial(hello, "Python", end='.'))
thread.start()
thread.join()
2 Likes

Group isn’t implemented, cross that bridge when we get to it. Name - I’m fine with a convenience function that doesn’t let you set the thread name, you can always use the full Thread() constructor for that. (Or threading.current_thread().name = ... inside the thread.) Daemon - I agree, run_daemon() is fine for that.

Context already has some special default handling. I’ve never done much with contexts. Would it be fine to just always leave context unset and use the global default? Again, you can always use the full Thread() constructor if you need different handling.

1 Like

Note the word “every”. I agree that not every 3-line function needs to be in the stdlib, but I think it would be nice to have some 3-line functions in the stdlib, especially for super common operations.

7 Likes

Here’s my experiment with a different API style. It is heavily inspired by Cereggii’s ThreadSet class. I have a ThreadManager context manager, that ensures that created threads are joined.

1 Like

From a rapid scan of the libraries on my machine:

52 occurences (33 daemons, 18 nondaemons, 1 unspec)

  • 30 directly started

    t = threading.Thread(target=func, args=...)
    t.daemon = True
    t.start()
    
  • 11 direct with a basic name (using a default like name='%s (%s)' % (func.__name__, func.__module__)would cover them)

    t = threading.Thread(target=func, args=..., name="...")
    t.daemon = True
    t.start()
    
  • 7 direct with a complex name

  • 3 not started

    t = threading.Thread(target=func, args=...)
    t.daemon = True
    
  • 1 custom

    t = threading.Thread(target=func, args=...)
    t.daemon = self.daemon_threads
    t.start()
    

80% of these uses match the proposed threading.run function.

My proposal is only to add a shorcut, not to remove threading.Thread() which would remain accessible if needed.

@jsbueno

I don’t agree with this argument.
To make a parallel with PEP 584 discussed elsewhere:

d = d1.copy()
d.update(d2)
# vs
d = d1 | d2

one can also say it’s just 3 LoC, but it’s all these simplifications that make the language pleasant to work with.

Having a start_now option add to the verbosity because the most common case (start_now=True) cannot be the default without breaking compatibility.

@jb2170

  1. I don’t see a functional difference between the two start() orderings, but maybe I’m missing something.

  2. I didn’t aim to mimic asyncio.run behavior. I think the difference would be graspable
    because there is no use of a threading.run waiting for completion: one might as well directly call the target function. But maybe with a name like threading.start it would not remind this.

2 Likes