Как написать протокол передачи данных
Перейти к содержимому

Как написать протокол передачи данных

  • автор:

Написать протокол на java?

Подскажите как написать протокол на java, для передачи коротких данных (также интересна передача Rreal-time и Stream трафика). Передача должна осуществляться на основе транспортного протокола UDP.

Ну а чего тут особо сильно думать то? Транспортный протокол известен/задан UPD — соответственно теперь надо озаботиться следующим уровнем стека протоколов (согласно великой могучей науке) — фактически прикладным уровнем.

На одной стороне сервер принимаем/посылает массив байтов, а на другой стороне клиент аналогично принимает/посылает массив байтов.

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

В общем все ограничено только вашей фантазией.

Update Посылка байтов также лишена романтики и выглядит примерно так (грубо):

TCP/IP Network Programming Design Patterns in C++

Network programming with the BSD Sockets API involves making a series of boilerplate calls to several operating system level functions every time you want to create connections and transfer data over TCP/IP networks. This process can be both cumbersome and error prone.

Fortunately there is an easier way to develop network applications. By thinking in terms of design patterns, we can devise abstractions for creating connections and transferring data between network peers that encapsulate socket calls in easy to use C++ classes.

Network Programming Basics

Internet Model

Before launching into the design patterns, let’s go over some basics of network programming with BSD Sockets.

The Internet model is a subset of the Open Systems Interconect (OSI) model that describes how network protocols and equipment should interoperate. The mapping of the Internet stack layers to the OSI model is illustrated below.

The Internet application layer combines the application, presentation and session layers of the OSI model. It’s in this layer where the Internet protocols – HTTP, SSH, DNS, etc. – are implemented that directly interact with Internet applications.

At the bottom of the OSI stack is the datalink and physical layers which map to a single Network Link layer in the Internet model. Network drivers are implemented here that provide the Network layer with the means to send packets over physical network media such as Ethernet, PPP and ADSL.

The Network and Transport layers are the same across both models. The Network layer in the Internet model provides connectionless Internet protocol packet delivery, host IP addresses and routing hosts and other networks. The ICMP, ARP and DHCP are implemented in the Network layer on top of IP.

Both TCP and UDP protocols live in the transport layer and add the concept of ports to differentiate applications running on a given host. TCP provides connection based, reliable network communication and stream based data delivery services. Reliability is achieved through retransmission of dropped packets. UDP provides connectionless and packet based delivery where the data is delivered in datagrams – packets with port numbers. UDP, like IP, gives only best effort data delivery without retransmissions of dropped packets.

BSD Sockets is an API to the transport layer of the Internet Protocol Stack. It supports creating both TCP and UDP network I/O.

Socket Workflow

To establish TCP connections the server host calls socket() to create a listening socket then specifies the IP address and TCP port on which the server will receive connection requests with a call to bind() . Calling listen() puts the server into listening mode which then blocks on the accept() waiting for incoming connections.

The client connects to the server by calling socket() then connect() with a socket address that includes the IP address and TCP port specifying used for the bind() call on the server. On the server the accept() function returns with a connection socket descriptor when the client’s connection request is received.

After connecting the server blocks on a call to read() waiting for a client request. The client calls write() to send a request then blocks on a call to read() waiting for the server’s response. When the server is done processing the request, it sends back a response to the client. The exchange of requests and responses repeats until the client is done, at which time it closes the connection. The server detects this event when read() returns 0 . The server responds by closing its end of the connection then returning to get another connection.

In most servers connections are accepted in one thread and a new thread or process is created to handle each connection. To keep things simple the example here describes an iterative server where each request is handled one at a time.

Network Programming Patterns

The key to designing an object-oriented network programming API is to recognize that TCP/IP network programs involve three basic pattens of usage or behaviors: actively connecting to servers, passively accepting connections from clients and transferring data between network peers – in other words clients and servers. Each behavior suggests a distinct abstraction that can be implemented in a separate class.

TCPConnector — Encapsulates the socket mechanisms to actively connect to a server. This is a factory class which produces TCPStream objects when client applications establish connections with servers. TCPAcceptor — Encapsulates the socket mechanisms to passively accept connections from a client. This is also a factory class which produces TCPStream objects when server applications establish connections with clients TCPStream — Provides network I/O mechanisms and returns IP address and TCP port of peer applications.

For the code examples in this blog, each of these classes has an include file (.h) and source file (.cpp) of the same name. For example, tcpconnector.h and tcpconnector.cpp for the TCPConnector class.

TCPStream Class

Interface

The TCPStream class provides methods to send and receive data over a TCP/IP connection. It contains a connected socket descriptor and information about the peer – either client or server – in the form of the IP address and TCP port. TCPStream includes simple get methods that return address and port, but not the socket descriptor which is kept private. One of the advantages of programming with objects is the ability to logically group data members and methods to avoid exposing data, in this case the socket descriptor, to the calling program that it does not need to see. Each connection is completely encapsulated in each TCPStream object.

TCPStream objects are created by TCPConnector and TCPAcceptor objects only, so the TCPStream constructors must be declared private to prevent them from being called directly by any other objects. The TCPStream class grants friend privileges to the TCPConnector and TCPAcceptor classes so they can access the TCPStream constructors to supply connected socket descriptors.

Constructor

The constructor stores the connected socket descriptor then converts the socket information structure fields to a peer IP address string and peer TCP port. These parameters can be inspected with calls to TCPStream::getPeerIP() and TCPStream::getPeerPort() .

Destructor

The destructor simply closes the connection.

Network I/O Methods

TCPStream::send() and TCPStream::receive() simply wrap calls to read() and write() , returning the number of bytes sent and bytes received, respectively. No additional buffering or other capabilities are added.

Get Peer Information

TCPStream::getPeerIP() and TCPStream::getPeerPort() return the IP address and TCP port information of the peer to which the network application, client or server, are connected. You can get the same information from the sockets getpeername() function but it far easier to just capture that information when the connections are established. Clients know in advance to where they are connecting and the client’s socket address is returned the accept() function when the server accepts a client connection – see the TCPAcceptor::accept() method definition. In both cases the socket address information is passed to the TCPStream object when it is constructed.

TCPConnector Class

Interface

TCPConnector provides the connect() method to actively establish a connection with a server. It accepts the server port and a string containing the server host name or IP address. If successful, a pointer to a TCPStream object is returned to the caller.

Constructor/Destructor

The TCPConnector class does not use any member variables so the default constructor and destructor generated by the C++ compiler are fine. No others are defined.

Connect to Server

[Lines 6-12] TCPConnector::connect() call takes a server host name or IP address string and the server listening port as arguments. The server struct sockaddr_in sin_family is set to PF_INET and the sin_port is set to the TCP port on which the server is listening for connections.

[Lines 13-15] TCPConnector::resolveHost() to convert the DNS host name string to an IP address. If this call fails the assumption is made the server string contains an IP address and it is converted to an IP address in network byte order.

[Lines 16] The first argument to socket() selects the protocol family and the second specifies the nature of the network communication. Together PF_INET and SOCK_STREAM mandate the TCP/IP protocol.

[Lines 17-20] We call ::connect() passing it the socket descriptor, pointer to the server struct sockaddr_in structure, cast to a struct sockaddr pointer, and the length of the server address structure. The ::connect() call is prefeced with the :: qualifier so the compiler does not confuse this function with TCPConnector::connect() . If ::connect() succeeds a TCPStream object is created with the connected socket descriptor and the server socket address information and a pointer to the TCPStream object is returned to the caller.

Resolve Host Name

TCPConnector::resolveHostName() converts a DNS host name to an IP address in network byte order by calling getaddrinfo() . This function was chosen over gethostbyname() since it is thread safe whereas gethostbyname() is not. If the host name is not a valid DNS name, i.e. it is an IP address string or something else, -1 is returned, otherwise 0 is returned.

TCPAcceptor Class

Interface

TCPAcceptor includes member variables for the listening socket descriptor, the socket address information – IP address and TCP port – and a flag that indicates whether or not the TCPAcceptor has started listening for connections.

Two public methods are supported. One to start the listening and the other to accept connections.

Constructor

The constructor sets the member variables to as shown here. Setting m_lsd indicates that the listening socket has not been created.

Destructor

If the listening socket has been created then it is closed in the destructor.

Start Listening for Connections

[Line 3-5] Creating a listening socket involves the most socket calls of any operation. Before going through the series of calls, TCPAcceptor::start() checks to see if a listening socket already exists. If so, the method just returns 0.

[Line 7] First we create a listening socket descriptor for TCP/IP. The socket() call for servers is the same as it is for clients.

[Lines 9-12] Next we initialize a socket address structure setting the protocol family PF_INET and the listening TCP port.

[Lines 13-18] If the server listening IP address has m_address has been set, inet_ntop() is called to convert it to a numerical IP address in network byte order. If inet_ntop() fails then the socket listening address is set to any IP address meaning the server will listening for connections on all the network interfaces.

[Lines 20-21] Normally when you stop a server listening on a given IP address and port, it takes a few seconds before you can starting listening on the same IP address and port when you restart your server. To disable this condition and make it possible to immediately resue a listening port, we set the SO_REUSEADDR socket option for the listening socket before calling bind() .

[Lines 23-27] Bind the listening socket address to the socket descriptor. If bind() fails display and error message then return value returned by bind() .

[Lines 28-34] Turn on server listening with the listen() function. The second argument of this function sets the number of connection requests TCP will queue. This may not be supported for your particular operating system. If listen() fails, display an error message. Otherwise, set the m_listening flag to true and return the listen() call return value

Accept Connections from Clients

[Lines 3-10] TCPAcceptor::accept() returns NULL if the socket is not in a listening state. Otherwise a sockaddr_in structure is set to NULL and a pointer to it, cast as a sockaddr structure, is passed to ::accept() . The ::accept() call is qualified by the :: operator so the compiler does not confuse this function with the TCPAcceptor::accept() . The ::accept() blocks until a connections is received.

[Lines 11-15] When a connection with a client is established, the socket address structure is populated with the client’s socket information and ::accept() returns 0 . Then a pointer to a TCPStream object is returned to the caller.

Test Applications

Echo Server

First let’s build a server with the TCPAcceptor class. To keep things simple we’ll just make an iterative server that handles one connection at a time. The server will be defined in the file server.cpp.

[Lines 5-10] The server accepts the listening TCP port and optionally the listening IP Address on the command line. If the number of arguments is not correct an error message is displayed informing the user how to correctly invoke the application.

[Lines 12-20] The TCPAcceptor object is created with the command line arguments. Minimally the IP address must be specified. Then the server starts listening for connections.

[Lines 21-32] If the call to TCPAcceptor::start() is successful, the server continually and indefinitely accepts connections from clients and processes each connection one at a time. Processing consists of getting a string of bytes from the client, displaying the string and returning it to the client. The string of bytes is NULL terminated at the index in the receive buffer equal to the value returned by the receive operation. This is repeated until the client closes the connection indicated by a return value of 0 from TCPStream::receive() . Deleting the stream object closes the connection on the server side.

Echo Client

The client application takes the server TCP port and IP address on the command line. For each connection a string is displayed and sent to the server, the echoed string is received back and displayed, then the connection is closed. The client will be defined in the file client.cpp.

Build and Run

You can get the source code for the project from Github – https://github.com/vichargrave/tcpsockets.git. Create the test apps by running make. You can build the client and server separately by running:

First run the server on port 9999 and localhost in a terminal window:

In another terminal window run the client and you should get the following output:

1.6 Разработка протокола для обмена данными

Для обмена данными между основным блоком и другими устройствами необходимо выбрать определённый формат данными, чтобы не было неопределённости. Было решено разработать свой собственный протокол, удовлетворяющий требованиям нашей системы. Каждый пакет данных состоит при этом из трёх частей:

Таблица 15 — Структура пакета

Инициализирующая часть, состоящая из трёх символов «+», указывает на начало нового пакета. Соответственно длина части равна трём байтам.

Код пакета. Указывается на тип передаваемых данных. Длина этой части всегда равна одному байту.

Данные, смысл которых зависит от кода пакета. Длина данных варьируется.

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

Таблица 16 — Команды для основного блока

Синхронизация часов, три байта — это соответственно часы, минуты и секунды.

Запрос на получение основных данных от блока, это список доступных устройств, их состояние и пр.

Вывод произвольного текста на дисплей, 48 байт — это соответственно 48 символов. После получения команду происходит переключение на соответствующий буфер.

Возвращение на основной буфер, т.е. вызывает стирание текста, который был выведен на дисплей предыдущей командой. Один байт данных игнорируется.

Вывод второстепенной информации на дисплей, которая будет отображаться только одну секунду. 48 байт — это соответственно 48 символов.

Команда на включение или выключение устройства. Первый байт — номер устройства, второй определяет задание: «0» — выключение, «1» — включение.

Команда, сбрасывающая таймер отсутствия движения. Применяется для уведомления основного блока о том, что в помещении кто-то есть, чтобы предотвратить ложные срабатывания датчика движения. Один байт игнорируется.

Команда указывает, что кнопки управления основным блоком на пульте ДУ надо игнорировать. Используется для управления другими устройствами и ПО. Один байт данных игнорируется.

Команда обратная предыдущей, возвращает кнопки в обычный режим работы. Один байт данных игнорируется.

При передаче пакетов от микроконтроллера другим устройствам используется уже совсем другие команды:

Таблица 17 — Команды основного блока

Сигнал, который посылается после включения блока, указывая на начало работы. Данные — любой байт.

Данные о температуре. По два байта на каждый датчик, в моём случае это соответственно 4 байта.

Пакет указывает, что на основном блоке была нажата кнопка. Один байт данных — номер кнопки.

Команда указывает, что на пульте ДУ была нажата кнопка. 4 байта данных — код кнопки.

Команда указывает, что на пульте ДУ удерживается кнопка. 4 байта данных — код кнопки.

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

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

Названия устройств. Первый байт — номер устройства, остальные 22 байта — название.

Информация о движении в комнате. Один байт данных, «1» — движение есть, «0» — движение отсутствует.

В итоге прерывание, обрабатывающее входящие данные, выглядит так:

unsigned char b;

if (command_pluscount >= 3)

> else command_pluscount = 0;

case 1: command_state = 2; break;

if (command_len < 64)

  • ((command_num == 1) && (command_len >= 3 )) || (command_num == 2) ((command_num == 3) && (command_len >= 48 )) ||
  • (command_num == 4) ||
  • ((command_num == 5) && (command_len >= 48 )) ||
  • ((command_num == 6) && (command_len >= 2 )) ||
  • (command_num == 7) ||
  • (command_num == 8) ||
  • (command_num == 9)

Здесь переменные «command_pluscount», «command_state», «command_num» и «command_len» — это глобальные целочисленные переменные, которые определяют на какой стадии происходит получение данных. «command_buf» — это буфер, в который принимаются сами данные. Возвращение из прерывания в основную программу должно происходить как можно быстрее, поэтому выполнение полученной команды происходит в основном коде программы, где с очень небольшим интервалом времени происходит проверка на новые команды (переменная «command_complete»).

Функции основного блока

Подведу итоги, пояснив какие функции выполняет основной блок, и по какой схеме он работает:

Пишем свой протокол поверх UDP

Первые прямые трансляции с места событий появились в России почти 70 лет назад и вели их из передвижной телевизионной станции (ПТС), которая внешне походила на «троллейбус» и позволяла вести эфиры не из студии. А всего лишь три года назад Periscope позволил вместо «троллейбуса» использовать мобильный телефон.

Но это приложение имело ряд проблем, связанных, например, с задержками в эфирах, с невозможностью смотреть трансляции в высоком качестве и т.д.


Еще через полгода, летом 2016, Одноклассники запустили свое мобильное приложение OK Live для стриминга, в котором постарались решить эти проблемы.

Александр Тоболь отвечает за техническую часть видео в Одноклассниках и на Highload++ 2017 рассказал про то, как писать свой UDP протокол, и зачем это может потребоваться.

https://amdy.su/wp-admin/options-general.php?page=ad-inserter.php#tab-8

Из расшифровки его доклада вы узнаете все про другие протоколы стриминга видео, какие есть нюансы, и про то, какие уловки иногда требуются.

Архитектура и ТЗ

На слайде ниже схема архитектуры любого стримингового сервиса: видео подается на вход, преобразуется и передается на выход. К этой архитектуре мы добавили еще немножко требований: видео должно подаваться с десктопов и мобильных телефонов, а на выход — попадать на те же десктопы, мобильные телефоны, smartTV, Chromcast, AppleTV и другие устройства — все, на чем можно играть видео.


Дальше переходим к техническому заданию. Если у вас есть заказчик, у вас есть ТЗ. Если вы — социальная сеть, ТЗ у вас нет. Как его составить?

Можно конечно опросить пользователей и узнать все, что они хотят. Но это будет целая куча желаний, которые никак не коррелируют с тем, что людям действительно надо.

Мы решили пойти методом от противного и посмотрели, что пользователи НЕ хотят видеть от сервиса трансляции.

  • Первое, что не хочет пользователь — это видеть задержку на старте трансляции.
  • Пользователь не хочет видеть некачественную картинку стрима.
  • Если в трансляции есть интерактив, когда пользователь общается со своей аудиторией (встречные прямые эфиры, звонки и т.д.), то он не хочет видеть задержку между стримером и зрителем.


Начать можно было бы с просмотра всех протоколов стриминга, выбрать наиболее интересные и сравнить их. Но мы сделали по-другому.

Что у конкурентов?

Мы начали с изучения сервисов конкурентов. Открываем Periscope — что у них?

Как всегда, главное — архитектура.


Сара Хайдер, ведущий инженер Periscope, пишет, что для бэкенда они используют Wowza. Если еще немножко почитать статьи, то мы увидим, что стрим они делают с использованием протокола RTMP, а раздают его либо в RTMP, либо в HLS. Посмотрим, что это за протоколы и как они работают.

Протестируем Periscope на три наших главных требования.

Скорость старта у них приемлемая (меньше секунды на хороших сетях), постоянноекачество порядка 600 px (не HD) и при этом задержки могут составлять до 12 секунд.

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

Это фотография измерения задержки. Есть мобильный телефон с таймером. Мы включаем трансляцию и видим изображение этого телефона на экране. За 0,15 миллисекунд изображение попало на сенсор камеры и вывелось из видеопамяти на экран телефона. После этого мы включаем браузер и смотрим трансляцию.

Ой! Она немножко отстала — примерно на 12 секунд.

Чтобы найти причины задержки, попрофилируем стриминг видео.

Итак, есть мобильный телефон, видео идет с камеры и попадает в видеобуфер. Тут задержки минимальны (≈0,15 мс). Потом кодировщик кодирует сигнал, упаковывает в пакет и отправляет в socket-буфер. Это все летит в сеть. Дальше на принимающем устройстве происходит все то же самое.

В принципе, есть две основные трудные точки, которые нужно рассмотреть:

  • кодирование/декодирование видео;
  • сетевые протоколы.

Кодирование/декодирование видео

Немного расскажу про кодирование. Вы все равно с ним столкнетесь, если будете делать Low Latency Live Streaming.

Что такое видео? Это набор кадров, но не совсем простых. Кадры бывают трех типов: I, P и B-frame:

  • I-frame — это просто jpg. По сути, это опорный кадр, он ни от кого не зависит и содержит четкую картинку.
  • P-frame зависит исключительно от предыдущих кадров.
  • Хитрые B-frame могут зависеть от будущего. Это означает, что чтобы посчитать b-frame, нужно, чтобы с камеры пришли еще и будущие кадры. Только тогда с некоторой задержкой можно декодировать b-frame.
  1. Если вы стримите с мобильного устройства, можно попробовать включить профайл baseline. Он отключит B-frame.
  2. Можно попробовать настроить кодек и уменьшить задержку на будущие кадры, чтобы кадры приходили быстрее.
  3. Еще одна важная штука в тюнинге кодека — это включение CBR (константного битрейта).

Но в тот момент, когда начались активные изменения, и вырос битрейт, скорее всего все данные в сеть не пролезут. Это как раз то, что происходит, когда вы делаете видеозвонок и начинаете поворачиваться, а у вашего абонента подтормаживает картинка. Это связано с тем, что сеть не успевает адаптироваться под изменение битрейта.

Надо включать CBR. Не все кодеки на Android будут его корректно поддерживать, но они будут к этому стремиться. То есть нужно понимать, что с CBR идеальной картины мира, как на нижней картинке, вы не получите, но включить его все-таки стоит.

4. А на бэкенде необходимо добавить к H264 кодеку zerolatency — это позволит как раз не делать зависимости в кадрах на будущее.

Протоколы передачи видео

Рассмотрим, какие протоколы стриминга предлагает индустрия. Я их условно разбил на два типа:

  1. потоковые протоколы;
  2. cегментные протоколы.

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

Рассмотрим протоколы более детально. Начнем с потоковых протоколов и разберемся, с какими проблемами мы можем столкнуться, если будем использовать потоковые протоколы для broadcast-стриминга.

Потоковые протоколы

Periscope использует RTMP. Этот протокол появился в 2009 году, и Adobe сначала не полностью его специфицировал. Потом у него были определенного рода трудности с тем, что Adobe хотел продавать исключительно свой сервер. То есть RTMP развивался довольно трудно. Его основная проблема в том, что он использует TCP, но почему-то именно его выбрал Periscope.

Если почитать детально, то оказывается, что Periscope использует RTMP для трансляции с малым количеством зрителей. Как раз такие трансляции, если у вас недостаточный канал, скорее всего, вы не сможете посмотреть.

Рассмотрим на конкретном примере. Есть пользователь с узким каналом связи, который смотрит вашу трансляцию. Вы с ним договариваетесь по RTMP о низком битрейте и начинаете персонально для него стримить.

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

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

Но все равно RTMP у нас поверх TCP, и никто нас от блокировки начала очереди не застраховал.

На рисунке это проиллюстрировано: к нам поступают аудио и видео фреймы, RTMP их пакует, возможно их как-то перемешивает, и они улетают в сеть.

Но допустим, мы теряем один пакет. Возможно, что тот самый желтый потерянный пакет — это вообще P-frame от какого-то предыдущего — его можно было бы дропнуть. Возможно, как минимум, можно было бы играть аудио. Но TCP нам не отдаст остальные пакеты, так как он гарантирует доставку и последовательность пакетов. С этим надо как-то бороться.


Существует еще одна проблема использования протокола TCP в стриминге.

Допустим, у нас есть буфер и высокая пропускная способность сети. Мы генерируем туда из нашего кодека пакеты в высоком разрешении. Потом — оп! — сеть стала работать хуже. На кодеке мы уже указали, что битрейт нужно понизить, но готовые пакеты уже в очереди и никаким образом изъять их оттуда нельзя. TCP отчаянно пытается пропихнуть HD-пакеты через наш 3G.

У нас нет никакого управления буфером, нет приоритезации, поэтому TCP крайне не подходит для стриминга.

Давайте теперь взглянем на мобильные сети. Возможно для жителей столиц это будет удивительно, но наша средняя мобильная сеть выглядит примерно так:

  • 1,1 Мбит/с трафика;
  • 0,1% packet loss;
  • 300 мс средний RTT.

TCP — это, с одной стороны, классный протокол — очень трудно научить машину ездить сразу же и по хайвэю, и по бездорожью. Но научить ее потом еще и летать по беспроводным сетям оказалось очень сложно.

В определенных регионах packet loss доходит до 1%, тогда у пользователя остается порядка 10% процентов пропускной способности.

Поэтому на TCP делать не будем.

Посмотрим, что есть еще в мире стриминга из UDP.

Протокол WebRTC очень хорошо зарекомендовал себя для p2p звонков. На очень популярных сайтах пишут, что использовать для звонков его очень здорово, а вот для доставки видео и музыки — не хорошо.

Его основная проблема в том, что он пренебрегает потерями. При всех непонятных ситуациях он просто дропает.

Есть еще некоторая проблема в его привязанности к звонкам, дело в том, что он шифрует все. Поэтому, если вы ведете броадкаст на трансляцию, и нет необходимости шифровать весь аудио/видео поток, запуская WebRTC, вы все равно напрягаете свой процессор. Возможно, вам это не нужно.

RTP-стриминг — это базовый протокол передачи данных по UDP. Ниже на слайде справа приведен набор расширений и RFC, которые пришлось реализовать в WebRTC для того, чтобы адаптировать этот протокол для звонков. В принципе, можно попробовать сделать что-то подобное — набрать набор расширений к RTP и получить UDP стриминг. Но это очень сложно.

Вторая проблема в том, что если кто-то из ваших клиентов не поддерживает какой-либо extension, то протокол не заработает.

Сегментные протоколы

Хорошим примером сегментного протокола видео является MPEG-Dash. Он состоит из manifest-файла, который вы выкладываете у себя на портале. Он содержит ссылки на файлы в разных качествах, в начале файла есть некоторый индекс, который говорит, в каком месте файла начинается какой сегмент.

Все видео разбито на сегменты, например, по 3 секунды, каждый сегмент начинается с опорного кадра. Если вы смотрите такое видео и у вас меняется битрейт, то вы просто на стороне клиента начинаете брать сегмент того качества, которое вам нужно.

Еще одним примером сегментного стриминга является HLS.

MPEG-Dash — решение от Google, оно хорошо работает в Android, а Apple-решение более старое, у него есть ряд определенных недостатков.

Первый из них — это то, что основной манифест содержит ссылки на вторичные манифесты, вторичные манифесты по каждому конкретному качеству содержат ссылки на каждый отдельный сегмент, а каждый отдельный сегмент представлен отдельным файлом.

Если взглянуть еще более детально, то внутри каждого сегмента находится MPEG2-TS. Этот протокол делали еще для спутника, размер его пакета 188 байт. Упаковывать видео в такой размер очень неудобно, особенно потому, что вы все время его снабжаете небольшим хедером.

На самом деле это трудно не только серверам, которые для того, чтобы обработать 40 Гб трафика должны собрать 26 млн пакетов, но это еще трудно и на клиенте. Поэтому, когда мы переписали iOS плеер на MPEG-Dash, мы даже увидели некоторый прирост производительности.

Но Apple не стоит на месте. В 2016 году они наконец-то анонсировали, что у них есть возможность запихнуть фрагмент от MPEG4 в HLS. Тогда они обещали это добавить только для разработчиков, но вроде бы сейчас должна появиться поддержка на macOS и iOS.

То есть, казалось бы, фрагментный стриминг удобный — приходите, берете нужный фрагмент, с опорного кадра стартуете — работает.

Минус: понятно, что опорный кадр, с которого вы стартовали — это не тот кадр, который сейчас у того, кто стримит. Поэтому всегда появляется задержка.

Вообще есть возможность допилить HLS до задержек порядка 5 секунд, кто-то говорит, что ему удалось получить 4, но в принципе решение использовать фрагментный стриминг для трансляции не очень хорошее.

Сложность vs задержка

Посмотрим на все имеющиеся протоколы и рассортируем их по двум параметрам:

  • latency, который они дают между трансляцией и смотрящим;
  • complexity (сложность).

Что мы хотим?

Мы хотим сделать UDP-протокол для стриминга от 1 к N с задержкой, сравнимой с p2p связью, с возможностью опционального шифрования пакетов в зависимости от того, приватная или публичная трансляция.

Какие есть еще варианты? Можно подождать, например, когда Google выпустит свой QUIC.

Расскажу немного, что это такое. Google позиционирует Google QUIC, как замену TCP — некий TCP 2.0. Его разрабатывают с 2013 года, сейчас спецификации у него нет, зато он полностью доступен в Google Chrome, и мне кажется, что они иногда включают его некоторым пользователям для того, чтобы посмотреть, как он работает. В принципе, можно зайти в настройки, включить себе QUIC, зайти на любой Google сайт и получить этот ресурс по UDP.

Мы решили не ждать, пока они все специфицируют, и запилить свое решение.

Требования к протоколу:

  1. Многопоточность, то есть мы имеем несколько потоков — управляющий, видео, аудио.
  2. Опциональная гарантия доставки — управляющий поток имеет 100% гарантию, видео нам нужно меньше всего — мы там можем дропать фрейм, аудио нам все-таки бы хотелось.
  3. Приоритезация потоков — чтобы аудио уходило вперед, а управляющий вообще летел.
  4. Опциональное шифрование: или все данные, или только заголовки и критичные данные.

Это стандартный треугольник: если хорошая сеть, то высокое качество и низкие задержки. Как только появляется нестабильная сеть, начинают пропадать пакеты, мы балансируем между качеством и задержкой. У нас есть выбор: либо подождать, пока сеть наладится и отправить все, что накопилось, либо дропнуть и как-то с этим жить.

Если сортировать протоколы по такому принципу, то видно, что чем меньше время ожидания, тем хуже качество — довольно простой вывод.

Мы хотим свой протокол вклинить в зону, где задержки близки к WebRTC, но при этом иметь возможность его немножко отодвинуть, потому что все-таки у нас не звонки, а трансляции. Пользователь хочет в конечном итоге получать качественный стрим.

Разработка

Давайте уже начнем писать UDP протокол, но сначала посмотрим на статистику.

Это наша статистика по мобильным сетям. Тут видно, что средний интернет чуть больше мегабита, packet loss около 1% — это нормально, и RTT в районе 600 мс — на 3G это просто средние величины.

Будем на это ориентироваться при написании протокола — поехали!

UDP-протокол

Открываем socket UDP, забираем данные, упаковываем, отправляем. Берем вторую пачку от кодека, еще отправляем. Вроде бы все здорово!

Но мы получим такую картину: если мы начинаем беспорядочно слать UDP пакеты в socket, то по статистике к 21-му пакету вероятность того, что он дойдет, будет всего лишь 85%. То есть packet loss уже будет 15%, что никуда не годится. Это нужно исправлять.

Исправляется это стандартно. На рисунке проиллюстрирована жизнь без Pacer и жизнь с Pacer.

Pacer — это такая штука, которая раздвигает пакеты во времени и контролирует их потерю; смотрит, какой сейчас packet loss, в зависимости от этого адаптируется под скорость канала.

Как мы помним, для мобильных сетей 1-3% packet loss — это норма. Соответственно, надо с этим как-то работать. Что делать, если мы теряем пакеты?

Retransmit

В TCP, как известно, есть алгоритм fast retransmit: мы отправляем один пакет, второй, если пакет потеряли, то через некоторое время (retransmit period) отправляем этот же пакет.

Какие здесь плюсы? Никаких проблем, никакой избыточности, но есть минус — некоторый retransmit period.

Кажется, что очень просто: через какое-то время нужно повторить пакет, если вы не получили на него подтверждение. Логично, что это может быть время равное времени пинга. Но ping — это величина не стабильная, и поэтому точно через средний RTT time определить, что потерян пакет, мы не можем.

Для того, чтобы это оценить можно, например, использовать такую величину, как jitter: мы считаем разницу между всеми нашими ping-пакетами. Например, в примере выше, средняя величина равна 46 мс. На нашем портале средний jitter — 50.

Посмотрим на распределение вероятности приходов пакетов ко времени. Есть некоторый RTT и некоторая величина, после которой мы можем действительно понять, что acknowledge не пришел и повторить отправку пакета. В принципе, есть RFC6298, который в TCP говорит, как это можно хитро посчитать.

Мы это делаем через jitter. На портале у нас jitter по ping примерно 15%. Понятно, что retransmit period должен быть, как минимум, на 20% больше, чем RTT.

Еще один кейс с retransmit. С прошлого раза у нас был acknowledge на второй пакет. Мы отправляем третий пакет, который теряется, другие пакеты пока ходят. После этого наступает retransmit period, и мы отправляем третий пакет еще раз. Он еще раз дропнулся, и мы еще раз отправляем его.

Если у нас случается двойная потеря пакета, то на retransmit появляется новая проблема. Если у нас, например, packet loss 5%, и мы отправляем 400 пакетов, то на 400 пакетов у нас 1 раз точно будет ситуация двойного packet-drop, то есть, когда мы через retransmit period отправили пакет, и он еще раз не дошел.

Эту ситуацию можно исправить, добавив некоторую избыточность. Можно начать отправлять пакет, например, если мы получили acknowledge от другого пакета. Считаем, что опережение — это редкая ситуация, можем начать отправку третьего пакет в момент, обозначенный speculative retransmit на слайде выше.

Можно еще пошаманить со спекулятивным retransmit, и все будет неплохо работать.

Но тут мы заговорили про избыточность. А что, если добавить Forward Error Correction? Давайте просто все наши пакеты снабдим, например, XOR. Если мы точно знаем, что в мобильных сетях все так печально, то давайте просто добавим еще один пакетик.

Здорово! Нам не нужны никакие round trip, но у нас уже появилась избыточность.

А что, если пропадет не один пакет, а сразу два? Давайте вместо XOR возьмем другое решение — например, есть код Reed-Solomon, Fountain codes и т.д. Идея такая: если есть K пакетов, можно добавить к ним N пакетов так, что любые N можно было потерять.

Вроде бы классно!

Хорошо, если у нас такая плохая сеть, что пропали просто все пакеты, то к нашему Forward Error Correction очень удобно добавляется negative acknowledgement.

Если мы потеряли столько пакетов, что наш parity protection (назовем его так) нас уже не спасает, запрашиваем этот пакет дополнительно.

  • Простой в реализации, правда можно потерять и сам negative acknowledgement, но это мелкая проблема.
  • Хорошо совместим с FEC.
  1. С одной стороны, FEC + NACK;
  2. С другой стороны, Fast retransmit.

Оказывается, что пакеты теряются не равномерно по одной штучке, а пачками (выше график распределений). Причем есть интересные пики, например, на 11 пакетах, есть еще пики на 60-80 пакетах. Они повторяются, и мы изучаем, откуда они берутся.

В среднем на нашем портале теряется по 6 пакетов.

Детальное рассмотрение по сетям показывает, что чем хуже сеть, тем больше это количество. В таблице указано время, которое сеть была недоступна. Например, Wi-Fi недоступен 22 мс и теряет 5 пакетов, 3G может за 34 мс потерять 8 пакетов.

Вопрос: если мы знаем, что у нас 90% packet loss на портале укладывается в 10 пакетов, и при этом средний gap равен 25 мс, что будет работать лучше — FEC + NACK или Fast retransmit?

Тут, наверное, надо рассказать, что Google, когда делал свой протокол QUIC в 2013 году, ставил Forward Error Correction во главу, думая, что он решит все проблемы. Но в 2015 они его отключили.

Мы протестировали оба варианта и у нас не получилось завести FEC + NACK, но мы еще пытаемся и не отчаиваемся.

Рассмотрим, как он работает.

Это цифры, близкие к средней сети, проcто чтобы было удобно считать:

  • 1 Мб/с сеть;
  • 1% packet loss;
  • 300 мс RTT;
  • 1 000 байт — размер пересылаемых пакетов;
  • 1 000 пакетов в секунду уходит.

Мы начинаем делать такие добавки, и вроде бы все здорово. И тут на 500-м пакете, теряем ту самую пачку из 10 штук.

У нас есть варианты:

  • Дождаться оставшиеся 500 пакетов и восстановить данные через Forward Error Correction. Но на это у нас потратится примерно полсекунды, а пользователь эти данные ждет.
  • Можно воспользоваться NACK, причем это дешевле, чем дожидаться кодов коррекции.
  • А еще можно просто взять Fast Retransmit, не добавлять никаких кодов коррекции и получить тот же самый результат.

Fast Retransmit

Это работает так: после того, как мы потеряли пачку в 10 пакетов, отправив пока другие пакеты, понимаем, что у нас retransmit period прошел, и отправляем эти пакеты заново.

Самое интересное в том, что retransmit period на такой сети будет 350 мс, а средняя длительность этого packet gap — 25-30 мс, пусть даже 100. Это означает, что к моменту, когда retransmit начнет обрабатывать пакеты, в большинстве случаев сеть уже восстановится и они уйдут.

У нас получилось, что эта штука работает лучше и быстрее.

Дополнительные опции

Когда вы пишете свой протокол поверх UDP и у вас есть возможность отправки пакетов, вы получаете дополнительные плюшки.

Есть буфер отправки, в нем лежит опорный кадр, к нему p/b-кадры. Они равномерно уходят в сеть. Тут они перестали уходить в сеть, а в очередь прилетели еще пакеты.

Вы понимаете, что на самом деле все пакеты, которые лежат в очереди, уже больше не интересны клиенту, потому что прошло, например, больше 0,5с и надо на клиенте просто склеить разрыв и жить дальше.

Вы можете, имея информацию о том, что у вас хранится в этих пакетах, почистить не только опорный кадр, но и все p/b, от него зависящие, и оставить исключительно нужные и целостные данные, которые потом могут потребоваться клиенту.

Так как мы сами пишем протокол, то придется столкнуться с IP fragmentation. Думаю, многие про это знают, но на всякий случай вкратце расскажу.

У нас есть сервер, он отправляет какие-то пакеты в сеть, они приходят к маршрутизатору и на его уровне MTU (maximum transmission unit) становится ниже, чем размер пакета, который пришел. Он дробит пакет на большой и маленький (здесь 1100 и 400 байт) и отправляет.

В принципе, проблемы нет, это все соберется на клиенте и будет работать. Но если мы теряем 1 пакет, мы дропаем все пакеты, плюс получаем дополнительные издержки на header’ы пакетов. Поэтому, если вы пишете свой протокол, идеально работать в размере MTU.

Как его посчитать?

На самом деле Google не заморачивается, ставит порядка 1200 байт в своем QUIC и не занимается его подбором, потому что IP фрагментация потом все пакетики соберет.

Мы делаем точно также — сначала ставим какой-то дефолтный размер и начинаем слать пакеты — пусть он их фрагментирует.

Параллельно запускаем отдельный поток и создаем socket с флагом запрета фрагментации для всех пакетов. Если маршрутизатор встречает такой пакет и не может эти данные фрагментировать, то он дропнет пакет и возможно по ICMP вам отправит, что есть проблемы, но скорее всего, ICMP будет закрыт и этого не будет. Поэтому мы просто, например, три раза пытаемся отправить пакет определенного размера с каким-то интервалом. Если он не дошел, мы считаем, что MTU превышен и дальше его уменьшаем.

Таким образом, имея MTU интернет интерфейса, который есть на устройстве, и какое-то минимальное MTU, просто одномерным поиском подбираем правильный MTU. После этого корректируем размер пакета в протоколе.

На самом деле, он иногда меняется. Мы были удивлены, но в процессе переключения Wi-Fi и пр. MTU меняется. Этот параллельный процесс лучше не останавливать и время от времени подправлять MTU.

Выше распределение MTU в мире. У нас на портале получилось около 1100 байт.

Шифрование

Мы говорили, что мы хотим опционально управлять шифрованием. Делаем самый простой вариант — Diffie-Hellman на эллиптических кривых. Делаем его опционально — шифруем только управляющие пакеты и заголовки, чтобы man-in-the-middle не мог получить ключ трансляции, перехватить и так далее.

Если трансляция приватная, то можем добавить еще и шифрование всех данных.

Пакеты шифруем AES-256 независимо, чтобы packet drop никак не влиял на дальнейшее шифрование пакетов.

Приоритезация

Помните, мы хотели от протокола еще приоритезацию.

У нас есть метаданные, аудио и видеофреймы, мы их успешно отправляем в сеть. Потом наша сеть сгорает в аду и долго-долго не работает — мы понимаем, что нам нужно дропать пакеты.

Мы приоритетно дропаем видеопакеты, потом пытаемся дропать аудио и никогда не трогаем управляющие пакеты, потому что по ним могут ходить такие данные, как изменение разрешения и другие важные вопросы.

Дополнительная плюшка по поводу UDP

Если вы будете писать свой UDP протокол, например, с двухсторонней связью, то нужно понимать, что есть NAT Unbinding и шанс, что вы не сможете обратно с сервера найти клиента.

На слайде как раз времена, когда не удалось достучаться до клиента с сервера по UDP.

Многие скептики говорят, что маршрутизаторы устроены так, что NAT Unbinding вытесняет в первую очередь именно UDP маршруты. Но выше видно, что если Keep-Alive или ping будет меньше 30 секунд, то с вероятностью 99% будет возможно достичь клиента.

Доступность UDP на мобильных устройствах в мире

Google говорит, что 6%, но у нас получилось, что 7% мобильных пользователей не могут пользоваться UDP. В этом случае мы оставляем наш прекрасный протокол с приоритезацией, шифрованием и всем, только на TCP.

На UDP сейчас работает VOIP по WebRTC, Google QUIC, и многие игры работают по UDP. Поэтому верить, что UDP на мобильных устройствах закроют, я бы не стал.

  • Снизили задержку между стримером и смотрящим до 1 с.
  • Избавились от накопительного эффекта в буферах, то есть трансляция не отстает.
  • Снизилось количество stall’ов у зрителей.
  • Смогли поддержать на мобильных устройствах FullHD стриминг.
  • Задержка в нашем мобильном приложении OK Live 25 мс — на 10 мс дольше, чем работает сканер камеры, но это не так страшно.
  • Трансляция на Web показывает задержку всего 690 мс — космос!

Что еще умеет стриминг на Одноклассниках

  • Принимает наш протокол OKMP с мобильных устройств;
  • может принимать RTMP и WebRTC;
  • выдает на выходе HLS, MPEG-Dash и т.д.

Тут есть нюанс. На самом деле WebRTC — протокол, ориентированный на дроп пакетов, и у него используется аудио кодек OPUS. В RTMP использовать OPUS нельзя.

На серверах бэкенда мы везде используем RTMP. Поэтому нам пришлось сделать еще некоторый фикс в FF MPEG, который позволяет запихнуть OPUS в RTMP, его сконвертировать в AAC и отдать пользователям уже в HLS или еще в чем-то.

Как это выглядит у нас внутри?

  • Пользователи по одному из протоколов загружают оригинал видео на наши upload-сервера.
  • Там мы разворачиваем протокол.
  • По RTMP отправляем на один из серверов трансформации видео.
  • Оригинал всегда сохраняем в распределенное хранилище, чтобы ничего не пропало.
  • После этого все видео поступают на сервер раздачи.


Расскажу еще немного про отказоустойчивость:

  • Upload-сервера распределены по разным дата-центрам, стоят за разными IP.
  • Пользователи приходят, по DNS получают IP.
  • Upload-сервер отправляет видео на серверы нарезки, те нарезают и отдают серверам раздачи.
  • Под более популярные трансляции мы начинаем добавлять большее количество серверов раздачи.
  • Все, что пришло от пользователя, сохраняем в хранилище, чтобы потом создать архив трансляций и ничего не потерять.
  • Хранилище отказоустойчивое, распределенное по трем дата-центрам.

Тестировать отказоустойчивость будем по-быстрому. Начнем сразу же с пропадания всего дата-центра.

Что при этом произойдет?

  • Пользователь на DNS возьмет следующий IP другого upload-сервера.
  • К этому времени ZooKeeper поймет, что сервер в том дата-центре умер, и выберет для другой сервер нарезки.
  • Download-серверы узнают, кто теперь отвечает за трансформацию этого стрима и будут это раздавать.

Использование протокола в продукте

Мы сделали мобильное приложение для стриминга OK Live. Оно полностью интегрировано с порталом. Пользователи там могут общаться, вести прямые эфиры, есть карта эфиров, список популярных эфиров — в общем, все, что можно хотеть.

Также мы добавили возможность вести эфиры в FullHD. К Android-устройству можно подключать action-камеру на Android.

Теперь у нас есть механизм, который позволяет вести прямые трансляции. Например, мы проводили прямую линию с Президентом через OK Live и транслировали ее на всю страну. Пользователи смотрели и через встречный стрим могли попадать в эфир и задавать свои вопросы.

То есть, по сути, два встречных стрима на минимальной задержке обеспечивают некий формат публичной конференцсвязи.

На самом деле мы уложились где-то в 2 секунды — секунда туда и секунда обратно. Помните тот «троллейбус», про который я рассказывал в начале статьи — он сейчас выглядит как 2 огромных грузовика. Для ТВ эфира снять с камеры и просто все смикшировать с задержкой в порядка 1-2 с совершенно нормально.

В действительности нам удалось у себя воспроизвести что-то сравнимое с текущими современными ПТС.

Прямые эфиры — это текущий тренд. За последние полтора года на портале ОК они выросли в три раза (не без помощи приложения OK Live).

Все трансляции по умолчанию записываются. У нас порядка 50 тысяч стримов в сутки, это генерирует порядка 17 терабайт трафика в сутки, а вообще все видео на портале генерирует около петабайта данных в месяц.

Что получили мы:

  • Смогли гарантировать длительность задержки между стримером и зрителями.
  • Сделали первое мобильное FullHD приложение для стриминга на динамично меняющемся мобильном интернет-канале.
  • Получили возможность терять дата-центры и при этом не прерывать трансляции
  • Что такое видео и как его стримить.
  • Что можно писать свой UDP протокол, если вы точно знаете, что у вас очень специфичная задача и конкретные пользователи.
  • Про архитектуру любого стримингового сервиса — видео входит на вход, преобразуется, и выходит на выход.

На Highload++ Siberia Александр Тоболь обещает рассказать про сервис звонков на ОК, будет интересно узнать, что из рассмотренного в этой статье удалось применить, а что пришлось реализовывать совершенно заново.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *