python / python/cpython

Asyncio BufferedProtocol with SSL is significantly slower than asyncio sockets with SSL

Đang mở
#133,112 0 bình luận 1 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

performance topic-asyncio topic-SSL
Ngôn ngữ chính
Python
Star
77.2k
Fork
35.9k
Chỉ số merge pull request
Chỉ số pull request đang chờ

Mô tả

Bug report

Bug description:

With SSL enabled, asyncio.BufferedProtocol is significantly slower than using sockets:

$ python clients.py
Python: 3.13.0 (main, Oct 14 2024, 11:12:17) [Clang 15.0.0 (clang-1500.3.9.4)]
Running 100 trials with message size 100,000,000 bytes
Sockets: 10.36 seconds
Protocols: 17.50 seconds

Reproducible example:

shared.py:

MESSAGE_SIZE = 1_000_000 * 100
MESSAGE = b"a" * MESSAGE_SIZE 

HOST = "127.0.0.1"
PORT = 1234

server.py:

import socket
import ssl
from shared import MESSAGE, MESSAGE_SIZE, HOST, PORT


context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.verify_mode = ssl.CERT_NONE
context.load_cert_chain(certfile="cert.pem", keyfile="cert.pem")

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s = context.wrap_socket(s, server_side=True)

s.bind((HOST, PORT))
s.listen()

while True:
    conn, _= s.accept()
    bytes_read = 0

    mv = memoryview(bytearray(MESSAGE_SIZE))
    while bytes_read < MESSAGE_SIZE:
        read = conn.recv_into(mv[bytes_read:])
        if read == 0:
            raise OSError("Closed by peer")
        bytes_read += read
    conn.sendall(MESSAGE)
    conn.close()

clients.py:

import socket
import sys
import asyncio
import ssl
import timeit

from shared import MESSAGE, MESSAGE_SIZE, HOST, PORT


TRIALS=100

context = ssl.SSLContext()
context.verify_mode = ssl.CERT_NONE
context.check_hostname = False

class Protocol(asyncio.BufferedProtocol):
    def __init__(self):
        super().__init__()
        self._buffer = memoryview(bytearray(MESSAGE_SIZE))
        self._offset = 0
        self._done = None
        self._loop = asyncio.get_running_loop()

    def connection_made(self, transport):
        self.transport = transport
        self.transport.set_write_buffer_limits(MESSAGE_SIZE, MESSAGE_SIZE)

    async def write(self, message: bytes):
        self.transport.write(message)

    async def read(self):
        self._done = self._loop.create_future()
        await self._done

    def get_buffer(self, sizehint: int):
        return self._buffer[self._offset:]

    def buffer_updated(self, nbytes: int):
        if self._done and not self._done.done():
            self._offset += nbytes
            if self._offset == MESSAGE_SIZE:
                self._done.set_result(True)

    def data(self):
        return self._buffer

async def _async_socket_sendall_ssl(
    sock: ssl.SSLSocket, buf: bytes, loop: asyncio.AbstractEventLoop
) -> None:
    view = memoryview(buf)
    sent = 0

    def _is_ready(fut: asyncio.Future) -> None:
        if fut.done():
            return
        fut.set_result(None)

    while sent < len(buf):
        try:
            sent += sock.send(view[sent:])
        except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as exc:
            fd = sock.fileno()
            # Check for closed socket.
            if fd == -1:
                raise ssl.SSLError("Underlying socket has been closed") from None
            if isinstance(exc, ssl.SSLWantReadError):
                fut = loop.create_future()
                loop.add_reader(fd, _is_ready, fut)
                try:
                    await fut
                finally:
                    loop.remove_reader(fd)
            if isinstance(exc, ssl.SSLWantWriteError):
                fut = loop.create_future()
                loop.add_writer(fd, _is_ready, fut)
                try:
                    await fut
                finally:
                    loop.remove_writer(fd)

async def _async_socket_receive_ssl(
    conn: ssl.SSLSocket, length: int, loop: asyncio.AbstractEventLoop
) -> memoryview:
    mv = memoryview(bytearray(length))
    total_read = 0

    def _is_ready(fut: asyncio.Future) -> None:
        if fut.done():
            return
        fut.set_result(None)

    while total_read < length:
        try:
            read = conn.recv_into(mv[total_read:])
            if read == 0:
                raise OSError("connection closed")
            total_read += read
        except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as exc:
            fd = conn.fileno()
            # Check for closed socket.
            if fd == -1:
                raise ssl.SSLError("Underlying socket has been closed") from None
            if isinstance(exc, ssl.SSLWantReadError):
                fut = loop.create_future()
                loop.add_reader(fd, _is_ready, fut)
                try:
                    await fut
                finally:
                    loop.remove_reader(fd)
            if isinstance(exc, ssl.SSLWantWriteError):
                fut = loop.create_future()
                loop.add_writer(fd, _is_ready, fut)
                try:
                    await fut
                finally:
                    loop.remove_writer(fd)
    return mv


def socket_client():
    async def inner():
        loop = asyncio.get_running_loop()
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s = context.wrap_socket(s)
        s.connect((HOST, PORT))
        s.setblocking(False)
        await _async_socket_sendall_ssl(s, MESSAGE, loop)
        data = await _async_socket_receive_ssl(s, MESSAGE_SIZE, loop)
        assert len(data) == MESSAGE_SIZE and data[0]
        s.close()
    
    asyncio.run(inner())


def protocols_client():
    async def inner():
        loop = asyncio.get_running_loop()

        transport, protocol = await loop.create_connection(
            lambda: Protocol(),
            HOST, PORT, ssl=context)
        
        await asyncio.wait_for(protocol.write(MESSAGE), timeout=None)

        await asyncio.wait_for(protocol.read(), timeout=None)
        data = protocol.data()

        assert len(data) == MESSAGE_SIZE and data[0] == ord("a")
        transport.close()

    asyncio.run(inner())


def run_test(title, func):
    result = timeit.timeit(f"{func}()", setup=f"from __main__ import {func}", number=TRIALS)
    print(f"{title}: {result:.2f} seconds")


if __name__ == '__main__':
    print(f"Python: {sys.version}")
    print(f"Running {TRIALS} trials with message size {format(MESSAGE_SIZE, ',')} bytes")
    run_test("Sockets", "socket_client")
    run_test("Protocols", "protocols_client")

Profiling with cProfile + snakeviz shows that the protocol is calling ssl.write, while the socket is calling ssl.send, but that seems like an unlikely cause by itself.

Protocol:
Image

Socket:
Image

CPython versions tested on:

3.13

Operating systems tested on:

macOS, Linux

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu bằng cách chạy reproducer server.py và clients.py trên các phiên bản Python và hệ điều hành được liệt kê, sau đó so sánh các profile SSL của socket và BufferedProtocol. Truy vết đường dẫn transport của asyncio BufferedProtocol và SSL chịu trách nhiệm cho khác biệt này. Hoàn tất khi đã xác định được nguyên nhân và thêm một bản sửa lỗi đã được xác minh hoặc regression coverage cho chênh lệch hiệu năng được báo cáo.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
networking, performance
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Đình trệ
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
35/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.