Skip to content

Commit d2d1edd

Browse files
illia-vpicnixzZeroIntensity
authored andcommitted
pythongh-99813: Start using SSL_sendfile when available (python#99907)
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> Co-authored-by: Peter Bierma <zintensitydev@gmail.com>
1 parent 15ef469 commit d2d1edd

File tree

7 files changed

+449
-82
lines changed

7 files changed

+449
-82
lines changed

Doc/library/ssl.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,8 +1078,9 @@ SSL Sockets
10781078
(but passing a non-zero ``flags`` argument is not allowed)
10791079
- :meth:`~socket.socket.send`, :meth:`~socket.socket.sendall` (with
10801080
the same limitation)
1081-
- :meth:`~socket.socket.sendfile` (but :mod:`os.sendfile` will be used
1082-
for plain-text sockets only, else :meth:`~socket.socket.send` will be used)
1081+
- :meth:`~socket.socket.sendfile` (it may be high-performant only when
1082+
the kernel TLS is enabled by setting :data:`~ssl.OP_ENABLE_KTLS` or when a
1083+
socket is plain-text, else :meth:`~socket.socket.send` will be used)
10831084
- :meth:`~socket.socket.shutdown`
10841085

10851086
However, since the SSL (and TLS) protocol has its own framing atop
@@ -1113,6 +1114,11 @@ SSL Sockets
11131114
functions support reading and writing of data larger than 2 GB. Writing
11141115
zero-length data no longer fails with a protocol violation error.
11151116

1117+
.. versionchanged:: next
1118+
Python now uses ``SSL_sendfile`` internally when possible. The
1119+
function sends a file more efficiently because it performs TLS encryption
1120+
in the kernel to avoid additional context switches.
1121+
11161122
SSL sockets also have the following additional methods and attributes:
11171123

11181124
.. method:: SSLSocket.read(len=1024, buffer=None)

Lib/socket.py

Lines changed: 76 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import os
5757
import sys
5858
from enum import IntEnum, IntFlag
59+
from functools import partial
5960

6061
try:
6162
import errno
@@ -348,75 +349,83 @@ def makefile(self, mode="r", buffering=None, *,
348349
text.mode = mode
349350
return text
350351

351-
if hasattr(os, 'sendfile'):
352+
def _sendfile_zerocopy(self, zerocopy_func, giveup_exc_type, file,
353+
offset=0, count=None):
354+
"""
355+
Send a file using a zero-copy function.
356+
"""
357+
import selectors
352358

353-
def _sendfile_use_sendfile(self, file, offset=0, count=None):
354-
# Lazy import to improve module import time
355-
import selectors
359+
self._check_sendfile_params(file, offset, count)
360+
sockno = self.fileno()
361+
try:
362+
fileno = file.fileno()
363+
except (AttributeError, io.UnsupportedOperation) as err:
364+
raise giveup_exc_type(err) # not a regular file
365+
try:
366+
fsize = os.fstat(fileno).st_size
367+
except OSError as err:
368+
raise giveup_exc_type(err) # not a regular file
369+
if not fsize:
370+
return 0 # empty file
371+
# Truncate to 1GiB to avoid OverflowError, see bpo-38319.
372+
blocksize = min(count or fsize, 2 ** 30)
373+
timeout = self.gettimeout()
374+
if timeout == 0:
375+
raise ValueError("non-blocking sockets are not supported")
376+
# poll/select have the advantage of not requiring any
377+
# extra file descriptor, contrarily to epoll/kqueue
378+
# (also, they require a single syscall).
379+
if hasattr(selectors, 'PollSelector'):
380+
selector = selectors.PollSelector()
381+
else:
382+
selector = selectors.SelectSelector()
383+
selector.register(sockno, selectors.EVENT_WRITE)
356384

357-
self._check_sendfile_params(file, offset, count)
358-
sockno = self.fileno()
359-
try:
360-
fileno = file.fileno()
361-
except (AttributeError, io.UnsupportedOperation) as err:
362-
raise _GiveupOnSendfile(err) # not a regular file
363-
try:
364-
fsize = os.fstat(fileno).st_size
365-
except OSError as err:
366-
raise _GiveupOnSendfile(err) # not a regular file
367-
if not fsize:
368-
return 0 # empty file
369-
# Truncate to 1GiB to avoid OverflowError, see bpo-38319.
370-
blocksize = min(count or fsize, 2 ** 30)
371-
timeout = self.gettimeout()
372-
if timeout == 0:
373-
raise ValueError("non-blocking sockets are not supported")
374-
# poll/select have the advantage of not requiring any
375-
# extra file descriptor, contrarily to epoll/kqueue
376-
# (also, they require a single syscall).
377-
if hasattr(selectors, 'PollSelector'):
378-
selector = selectors.PollSelector()
379-
else:
380-
selector = selectors.SelectSelector()
381-
selector.register(sockno, selectors.EVENT_WRITE)
382-
383-
total_sent = 0
384-
# localize variable access to minimize overhead
385-
selector_select = selector.select
386-
os_sendfile = os.sendfile
387-
try:
388-
while True:
389-
if timeout and not selector_select(timeout):
390-
raise TimeoutError('timed out')
391-
if count:
392-
blocksize = min(count - total_sent, blocksize)
393-
if blocksize <= 0:
394-
break
395-
try:
396-
sent = os_sendfile(sockno, fileno, offset, blocksize)
397-
except BlockingIOError:
398-
if not timeout:
399-
# Block until the socket is ready to send some
400-
# data; avoids hogging CPU resources.
401-
selector_select()
402-
continue
403-
except OSError as err:
404-
if total_sent == 0:
405-
# We can get here for different reasons, the main
406-
# one being 'file' is not a regular mmap(2)-like
407-
# file, in which case we'll fall back on using
408-
# plain send().
409-
raise _GiveupOnSendfile(err)
410-
raise err from None
411-
else:
412-
if sent == 0:
413-
break # EOF
414-
offset += sent
415-
total_sent += sent
416-
return total_sent
417-
finally:
418-
if total_sent > 0 and hasattr(file, 'seek'):
419-
file.seek(offset)
385+
total_sent = 0
386+
# localize variable access to minimize overhead
387+
selector_select = selector.select
388+
try:
389+
while True:
390+
if timeout and not selector_select(timeout):
391+
raise TimeoutError('timed out')
392+
if count:
393+
blocksize = min(count - total_sent, blocksize)
394+
if blocksize <= 0:
395+
break
396+
try:
397+
sent = zerocopy_func(fileno, offset, blocksize)
398+
except BlockingIOError:
399+
if not timeout:
400+
# Block until the socket is ready to send some
401+
# data; avoids hogging CPU resources.
402+
selector_select()
403+
continue
404+
except OSError as err:
405+
if total_sent == 0:
406+
# We can get here for different reasons, the main
407+
# one being 'file' is not a regular mmap(2)-like
408+
# file, in which case we'll fall back on using
409+
# plain send().
410+
raise giveup_exc_type(err)
411+
raise err from None
412+
else:
413+
if sent == 0:
414+
break # EOF
415+
offset += sent
416+
total_sent += sent
417+
return total_sent
418+
finally:
419+
if total_sent > 0 and hasattr(file, 'seek'):
420+
file.seek(offset)
421+
422+
if hasattr(os, 'sendfile'):
423+
def _sendfile_use_sendfile(self, file, offset=0, count=None):
424+
return self._sendfile_zerocopy(
425+
partial(os.sendfile, self.fileno()),
426+
_GiveupOnSendfile,
427+
file, offset, count,
428+
)
420429
else:
421430
def _sendfile_use_sendfile(self, file, offset=0, count=None):
422431
raise _GiveupOnSendfile(

Lib/ssl.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,10 @@ def _sslcopydoc(func):
975975
return func
976976

977977

978+
class _GiveupOnSSLSendfile(Exception):
979+
pass
980+
981+
978982
class SSLSocket(socket):
979983
"""This class implements a subtype of socket.socket that wraps
980984
the underlying OS socket in an SSL context when necessary, and
@@ -1266,15 +1270,26 @@ def sendall(self, data, flags=0):
12661270
return super().sendall(data, flags)
12671271

12681272
def sendfile(self, file, offset=0, count=None):
1269-
"""Send a file, possibly by using os.sendfile() if this is a
1270-
clear-text socket. Return the total number of bytes sent.
1273+
"""Send a file, possibly by using an efficient sendfile() call if
1274+
the system supports it. Return the total number of bytes sent.
12711275
"""
1272-
if self._sslobj is not None:
1273-
return self._sendfile_use_send(file, offset, count)
1274-
else:
1275-
# os.sendfile() works with plain sockets only
1276+
if self._sslobj is None:
12761277
return super().sendfile(file, offset, count)
12771278

1279+
if not self._sslobj.uses_ktls_for_send():
1280+
return self._sendfile_use_send(file, offset, count)
1281+
1282+
sendfile = getattr(self._sslobj, "sendfile", None)
1283+
if sendfile is None:
1284+
return self._sendfile_use_send(file, offset, count)
1285+
1286+
try:
1287+
return self._sendfile_zerocopy(
1288+
sendfile, _GiveupOnSSLSendfile, file, offset, count,
1289+
)
1290+
except _GiveupOnSSLSendfile:
1291+
return self._sendfile_use_send(file, offset, count)
1292+
12781293
def recv(self, buflen=1024, flags=0):
12791294
self._checkClosed()
12801295
if self._sslobj is not None:

Lib/test/test_ssl.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4316,19 +4316,30 @@ def test_read_write_after_close_raises_valuerror(self):
43164316
self.assertRaises(ValueError, s.write, b'hello')
43174317

43184318
def test_sendfile(self):
4319+
"""Try to send a file using kTLS if possible."""
43194320
TEST_DATA = b"x" * 512
43204321
with open(os_helper.TESTFN, 'wb') as f:
43214322
f.write(TEST_DATA)
43224323
self.addCleanup(os_helper.unlink, os_helper.TESTFN)
43234324
client_context, server_context, hostname = testing_context()
4325+
client_context.options |= getattr(ssl, 'OP_ENABLE_KTLS', 0)
43244326
server = ThreadedEchoServer(context=server_context, chatty=False)
4325-
with server:
4326-
with client_context.wrap_socket(socket.socket(),
4327-
server_hostname=hostname) as s:
4328-
s.connect((HOST, server.port))
4327+
# kTLS seems to work only with a connection created before
4328+
# wrapping `sock` by the SSL context in contrast to calling
4329+
# `sock.connect()` after the wrapping.
4330+
with server, socket.create_connection((HOST, server.port)) as sock:
4331+
with client_context.wrap_socket(
4332+
sock, server_hostname=hostname
4333+
) as ssock:
4334+
if support.verbose:
4335+
ktls_used = ssock._sslobj.uses_ktls_for_send()
4336+
print(
4337+
'kTLS is',
4338+
'available' if ktls_used else 'unavailable',
4339+
)
43294340
with open(os_helper.TESTFN, 'rb') as file:
4330-
s.sendfile(file)
4331-
self.assertEqual(s.recv(1024), TEST_DATA)
4341+
ssock.sendfile(file)
4342+
self.assertEqual(ssock.recv(1024), TEST_DATA)
43324343

43334344
def test_session(self):
43344345
client_context, server_context, hostname = testing_context()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:mod:`ssl` now uses ``SSL_sendfile`` internally when it is possible (see
2+
:data:`~ssl.OP_ENABLE_KTLS`). The function sends a file more efficiently
3+
because it performs TLS encryption in the kernel to avoid additional context
4+
switches. Patch by Illia Volochii.

0 commit comments

Comments
 (0)