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 — программа работает с числом, а ядро видит структуру.
Главное, что 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)
Три параметра:
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
Серверный сокет проходит несколько стадий: создан -> привязан к адресу -> слушает -> принимает соединение.
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() под капотом:
- DNS-резолв. Если имя host — сначала резолв через системный resolver.
- TCP 3-way handshake. Отсылается SYN, ждётся SYN-ACK, отсылается ACK.
- Если успешно — возврат. Socket готов к send/recv.
- Если ошибка — 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 для серверов, которые читают сообщения известной длины.
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)
Главные:
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)
Ключевые различия:
- Нет connect/accept. UDP connectionless — каждое сообщение независимо.
recvfromвозвращает данные и адрес отправителя.sendtoтребует адрес получателя.- Message-oriented: каждый
recvfromдаёт ровно одно сообщение от одного отправителя. Не нужен framing. - Без гарантий. Сообщение может потеряться, прийти не в порядке, прийти дважды.
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'
Что вы должны вынести
- Socket — это file descriptor + структура в ядре, представляющая network endpoint.
- API одинаковый для TCP/UDP/Unix — type параметр отличает.
- Server flow: socket -> bind -> listen -> accept -> send/recv -> close.
- Client flow: socket -> connect -> send/recv -> close.
- TCP — stream без message boundaries; нужно framing для message-oriented обмена.
- UDP — message-oriented через recvfrom/sendto, но без гарантий.
- send/recv могут вернуть меньше байт, чем хочется — нужно loops или sendall.
- setsockopt для тонкой настройки (REUSEADDR, NODELAY, KEEPALIVE).
В следующих уроках детально разберём TCP server/client, UDP server/client, и concurrency-модели.