Join GitHub today
Fine-tuning channels #719
Channels (#586, #497) are a pretty complicated design problem, and fairly central to user experience, so while I think our first cut is pretty decent, we'll likely want to fine tune it as we get experience using them.
Here are my initial notes (basically questions that I decided to defer while implementing version 1):
For channels that allow buffering, it's theoretically possible for values to get "lost" without there ever being an exception (
Should memory channels have a sync
I have mixed feelings about
And btw, the
We should probably have a
added a commit
Oct 5, 2018
Would it be helpful to have a way to explicitly mark a channel as broken, e.g.
While I'm here, also check out this mailing list post from 2002 with subject "Concurrency, Exceptions, and Poison", by the same author. It's basically about how exceptions and nurseries should interact.
Would it be possible to document how we should replace
I'm currently using a Queue as a pool of objects. These objects are basically wrappers around
The pseudo code looks like:
class MonitorPool: pool_size = 5 pool = trio.Queue(pool_size) async def handle(self, server_stream: trio.StapledStream): # do something ident = next(CONNECTION_COUNTER) data = await server_stream.receive_some(8) async with self.get_mon(ident) as mon: response = mon.status(data) # do other things async def launch_monitor(self, id): mon = Monitor(ident=id) await self.pool.put(mon) async def cleanup_monitor(self): while not self.pool.empty(): mon = await self.pool.get() # noqa del mon @asynccontextmanager async def get_mon(self, ident) -> Monitor: mon = await self.pool.get() # type: Monitor yield mon await self.pool.put(mon) async def run(self): async with trio.open_nursery() as nursery: for i in range(self.pool_size): nursery.start_soon(self.launch_monitor, i + 1) try: await trio.serve_tcp(self.handle, self.port, host=self.bind) except KeyboardInterrupt: pass async with trio.open_nursery() as nursery: nursery.start_soon(self.cleanup_monitor)
@ziirish Oo, that's clever! If I were implementing a pool I probably would have reached for a
Generally, to replace a
class MonitorPool: def __init__(self, pool_size=5): self.pool_size = pool_size self.send_channel, self.receive_channel = trio.open_memory_stream(pool_size) async def launch_monitor(self, id): mon = Monitor(ident=id) await self.send_channel.send(mon) @asynccontextmanager async def get_mon(self, ident) -> Monitor: mon = await self.receive_channel.receive() # type: Monitor yield mon await self.pool.send_channel.send(mon)
The one thing that doesn't transfer over directly is
Also, this code:
async with trio.open_nursery() as nursery: nursery.start_soon(self.cleanup_monitor)
is basically a more complicated way of writing:
And it's generally nice to run cleanup code even if there's an exception...
async def run(self): async with self.receive_stream: async with trio.open_nursery() as nursery: for i in range(self.pool_size): nursery.start_soon(self.launch_monitor, i + 1) try: await trio.serve_tcp(self.handle, self.port, host=self.bind) except KeyboardInterrupt: pass
BTW, the type annotation for
Speaking of type annotations, I wonder if we should make the channel ABCs into parametrized types, like:
T_cov = TypeVar("T_cov", covariant=True) T_contra = TypeVar("T_contra", contravariant=True) class ReceiveChannel(Generic[T_cov]): async def receive(self) -> T_cov: ... class SendChannel(Generic[T_contra]): async def send(self, obj: T_contra) -> None: ... def open_memory_channel(max_buffer_size) -> Tuple[SendChannel[Any], ReceiveChannel[Any]]: ...
(For the variance stuff, see: https://mypy.readthedocs.io/en/latest/generics.html#variance-of-generic-types. I always get confused by this, so I might have it wrong...)
It might even be nice to be able to request a type-restricted memory channel. E.g. @ziirish might want to do something like:
s, r = open_memory_channel[Monitor](pool_size) # or maybe: s, r = open_memory_channel(pool_size, restrict_type=Monitor)
Ideally this would both be enforced at runtime (the channels would check for
Runtime and static types are kind of different things. They overlap a lot, but I don't actually know if there's any way to take an arbitrary static type and check it at runtime? And PEP 563 probably affects this somehow too... It's possible we might want both a way to pass in a runtime type (and have the static type system automatically pick this up when possible), and also a purely static way to tell the static type system what type we want it to enforce for these streams without any runtime overhead?
Even if we ignore the runtime-checking part, I don't actually know whether there's any standard syntax for a function like this in python's static type system. Maybe it would need a mypy plugin regardless? (Of course, we're already talking about maintaining a mypy plugin for Trio, see python/mypy#5650, so this may not be a big deal.)
Of course for a pure static check you can write something ugly like:
s, r = cast(Tuple[SendStream[Monitor], ReceiveStream[Monitor]], open_memory_stream(pool_size))
but that's awful.
Actually, it is possible to do something like
@overload def open_memory_channel() -> Tuple[SendChannel[Any], ReceiveChannel[Any]]: pass @overload def open_memory_channel(*, restrict_type: Type[T]) -> Tuple[SendChannel[T], ReceiveChannel[T]]: pass def open_memory_channel(*, restrict_type=object): ... reveal_type(open_memory_channel()) # Tuple[SendChannel[Any], ReceiveChannel[Any]] reveal_type(open_memory_channel(restrict_type=int)) # Tuple[SendChannel[int], ReceiveChannel[int]]
You would think you could write:
def open_memory_channel(*, restrict_type: Type[T]=object): ...
but if you try then mypy gets confused because it checks the default value against the type annotation before doing generic inferencing (see python/mypy#4236).
One annoying issue with this is that it doesn't support
Also, you can't express types like
BUT AFAICT you can't make them work at the same time.
The way you make
class open_memory_channel(Tuple[SendChannel[T], ReceiveChannel[T]]): def __new__(self, max_buffer_size): return (SendChannel[T](), ReceiveChannel[T]()) # Never called, but must be provided, with the same signature as __new__ def __init__(self, max_buffer_size): assert False # This is basically a Tuple[SendChannel[int], ReceiveChannel[int]] reveal_type(open_memory_channel[int](0)) # This is Tuple[SendChannel[<nothing>], ReceiveChannel[<nothing>]], and you have to start throwing # around manual type annotations to get anything sensible reveal_type(open_memory_channel(0))
Did I mention it was gross? It's pretty gross.
Now... in this version, a bare
Thanks @njsmith for the explanations.
But the question then will be about this:
The purpose of the pool is to be always filled with objects and then to block/pause the execution of the job when it is empty. So at the end of your job, the pool should be "full" resulting in "lost" data.
I see that type annotations are used in a few spots already. @njsmith would you accept a PR to add type annotation to open_memory_channel() return value?