Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support zerocopy send #1210

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a1538b8
Support zerocopy send with httptools
abersheeran Oct 5, 2021
8d9f1c4
Add zero copy send in docs
abersheeran Oct 5, 2021
0eec27b
Wait process terminate
abersheeran Oct 5, 2021
6bac4ba
Fixed unclosed file
abersheeran Oct 5, 2021
8ddc937
Fixed offset, count and chunked_encoding mode
abersheeran Oct 5, 2021
c3ede54
Only test in platform that has sendfile
abersheeran Oct 5, 2021
c778541
Delete additional dependancy
abersheeran Oct 6, 2021
c243a45
Fixed hanging error
abersheeran Oct 7, 2021
1021a04
Complete
abersheeran Oct 7, 2021
8d50e9f
Wait flow.drain() before sendfile
abersheeran Oct 7, 2021
c61c4a7
Use loop.sendfile replace os.sendfile
abersheeran Oct 7, 2021
1f457b9
Fixed python version judge
abersheeran Oct 7, 2021
8d40c26
Skip test_sendfile in python3.6
abersheeran Oct 7, 2021
fc76503
Fixed flake8 F401
abersheeran Oct 7, 2021
cf59224
Fixed skip test
abersheeran Oct 7, 2021
3c13068
Fixed skip test condition
abersheeran Oct 7, 2021
3782b90
Fixed LF in response with windows
abersheeran Oct 7, 2021
7f6c09a
use bytes replace str
abersheeran Oct 7, 2021
d8f004e
Add test_sendfile skip condition
abersheeran Oct 7, 2021
9eb5a12
Add # pragma: no cover
abersheeran Oct 7, 2021
7877338
Update docs
abersheeran Oct 8, 2021
ddb3e58
fixed HEAD
abersheeran Dec 6, 2021
9971c6b
add uvloop issue in docs
abersheeran Dec 6, 2021
557faa0
Merge branch 'encode:master' into zerocopy-send
abersheeran Dec 8, 2021
6bb272b
Merge branch 'master' of https://github.com/encode/uvicorn into zeroc…
abersheeran Feb 11, 2022
6a8e53d
lint
abersheeran Feb 11, 2022
de68433
In Python3.7, a litter code can't be cover
abersheeran Feb 11, 2022
373b19e
Just for run actions
abersheeran Feb 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/server-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ Applications should generally treat `HEAD` requests in the same manner as `GET`

One exception to this might be if your application serves large file downloads, in which case you might wish to only generate the response headers.

### Zero copy send

Uvicorn supports [ASGI Zero Copy Send Extension](https://asgi.readthedocs.io/en/latest/extensions.html#zero-copy-send). Using the method specified by the extension, you can call the zero-copy interface of the operating system to send files.

Note: This is currently only available on Python3.7+. And it is temporarily unavailable with uvloop because [uvloop#228](https://github.com/MagicStack/uvloop/issues/228).

---

## Timeouts
Expand Down
2 changes: 1 addition & 1 deletion scripts/coverage
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ export SOURCE_FILES="uvicorn tests"

set -x

${PREFIX}coverage report --show-missing --skip-covered --fail-under=96.93
${PREFIX}coverage report --show-missing --skip-covered --fail-under=96.69
47 changes: 47 additions & 0 deletions tests/protocols/test_http.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import asyncio
import contextlib
import logging
import os
import sys

import httpx
import pytest

from tests.response import Response
from tests.utils import run_server
from uvicorn.config import Config
from uvicorn.main import ServerState
from uvicorn.protocols.http.h11_impl import H11Protocol
Expand Down Expand Up @@ -123,6 +127,9 @@ def clear_buffer(self):
def set_protocol(self, protocol):
pass

def set_write_buffer_limits(self, high=None, low=None):
pass


class MockLoop(asyncio.AbstractEventLoop):
def __init__(self, event_loop):
Expand Down Expand Up @@ -744,3 +751,43 @@ def test_invalid_http_request(request_line, protocol_cls, caplog, event_loop):
protocol.data_received(request)
assert not protocol.transport.buffer
assert "Invalid HTTP request received." in caplog.messages


@pytest.mark.skipif(
sys.version_info[:2] < (3, 7),
not hasattr(os, "sendfile"),
reason="Sendfile only available in python3.7+",
)
@pytest.mark.parametrize("http", ["h11", "httptools"])
@pytest.mark.parametrize("loop", ["asyncio"])
@pytest.mark.asyncio
async def test_sendfile(http, loop):
async def app(scope, receive, send):
with open("./README.md", "rb") as file:
content_length = len(file.read())
file.seek(0, 0)
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"Content-Length", str(content_length).encode("ascii")),
(b"Content-Type", b"text/plain; charset=utf8"),
],
}
)
await send(
{
"type": "http.response.zerocopysend",
"file": file.fileno(),
}
)

config = Config(app=app, http=http, loop=loop, limit_max_requests=1)
async with run_server(config):
with open("./README.md", "rb") as file:
file_content = file.read()

async with httpx.AsyncClient() as client:
response = await client.get("http://127.0.0.1:8000")
assert response.content == file_content
78 changes: 70 additions & 8 deletions uvicorn/protocols/http/h11_impl.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import http
import logging
import os
from urllib.parse import unquote

import h11
Expand All @@ -12,6 +13,7 @@
FlowControl,
service_unavailable,
)
from uvicorn.protocols.http.sendfile import can_sendfile
from uvicorn.protocols.utils import (
get_client_addr,
get_local_addr,
Expand Down Expand Up @@ -76,6 +78,7 @@ def connection_made(self, transport):
self.connections.add(self)

self.transport = transport
self.allow_sendfile = can_sendfile(self.loop) and not is_ssl(self.transport)
self.flow = FlowControl(transport)
self.server = get_local_addr(transport)
self.client = get_remote_addr(transport)
Expand Down Expand Up @@ -165,6 +168,9 @@ def handle_events(self):
"query_string": query_string,
"headers": self.headers,
}
if self.allow_sendfile: # pragma: no cover
extensions = self.scope.setdefault("extensions", {})
extensions["http.response.zerocopysend"] = {}

for name, value in self.headers:
if name == b"connection":
Expand All @@ -185,6 +191,8 @@ def handle_events(self):
app = self.app

self.cycle = RequestResponseCycle(
loop=self.loop,
allow_sendfile=self.allow_sendfile,
scope=self.scope,
conn=self.conn,
transport=self.transport,
Expand Down Expand Up @@ -323,6 +331,8 @@ def timeout_keep_alive_handler(self):
class RequestResponseCycle:
def __init__(
self,
loop,
allow_sendfile,
scope,
conn,
transport,
Expand All @@ -334,6 +344,8 @@ def __init__(
message_event,
on_response,
):
self.loop = loop
self.allow_sendfile = allow_sendfile
self.scope = scope
self.conn = conn
self.transport = transport
Expand All @@ -358,6 +370,11 @@ def __init__(
self.response_started = False
self.response_complete = False

# Sendfile
if self.allow_sendfile: # pragma: no cover
# Set the buffer to 0 to avoid the problem of sending file before headers.
transport.set_write_buffer_limits(0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really comfortable with the fact that this'll change the behaviour throughout, regardless of if http.response.zerocopysend is actually being used or not for any given request/response.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this option is not configured, the headers may be sent after the body is sent.


# ASGI exception wrapper
async def run_asgi(self, app):
try:
Expand Down Expand Up @@ -445,20 +462,55 @@ async def send(self, message):

elif not self.response_complete:
# Sending response body
if message_type != "http.response.body":
msg = "Expected ASGI message 'http.response.body', but got '%s'."
raise RuntimeError(msg % message_type)

body = message.get("body", b"")
more_body = message.get("more_body", False)
use_sendfile = False
if message_type == "http.response.body":
body = message.get("body", b"")
more_body = message.get("more_body", False)
elif (
self.allow_sendfile and message_type == "http.response.zerocopysend"
): # pragma: no cover
file_fd = message["file"]
sendfile_offset = message.get("offset", None)
if sendfile_offset is None:
sendfile_offset = os.lseek(file_fd, 0, os.SEEK_CUR)
sendfile_count = message.get("count", None)
if sendfile_count is None:
sendfile_count = os.stat(file_fd).st_size - sendfile_offset
more_body = message.get("more_body", False)
use_sendfile = True
else:
if self.allow_sendfile: # pragma: no cover
expect_message_types = (
"http.response.body",
"http.response.zerocopysend",
)
else:
expect_message_types = ("http.response.body",)
msg = "Expected ASGI message %s, but got '%s'."
raise RuntimeError(msg % expect_message_types, message_type)

# Write response body
if self.scope["method"] == "HEAD":
event = h11.Data(data=b"")
output = self.conn.send(event)
self.transport.write(output)
elif use_sendfile: # pragma: no cover
with os.fdopen(os.dup(file_fd), "rb") as file:
event = SendfileData(file, sendfile_offset, sendfile_count)
for data in self.conn.send_with_data_passthrough(
h11.Data(data=event)
):
if isinstance(data, SendfileData):
await self.flow.drain()
await self.loop.sendfile(
self.transport, data.file, data.offset, data.count
)
else:
self.transport.write(data)
else:
event = h11.Data(data=body)
output = self.conn.send(event)
self.transport.write(output)
output = self.conn.send(event)
self.transport.write(output)
abersheeran marked this conversation as resolved.
Show resolved Hide resolved

# Handle response completion
if not more_body:
Expand Down Expand Up @@ -505,3 +557,13 @@ async def receive(self):
self.body = b""

return message


class SendfileData:
def __init__(self, file, offset, count):
self.file = file
self.offset = offset
self.count = count

def __len__(self):
return self.count
88 changes: 73 additions & 15 deletions uvicorn/protocols/http/httptools_impl.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import http
import logging
import os
import re
import urllib
from collections import deque
Expand All @@ -14,6 +15,7 @@
FlowControl,
service_unavailable,
)
from uvicorn.protocols.http.sendfile import can_sendfile
from uvicorn.protocols.utils import (
get_client_addr,
get_local_addr,
Expand Down Expand Up @@ -85,6 +87,7 @@ def connection_made(self, transport):
self.connections.add(self)

self.transport = transport
self.allow_sendfile = can_sendfile(self.loop) and not is_ssl(self.transport)
self.flow = FlowControl(transport)
self.server = get_local_addr(transport)
self.client = get_remote_addr(transport)
Expand Down Expand Up @@ -204,6 +207,9 @@ def on_url(self, url):
"query_string": parsed_url.query if parsed_url.query else b"",
"headers": self.headers,
}
if self.allow_sendfile: # pragma: no cover
extensions = self.scope.setdefault("extensions", {})
extensions["http.response.zerocopysend"] = {}

def on_header(self, name: bytes, value: bytes):
name = name.lower()
Expand Down Expand Up @@ -231,6 +237,8 @@ def on_headers_complete(self):

existing_cycle = self.cycle
self.cycle = RequestResponseCycle(
loop=self.loop,
allow_sendfile=self.allow_sendfile,
scope=self.scope,
transport=self.transport,
flow=self.flow,
Expand Down Expand Up @@ -324,6 +332,8 @@ def timeout_keep_alive_handler(self):
class RequestResponseCycle:
def __init__(
self,
loop,
allow_sendfile,
scope,
transport,
flow,
Expand All @@ -336,6 +346,8 @@ def __init__(
keep_alive,
on_response,
):
self.loop = loop
self.allow_sendfile = allow_sendfile
self.scope = scope
self.transport = transport
self.flow = flow
Expand All @@ -361,6 +373,11 @@ def __init__(
self.chunked_encoding = None
self.expected_content_length = 0

# Sendfile
if self.allow_sendfile: # pragma: no cover
# Set the buffer to 0 to avoid the problem of sending file before headers.
transport.set_write_buffer_limits(0)

# ASGI exception wrapper
async def run_asgi(self, app):
try:
Expand Down Expand Up @@ -472,31 +489,72 @@ async def send(self, message):

elif not self.response_complete:
# Sending response body
if message_type != "http.response.body":
msg = "Expected ASGI message 'http.response.body', but got '%s'."
raise RuntimeError(msg % message_type)

body = message.get("body", b"")
more_body = message.get("more_body", False)
use_sendfile = False
if message_type == "http.response.body":
body = message.get("body", b"")
more_body = message.get("more_body", False)
elif (
self.allow_sendfile and message_type == "http.response.zerocopysend"
): # pragma: no cover
file_fd = message["file"]
sendfile_offset = message.get("offset", None)
if sendfile_offset is None:
sendfile_offset = os.lseek(file_fd, 0, os.SEEK_CUR)
sendfile_count = message.get("count", None)
if sendfile_count is None:
sendfile_count = os.stat(file_fd).st_size - sendfile_offset
more_body = message.get("more_body", False)
use_sendfile = True
else:
if self.allow_sendfile: # pragma: no cover
expect_message_types = (
"http.response.body",
"http.response.zerocopysend",
)
else:
expect_message_types = ("http.response.body",)
msg = "Expected ASGI message %s, but got '%s'."
raise RuntimeError(msg % expect_message_types, message_type)

# Write response body
if self.scope["method"] == "HEAD":
self.expected_content_length = 0
elif self.chunked_encoding:
if body:
content = [b"%x\r\n" % len(body), body, b"\r\n"]
else:
content = []
if not more_body:
content.append(b"0\r\n\r\n")
self.transport.write(b"".join(content))
if not use_sendfile:
if body:
content = [b"%x\r\n" % len(body), body, b"\r\n"]
else:
content = []
if not more_body:
content.append(b"0\r\n\r\n")
self.transport.write(b"".join(content))
else: # pragma: no cover
self.transport.write(b"%x\r\n" % sendfile_count)
await self.flow.drain()
with os.fdopen(os.dup(file_fd), "rb") as file:
await self.loop.sendfile(
self.transport, file, sendfile_offset, sendfile_count
)
if more_body:
self.transport.write(b"\r\n")
else:
self.transport.write(b"\r\n0\r\n\r\n")
else:
num_bytes = len(body)
if not use_sendfile:
num_bytes = len(body)
self.transport.write(body)
else: # pragma: no cover
num_bytes = sendfile_count
await self.flow.drain()
with os.fdopen(os.dup(file_fd), "rb") as file:
await self.loop.sendfile(
self.transport, file, sendfile_offset, sendfile_count
)

if num_bytes > self.expected_content_length:
raise RuntimeError("Response content longer than Content-Length")
else:
self.expected_content_length -= num_bytes
self.transport.write(body)

# Handle response completion
if not more_body:
Expand Down
Loading