-
Notifications
You must be signed in to change notification settings - Fork 106
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
Connection pool optimization: move socket polling from expiry checks to connection usage #928
base: master
Are you sure you want to change the base?
Connection pool optimization: move socket polling from expiry checks to connection usage #928
Conversation
c4ab106
to
90bbfdc
Compare
@T-256 I fixed this PR to drop the interval based socket polling approach as you suggested here. Instead this is now using a very similar approach as in Even with the polling on usage, performance is much better than previous one. |
e3778a4
to
27e73bb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Less overheads than interval mechanism :)
btw here are few review comments:
httpcore/_exceptions.py
Outdated
@@ -19,6 +19,10 @@ class ConnectionNotAvailable(Exception): | |||
pass | |||
|
|||
|
|||
class ServerDisconnectedError(Exception): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could be subclass of ConnectionNotAvailable
since there connection won't be available after server closed it. this would prevent from this change.
Also, we need to add it in docs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, made it a subclass of the existing one. Should we have a subclass also for the previous state related error?
Also, we need to add it in docs.
I can not see existing one mentioned in the docs. Probably due to the exception being handled by the pool. I added docstrings instead to the classes about this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Im now wondering do we even need the new ServerDisconnectedError
. Because this is also internal to the pooling mechanism like the previous one. Im fine either way.
httpcore/_async/http11.py
Outdated
if self._state == HTTPConnectionState.SERVER_DISCONNECTED: | ||
raise ServerDisconnectedError() | ||
|
||
# If the HTTP connection is idle but the socket is readable, then the | ||
# only valid state is that the socket is about to return b"", indicating | ||
# a server-initiated disconnect. | ||
server_disconnected = ( | ||
self._state == HTTPConnectionState.IDLE | ||
and self._network_stream.get_extra_info("is_readable") | ||
) | ||
if server_disconnected: | ||
self._state = HTTPConnectionState.SERVER_DISCONNECTED | ||
raise ServerDisconnectedError() | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
most of users make subclass of handle_async_request
to customize their Client instances. I think it could be better for us to keep it cleaner and more readable.
Could you export this change into priavte method _raise_for_state
which have no returns and only raises if possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, moved it into _update_state
function as we also need to update the state as part of raising. (Also for http2 to have it consistent)
tests/_sync/test_http11.py
Outdated
import pytest | ||
|
||
import httpcore | ||
from httpcore._exceptions import ServerDisconnectedError |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
from httpcore._exceptions import ServerDisconnectedError |
Don't import from private modules
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
tests/_sync/test_http11.py
Outdated
with pytest.raises(ServerDisconnectedError): | ||
conn.request("GET", "https://example.com/") | ||
assert conn.has_expired() and not conn.is_idle() | ||
|
||
with pytest.raises(ServerDisconnectedError): | ||
conn.request("GET", "https://example.com/") | ||
assert conn.has_expired() and not conn.is_idle() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with pytest.raises(ServerDisconnectedError): | |
conn.request("GET", "https://example.com/") | |
assert conn.has_expired() and not conn.is_idle() | |
with pytest.raises(ServerDisconnectedError): | |
conn.request("GET", "https://example.com/") | |
assert conn.has_expired() and not conn.is_idle() | |
with pytest.raises(httpcore.ServerDisconnectedError): | |
conn.request("GET", "https://example.com/") | |
assert conn.has_expired() and not conn.is_idle() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed (the latter one was actually for coverage but I changed to logic so that we dont need the extra assert step here for coverage)
@MarkusSintonen Do you mind/working to send PR for re-adding |
Sure, I was wishing on getting the synchronization PR first approved/merged before opening it. But I can open it already. |
pool_request.assign_to_connection(connection) | ||
else: | ||
purged_connection = next( | ||
(c for c in self._connections if c.is_idle() or c.has_expired()), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also considers expired connections here for removal. Because A) clock may have moved forward so that some are now expired here B) expired ones are the ones that maybe have been disconnected by the server (they are not idle)
server_disconnected = ( | ||
self._state == HTTPConnectionState.IDLE | ||
and self._network_stream.get_extra_info("is_readable") | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this check be also done in AsyncHTTP2Connection
similarly as here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think yes, but cc @tomchristie who knows better about http2
b19e879
to
6422799
Compare
Its now here: #930 |
@@ -267,15 +267,12 @@ def _assign_requests_to_connections(self) -> List[AsyncConnectionInterface]: | |||
for connection in self._connections | |||
if connection.can_handle_request(origin) and connection.is_available() | |||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like we could just find the first available one here?
available_connections = next(
(
connection
for connection in self._connections
if connection.can_handle_request(origin)
and connection.is_available()
),
None,
)
...
if available_connections:
connection = available_connections
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I just noticed that this has been split into separate PRs. 😅
@@ -267,15 +267,12 @@ def _assign_requests_to_connections(self) -> List[ConnectionInterface]: | |||
for connection in self._connections | |||
if connection.can_handle_request(origin) and connection.is_available() | |||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto.
Summary
(Split from original PR here #924 but this is more refined)
Connection pool implementation is heavily doing socket polling via
AsyncConnectionInterface.has_expired()
. This check in turn doesAsyncNetworkStream.get_extra_info("is_readable")
which finally doesselect.poll()
. This check quickly starts to add up when doing many requests throughhttpcore
with great concurrency. Many connection sockets in the pool are constantly polled over and over again when doing requests and also when request closes.This PR instead moved the socket polling into
AsyncHTTP11Connection.handle_async_request
. Here the readable-IDLE connection raisesConnectionNotAvailable
which makes the request to choose a next connection from the pool. This causes lot less socket polling as now its not done every single time for all connections when pool connections are assigned. Broken connections are still properly maintained as they are still removed from the pool.This approach is very similar on how
urllib3
is validating the health of the connection coming from the pool. (This check finally uses wait_for_read which uses similar socket polling as httpcore)Adds some previously missing tests.
Benchmark shows how the heavy socket logic causes over 4.6x overhead.
In master with async requests:
PR with async:
With synchronous code the overhead is not as large. There the connection pools socket polling causes 1.6x overhead (maybe the overhead is lower due to sync concurrency using threads where the socket polling IO is not in GIL). Rest of overhead in sync is coming from the connection pools maintenance loops that is fixed here.
In master with sync code:
PR with sync:
Trio-based backend is also affected by this. There the overhead is at similar levels of about 5x as was seen here.
Checklist