Learning Platform
Глоссарий Troubleshooting
Урок 13.01 · 25 мин
Средний
SocketsBerkeley APINetwork programmingPOSIX

Berkeley sockets API — основа всех сетевых программ

Когда вы пишете requests.get('https://api.github.com'), под капотом библиотека вызывает Berkeley sockets API — набор системных вызовов ОС (socket(), connect(), send(), recv() и т.д.), который существует с 1983 года и используется до сих пор. Любая сетевая программа в Linux, macOS, BSD, Windows — в конечном счёте делает эти вызовы. Все frameworks (asyncio, Twisted, Node.js, Go std/net) — обёртки над ним.

В этом уроке разберём, что такое socket с точки зрения операционной системы, как выглядит API, и почему он одинаковый для TCP и UDP. Это base level, без которого невозможно понять, как работают сетевые программы и что значат всякие «AF_INET», «SOCK_STREAM», «select», «epoll», которые встретятся дальше.


Socket — что это вообще

С точки зрения программы, socket — это файловый дескриптор (file descriptor). В Linux всё — файл: открытая файл, директория, pipe, регулярный файл на диске, и сетевое соединение. С каждой такой сущностью связан номер — небольшое целое число (int), которое программа использует для работы с ней.

int fd = socket(AF_INET, SOCK_STREAM, 0);
// fd теперь -- например 3, представляет TCP-сокет
// Можем использовать его как файл: read(), write(), close()

stdin, stdout, stderr — это просто fd 0, 1, 2. Открыли файл — получили fd 3. Открыли сокет — fd 4. Создали ещё — 5. И так далее.

С точки зрения ядра ОС socket — это структура данных в kernel, содержащая:

  • Тип (TCP, UDP, raw, unix).
  • Состояние (open, connected, closed).
  • Local address + port.
  • Remote address + port (для connected sockets).
  • Буферы send/receive.
  • Параметры (timeouts, options).

API спрятает эти детали за int fd — программа работает с числом, а ядро видит структуру.

Что такое socket -- userspace и kernel
Пользовательская программаВидит socket как int fd. Например, socket(AF_INET, SOCK_STREAM, 0) вернёт fd=3
Системные вызовыЧерез syscalls (send, recv, ...) программа обращается к ядру. fd передаётся как параметр
Kernel: struct socketЯдро в своих структурах хранит данные сокета: TCP state machine, sequence numbers, окна, буферы, IP-адреса, порт
Network stackTCP -> IP -> Ethernet -- ядро инкапсулирует данные и шлёт через NIC. RX обратно вверх по stack

Главное, что fd — это абстракция. С его помощью одни и те же системные вызовы (read, write, close, select) работают и с файлами, и с сокетами, и с pipes.


socket() — создание сокета

Первый шаг любой сетевой программы. Создаёт сокет, возвращает fd:

int sock = socket(domain, type, protocol);

В Python:

import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
# или короче, последний аргумент опциональный:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Три параметра:

Параметры socket()
domain (family)Какая адресная семья. AF_INET -- IPv4 (4-байтные IP), AF_INET6 -- IPv6 (16-байт), AF_UNIX -- unix domain sockets (внутри одного хоста)
typeТип семантики передачи. SOCK_STREAM -- TCP (надёжный поток байт). SOCK_DGRAM -- UDP (пакеты, без гарантий). SOCK_RAW -- raw IP-пакеты (нужны root rights)
protocolКонкретный протокол внутри type. Обычно 0 -- ОС выбирает default: для SOCK_STREAM = TCP, для SOCK_DGRAM = UDP. Можно явно указать IPPROTO_TCP, IPPROTO_UDP

Combinations:

  • socket(AF_INET, SOCK_STREAM, 0) — IPv4 TCP socket. 95% случаев.
  • socket(AF_INET, SOCK_DGRAM, 0) — IPv4 UDP.
  • socket(AF_INET6, SOCK_STREAM, 0) — IPv6 TCP.
  • socket(AF_UNIX, SOCK_STREAM, 0) — Unix domain socket для IPC.

Возвращает fd >= 0 или -1 при ошибке (тогда смотреть errno).

В Python в случае ошибки — exception OSError.


Server flow: bind, listen, accept

Серверный сокет проходит несколько стадий: создан -> привязан к адресу -> слушает -> принимает соединение.

Lifecycle серверного TCP-сокета
1. socket()Создан сокет, fd получен. Пока ни к чему не привязан, ничего не делает
2. bind()Привязать сокет к local IP + port. Теперь ядро знает, на каком интерфейсе и порту слушать. Можно bind на 0.0.0.0 (все интерфейсы) или на конкретный IP
3. listen()Перевести сокет в режим прослушивания. Ядро начинает принимать SYN-пакеты, делать handshake'и, складывать готовые connections в backlog queue
4. accept()Достать одно готовое соединение из queue. Возвращает НОВЫЙ fd для этого клиента. Original listening socket остаётся для следующих connections
5. send/recvЧерез client fd обмениваться данными. send() пишет, recv() читает. Может block'нуться, если нет данных
6. close()Закрыть соединение с клиентом. Original listening socket продолжает работать. Если хочется выключить сервер -- close listening socket тоже

bind()

sock.bind(('0.0.0.0', 8080))
# '0.0.0.0' -- слушать на всех интерфейсах (eth0, lo, и т.д.)
# 8080 -- порт

Если bind на привилегированный port (1-1023) — нужны root rights. На 0 — ядро выберет свободный port случайно (getsockname() потом узнает, какой).

Самая частая ошибка bind(): Address already in use. Значит, что порт уже занят другим процессом (или этим же процессом после crash, потому что TCP-соединения остаются в TIME_WAIT state). Решение: установить SO_REUSEADDR через setsockopt():

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', 8080))

listen()

sock.listen(128)
# backlog = 128 -- глубина очереди completed connections, ждущих accept

Backlog — сколько completed TCP-handshakes ядро может буферизовать в queue, пока приложение их не accept(). Если queue заполнена, новые SYN-пакеты дропаются — клиент видит connection refused или таймаут.

В Linux фактический лимит — min(backlog, /proc/sys/net/core/somaxconn). Часто somaxconn = 4096 по умолчанию. Production-серверам делают backlog большим (несколько тысяч).

accept()

client_sock, client_addr = sock.accept()
# Блокируется, пока кто-то не подключится
# client_sock -- новый fd для этого клиента (НЕ listening sock)
# client_addr -- ('192.168.1.42', 51234) tuple

Это blocking call — программа спит, пока не появится клиент. После accept() мы получаем НОВЫЙ socket для общения с конкретным клиентом. Listening socket продолжает существовать — следующий accept() даст следующего клиента.

В однопоточном сервере: после accept делаем работу с клиентом (send/recv), потом close клиента, потом новый accept. Получаем sequential server — один клиент в момент времени. Слабо. В уроке 4 разберём, как делать concurrent.


Client flow: connect

Клиент намного проще:

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('api.example.com', 443))
# DNS-резолв 'api.example.com' -> IP сделан внутри connect
# Затем TCP 3-way handshake
# Когда connect() вернётся -- TCP-соединение установлено

Что происходит в connect() под капотом:

  1. DNS-резолв. Если имя host — сначала резолв через системный resolver.
  2. TCP 3-way handshake. Отсылается SYN, ждётся SYN-ACK, отсылается ACK.
  3. Если успешно — возврат. Socket готов к send/recv.
  4. Если ошибка — exception (ConnectionRefusedError, TimeoutError и т.д.).

connect() blocking по умолчанию. На медленной сети может занять секунды. Часто хочется timeout:

sock.settimeout(5.0)
try:
    sock.connect(('slow-server.com', 443))
except socket.timeout:
    print("Server is slow")

send и recv: передача данных

После того, как соединение установлено (clientside) или принято (serverside) — обмениваемся данными.

send()

n = sock.send(b'Hello, world!')
# Возвращает количество фактически отправленных байт

Важно: send() не гарантирует, что ВСЕ байты отправлены за один вызов. Возвращает, сколько ядро смогло положить в send buffer. Может быть меньше, чем дали. Корректный way — цикл:

def send_all(sock, data):
    total_sent = 0
    while total_sent < len(data):
        sent = sock.send(data[total_sent:])
        if sent == 0:
            raise ConnectionError("Connection broken")
        total_sent += sent

В Python есть удобный sock.sendall(data) — делает этот цикл сам, кидает exception при проблемах. Используйте его.

recv()

data = sock.recv(4096)
# Прочитать до 4096 байт. Может вернуть меньше -- что было в receive buffer
# Если вернёт b'' (пустой bytes) -- удалённая сторона закрыла соединение

recv() НЕ гарантирует, что вы получите ровно столько, сколько хотите. Это stream-протокол (TCP) — байты могут прийти в несколько кусков. Чтобы прочитать конкретно N байт:

def recv_n(sock, n):
    chunks = []
    bytes_received = 0
    while bytes_received < n:
        chunk = sock.recv(min(n - bytes_received, 4096))
        if not chunk:
            raise ConnectionError("Connection broken")
        chunks.append(chunk)
        bytes_received += len(chunk)
    return b''.join(chunks)

Это типичный pattern для серверов, которые читают сообщения известной длины.

WARNING

TCP не имеет message boundaries. Если клиент сделал send(b"hello"), потом send(b"world"), сервер может получить через recv(): один раз b"helloworld", или b"hel" + b"loworld", или 10 кусков по байту. Если вы хотите message-oriented обмен, нужно явное framing: либо length-prefix (4 байта длины, потом тело), либо delimiter (\n), либо что-то ещё. Без этого парсер сходит с ума.


close() и shutdown()

Когда работа с сокетом закончена:

sock.close()
# Закрывает fd, освобождает ресурсы ядра
# Для TCP инициирует FIN, начинает graceful close

Иногда хочется частичное закрытие — закончить отправку, но продолжать принимать:

sock.shutdown(socket.SHUT_WR)  # больше не будем отправлять
data = sock.recv(4096)         # но получать продолжаем

shutdown бывает:

  • SHUT_RD — больше не будем читать.
  • SHUT_WR — больше не будем писать (шлёт FIN).
  • SHUT_RDWR — ничего больше.

В обычных приложениях используют close(). shutdown() нужен для редких случаев (half-close protocols).


Socket options через setsockopt

Можно настраивать параметры сокета через setsockopt:

# Разрешить bind на TIME_WAIT
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# TCP keepalive -- посылать probes на idle connection
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

# Размер send buffer
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 65536)

# Отключить Nagle's algorithm (для low-latency)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

Главные:

Полезные socket options
SO_REUSEADDRРазрешает bind на порт, который в TIME_WAIT. Без него после crash сервера приходится ждать минуту перед перезапуском
SO_KEEPALIVEВключает TCP keepalive: ядро шлёт keep-alive probes каждые ~2 часа, обнаруживает мёртвые connections
TCP_NODELAYОтключает Nagle's algorithm. Маленькие пакеты отправляются сразу, не буферизуются. Нужно для low-latency игр, RPC
SO_RCVTIMEO / SO_SNDTIMEOТаймауты на blocking операции. recv с таймаутом -- если 5 секунд нет данных, кидает exception

UDP-сокеты: stateless обмен

С UDP API похожий, но семантика другая:

# UDP-сокет
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', 5353))

# Принять одно сообщение
data, addr = sock.recvfrom(4096)
print(f"Received {data!r} from {addr}")

# Отправить ответ
sock.sendto(b'reply', addr)

Ключевые различия:

  1. Нет connect/accept. UDP connectionless — каждое сообщение независимо.
  2. recvfrom возвращает данные и адрес отправителя.
  3. sendto требует адрес получателя.
  4. Message-oriented: каждый recvfrom даёт ровно одно сообщение от одного отправителя. Не нужен framing.
  5. Без гарантий. Сообщение может потеряться, прийти не в порядке, прийти дважды.

UDP idiomatic для request/response где не страшна потеря (DNS, NTP, video streaming) или для коротких маленьких сообщений.


Соответствие API и протоколам

Зачем один и тот же API для TCP и UDP? Потому что Berkeley sockets изначально проектировались как abstract interface к разным протоколам. Тип SOCK_STREAM имеет одну семантику (надёжный поток байт), SOCK_DGRAM — другую (пакеты, без гарантий). Внутри ядра — разные state machines, разные алгоритмы, но user-space видит унифицированный API.

Кроме TCP/UDP, через тот же API:

  • AF_UNIX + SOCK_STREAM — unix domain sockets для IPC (быстрее TCP localhost через kernel).
  • AF_INET + SOCK_RAW — raw IP-пакеты (для ping, traceroute — нужны root).
  • AF_NETLINK — общение с ядром (через который вы получаете route table, interfaces).

Это и есть сила Berkeley sockets — единый API для совершенно разных things.


Минимальный TCP-клиент на Python

import socket

# 1. Создать сокет
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 2. Подключиться
sock.connect(('httpbin.org', 80))

# 3. Отправить HTTP-запрос
request = b'GET /get HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n'
sock.sendall(request)

# 4. Получить ответ
response = b''
while True:
    chunk = sock.recv(4096)
    if not chunk:  # сервер закрыл соединение (FIN)
        break
    response += chunk

# 5. Закрыть
sock.close()

print(response.decode('utf-8'))

Это и есть HTTP-клиент. Самый минимальный, без error handling, без чтения Content-Length. Но он работает! Запустите — увидите ответ httpbin.org. Это иллюстрация того, что HTTP — это просто bytes поверх TCP.


Попробуй сам

# 1. Запустить минимальный TCP-клиент выше
python3 -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('httpbin.org', 80))
s.sendall(b'GET /ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n')
print(s.recv(4096).decode())
s.close()
"

# 2. Посмотреть, какие сокеты открыты на машине (Linux)
ss -t -a -n | head -20
# или (macOS, иногда работает на linux):
netstat -an -p tcp | head -20

# 3. Запустить простейший TCP server через nc (netcat)
# Терминал 1 -- сервер
nc -l 8888

# Терминал 2 -- клиент
echo 'Hello' | nc localhost 8888
# Server увидит 'Hello'

# 4. Посмотреть на сокеты процесса
# Найти Python-процесс
pgrep -fl python

# Посмотреть его сокеты (где PID -- ваш id процесса):
# lsof -p <PID> | grep -E 'TCP|UDP'

# 5. Эксперимент с UDP
# Терминал 1 -- слушать UDP на порту 5353
nc -u -l 5353

# Терминал 2 -- отправить datagram
echo 'hello UDP' | nc -u localhost 5353
# В первом терминале увидите 'hello UDP'

# 6. Запустить TCP-сервер на одной строке Python
python3 -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 9999))
s.listen(5)
print('Listening on 0.0.0.0:9999')
client, addr = s.accept()
print(f'Connected: {addr}')
data = client.recv(1024)
print(f'Received: {data!r}')
client.sendall(b'echo: ' + data)
client.close()
s.close()
"
# В другом терминале:
echo 'test message' | nc localhost 9999
# Получите 'echo: test message'

Что вы должны вынести

  1. Socket — это file descriptor + структура в ядре, представляющая network endpoint.
  2. API одинаковый для TCP/UDP/Unix — type параметр отличает.
  3. Server flow: socket -> bind -> listen -> accept -> send/recv -> close.
  4. Client flow: socket -> connect -> send/recv -> close.
  5. TCP — stream без message boundaries; нужно framing для message-oriented обмена.
  6. UDP — message-oriented через recvfrom/sendto, но без гарантий.
  7. send/recv могут вернуть меньше байт, чем хочется — нужно loops или sendall.
  8. setsockopt для тонкой настройки (REUSEADDR, NODELAY, KEEPALIVE).
Unix-сокеты в ОС: fd, namespaces, kernel buffers ss, lsof, netstat — диагностика сокетов на Linux

В следующих уроках детально разберём TCP server/client, UDP server/client, и concurrency-модели.


Проверка знанийKnowledge check
Junior пишет TCP-сервер, который ждёт от клиента сообщение в формате '<длина 4 байта><тело длиной N>'. Код такой: 'def handle(sock): length = int.from_bytes(sock.recv(4), 'big'); body = sock.recv(length); process(body)'. Какие два бага в этом коде?
ОтветAnswer
Это классические ошибки начинающих TCP-программистов, обе связанные с тем, что TCP -- stream без message boundaries. Баг 1: sock.recv(4) НЕ ГАРАНТИРУЕТ возврат ровно 4 байт. recv() возвращает 'до N байт' -- то, что уже в receive buffer ядра. Если клиент послал 4 байта длины в одном TCP-сегменте, и до приёма body ничего не произошло -- может вернуть 1, 2, 3 или 4 байта. Если вернёт 2 байта, int.from_bytes(2 bytes, ...) даст неправильное число, потом recv по этому broken length прочитает мусор или зависнет. Баг 2: sock.recv(length) ту же проблему имеет. Если body 1000 байт, ядро могло получить 500 из них, recv вернёт 500 -- мы прочитаем половину тела и подумаем, что это всё. process(body) обработает usercated сообщение. Правильное решение -- helper функция recv_exactly: def recv_exactly(sock, n): data = b''; while len(data) < n: chunk = sock.recv(n - len(data)); if not chunk: raise ConnectionError('disconnected'); data += chunk; return data. Тогда: length_bytes = recv_exactly(sock, 4); length = int.from_bytes(length_bytes, 'big'); body = recv_exactly(sock, length); process(body). Этот pattern (length-prefix framing) -- стандарт для message-oriented обмена поверх TCP. Также важно: добавить sanity check на length (например, < 10 MB) -- иначе атакующий шлёт 4 байта 0xFFFFFFFF и ваш сервер аллоцирует 4ГБ memory ждать body, OOM. Bonus: использовать struct.unpack('!I', data)[0] вместо int.from_bytes -- более pythonic для network byte order.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. В выражении socket.socket(socket.AF_INET, socket.SOCK_STREAM) что означают эти два параметра?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5