-
Notifications
You must be signed in to change notification settings - Fork 137
Connection pool optimization: move socket polling from expiry checks to connection usage #928
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
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
T-256
left a comment
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
| 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 |
| connection | ||
| 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. 😅
| connection | ||
| 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.
|
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
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 throughhttpcorewith 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 raisesConnectionNotAvailablewhich 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
urllib3is 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