Add Virtual Threads to Python

That is also not true.

This thread was started by a core dev…granted, that was a long time ago

3 Likes

I can attest to this. I built a relatively simple app in Go: web server, open socket connection. Then I asked the LLM to convert the entire thing to Python which it did, but it took quite some time going back and forth between asyncio/trio/etc. to get it right.

When asking around then about whether there’s any “best” way to do this in Python, the answer was: not really.

For all the wailing in the Rust community around async, at least there’s a clear winner there (tokio) which, if you pick it, will make your life pretty easy.

This is a burning topic in Python and anything you can do to fix it, would be much much appreciated.

4 Likes

Among languages that use async/await, Rust is quite an odd one.

Rust chose postfix await.
It’s not the prettiest syntax, but it’s practical—especially for method chaining:

let result = create_async_chain(5).await
.method1().await
.method2().await
.method3().await;

This reduces the “disjointed” feeling between synchronous and asynchronous code, making them feel more consistent.

The Rust community has also been actively working to align the asynchronous development experience with synchronous code:
https://github.com/rust-lang/rust-project-goals/issues/105

Interestingly, someone once proposed postfix await for C#, but it was rejected:
https://github.com/dotnet/csharplang/issues/4076

Ultimately, I think many people (myself included) just want to write synchronous-looking code and have the system handle the asynchronous waiting automatically.

6 Likes

I don’t really care about coloring/syntax issues, but virtual threads are really mind-blowing.

One thing I understand is that eventloop is very lightweight, but a blocking single task will stuck everything! This will be different with virtual threads (which are responsive). Even on a single OS thread, instead of freezing instantly, other tasks can still run but just get gradually slower. - This is fairer but comes at the price of runtime scheduling. Whereas eventloop is lazy/efficient.

1 Like

Should we include go routines in this discussion? I really like that approach.

2 Likes

+1 for me for virtual threads along the line of Java’s, I really like:

  1. No colouring of functions.
  2. Errors propagate from children to their parent.
  3. Cancel exists!
  4. Cancel propagates from the parent to its children.

However as others have demonstrated with the IODict.incrementer example above there are still problems, in the case of the example the sharing of t is the bug. Other languages, recently Swift, eliminate this bug by preventing shared access in parallel operations.

So in addition to Java style virtual threads I would like ownership constraints, in particular:

  • Add the concept of a transfer. I would propose the annotation @transferred_args that is used as an annotation when declaring a function like gevent.spawn that says that any mutable arguments the function takes are transferred and use the keyword transfer to transfer a mutable object to a transferred_args function. To transfer an object it must have a single reference to it. Once transferred that reference becomes None. Immutable objects don’t need to be transferred (the compiler would have to know common immutable objects like int and str and about frozen dataclasses, etc.).

For the sake of an example, lets assume that gevent.spawn had been annotated with @transferred_args then the IODict example would be the same as before except for:

gevent.joinall(
        [
            gevent.spawn(incrementer, "a", "k", transfer t),  # Note transfer keyword on this line and next
            gevent.spawn(incrementer, "b", "k", transfer t),  # Error t already transferred
        ]

The error in the code would be caught because an attempt is made to transfer t twice. Note that functions do not exist in two colours, in particular IODict can be used in single threaded and parallel code. Also note that spawn exists in one colour only, @transferred_args.

1 Like

‘Normal practice’ simply means it’s common, not that it’s optimal or appropriate. What was pragmatic in one case can be sub-optimal or gate keeping of better fit pathways exist.

We shouldn’t confuse the two.

I think it would pose a problem if cancellation became a thing outside of async code. Where could code be cancelled then? What happens when code that wasn’t designed for cancellation starts suddenly seeing cancellations? I think this is where function coloring has an upside.

As for “transfers”, you didn’t explain the concept. What would that do really?

Anywhere that it can currently get a MemoryError or KeyboardInterrupt. IOW, pretty much anywhere.

What happens if the thread gets cancelled during a system call?

It notices afterwards. This is all exactly as existing Python code is; there’s a check to see if an exception needs to be raised. Cancelling is just raising an exception.

This would mean that an exception can be raised at any point in any code. That is, in fact, exactly what you get when you aren’t colouring your functions - but even when you are, since certain types of exceptions can happen literally anywhere.

1 Like

Ok, that explanation makes perfect sense. But this makes it clear that virtual threads can’t replace async by themselves, as you can’t cancel (regular) socket operations. It would of course be possible to use the same mechanisms as async event loops, but it’s not clear how well those would work in a multi-threaded environment.

From the discussions above, and attempting to adopt asyncio on a Django website, I see the biggest difficulty with asyncio to be the function colouring - it blocks you using many of the Python features and built-in functions and causes all intermediate libraries to need to be written twice. To my eyes, to disrupt the language as little as possible, I would allow await (and async for and async with) in non-async functions.

There is no point proposing a change without a path to implement it. (see GitHub - JonathanRoach/cpython-await-anywhere: The Python programming language, with await allowed anywhere please have a go - comments welcome). To have access to built-in functions and Python features, because they work in C-land, you have to be able to switch the C stack as well as the Python stack. Switching the Python Datastack is straightforward. Switching the C stack is harder. greenlets use assembler fragments to do this, but cpython currently does not have any assembler, and doing so would add friction for Python on new platforms. However, it is possible to switch C stacks assuming: setjmp() and longjmp(); a descending C stack; alloca. Only setjmp() and longjmp() would be new to cpython, but these functions are part of the standard C library and well supported. (see https://svnplace.com/artoflibs/ccoroutines for the C coroutine library on its own).

Having implemented this, there are a couple of incompatibilities:

  • (i async for i in someasyncgenerator()) returns a normal generator, not an async one
  • each Task sets aside at least 50k of C stack (actual amount depends on which build), which means the number of Tasks is limited by the C stack. A default C stacks tends to be 1M-10M, so you can’t create 1000 Tasks in the default thread - there are a number of tests, for example, which do this.
1 Like

What are the semantics of awaiting from a non-async function? Does it run other asyncio tasks while waiting, or does it simply block? If it blocks, all you really have is synchronous functions and the problems that you get with those. If it runs other tasks, though, you’ve just made it so that ANY function call could become a yield point.

async def spam():
    global x
    await fetch()
    x += 1
    process()
    x -= 1
    await deliver()

In Python as we have it today, you have a guarantee that no other asynchronous tasks will run between the incrementing and decrementing of x. The only yield points are at the two awaits. But if process() could await (and run other tasks), there’s really no difference between synchronous and asynchronous functions, every function has now become red, and we’re right back to threading. In fact:

… you’ve even reintroduced the biggest problem with threading, which is that your number of tasks is restricted by resource availability. So asyncio becomes a strictly worse version of threads, since there’s no way to run them simultaneously across CPU cores.

5 Likes

Answers inserted below:

Someone replied to your post.

| Chris Angelico Rosuav
February 12 |

  • | - |

Jonathan Roach:

I see the biggest difficulty with asyncio to be the function colouring - it blocks you using many of the Python features and built-in functions and causes all intermediate libraries to need to be written twice. To my eyes, to disrupt the language as little as possible, I would allow await (and async for and async with) in non-async functions.

What are the semantics of awaiting from a non-async function?

It works like the current await - the Task is suspended until the Future completes, other Tasks run and then the original one is queued to resume once the Future completes. How this is achieved behind the scenes is completely different - the current C coroutine (everything is running in a C coroutine) yields instead of the Future being yield from’ed up the stack. If, by devious design, you await a Future outside of a Task the await throws an error.

Does it run other asyncio tasks while waiting, or does it simply block?

See above - other tasks run.

If it blocks, all you really have is synchronous functions and the problems that you get with those.

I agree

If it runs other tasks, though, you’ve just made it so that ANY function call could become a yield point.

Yes, exactly - that’s the whole point of fixing the two colour problem

async def spam():
    global x
    await fetch()
    x += 1
    process()
    x -= 1
    await deliver()

In Python as we have it today, you have a guarantee that no other asynchronous tasks will run between the incrementing and decrementing of x. The only yield points are at the two awaits. But if process() could await (and run other tasks), there’s really no difference between synchronous and asynchronous functions, every function has now become red, and we’re right back to threading.

Yes, but not completely back to threading. The difference is, without needing locks, you can write code you absolutely know will complete before another Task can intervene. With Threading, that intervention can happen at any moment. I understand the nervousness of removing this guard-rail. I’m looking at it from the other direction of can you do what you need to and the two-colour function-ness blocks off all builtin functions, properties, operator overrides and other functions stuff in the Python toolbox, not to mention reusable code. That’s a huge pile of help to a developer not available. Not to mention having to decorate everything with an await.

The original PEP mentioned web serving as a motivating use case. In this case asyncio needs await from page serving entry point all the way down to database access to get the threads-on-the-cheap benefit web server authors want. You just can’t do that in Django - which, I think, is one of the most popular web serving frameworks (for good reason!). Django leans heavily on the blocked-off features.

In fact:

Jonathan Roach:

each Task sets aside at least 50k of C stack (actual amount depends on which build), which means the number of Tasks is limited by the C stack.

… you’ve even reintroduced the biggest problem with threading, which is that your number of tasks is restricted by resource availability.

Yes, you have put your finger on one of the weaknesses of my approach. Tasks are restricted too by resource limit - malloc space, which tends to be huge . A developer could start a thread with a big stack at the beginning of the program (stack space, not committed memory), and run a C10k web server using Tasks, and use Django (with a few tweaks, rather than a wholesale rewrite)

So asyncio becomes a strictly worse version of threads, since there’s no way to run them simultaneously across CPU cores.

Not strictly worse. Threads are horrendously (execution time and OS resource) expensive, that’s why Tasks exist (from a web server-writers POV). Tasks would become same-cost-as-sync-code expensive, and bring coroutine parallelism - do other stuff while waiting. Yes, moving a Task between cores doesn’t happen in the current implementation - I’m not sure it’s wise as then your back to needing locks often (actually, thinking about it, that could be done - so long as the thread with the Coroutine stack doesn’t go away it’s just a longjmp() to enter it. It’s a little complicated, but could work.).

Quick question: does GIL get unlocked during I/O?

How do you write that? You have to make sure you never call ANY code whatsoever, since ANY code could, by your proposal, have an await point in it. You have to make sure you don’t do anything that triggers any operator overloads or attribute lookups either, since those can be implemented with functions. Basically, all you could ever do without locks would be some simple arithmetic using local variables, and only core data types like int or float.

Can you give me an example of code that you can trust with your proposal, but can’t trust with threading?

Assuming that the actual work of the task has to be done one way or another (eg it’s a socket server and so it needs the socket, its buffers, etc), the functional difference is the overhead of the Task object itself. You’re adding on a 50KB C stack (I assume you got that figure from somewhere?), so that’s the limiting factor. Here’s my silly script for testing:

# Grab both libraries either way for consistency
import asyncio
import threading
import os

async def aspam(): pass
def cspam(): pass

PID = os.getpid()
def memusage():
	"""Get memory usage - Linux version"""
	with open("/proc/%d/stat" % PID) as f: stats = f.read().split()
	return int(stats[22])

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
start = memusage()
tasks = [asyncio.Task(aspam()) for _ in range(10000)]
print("%d tasks get about %d bytes each" % (len(tasks), (memusage() - start) // len(tasks)))

And the results?

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 649 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
100000 tasks get about 697 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
1000000 tasks get about 701 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
10000000 tasks get about 705 bytes each

(At ten million tasks, it took about half a minute just to allocate and deallocate memory; and one out of the three times I ran it at that scale, it actually segfaulted.)

Replacing the list comp with tasks = [threading.Thread(target=cspam) for _ in range(10000)] gives the following results:

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 2464 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
100000 tasks get about 2456 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
1000000 tasks get about 2455 bytes each

But that’s ignoring the additional system costs for running that many threads, so this test isn’t fair. Let’s add that in, after the list comp: for t in tasks: t.start()

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 8402182 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
Traceback (most recent call last):
  File "/home/rosuav/tmp/spam.py", line 20, in <module>
    for t in tasks: t.start()
                    ~~~~~~~^^
  File "/usr/local/lib/python3.15/threading.py", line 998, in start
    _start_joinable_thread(self._bootstrap, handle=self._os_thread_handle,
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                           daemon=self.daemon)
                           ^^^^^^^^^^^^^^^^^^^
RuntimeError: can't start new thread

Oh. Okay, so we have a hard limit somewhere north of 10K threads. Anyhow, I think we can reasonably say that threads are costing very roughly 8-9MB apiece, which is pretty much just the C stack size.

So, what would it be like to have a 50KB buffer for each one? tasks = [bytearray(50000) for _ in range(10000)]

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 50080 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
100000 tasks get about 50123 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
1000000 tasks get about 50120 bytes each

It’s a lot more memory to be allocating than a simple asyncio task, but still less than a thread. But this is where my aside from earlier comes in: Where does the 50KB figure come from? Do you know for sure that this is enough for deeply-recursive code? Imagine, for example, that you’re running a web server and you parse someone’s JSON submission. Is there enough stack space to do that?

Right. In fact, you’re reinventing threads from first principles :slight_smile: At some point, you’re going to want your tasks to be able to be run on any CPU core, to be able to be interrupted inside C code and not just Python code, and maybe even make it so they can call gethostbyname() without blocking other tasks, and at that point, well, welcome to threading :slight_smile:

Yes, it most certainly does! That’s why people often describe Python threads as “good for I/O loads, bad for CPU loads”, which, while it is definitely an oversimplification, has a lot of truth to it. Threads are going to end up limited by resources (8MB per stack is a good amount), but on a modern system with a decent amount of memory, you can have 100-1000 threads without too much trouble, and if you’re building a small home-grade server, where you aren’t expecting more than 1-10 threads, they won’t ever get in your way. Switching to asyncio should DRASTICALLY raise that limit, though.

Maybe the true conclusion here is “threads are actually really good and we should consider using them more”?

Responses inline…

Someone replied to your post.

| Chris Angelico Rosuav
February 12 |

  • | - |

Jonathan Roach:

Yes, but not completely back to threading. The difference is, without needing locks, you can write code you absolutely know will complete before another Task can intervene. With Threading, that intervention can happen at any moment.

How do you write that? You have to make sure you never call ANY code whatsoever, since ANY code could, by your proposal, have an await point in it.

Technically you’re right, practically I think most program authors will know they’re using a vanilla dict or list or whatever thing they’ve made or used which doesn’t end up awaiting. I agree there will get some people who get caught out - the same happens in threading. There’s got to be a balance between being able to do stuff and adding guard rails to prevent accidents.

You have to make sure you don’t do anything that triggers any operator overloads or attribute lookups either, since those can be implemented with functions. Basically, all you could ever do without locks would be some simple arithmetic using local variables, and only core data types like int or float.

Can you give me an example of code that you can trust with your proposal, but can’t trust with threading?

mybiglist =

listsum = 0

def dothesum():

sum = 0

for item in mybiglist:

sum += item

return sum

def appendone(x):

mybiglist.append(x)

listsum = dothesum()

It’s a bit rough, but shows the kind of thing. I think the author will make sure the containers are vanilla. In GIL, it includes a few switch points so it’s not thread safe, but it is Task safe.

Jonathan Roach:

Yes, you have put your finger on one of the weaknesses of my approach. Tasks are restricted too by resource limit - malloc space, which tends to be huge .

Assuming that the actual work of the task has to be done one way or another (eg it’s a socket server and so it needs the socket, its buffers, etc), the functional difference is the overhead of the Task object itself. You’re adding on a 50KB C stack (I assume you got that figure from somewhere?)

3* PYOS_STACK_MARGIN_BYTES
… depends on build, debug 64 bit Mac it’s actually 48K. If you’re switching C stacks that’s probably you’re minimum lump of stack you’ll want per Task/virtual thread

, so that’s the limiting factor. Here’s my silly script for testing:

# Grab both libraries either way for consistency
import asyncio
import threading
import os

async def aspam(): pass
def cspam(): pass

PID = os.getpid()
def memusage():
	"""Get memory usage - Linux version"""
	with open("/proc/%d/stat" % PID) as f: stats = f.read().split()
	return int(stats[22])

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
start = memusage()
tasks = [asyncio.Task(aspam()) for _ in range(10000)]
print("%d tasks get about %d bytes each" % (len(tasks), (memusage() - start) // len(tasks)))

And the results?

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 649 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
100000 tasks get about 697 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
1000000 tasks get about 701 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
10000000 tasks get about 705 bytes each

(At ten million tasks, it took about half a minute just to allocate and deallocate memory; and one out of the three times I ran it at that scale, it actually segfaulted.)

Replacing the list comp with tasks = [threading.Thread(target=cspam) for _ in range(10000)] gives the following results:

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 2464 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
100000 tasks get about 2456 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
1000000 tasks get about 2455 bytes each

But that’s ignoring the additional system costs for running that many threads, so this test isn’t fair. Let’s add that in, after the list comp: for t in tasks: t.start()

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 8402182 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
Traceback (most recent call last):
  File "/home/rosuav/tmp/spam.py", line 20, in <module>
    for t in tasks: t.start()
                    ~~~~~~~^^
  File "/usr/local/lib/python3.15/threading.py", line 998, in start
    _start_joinable_thread(self._bootstrap, handle=self._os_thread_handle,
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                           daemon=self.daemon)
                           ^^^^^^^^^^^^^^^^^^^
RuntimeError: can't start new thread

Oh. Okay, so we have a hard limit somewhere north of 10K threads. Anyhow, I think we can reasonably say that threads are costing very roughly 8-9MB apiece, which is pretty much just the C stack size.

So, what would it be like to have a 50KB buffer for each one? tasks = [bytearray(50000) for _ in range(10000)]

rosuav@sikorsky:~/tmp$ python3 spam.py 
10000 tasks get about 50080 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
100000 tasks get about 50123 bytes each
rosuav@sikorsky:~/tmp$ python3 spam.py 
1000000 tasks get about 50120 bytes each

It’s a lot more memory to be allocating than a simple asyncio task, but still less than a thread.

Does memusage include stack? I’d think each threads stack commits a page or RAM, which is IIRC 128k these days. Agreed, the heap usage isn’t much, but you’re going to need that stack

But this is where my aside from earlier comes in: Where does the 50KB figure come from?

See above. Also, Python sets aside 2*PYOS_STACK_MARGIN_BYTES at end of stack - one for “we’re getting low” exception throwing and 1 for “we’re out completely” exiting under control. This means PYOS_STACK_MARGIN_BYTES is the working headroom on the stack. So 3 of those per stack chunk to balance unused stack chunk memory vs keeping the chunk size down. 3 on my machine & build comes to 48k. Note when the chunk is getting full execution carries on in a new chunk.

Do you know for sure that this is enough for deeply-recursive code? Imagine, for example, that you’re running a web server and you parse someone’s JSON submission. Is there enough stack space to do that?

See above - new chunks are claimed as needed. Also sometimes 48k isn’t enough (DLL loading and tkinter for example, so they allocate big enough chunks as needed.

Jonathan Roach:

Yes, moving a Task between cores doesn’t happen in the current implementation - I’m not sure it’s wise as then your back to needing locks often (actually, thinking about it, that could be done - so long as the thread with the Coroutine stack doesn’t go away it’s just a longjmp() to enter it. It’s a little complicated, but could work.).

Right. In fact, you’re reinventing threads from first principles :slight_smile:At some point, you’re going to want your tasks to be able to be run on any CPU core,

Nope.. for now (wasn’t it you who mentioned running on any thread?)

to be able to be interrupted inside C code

…a side effect is that C code can yield its coroutine, but I haven’t advertised that.

and not just Python code, and maybe even make it so they can call gethostbyname() without blocking other tasks, and at that point, well, welcome to threading :slight_smile:

Nope… ish. I’m sure you’re trying to yank my chain :slight_smile:Keep it single threaded and as close to asyncio fir now is my thought.

Jonathan Roach:

Quick question: does GIL get unlocked during I/O?

Yes, it most certainly does! That’s why people often describe Python threads as “good for I/O loads, bad for CPU loads”, which, while it is definitely an oversimplification, has a lot of truth to it. Threads are going to end up limited by resources (8MB per stack is a good amount), but on a modern system with a decent amount of memory, you can have 100-1000 threads without too much trouble, and if you’re building a small home-grade server, where you aren’t expecting more than 1-10 threads, they won’t ever get in your way. Switching to asyncio should DRASTICALLY raise that limit, though.

…if you didn’t have the two colour function problem.

Maybe the true conclusion here is “threads are actually really good and we should consider using them more”?

Thread construction is horribly time expensive (and kernel resource expensive). When I checked function calls / awaits / threads I found thread construction cost 1000s of function calls and between 3:1 and 5:1 awaits vs function call. If the cost was closer I’d not have gone this route and used threads.

Thinking about it, being limited by a single threads stack could be an issue. An approach might be to start ‘sacrificial’ threads as needed whose only purpose is to have a stack to provide to real threads for Tasks, then users would never see a limit until memory is exhausted.

AND make sure that the objects inside the containers are vanilla. But, the code you gave wouldn’t be bothered by a context switch anyway; simply appending to the list won’t break the summation (the new item may or may not be included in the iteration, but either way, it’s consistent). There would need to be some other code that would be broken by this. What else would be happening that doesn’t include any sort of function calls?

Here’s what that figure means:

It’s not the size of the stack, it’s the amount that has to still be remaining in order for the stack probes to consider it safe to continue.

Yes, memory usage (specifically, the marginal memory usage - that is, the amount of additional memory required to support one more task/thread) does include the stack; the heap size isn’t very much, since that’s all shared, so the only stuff on the heap is the actual management of the thread object and erlated.

So realistically, how much stack space are you going to need to allocate to each Task?

True, but I was working with a very simplistic model. Realistically, you’d use a thread pool, so there’s a little bit of overhead handing out tasks to the pool, but no overhead of creating threads (at least, not per task).

Thread pool. Use a thread pool and don’t worry about asyncio at all. It’s not what you’re looking for here.

Threads avoid the function colouring problem by making every function blue. You’re trying to avoid the function colouring problem by making every function red. Both avoid that one specific problem, but they each have other consequences.

mybiglist = []

listsum = 0

def dothesum():
    sum = 0
    for item in mybiglist:
        sum += item

    return sum

def appendone(x):
    mybiglist.append(x)
    listsum = dothesum()

Thread A calls appendone(), and switches to Thread B when dothesum() returns, but before the answer is stored in listsum (this is a GIL switch point). Thread B does an appendone() and some time later Thread A continues and stores its result of dothesum() to listsum. The net result is the list and listsum are inconsistent with each other. This wouldn’t happen in Tasks.

The stack picture makes the current situation clear - no more than _PyOS_STACK_MARGIN_BYTES of stack can be used between stack full checks, or the system breaks (there are a number of cases where more is used).

Minimum is a C stack chunk (24k in release, 48k in debug on MacOS), plus the Task admin (~700 bytes from your results). The trouble is, as I have it implemented, that 24k/48k has to be on the C stack, which is a very limited space.

…if your problem is Task/Thread creation time. You noted above in your timing checks there’s a count limit on started threads, which, if you’re making a 10k connection web server, you’ll need 10k of them - that’s why NGINX exists.

The function colouring is the problem. This is why I found asyncio to be unusable for a Django a web server.