Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why should asyncio.StreamWriter.drain be explicitly called?

From doc:

write(data)

Write data to the stream.

This method is not subject to flow control. Calls to write() should be followed by drain().

coroutine drain()

Wait until it is appropriate to resume writing to the stream. Example:

writer.write(data)
await writer.drain()

From what I understand,

  • You need to call drain every time write is called.
  • If not I guess, write will block the loop thread

Then why is write not a coroutine that calls it automatically? Why would one call write without having to drain? I can think of two cases

  1. You want to write and close immediately
  2. You have to buffer some data before the message it is complete.

First one is a special case, I think we can have a different API. Buffering should be handled inside write function and application should not care.


Let me put the question differently. What is the drawback of doing this? Does the python3.8 version effectively do this?

async def awrite(writer, data):
    writer.write(data)
    await writer.drain()

Note: drain doc explicitly states the below:

When there is nothing to wait for, the drain() returns immediately.


Reading the answer and links again, I think the functions work like this. Note: Check accepted answer for more accurate version.

def write(data):
    remaining = socket.try_write(data)
    if remaining:
        _pendingbuffer.append(remaining) # Buffer will keep growing if other side is slow and we have a lot of data

async def drain():
    if len(_pendingbuffer) < BUF_LIMIT:
        return
    await wait_until_other_side_is_up_to_speed()
    assert len(_pendingbuffer) < BUF_LIMIT

async def awrite(writer, data):
    writer.write(data)
    await writer.drain()        

So when to use what:

  1. When the data is not continuous, Like responding to an HTTP request. We just need to send some data and don't care about when it is reached and memory is not a concern - Just use write
  2. Same as above but memory is a concern, use awrite
  3. When streaming data to a large number of clients (e.g. some live stream or a huge file). If the data is duplicated in each of the connection's buffers, it will definitely overflow RAM. In this case, write a loop that takes a chunk of data each iteration and call awrite. In case of a huge file, loop.sendfile is better if available.
like image 305
balki Avatar asked Dec 14 '18 12:12

balki


1 Answers

From what I understand, (1) You need to call drain every time write is called. (2) If not I guess, write will block the loop thread

Neither is correct, but the confusion is quite understandable. The way write() works is as follows:

  • A call to write() just stashes the data to a buffer, leaving it to the event loop to actually write it out at a later time, and without further intervention by the program. As far as the application is concerned, the data is written in the background as fast as the other side is capable of receiving it. In other words, each write() will schedule its data to be transferred using as many OS-level writes as it takes, with those writes issued when the corresponding file descriptor is actually writable. All this happens automatically, even without ever awaiting drain().

  • write() is not a coroutine, and it absolutely never blocks the event loop.

The second property sounds convenient - you can call write() wherever you need to, even from a function that's not async def - but it's actually a major flaw of write(). Writing as exposed by the streams API is completely decoupled from the OS accepting the data, so if you write data faster than your network peer can read it, the internal buffer will keep growing and you'll have a memory leak on your hands. drain() fixes that problem: awaiting it pauses the coroutine if the write buffer has grown too large, and resumes it again once the os.write()'s performed in the background are successful and the buffer shrinks.

You don't need to await drain() after every write, but you do need to await it occasionally, typically between iterations of a loop in which write() is invoked. For example:

while True:
    response = await peer1.readline()
    peer2.write(b'<response>')
    peer2.write(response)
    peer2.write(b'</response>')
    await peer2.drain()

drain() returns immediately if the amount of pending unwritten data is small. If the data exceeds a high threshold, drain() will suspend the calling coroutine until the amount of pending unwritten data drops beneath a low threshold. The pause will cause the coroutine to stop reading from peer1, which will in turn cause the peer to slow down the rate at which it sends us data. This kind of feedback is referred to as back-pressure.

Buffering should be handled inside write function and application should not care.

That is pretty much how write() works now - it does handle buffering and it lets the application not care, for better or worse. Also see this answer for additional info.


Addressing the edited part of the question:

Reading the answer and links again, I think the the functions work like this.

write() is still a bit smarter than that. It won't try to write only once, it will actually arrange for data to continue to be written until there is no data left to write. This will happen even if you never await drain() - the only thing the application must do is let the event loop run its course for long enough to write everything out.

A more correct pseudo code of write and drain might look like this:

class ToyWriter:
    def __init__(self):
        self._buf = bytearray()
        self._empty = asyncio.Event(True)

    def write(self, data):
        self._buf.extend(data)
        loop.add_writer(self._fd, self._do_write)
        self._empty.clear()

    def _do_write(self):
        # Automatically invoked by the event loop when the
        # file descriptor is writable, regardless of whether
        # anyone calls drain()
        while self._buf:
            try:
                nwritten = os.write(self._fd, self._buf)
            except OSError as e:
                if e.errno == errno.EWOULDBLOCK:
                    return  # continue once we're writable again
                raise
            self._buf = self._buf[nwritten:]
        self._empty.set()
        loop.remove_writer(self._fd, self._do_write)

    async def drain(self):
        if len(self._buf) > 64*1024:
            await self._empty.wait()

The actual implementation is more complicated because:

  • it's written on top of a Twisted-style transport/protocol layer with its own sophisticated flow control, not on top of os.write;
  • drain() doesn't really wait until the buffer is empty, but until it reaches a low watermark;
  • exceptions other than EWOULDBLOCK raised in _do_write are stored and re-raised in drain().

The last point is another good reason to call drain() - to actually notice that the peer is gone by the fact that writing to it is failing.

like image 159
user4815162342 Avatar answered Sep 30 '22 02:09

user4815162342