Надёжные сетевые приложения с помощью asyncio, socket и TLS

Раздел: Продвинутый Python -> Сетевое программирование

Способы создания сетевых служб в Python 3

В Python 3 для работы с сетью доступны модули socket, socketserver, selectors, asyncio и ssl. Сокеты открывают низкоуровневый доступ к TCP и UDP. Модуль asyncio организует конкурентное выполнение, а ssl добавляет защищённый канал. Выбор API влияет на количество обслуживаемых подключений и сложность кода.

Какой способ обслуживания многих TCP-подключений подходит для высоконагруженных сервисов?

Библиотека asyncio создана для событийной обработки сети. В одном процессе работает цикл событий, а каждое соединение представлено парой reader и writer. Такой сервер не простаивает, пока клиент не отправил данные, и не тратит ресурсы на лишние потоки.

import asyncio

async def echo_handler(reader: asyncio.StreamReader,
                       writer: asyncio.StreamWriter) -> None:
    peer = writer.get_extra_info('peername')
    print('Клиент подключён:', peer)
    try:
        while True:
            data = await reader.read(1024)
            if not data:
                break
            writer.write(data.upper())
            await writer.drain()
    except (ConnectionResetError, BrokenPipeError):
        print('Соединение потеряно:', peer)
    finally:
        writer.close()
        await writer.wait_closed()

async def start_server() -> None:
    server = await asyncio.start_server(echo_handler, '127.0.0.1', 8888)
    async with server:
        await server.serve_forever()

if __name__ == '__main__':
    asyncio.run(start_server())

Python ipaddress ip network (модуль ipaddress в python)

Клиент для проверки отправляет байтовую строку и ждёт ответ.

import socket

with socket.create_connection(('127.0.0.1', 8888)) as client:
    client.sendall(b'hello')
    answer = client.recv(1024)
    print(answer.decode())

Python 3 сеть (сетевые возможности python 3)

Ответ сервера становится строкой HELLO. Метод read возвращает пустую последовательность при закрытии соединения. Вызов drain передаёт данные из буфера записи без блокировки всего цикла событий.

Частая ошибка появляется при использовании блокирующей функции внутри корутины. Код time.sleep останавливает обслуживание всех клиентов. Вместо неё нужен await asyncio.sleep, а для синхронных операций применяется метод run_in_executor.

Что изменить в обычном сокетном сервере, чтобы каждый клиент обрабатывался параллельно?

Поток на соединение остаётся простым и понятным способом распараллеливания. Этот вариант подходит для локальных сервисов и небольшого числа абонентов.

import socket
from threading import Thread

def handle_tcp(connection, address):
    print('Новое подключение:', address)
    with connection:
        while True:
            data = connection.recv(1024)
            if not data:
                break
            connection.sendall(data.upper())

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 9000))
server.listen()
print('TCP сервер слушает порт 9000')
while True:
    client_socket, client_address = server.accept()
    Thread(target=handle_tcp, args=(client_socket, client_address), daemon=True).start()

Python client py (клиент на python)

После accept создаётся поток, в котором выполняются чтение и запись. Основной цикл сразу возвращается к приёму новых подключений. Такой подход удобен, когда клиентов десятки, а не тысячи.

Число одновременно работающих потоков ограничено памятью. При большом количестве подключений расход стека становится избыточным. Кроме того, GIL не ускоряет вычисления в потоках, поэтому для процессорной работы они дают малую пользу.

Как обработать несколько сокетов в одном потоке без ожидания на каждом из них?

Модуль selectors передаёт управление операционной системе, которая сообщает о готовности сокета к чтению или записи. Это событийная альтернатива потокам.

import selectors
import socket

sel = selectors.DefaultSelector()

def accept_client(server_socket):
    connection, address = server_socket.accept()
    connection.setblocking(False)
    print('Новый клиент:', address)
    sel.register(connection, selectors.EVENT_READ, read_client)

def read_client(connection):
    data = connection.recv(1024)
    if data:
        connection.sendall(data.upper())
    else:
        print('Клиент отключился:', connection.getpeername())
        sel.unregister(connection)
        connection.close()

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 9100))
server_socket.listen()
server_socket.setblocking(False)
sel.register(server_socket, selectors.EVENT_READ, accept_client)

while True:
    events = sel.select()
    for key, _ in events:
        key.data(key.fileobj)

Python socket (сокеты в python (socket))

Функция key.data содержит обработчик, который вызывается для готового файлового объекта. Сервер способен держать открытыми тысячи сокетов без создания потоков.

Если забыть про неблокирующий режим, вызов recv внутри обработчика канала остановит весь цикл. После accept сокет клиента должен быть переведён в неблокирующий режим до регистрации.

Каким образом передаются короткие сообщения по UDP без установления соединения?

UDP подходит для быстрых дейтаграмм, когда допустима потеря пакетов. Сервер читает адрес клиента и отправляет ответ по этому же адресу.

import socket

udp_server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_server.bind(('127.0.0.1', 9500))
print('Сервер ожидает данные на порту 9500')
while True:
    message, address = udp_server.recvfrom(1024)
    print('Сообщение:', message.decode(), 'от', address)
    udp_server.sendto(message.upper(), address)

Python network programming (сетевое программирование на python)

Клиент отправляет дейтаграмму и ожидает ответа с таймаутом.

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.settimeout(2)
client.sendto(b'hello protocol', ('127.0.0.1', 9500))
try:
    response, _ = client.recvfrom(1024)
    print('Ответ:', response.decode())
except socket.timeout:
    print('Ответ не пришёл')

Отправка происходит без логического соединения, поэтому отсутствует фаза подтверждения. Использование таймаута защищает клиент от вечного ожидания при потере пакета.

UDP не гарантирует доставку, целостность и порядок сообщений. Если приложение требует надёжности, поверх UDP следует строить контрольные номера и повторные передачи либо переходить на TCP.

Сетевые сценарии для повторного использования

Приведённые примеры расширяют базовую работу с сокетами и демонстрируют совмещение разных модулей Python 3.

Как организовать локальный обмен данными через Unix domain socket?

Unix socket привязан к файлу в файловой системе. Это удобно для связи контейнеров и процессов на одном хосте.

Пример
import os
import socket

path = '/tmp/py_net_service.sock'
if os.path.exists(path):
    os.remove(path)

server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(path)
server.listen(1)
print('Ожидание клиента')
connection, _ = server.accept()
with connection:
    request = connection.recv(1024)
    print('Запрос:', request.decode())
    connection.sendall(b'ответ локального сервера')
server.close()

Клиент подключается к пути без указания port.

Пример
import socket

client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect('/tmp/py_net_service.sock')
client.sendall(b'hello from client')
response = client.recv(1024)
print('Ответ:', response.decode())
client.close()

Сервер выводит запрос и отправляет ответ. Дополнительный пример вывода для сервера:

Ожидание клиента
Запрос: hello from client

Для клиента результат будет таким.

Ответ: ответ локального сервера

Как проверить порты с таймаутом с помощью asyncio?

Функция open_connection пытается установить TCP-соединение. Обёртка wait_for ограничивает время ожидания.

Пример
import asyncio

async def check_port(host, port, timeout=1.5):
    try:
        reader, writer = await asyncio.wait_for(
            asyncio.open_connection(host, port), timeout)
    except (asyncio.TimeoutError, OSError):
        return False
    writer.close()
    await writer.wait_closed()
    return True

async def scan(host, ports):
    tasks = [asyncio.create_task(check_port(host, p)) for p in ports]
    results = await asyncio.gather(*tasks)
    return [port for port, is_open in zip(ports, results) if is_open]

if __name__ == '__main__':
    open_ports = asyncio.run(scan('127.0.0.1', [22, 80, 443, 8000, 8888]))
    print('Открытые порты:', open_ports)

Когда хост недоступен, возникает OSError, и функция возвращает False. Вывод может выглядеть так.

Открытые порты: [8000, 8888]

Как отправить защищённый HTTP-запрос с проверкой сертификата?

Модуль ssl создаёт контекст с сертификатами доверенных центров. Класс HTTPSConnection применяет этот контекст при установке TLS-сессии.

Пример
import http.client
import ssl

context = ssl.create_default_context()
connection = http.client.HTTPSConnection('example.com', timeout=5, context=context)
connection.request('GET', '/')
response = connection.getresponse()
body = response.read(100)
print(response.status, response.reason)
print(body.decode('utf-8', errors='replace').strip())
connection.close()

Программа выполнит TLS-руккопожатие и проверит имя хоста в сертификате. Возможный фрагмент вывода.

200 OK



    Example Domain
Если сертификат не проходит проверку, возникает ssl.SSLCertVerificationError. Для собственного удостоверяющего центра используется ssl.create_default_context(cafile='ca.pem') или доверие к конкретному сертификату через load_verify_locations.

Как реализовать UDP-приёмник на основе асинхронного протокола?

DatagramProtocol подходит для неблокирующего UDP в приложениях asyncio.

Пример
import asyncio

class UdpEchoProtocol(asyncio.DatagramProtocol):
    def connection_made(self, transport):
        self.transport = transport
        print('UDP транспорт запущен')

    def datagram_received(self, data, addr):
        message = data.decode()
        print(f'Пакет от {addr[0]}:{addr[1]}: {message}')
        self.transport.sendto(f'эхо: {message}'.encode(), addr)

    def error_received(self, exception):
        print('Ошибка сокета:', exception)

async def run_udp():
    loop = asyncio.get_running_loop()
    transport, _ = await loop.create_datagram_endpoint(
        UdpEchoProtocol,
        local_addr=('127.0.0.1', 8889)
    )
    try:
        await asyncio.Event().wait()
    finally:
        transport.close()

if __name__ == '__main__':
    try:
        asyncio.run(run_udp())
    except KeyboardInterrupt:
        pass

Синхронный клиент отправляет дейтаграмму на порт 8889.

Пример
import socket

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
    client.sendto(b'hello async udp', ('127.0.0.1', 8889))
    client.settimeout(2)
    try:
        response, _ = client.recvfrom(1024)
        print(response.decode())
    except socket.timeout:
        print('Таймаут')

Вывод сервера.

UDP транспорт запущен
Пакет от 127.0.0.1:60001: hello async udp

Вывод клиента.

эхо: hello async udp

Сетевые возможности Python 3 - comments

En
Python 3 сеть (python)