image


Привет всем! Меня зовут Сергей Камардин, я программист команды Почты Mail.Ru.


Это статья о том, как мы разработали высоконагруженный WebSocket-сервер на Go.


Если тема WebSocket вам близка, но Go — не совсем, надеюсь, статья все равно покажется вам интересной с точки зрения идей и приемов оптимизации.


1. Предисловие


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


В Почте Mail.Ru есть множество систем, состояние которых меняется. Очевидно, что такой системой является и хранилище писем пользователей. Об изменении состояния — о событиях — можно узнавать несколькими способами. В основном это либо периодический опрос системы (polling), либо — в обратном направлении — уведомления со стороны системы об изменении ее состояния.


У обоих способов есть свои плюсы и минусы, однако если говорить о почте, то чем быстрее пользователь получит новое письмо — тем лучше. Polling в почте — это около 50 тысяч HTTP-запросов в секунду, 60% которых возвращают статус 304, что означает отсутствие изменений в ящике.


Поэтому, чтобы сократить нагрузку на серверы и ускорить доставку писем пользователям, было решено изобрести велосипед написать publisher-subscriber сервер (он же bus, message-broker или event-channel), который, с одной стороны, получает сообщения об изменении состояний, а с другой — подписки на такие сообщения.


Было:


+-----------+           +-----------+           +-----------+
|           | <-------+ |           | <-------+ |           |
|  Storage  |           |    API    |    HTTP   |  Browser  |
|           | +-------> |           | +-------> |           |
+-----------+           +-----------+           +-----------+

Стало:


 +-------------+     +---------+   WebSocket   +-----------+
 |   Storage   |     |   API * | +-----------> |  Browser  |
 +-------------+     +---------+         (3)   +-----------+
        +             (2) ^
        |                 |
    (1) Ў                 +     
+---------------------------------+                                 
|               Bus               |
+---------------------------------+

На первой схеме отображено то, как было раньше. Браузер периодически ходил в API и спрашивал об изменениях на Storage (хранилище писем).


На второй — новый вариант архитектуры. Браузер устанавливает WebSocket-соединение с API, по которому происходит уведомление о событиях Storage. API является клиентом к серверу Bus и отправляет ему данные своих подписчиков (об этом сервере речи сегодня идти не будет; возможно, расскажу о нем в следующих публикациях). В момент получения нового письма Storage посылает об этом уведомление в Bus (1), Bus — своим подписчикам (2). API определяет, какому соединению отправить полученное уведомление, и посылает его в браузер пользователю (3).


Как вы могли догадаться, речь сегодня пойдет об API, или WebSocket-сервере. Забегая вперед, скажу, что на сервере будет около 3 миллионов живых соединений. Эта цифра еще не раз всплывет в последующем рассказе об оптимизациях.


2. Idiomatic way


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


Прежде чем рассматривать работу с net/http, поговорим об отправке и получении данных. Данные, которые находятся над протоколом WebSocket (например, json-конверты), здесь и далее я стану называть пакетами. Давайте начнем реализацию структуры Channel, которая будет содержать в себе логику получения и отправки пакетов через WebSocket-соединение.


2.1. Channel struct


// Packet represents application level data.
type Packet struct {
    ...
}

// Channel wraps user connection.
type Channel struct {
    conn net.Conn    // WebSocket connection.
    send chan Packet // Outgoing packets queue.
}

func NewChannel(conn net.Conn) *Channel {
    c := &Channel{
        conn: conn,
        send: make(chan Packet, N),
    }

    go c.reader()
    go c.writer()

    return c
}

Хочу обратить ваше внимание на запуск двух горутин чтения и записи. Для каждой горутины нужен свой стек, который в зависимости от операционной системы и версии Go может иметь начальный размер от 2 до 8 Кбайт. Если учесть цифру, названную ранее (3 миллиона живых соединений), то на все соединения нам потребуется 24 Гбайт памяти (при стеке в 4 Кбайт). И это без учета памяти, выделяемой на структуру Channel, очередь исходящих пакетов ch.send и другие внутренние поля.


2.2. Горутины I/O


Посмотрим на реализацию «читателя» из соединения:


func (c *Channel) reader() {
    // We make buffered read to reduce read syscalls.
    buf := bufio.NewReader(c.conn)

    for {
        pkt, _ := readPacket(buf)
        c.handle(pkt)
    }
}

Достаточно просто, верно? Мы используем буфер, чтобы сократить количество syscall’ов на чтение и вычитывать сразу столько, сколько позволит нам размер buf. В бесконечном цикле мы ожидаем поступления новых данных в соединение и читаем следующий пакет. Попрошу запомнить слова ожидаем поступления новых данных: к ним мы еще вернемся позже.


Парсинг и обработка входящих пакетов останутся в стороне, поскольку это неважно для тех оптимизаций, о которых будет идти речь. А вот на buf все же стоит обратить внимание сейчас: по умолчанию это 4 Кбайт и значит это еще 12 Гбайт памяти. Аналогичная ситуация с «писателем»:


func (c *Channel) writer() {
    // We make buffered write to reduce write syscalls. 
    buf := bufio.NewWriter(c.conn)

    for pkt := range c.send {
        _ := writePacket(buf, pkt)
        buf.Flush()
    }
}

Мы итерируемся по каналу исходящих пакетов c.send и пишем их в буфер. Это, как внимательный читатель уже мог догадаться, еще 4 Кбайт и 12 Гбайт памяти на наши 3 миллиона соединений.


2.3. HTTP


Простая реализация Channel у нас есть, теперь нужно раздобыть WebSocket-соединение, с которым мы будем работать. Поскольку мы все еще находимся под заголовком Idiomatic way, то сделаем это в соответствующем ключе.


Если вы не знакомы с тем, как работает WebSocket, то стоит сказать, что клиент переходит на протокол WebSocket с помощью специального механизма в HTTP, который называется Upgrade. После успешной обработки Upgrade-запроса сервер и клиент используют TCP-соединение для обмена бинарными WebSocket-фреймами.

Вот тут описана структура фрейма внутри соединения.

import (
    "net/http"
    "some/websocket"
)

http.HandleFunc("/v1/ws", func(w http.ResponseWriter, r *http.Request) {
    conn, _ := websocket.Upgrade(r, w)
    ch := NewChannel(conn)
    //...
})

Обратим внимание на то, что http.ResponseWriter внутри себя содержит буфер записи bufio.Writer на 4 Кбайт, а для инициализации *http.Request происходит выделение буфера чтения bufio.Reader тоже на 4 Кбайт.


Независимо от используемой библиотеки WebSocket после успешного ответа на Upgrade-запрос сервер получает буферы I/O вместе с TCP-соединением при вызове responseWriter.Hijack().


Hint: в некоторых случаях при помощи go:linkname можно вернуть буферы в пул net/http через вызов net/http.putBufio{Reader,Writer}.

Таким образом, нам нужно еще 24 Гбайт памяти на 3 миллиона соединений.


Итого уже 72 Гбайт памяти на приложение, которое еще ничего не делает!


3. Оптимизации


Стоит освежить в памяти то, о чем мы говорили в предисловии, и вспомнить, как ведет себя пользовательское соединение. После перехода на WebSocket клиент посылает пакет с интересующими его событиями — т. е. подписывается на события. После этого (не считая технических сообщений типа ping/pong) клиент может ничего больше не отправить за все время жизни соединения.


Время жизни соединения может быть от нескольких секунд до нескольких дней.

Получается, что наши Channel.reader() и Channel.writer() большую часть времени находятся в ожидании обработки данных на получение или отправку. А вместе с ними данных ожидают и буферы I/O, каждый по 4 Кбайт.


Теперь становится очевидным то, что можно сделать некоторые вещи лучше, не так ли?


3.1. netpoll


Помните реализацию Channel.reader(), который ожидал поступления новых данных, блокируясь на вызове conn.Read() внутри bufio.Reader? При наличии данных в соединении runtime go «будил» нашу горутину и позволял прочитать очередной пакет. После этого горутина снова блокировалась на ожидании новых данных. Давайте посмотрим, как runtime в go понимает, что горутину нужно «разбудить».


Заглянув в реализацию conn.Read(), мы увидим, что внутри происходит вызов net.netFD.Read():


// net/fd_unix.go

func (fd *netFD) Read(p []byte) (n int, err error) {
    //...
    for {
        n, err = syscall.Read(fd.sysfd, p)
        if err != nil {
            n = 0
            if err == syscall.EAGAIN {
                if err = fd.pd.waitRead(); err == nil {
                    continue
                }
            }
        }
        //...
        break
    }
    //...
}

Сокеты в go неблокирующие. EAGAIN говорит о том, что данных в сокете нет, и, чтобы не блокироваться на чтении из пустого сокета, ОС возвращает нам управление.

Мы видим, что происходит системный вызов read() из файлового дескриптора соединения. В случае если чтение возвращает ошибку EAGAIN, runtime делает вызов pollDesc.waitRead():


// net/fd_poll_runtime.go

func (pd *pollDesc) waitRead() error {
   return pd.wait('r')
}

func (pd *pollDesc) wait(mode int) error {
   res := runtime_pollWait(pd.runtimeCtx, mode)
   //...
}

Если покопать глубже, то мы увидим, что в Linux netpoll реализован с помощью epoll. Почему бы нам не использовать такой же подход для своих соединений? Мы могли бы выделять буфер на чтение и запускать горутину только тогда, когда это действительно нужно: когда в сокете точно есть данные.


На github.com/golang/go есть issue на экспорт функций netpoll.

3.2. Избавляемся от горутин


Предположим, что у нас есть реализация netpoll для Go. Теперь мы можем не запускать горутину Channel.reader() с буфером внутри, а «подписаться» вместо этого на событие наличия данных в соединении:


ch := NewChannel(conn)

// Make conn to be observed by netpoll instance.
// Note that EventRead is identical to EPOLLIN on Linux.
poller.Start(conn, netpoll.EventRead, func() {
    // We spawn goroutine here to prevent poller wait loop
    // to become locked during receiving packet from ch.
    go Receive(ch)
})

// Receive reads a packet from conn and handles it somehow.
func (ch *Channel) Receive() {
    buf := bufio.NewReader(ch.conn)
    pkt := readPacket(buf)
    c.handle(pkt)
}

С Channel.writer() дело обстоит проще — мы можем запускать горутину и аллоцировать буфер только тогда, когда мы собираемся отправить пакет:


func (ch *Channel) Send(p Packet) {
    if c.noWriterYet() {
        go ch.writer()
    }
    ch.send <- p
}

После чтения исходящих пакетов из ch.send (одного или нескольких) writer завершит свою работу и освободит стек и буфер.


Отлично! Мы сэкономили 48 Гбайт — избавились от стека и буферов I/O внутри двух постоянно «работающих» горутин.


3.3. Контроль ресурсов


Большое количество соединений — это не только большое потребление памяти. При разработке сервера у нас не раз случались race condition’ы и deadlock’и, которые очень часто сопровождались так называемым self-DDoS — ситуацией, когда клиенты приложения безудержно пытались подсоединиться к серверу и еще больше доламывали ломали его.


Например, если вдруг по какой-то причине мы не могли обрабатывать ping/pong сообщения, но обработчик idle-соединений продолжал такие соединения отключать (предполагая, что соединения закрыты некорректно, поэтому от них нет данных), то получалось, что клиент, вместо того чтобы после подключения ожидать событий, терял соединение каждые N секунд и пробовал подключиться снова.


Было бы здорово, если бы заблокированный или перегруженный сервер просто переставал принимать новые соединения, а балансер перед ним (например, nginx) переходил бы к следующему экземпляру сервера.


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


3.3.1 Goroutine pool


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


package gpool

func New(size int) *Pool {
    return &Pool{
        work: make(chan func()),
        sem:  make(chan struct{}, size),
    }
}

func (p *Pool) Schedule(task func()) error {
    select {
    case p.work <- task:
    case p.sem <- struct{}{}:
        go p.worker(task)
    }
}

func (p *Pool) worker(task func()) {
    defer func() { <-p.sem }
    for {
        task()
        task = <-p.work
    }
}

И теперь наш код с netpoll принимает следующий вид:


pool := gpool.New(128)

poller.Start(conn, netpoll.EventRead, func() {
    // We will block poller wait loop when
    // all pool workers are busy.
    pool.Schedule(func() {
        Receive(ch)
    })
})

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


Аналогично мы поменяем Send():


pool := gpool.New(128)

func (ch *Channel) Send(p Packet) {
    if c.noWriterYet() {
        pool.Schedule(ch.writer)
    }
    ch.send <- p
}

Вместо go ch.writer() мы хотим делать запись в одной из переиспользуемых горутин. Таким образом, в случае пула из N горутин мы гарантируем то, что при N одновременно обрабатываемых запросах и пришедшем N + 1 мы не будем аллоцировать N + 1 буфер на чтение. Пул горутин также позволяет лимитировать Accept() и Upgrade() новых соединений и избегать большинства ситуаций с DDoS.


3.4. Zero-copy upgrade


Давайте уйдем немного в сторону протокола WebSocket. Как уже говорилось выше, клиент переходит на протокол WebSocket с помощью HTTP-запроса Upgrade. Вот как это выглядит:


GET /ws HTTP/1.1
Host: mail.ru
Connection: Upgrade
Sec-Websocket-Key: A3xNe7sEB9HixkmBhVrYaA==
Sec-Websocket-Version: 13
Upgrade: websocket

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-Websocket-Accept: ksu0wXWG+YmkVx+KQR2agP0cQn4=
Upgrade: websocket

То есть HTTP-запрос и его заголовки в нашем случае нужны только для того, чтобы перейти на протокол WebSocket. Это знание, а также то, что хранится внутри http.Request, наводит нас на мысль, что, возможно, в целях оптимизации мы могли бы отказаться от ненужных аллокаций и копирований при разборе HTTP-запроса и уйти от стандартного сервера net/http.


http.Request содержит, например, поле с одноименным типом Header, которое безусловно заполняется всеми заголовками запроса путем копирования данных из соединения в строки. Представьте, сколько лишних данных можно держать внутри этого поля, например при большом размере заголовка Cookie.

Но что взять взамен?


3.4.1. Реализации WebSocket


К сожалению, все существовавшие на момент оптимизации нашего сервера библиотеки позволяли делать upgrade только при использовании стандартного net/http-сервера. Более того, ни одна (из двух) библиотек не позволяли применить все оптимизации чтения и записи, описанные выше. Для того чтобы эти оптимизации работали, нам нужно иметь достаточно низкоуровневый API для работы с WebSocket. Для переиспользования буферов нам нужно, чтобы функции по работе с соединением выглядели так:


func ReadFrame(io.Reader) (Frame, error)
func WriteFrame(io.Writer, Frame) error

Имея библиотеку с таким API, мы могли бы читать пакеты из соединения следующим образом (запись пакетов выглядела бы аналогично):


// getReadBuf, putReadBuf are intended to 
// reuse *bufio.Reader (with sync.Pool for example).
func getReadBuf(io.Reader) *bufio.Reader
func putReadBuf(*bufio.Reader)

// readPacket must be called when data could be read from conn.
func readPacket(conn io.Reader) error {
    buf := getReadBuf()
    defer putReadBuf(buf)

    buf.Reset(conn)
    frame, _ := ReadFrame(buf)
    parsePacket(frame.Payload)
    //...
}

Короче говоря, настало время запилить свою либу.


3.4.2. github.com/gobwas/ws


Идеологически библиотека ws была написана с мыслью, что она не должна навязывать пользователю логику работы с протоколом. Все методы чтения и записи принимают стандартные интерфейсы io.Reader и io.Writer, что позволяет использовать или не использовать буферизацию, равно как и любые другие обертки вокруг I/O.


Кроме upgrade-запросов от стандартного net/http, ws поддерживает zero-copy upgrade — обработку upgrade-запросов и переход на WebSocket без выделений памяти и копирований. ws.Upgrade() при этом принимает io.ReadWriter (net.Conn реализует этот интерфейс) — т. е. мы могли бы использовать стандартный net.Listen() и передавать полученное соединение от ln.Accept() сразу в ws.Upgrade(). При этом библиотека дает возможность копировать любые данные запроса для будущего использования в приложении (например, Cookie для проверки сессии).


Ниже сравнение обработки upgrade-запроса: стандартный net/http-сервер против net.Listen() и zero-copy upgrade:


BenchmarkUpgradeHTTP    5156 ns/op    8576 B/op    9 allocs/op
BenchmarkUpgradeTCP     973 ns/op     0 B/op       0 allocs/op

Переход на ws и zero-copy upgrade позволил сэкономить еще 24 Гбайт — тех самых, которые выделялись на буферы I/O при обработке запроса в хэндлере net/http.


3.5. Всё вместе


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


  • Горутина на чтение с буфером внутри — дорого.
    Решение: netpoll (epoll, kqueue); переиспользование буферов.
  • Горутина на запись с буфером внутри — дорого.
    Решение: стартовать горутину тогда, когда нужно; переиспользование буферов.
  • При лавине подключений netpoll не сработает.
    Решение: переиспользовать горутины с лимитом на их количество.
  • net/http не самый быстрый способ обработать Upgrade на WebSocket.
    Решение: использовать zero-copy upgrade на «голом» TCP-соединении.

Примерно так мог бы выглядеть код сервера:


import (
    "net"
    "github.com/gobwas/ws"
)

ln, _ := net.Listen("tcp", ":8080")

for {
    // Try to accept incoming connection inside free pool worker.
    // If there no free workers for 1ms, do not accept anything and try later.
    // This will help us to prevent many self-ddos or out of resource limit cases.
    err := pool.ScheduleTimeout(time.Millisecond, func() {
        conn := ln.Accept()
        _ = ws.Upgrade(conn)

        // Wrap WebSocket connection with our Channel struct.
        // This will help us to handle/send our app's packets.
        ch := NewChannel(conn)

        // Wait for incoming bytes from connection.
        poller.Start(conn, netpoll.EventRead, func() {
            // Do not cross the resource limits.
            pool.Schedule(func() {
                // Read and handle incoming packet(s).
                ch.Recevie()
            })
        })
    })
    if err != nil {   
        time.Sleep(time.Millisecond)
    }
}

4. Заключение


Premature optimization is the root of all evil (or at least most of it) in programming. Donald Knuth

Безусловно, оптимизации выше актуальны далеко не во всех случаях. Например, если отношение свободных ресурсов (память, CPU) к количеству живых соединений достаточно велико, наверное, нет смысла в оптимизации. Однако знание того, где и что можно улучшить, надеюсь, будет полезным.


Спасибо за внимание!


5. Ссылки


Поделиться с друзьями
-->

Комментарии (76)


  1. blanabrother
    28.06.2017 14:27
    -2

    Очень интересная и полезная статья!
    Потихоньку двигаю свой C# проект протокол-ориентированного сервера (к сожалению пока не компилируется, вчера не доборол реализацию простого пуллинга байт-буферов), в котором тоже использую то, что вы называете zero-copy upgrade. Грубо говоря, мне пришел буфер с байтами, из него сразу читаю некой машиной состояний данные, каждый шаг которой матчит ту или иную часть http запроса рукопожатия.
    Вот хэндлер для чтения фрейма 13 версии из байт-буфера, основанного тоже на машине состояний, где один из шагов — разбор заголовка.
    Кстати, пока что реализация однопоточная и обработка каждого пришедшего куска данных в байт-буффере происходит в потоке, вызванном из libuv (та, что из Node.js), но планирую добавить планировщик через похожий пул задач (или потоков), чтобы принимать подключения и данные в одном libuv-потоке, ставить в очередь на обработку, а обработку выполнять планировщиком по мере освобождения потоков.
    Эх… хотелось бы уже допилить прототип до нормального состояния и прогнать нагрузочный тест, сколько выдержит такая архитектура (честно говоря, содранная, но сильно упрощенная из проекта netty). По предварительный тестам, рукопожатие около 100-150нс, а чтение фреймов — ориентировочно 1-2нс на каждый байт payload данных на один поток обработки. На i7 2.2GHz.


    1. mayorovp
      28.06.2017 14:34

      А вы не пробовали доверить построение конечного автомата компилятору, через async/await?


      1. blanabrother
        28.06.2017 17:00

        Что Вы имеете ввиду? Автомат там удобен по одной простой причине: исходное Http (допустим) сообщение размером 512 байт может быть разбито на 6 кусков по 100, 100, 100, 100, 100, 12 байт, если размер буфера в сокетах установить в 100 байт. Автомат при чтении текущего куска может перейти в какое-то состояние и запомнить сколько прочитал, что прочитал, потом, не переходя в новое состояние, закончить выполнение. Причем текущий буфер может быть недочитан. При поступлении следующего буфера, предыдущий и новый буферы склеиваются и аккумулированный буфер передается снова в автомат, где текущее состояние пробует продолжить читать буфер и матчить что-нибудь, после чего может сделать переход в другое состояние для матчинга чего-то следующего. Не совсем понял, чем тут поможет async/await. Скорее всего Вы сделали предположение на основе недостаточной информации от меня, но и это понятно, в одном комментарии сложно описать, материала на целую статью.


        1. mayorovp
          28.06.2017 17:24
          +1

          Асинхронный код умеет так же.


          async ValueTask<(string name, string value)> ReadHeader() {
              var headerName = await ReadUntilSpace(MAX_HEADER_LENGTH);
              if (unknown header) {
                  await SkipUntilEOL();
                  return (null, null);
              }
              else {
                  var headerValue = await ReadUntilEOL();
          
                  return (headerName, headerValue);
              }
          }


          1. PSIAlt
            28.06.2017 17:36

            Код кажется не оптимальным. Много syscall-ов (отдельный вызов на каждый токен). Это может привести к большому количеству context switch и как следствие заметное замедление сервиса.
            Я не знаю C#, возможно ReadUntil* внутри содержит буффер, где копит данные «на следующие чтения» — тогда сисколов много не будет, но тогда будет плохо предсказуемое потребление памяти. Ну, еще я бы это не назвал конечным автоматом.
            Вариант с автоматом не так зависит от количества хедеров и имеет более «ровное» потребление памяти одновременно.


            1. mayorovp
              28.06.2017 17:52
              +1

              Нет, вы не поняли. Это не стандартные функции, их тоже надо реализовать.


          1. blanabrother
            28.06.2017 17:37
            -1

            Представьте, что вам пришел буфер, в котором header Connection находится в конце и не полностью, а его продолжение прийдет первыми байтами в новом буфере (на данный момент обработки нового буфера нет): первая порция байт-буфера "...................Connecti" и вторая порция байт-буфера «on: Upgrade\r\n........»
            — Ваш ReadUntilSpace что сделает? Не понятно. Допустим он завершится, тогда выполнение перейдет в SkipUntilEOL, который также никуда не сможет прочитать.
            Ваш код будет работать, если Вы получили целостное Http сообщение.
            А автомат, о котором я говорю, на шаге чтения названия заголовка просто остановится, прочитав Connecti, запишет, что заголовок Connection пока что еще матчится, но не до конца. Автомат закончит обработку буфера и все. При поступлении следующей порции байт-буфера «on: Upgrade\r\n........», автомат продолжит с сохраненного состояния, дочитает «on: », запомнит общее состояние автомата и что заголовок Connection заматчен успешно и переключится на следующий шаг чтения значения заголовка. Так обрабатывается все сообщение, всеми приходящими кусками. Декодирование веб-сокет фрейма работает почти аналогично, только сильно проще.


            1. mayorovp
              28.06.2017 17:58

              Э… Вы не обратили внимание на оператор await? Если в буфере неполный заголовок — то ReadUntilSpace вернет незавершенную задачу. Оператор await обнаружит это и остановит метод ReadHeader, он тоже вернет незавершенную задачу. Когда придет следующая часть, оба методв будут продолжены.


              Говорю же, это и есть конечный автомат, только генерируемый компилятором.


              1. blanabrother
                28.06.2017 18:16

                Мы точно говорим о C#? Когда придет следующая часть, то куда она будет передана, если у Вас уже висит незавершенная задача, запущенная с определенными параметрами (не вижу в Вашем примере)? Поясните, где в Вашем примере запускаемая задача берет первый кусок сообщения, потом останавливается и ждет получения слеующего. Это важно для понимания Вашего хода мыслей в этом обсуждении.


                1. mayorovp
                  28.06.2017 19:11

                  Если используется системный проактор — то функция ReadUntilSpace будет реализована как-то так:


                  private readonly StringBuilder sb = new StringBuilder();
                  private readonly byte[] rbuf = ...;
                  private int roff, rlen;
                  
                  ValueTask<string> ReadUntilSpace(int maxLen) {
                      sb.Clear();
                      while (sb[sb.Length-1] != ' ' && sb.Length <= maxLen) { // Тут на самом деле надо еще и на EOL проверить, но это усложнит пример
                          if (roff == rlen) {
                              rlen = await stream.ReadAsync(rbuf, 0, rbuf.Length);
                              roff = 0;
                              if (rlen == 0) throw ...;
                          }
                          if (rbuf[roff] > 128) throw ...;
                          sb,Append((char)rbuff[roff++]); // HTTP работает только с базовым набором ASCII
                      }
                      sb.Length--;
                      return sb.ToString();
                  }

                  Вызов NetworkStream.ReadAsync приведет к вызову Socket.BeginReceive, который начнет асинхронную операцию, привязав ее к системному IOCP, после чего NetworkStream.ReadAsyncвернет незавершенную задачу. После завершения операции чтения в пуле потоков будет вызван Socket.EndReceive, с последующей отметкой задачи, возвращенной NetworkStream.ReadAsync как завершенной, что в свою очередь вызовет продолжение выполнения метода ReadUntilSpace и далее по цепочке.


                  Если же писать свой реактор, пожертвовав скоростью ради памяти, то заполнение опустевшего буфера будет чуть сложнее:


                  private TaskCompletionSource<ArraySegment<byte>> readOperation;
                  ValueTask<string> ReadUntilSpace(int maxLen) {
                       // ...
                          if (roff == rlen) {
                              Debug.Assert(readOperation == null);
                              readOperation = new TaskCompletionSource<bool>();
                              await readOperation.Task;
                              if (rlen == 0) throw ...;
                          }
                       // ...
                  }
                  
                  public bool WaitingForData => readOperation != null;
                  
                  public void DataAvailable(byte[] buffer, int offset, int count) {
                      Debug.Assert(roff == rlen);
                      var op = readOperation;
                      readOperation = null;
                  
                      rbuf = buffer;
                      roff = offset;
                      rlen = count;
                  
                      op.SetResult(false);
                  }


                  1. blanabrother
                    28.06.2017 21:21

                    Теперь понял, что Вы имели ввиду. Ваш пример имеет право на жизнь.


            1. khim
              28.06.2017 20:04

              А почему просто ragel не прикрутить?


              1. mayorovp
                28.06.2017 20:25

                Ragel targets C, C++ and ASM.

                Где в этом списке C#?


                1. khim
                  29.06.2017 13:59

                  В более ранней версии: Ragel targets C, C++, Obj-C, C#, D, Java, Go and Ruby. Берёте ragel 7.0.0.9 — и всё.

                  Не знаю какое бревно на них упало и почему они вдруг решили выпилить поддержку C# и Java, но FSM — не картошка, старые версии не гниют…


  1. rule
    28.06.2017 14:29
    +4

    Шикарно, и очень лаконично. Даже не обладая глубокими знаниями всё понятно.

    Но у меня пару вопросов возникло, я не очень силен в высоконагруженных системах, но разве можно иметь 3 миллиона соединений на одной машине?
    Насколкьо я помню, в IP адресации четыре поля:

    • адрес отправителя
    • порт отправителя
    • адрес назначения
    • порт назначени

    И получается что количество уникальных соединений ограничивается количеством портов при наличии одного сетевого интерфейса: 64К. Или это по другому работает?
    Но даже при всем при этом, каждое сетевое соединение — это файловый дескриптор, а их вроде тоже там ограниченное количество, в районе 300К, что на порядок выше ваших чисел.
    Как это разруливается и считали вы системные затраты на каждое соединение?


    1. mayorovp
      28.06.2017 14:41
      +3

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


      Вот с количеством файловых дескрипторов уже сложнее, на ненастроенном сервере в него можно запросто упереться при таких экспериментах. К счастью, на linux лимит настраивается, а на windows просто очень большой (16 миллионов на процесс).


      1. rule
        28.06.2017 14:48
        +2

        получается что вот так всё будет работать?
        client1:11 <-> server:1000
        client2:11 <-> server:1000
        client3:11 <-> server:1000
        client4:11 <-> server:1000

        А на каждый дескриптор же память тоже выделяется, вы её тут не считали. Я не знаю сколько, но там точно права доступа, адрес, тип. Или это не значительно?


        1. mayorovp
          28.06.2017 14:55
          +1

          Да, будет работать. Более того, оно так и работает! — номер порта на сервере остается тем же самым, который и прослушивался.


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


    1. gobwas
      28.06.2017 14:57
      +5

      Привет, Спасибо!


      Теоретически, 3 миллиона соединений на одной машине могут жить. Но для распределения нагрузки и наличия запаса ресурсов мы распределяем соединения на несколько серверов. Сейчас это 8 машин, было 4. Но судя по цифрам, после оптимизаций мы смогли бы держать 3 миллиона соединений на одной машине: не было бы запаса по CPU, но с памятью было бы все ок. Но и там, возможно, можно было бы что-то докрутить.


      Что касается адресации – вы, наверное, имеете в виду проблему портов, когда nginx не может спроксировать больше ~64K коннектов на локальный демон? Ее можно решать, как вы правильно сказали, добавлением виртуальных интерфейсов, либо, как это сделали мы – уйти от TCP-сокетов с адресацией по порту, в сторону UNIX-сокетов.


      Лимит на открытые файловые дескрипторы – это "ручка", которая настраивается в Linux на процесс или на пользователя.


      По цифрам могу сказать про память, что до оптимизаций сервер потреблял ~60Кбайт на соединение, после – 10Кбайт. При этом, можно крутить флажок GOGC в Go, который так же немного влияет на цифры потребления памяти.


      1. mayorovp
        28.06.2017 15:11
        +1

        Хм, а зачем в этой схеме nginx? Мне почему-то кажется, что если отказаться еще и от него — можно еще сильнее разгрузить сервера.


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


        1. gobwas
          28.06.2017 15:19
          +6

          Nginx в первую очередь берет на себя ssl. Плюс, мы запускаем несколько экземпляров сервера на go, чтобы при падении/локе/жестком рестарте одного остальные продолжали работать: nginx распределяет коннекты между экземплярами.


          А что за демон для вебсокет-соединений?


          1. babylon
            29.06.2017 06:21
            -2

            gobwas отличная статья, но есть куда развиваться


            1. gobwas
              29.06.2017 10:57
              +1

              Спасибо! Всегда есть куда развиваться.


          1. doom369
            29.06.2017 10:00
            -1

            Nginx в первую очередь берет на себя ssl.


            А что go не умеет ssl? Или у go биндинги на openSSL другие?


            1. Kano
              29.06.2017 10:54
              +1

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


              1. doom369
                29.06.2017 11:10

                Например?


                1. gobwas
                  29.06.2017 11:16

                  nginx распределяет коннекты между экземплярами

                  Плюс, таймауты на чтение из клиентского коннекта, ограничение доступа к апстриму и т. д.


                  Можно конечно, переписать на Go логику, отлично работающую в nginx, но не уверен, что это принесет гигантский профит. Возможно, стоит попробовать – но сейчас есть более приоритетные задачи =)


                1. Kano
                  29.06.2017 11:27
                  +3

                  Как вам уже ответили, это работа с ожиданиями I/O операций.
                  Закрытие подключений, повторное использование socket, правильная работа с обработкой вновь поступающих подключений.
                  Оптимизированная работа с приемом и обработкой трафика (это не так просто как звучит, т.к. из удаленных источников данные могут приходить в довольно неожиданных порциях). На допущениях того что трафик в приложении идет через локальное подключение можно довольно сильно оптимизировать свой сервер обработчик.
                  «Fraud» трафик в конце концов.
                  Думаю что не целесообразно тратить кучу времени на реализацию полноценного web сервера когда есть уже готовые и проверенные временем решения (iis/http.sys под windows и nginx для остальных)


                  1. doom369
                    29.06.2017 11:34
                    -4

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


                    А еще это дополнительный оверхед. Так как юникс сокеты не бесплатны.

                    Думаю что не целесообразно тратить кучу времени на реализацию полноценного web сервера когда есть уже готовые и проверенные временем решения


                    Ну так я к чему и веду. Все это уже есть. Например, на яве в netty. Получается бросили кучу уисилий, написали велосипед и он всеравно будет медленней, чем готовые решения.


                    1. gobwas
                      29.06.2017 11:46
                      +2

                      Эм, кажется мы друг друга не поняли. Мы же вроде не бросили кучу усилий и не писали велосипед на разработку web-сервера, а взяли nginx?


                      1. doom369
                        29.06.2017 11:54
                        -2

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

                        Ну по крайней, мере это что я вижу. Из описаного выше.


                        1. gobwas
                          29.06.2017 12:09
                          +2

                          Дырок в функциональности Go нет. Дырка появилась бы во времени, которое пришлось бы потратить на призрачный профит переписывания функциональности nginx.


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


                          А вообще, что-то мне подсказывает, что в свете упоминания netty, уместно будет процитировать эту шутку:



                          1. doom369
                            29.06.2017 12:48
                            -5

                            Дырок в функциональности Go нет.


                            А то что Вы написали сокеты, это не в счет? И дырки есть, иначе — зачем вам енджинкс? Очевидно, что Вы им закрываете недостающий фукнционал в вашей реализации вебсокетов. (я не про лоад балансинг, а про другие пункты что Вы описали).

                            уместно будет процитировать эту шутку


                            Ну Вы же понимаете, что дело не в языке, а в людях, которые пишут код.


                            1. gobwas
                              29.06.2017 13:22
                              -1

                              И дырки есть, иначе — зачем вам енджинкс?

                              Кажется, это рекурсия!


                            1. wing_pin
                              29.06.2017 16:37

                              Кажется, у вас Java головного мозга. Срочно обратитесь к доктору.

                              ЗЫ: а у JVM время старта большое.


                              1. doom369
                                29.06.2017 16:45

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


                                1. wing_pin
                                  29.06.2017 17:35
                                  +3

                                  Вы так говорите, будто это что-то плохое. На самом деле ребята сделали очень хорошую вещь. Кроме решения собственной проблемы они еще и сообществу оказали помощь. Было бы странно менять инструменты при любой задаче, которая требует реализации чего-либо, или вы думаете, что в Java все мгновенно появилось?


                    1. gobwas
                      29.06.2017 11:48

                      Или вы решили через вопрос целесообразности nginx перед нашим WebSocket-сервером решили привести к тому, что нецелесообразно было пилить WebSocket-сервер? =)


                      1. doom369
                        29.06.2017 11:56
                        +1

                        Вы меня раскусили :)


                    1. blanabrother
                      29.06.2017 13:05
                      +2

                      Все это уже есть. Например, на яве в netty. Получается бросили кучу уисилий, написали велосипед и он всеравно будет медленней, чем готовые решения.

                      Автор в том числе акцентирует внимание на zero-copy upgrade, смысл которого в том, чтобы не парсить Http заголовки, а сразу их анализировать и матчить. В netty дефолтная реализация в виде пайплайна будет выглядеть примерно так: HttpResponseEncoder + HttpRequestDecoder + HttpObjectAggregator + WebSocketsHandshaker — т.е. в netty как раз готовое решение будет далеко не zero-copy.
                      Но на netty можно написать свой хэндлер, который будет zero-copy, но тогда это та же работа, что они и сделали (почти, за исключением остальной инфраструктуры).


                      1. doom369
                        29.06.2017 13:09
                        -1

                        Ну так задача специфичная. Вам никто не мешает переопределить HttpObjectDecoder и убрать от туда парсинг хидеров, вопрос нескольких часов.


            1. gobwas
              29.06.2017 11:00

              Go умеет SSL, при этом, если я не ошибаюсь, без binding'ов – т.е. своя реализация.


            1. babylon
              30.06.2017 08:53
              -2

              Недостаток Nginx в отсутствии иерархичности. Он плоский как блин:)


        1. gobwas
          28.06.2017 15:23

          Извиняюсь, с телефона прочитал "домен" как "демон" =) Вопрос тогда не актуален.


      1. Kano
        29.06.2017 11:07
        -2

        rule задал правильный вопрос на счет ограничения в количестве одновременных подключений к серверу по tcp/ip.
        В статье я не заметил ни слова о том как преодолено ограничение в количестве локальных портов.
        Как Nginx может держать открытыми 3 млн внешних подключений?
        Ведь все они приходят с одного внешнего порта (ip адреса) для которого драйвер tpc/ip будет использовать всего 65535 портов (к тому же часть из которых зарезервированы), которые даже после закрытия соединения еще какое то время удерживаются в ожидании повторных подключений с удаленного адреса.


        1. mayorovp
          29.06.2017 12:21
          +1

          Что касается адресации – вы, наверное, имеете в виду проблему портов, когда nginx не может спроксировать больше ~64K коннектов на локальный демон? Ее можно решать, как вы правильно сказали, добавлением виртуальных интерфейсов, либо, как это сделали мы – уйти от TCP-сокетов с адресацией по порту, в сторону UNIX-сокетов.


          1. Kano
            29.06.2017 22:35
            -2

            Скажу по другому.
            Как 3млн. ВНЕШНИХ подключений удерживаются Nginx?
            То как они взаимодействуют с внутренними процессами, отдельный разговор (и тут у меня нет вопросов, т.к. решений множество, на что автор и указал в своем сообщении).
            Помню на какой то конференции товарищи презентовали железку которая могла держать более 1млн. одновременных подключений и там была реализована черная магия, а на выходе (к обработчику подключений) множество небольших запросов с ключом сессии.
            Например в Windows это можно сделать через NLB который действует на третьем уровне сетевого протокола (по сути работает на уровне драйвера, до выделения порта) и перераспределяет запросы на множество сетевых адаптеров (возможно и виртуальные), но это решение довольно не оптимально в плане производительности (наверняка есть супер железки которые выполняют подобные действия и одну из них как раз использовали для своего решения в mail.ru)


            1. mayorovp
              29.06.2017 22:42
              +2

              А в чем, собственно, проблема держать 3 миллиона внешних подключений если памяти достаточно?


  1. Legion21
    28.06.2017 16:02
    +1

    Статья супер, спасибо!


  1. cjbars
    28.06.2017 17:51

    Сижу, думаю о том, что хватит бомбардировать сервера, пусть они сами звонят клиентам, пора решаться на websocket в продакшене, и тут БАЦ! Ваша статья! Это ж надо так вовремя! Сопицот СПАСИБО!


    1. gobwas
      28.06.2017 18:06
      +1

      Из "неизданного":
      image


  1. JC_IIB
    29.06.2017 06:37
    +1

    8 Кб на горутину в максимуме? Это много очень, по сравнению с более другими языками, в которых, например, на процесс выделяется 338 8-байтовых слов, причем 233 байта — это куча.


    1. JC_IIB
      29.06.2017 07:16

      Поправка — куча 233 слова, не байта.


    1. gobwas
      29.06.2017 10:14

      8 Кбайт – это который в зависимости от операционной системы и версии Go. В последних версиях и в Linux, если я не ошибаюсь, стек начинается с 2 Кбайт.


      на процесс выделяется 338 8-байтовых слов

      Судя по всему, речь идет об Erlang/Elixir? =)


      Получается, (338-223) * 8 = 920 байт стека? Выходит, с учетом кучи (338 * 8 = 2704) разницы нет? )


      Вот в ponylang, например, акторы занимают 256 байт памяти… Но это уже совсем другая история =)


      1. JC_IIB
        29.06.2017 12:12
        +1

        Угу, это я за любимый Erlang слегка подтапливаю:)


        1. gobwas
          29.06.2017 12:18

          Спору нет – erlang для этого, очевидно, хорош. Не зря WhatsApp рекорды по соединениям на одном сервере с ним ставил =)


          1. doom369
            29.06.2017 12:43
            -2

            Спору нет – erlang для этого, очевидно, хорош. Не зря WhatsApp рекорды по соединениям на одном сервере с ним ставил =)


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


            1. JC_IIB
              29.06.2017 14:09

              А где-то есть подробности?


              1. doom369
                29.06.2017 14:20
                -1

                Ну если далеко не ходить, то — https://habrahabr.ru/post/276951/


  1. greabock
    29.06.2017 09:19
    -3

    conn -> connection,
    ch -> channel,
    pkt -> packet

    пожалуйста не экономьте чернила.


    смотришь потом на переменную с именем c и думаешь… "хм… а это channel или connection?"


    1. mayorovp
      29.06.2017 09:42
      -2

      До тех пор, пока объявление переменной помещается в один экран с ее использованием — это неактуально.


  1. recompileme
    29.06.2017 10:05
    +5

    Вы, конечно, упоротые, в хорошем смысле этого слова.
    До конца прошли путь на го, опустившись на самое дно. У нас была похожая ситуация — написали сокет сервер над http://sophia.systems/ на nim — https://github.com/recoilme/pudge

    Но по мере написания, я чувствовал что мы опускаемся все ниже и ниже, переходя к ручному управлению памятью и погрязая в разборках как работает ним с епол и тп. В какой то момент я подумал что если мы все равно пишем на nim как на c — то почему бы не взять сразу готовую либу на си (libevent) и не скатиться в c окончательно?

    Получился сокет сервер на си — https://github.com/recoilme/okdb Меня смущало еще то, что до этого я никогда не писал на си, у вас то с этим ситуация лучше), да это не проблема как выяснилось, си довольно простой. Работает без сбоев уже пол года — тьфу-тьфу-тьфу) Ну и знаете, кода и волшебных мест — стало меньше. Зато теперь я точно уверен что ни байта данных не расходуется «налево». Те может быть в вашей истории тоже не стоило упираться в гоу раз уж вам критичны ресурсы? Я просто профита от гоу не вижу здесь особо.


    1. recompileme
      29.06.2017 10:20
      +4

      Ну и еще хотелось бы дополнить ложкой дегтя.
      Экономишь — экономишь на сисколах, барахтаешься на самом дне, чтобы сэкономить жалкие 500 наносекунд, а потом видишь что десериализация объекта в перл жрет в 10 раз больше времени чем вся твоя убер система, и все равно все сводится к тому, что нужен кешик, уже в перле, над бд. И потом смотришь сверху на систему и понимаешь — что можно было ничего не менять, остаться на высоком ЯП, ибо конечная система все равно работает примерно с той же скоростью, лил.

      Другими словами вот вы станцевали, а яваскрипт в http интерфейсе почты примерно так же тупит как раньше, например. И толку нет особо от всех этих танцев. Ну это я к примеру, может есть где то идеальный мир где всю систему пилит один человек и она работает максимально оптимально…


    1. gobwas
      29.06.2017 10:22
      +1

      Ну профит в Go здесь как раз в том, что мы можем выборочно оптимизировать узкие места. Например, там, где не требуется высокая эффективность и производительность можно положиться на go runtime, сборщик мусора и т. д.


      Были мысли из Go использовать pico http parser, но в итоге написал кусочек функционала на Go и на этом пока вопрос закрылся =)


      1. recompileme
        29.06.2017 10:42

        Ага. Смотрел тоже и pico и h2o и всякие facilio — тлен это всё)
        проще маленький нужный кусочек самому написать или стыбрить мякотку парсинга из подходящей либы


  1. pavelkolodin
    29.06.2017 10:23
    +1

    Делал C++ реализацию WebSocket (и заодно HTTP): https://github.com/pavelkolodin/fir
    На этом был построен сетевой стек веб-игруни: https://fintank.ru/
    Когда делал HTTP, читал сырцы nginx, пытался получить zero-copy где только можно, в websocket-части до сих пор не выпилил одно место где кусок данных лишний раз копируется.
    Я конечно могу сказать, что поделие было вызвано отсутствием на тот момент нормальной реализации websocket для C/C++, но посмотрим правде в лицо: скорее всего просто руки чесались :)


    1. gobwas
      29.06.2017 10:25
      +1

      На C++ вроде есть недурственный uWebSockets, но это не точно.


  1. yarkin
    29.06.2017 10:23

    Спасибо за статью!
    А планируются ли ещё статьи по этой системе? Интересно узнать ещё чем проводите нагрузочное тестирование, насколько сложная маршрутизация внутри и какое количество сообщений в среднем проходит через систему?


    1. gobwas
      29.06.2017 10:28
      +2

      В ближайшее время не планировалось. Если интересно про маршрутизацию и некоторые цифры – есть видео с РИТ2017:



  1. DarkOrion
    29.06.2017 10:27

    Прочитал постановку задачи и не понял чем не угодили:
    — Реализации обычных ESB — WSO2 или Mule — если это используется в другом приложении
    — Zabbix, если это все ради мониторинга

    Был какой-то анализ на тему взять готовое vs велосипед?


    1. gobwas
      29.06.2017 10:55

      Упс, ответил ниже в треде =)


  1. gobwas
    29.06.2017 10:55
    +1

    В общении между шиной и клиентами мы не хотим использовать протоколы кровавого энтерпрайза SOAP, JBI, JMS и т. д. В почте у нас нет Java, и большинство вариантов использования покрывает внутренний бинарный протокол IProto. Нам хотелось использовать что-то совсем легковесное и контролирумое чуть более, чем полностью – мы еще не пришли к конечной логике работы системы и можем захотеть ее поменять.


    И это не ради мониторинга.


  1. merhalak
    29.06.2017 13:35

    Ребят, у вас ASCII арт (диаграммы) "поплыли", если смотреть с телефона. Может замените на картинки?


  1. Kano
    29.06.2017 22:37
    +1

    Интересно было бы почитать про подобный же опыт, но на rust


  1. sania
    01.07.2017 07:50

    а как обстоит сейчас дело с GC — сильно ли он влияет на мгновенную отдачу результата клиенту?


    1. gobwas
      03.07.2017 12:01
      -1

      С GC дело обстоит неплохо. Это практически не влияет на мгновенную отдачу – GC случаются редко и на короткие промежутки времени (зависит от GOGC).