IO_URING. Часть 3
Всем привет! Симулятор написания echo серверов на связи. Сегодня завершающая часть из цикла посвященного io_uring. В этом материале мы поговорим о настройке io_uring, режимах работы и перформансе. Полученные знания используем чтобы улучшить конкурента netpoller из предыдущей статьи.
В предыдущих сериях
В первой части цикла мы познакомились с io_uring, посмотрели простенькие примеры его работы и даже написали небольшой tcp-сервер.
Во второй части мы попытались найти практическое применения для io_uring, а именно переписали кусок GO’шного рантайма с использованием новой технологии. К сожалению, производительность полученного решения оказалась так себе.
Reactor собирает войска
Итак, сегодня, разберём опции io_uring и подходы, которые помогут улучшить производительность. Оптимизируем замену для netpoller’а — reactor и напишем новые бенчмарки. Ну и, конечно, подведем какие-никакие выводы.
Промежуточная цель этой статьи — стать небольшим справочником для разработчика, решившего воспользоваться io_uring при работе с сетью. Так что все возможные опции, настройки и прочие аспекты, влияющие на socket I/O, будут рассмотрены подробно и дополнены субъективным мнением автора. Оставшиеся опции будут упомянуты вскользь, так как не относятся к сегодняшней теме.
Условно разделим возможные оптимизации на несколько групп:
настройка экземпляра io_uring:
опции, устанавливаемые при создании io_uring через системный вызов io_uring_setup;
флаги, которые устанавливаются для каждого SQE отдельно.
мультиплексирование I/O с несколькими экземплярами io_uring;
оптимизация кода event loop (reactor).
Настройка нового экземпляра io_uring
Полный список опций, а также способы их установки:
man io_uring_setup
IORING_FEAT_FAST_POLL
Начнем, правда, не с конфигурируемых пользователем опций, а с фичи FEAT_FAST_POLL доступность которой зависит только от версии ядра, она будет включена для ядер версии 5.7 и выше. Наличие этого механизма обязательно для высокого перформанса при сетевом I/O. FAST_POLL это хитрый алгоритм полинга и его понимание очень пригодится в будущем, поэтому давайте разбираться.
Допустим, необходимо выполнить чтение из сокета посредством операции recv (здесь под poll’ом подразумевается интерфейс ОС, который реализует конкретный драйвер, а не семейство syscall’ов):
Добавляем операцию recv через io_uring_enter.
io_uring выполняет системный вызов recv в неблокирующем режиме:
Вызов recv прошел успешно — можно коммитить соответствующий CQE в CQ.
Вызов вернул EAGAIN — poll’им файловый дескриптор, регистрируем асинхронный обработчик для события POLLIN:
Вызов poll для сокета незамедлительно вернул результат — добавляем новую операцию recv в SQ, переходим к (2).
В асинхронный обработчик пришло событие — добавляем новую операцию recv в SQ, переходим к (2).
Так при переходе из 2.2.2 к 2 проходит некоторое время то возможно ситуация, когда повторный вызов recv опять вернул EAGAIN. На этот случай есть fallback механизм: вызов recv и отправляется на выполнение в worker-pool основанный на linux-workers. Важный вывод здесь — основная работа происходит в контексте thread’а (task в терминологии ядра linux) который вызвал io_uring_enter, а worker-pool задействуется очень редко.
Теперь можно перейти к первоначальной конфигурации экземпляра io_uring (далее просто кольца).
IORING_SETUP_SQPOLL
Наверное, самая интересная опция. Как мы помним одно из преимуществ io_uring — возможность выполнять системные вызовы пачками. Вместо самостоятельных вызовов разнообразных syscall’ов можно поместить их в очередь и только единожды выполнить системный вызов io_uring_enter. При таком подходе уменьшается количество переходов user/kernel space, что положительно влият на производительность системы. Так вот, с помощью IORING_SETUP_SQPOLL можно избавиться и от вызова io_uring_enter. Работает это так — создается ядерный тред, который берет на себя работу по мониторингу очереди SQ. Если, на протяжении некоторого времени, в SQ не было новых вхождений, то поток засыпает и в дальнейшем его необходимо разбудить, чтобы продолжить обработку очереди.
Плюсы:
пропадает необходимость в вызове io_uring_enter, что может быть существенно в случае частых вызовов io_uring_enter. С другой стороны, подобные частые системные вызовы могут быть индикатором того, что батчинг syscall’ов используется недостаточно;
упрощаем код приложения. Вся работа по батчингу и определение момента, когда нужен вызов io_uring_enter, ложится на ядро и снимается с плеч разработчика.
Минусы:
осознанно теряем в контролируемости, так как часть работы отдаем на совесть kernel thread’а;
вместо одного потока, который работает с экземпляром io_uring, получаем два.
Мнение автора: опция хороша для быстрого старта — позволяет получить хорошую производительность с минимальными усилиями, но выжать все соки — не получится.
IORING_SETUP_SQ_AFF
Позволяет «прибить» поток, который был создан опцией IORING_SETUP_SQPOLL, к одному CPU. Может быть полезна при нескольких одновременно работающих io_uring’ах и соответственно нескольких SQPOLL потоках.
Мнение автора: по заявлениям коммитеров в т. н. экстремальных кейсах можно получить очень значительный прирост. Правда, на своих тестах я получил скорее деградацию производительности. В общем it depends.
IORING_SETUP_ATTACH_WQ
Ранее было упомянуто о том, что каждое кольцо io_uring использует свой worker-pool для выполнения асинхронных операций. С помощью этой опции можно заставить несколько колец работать на одном, общем, пуле (кстати, также можно заставить один SQPOLL thread обрабатывать SQ нескольких колец).
Мнение автора: интересная опция, на практике можно получить некоторый прирост, уменьшив количество пулов в системе. Как мы помним в контексте socket I/O пулы воркеров используются в fallback механизме, так что нет смысла держать пулов столько, сколько инстансов io_uring в системе, будет разумно разделить один-два пула между всеми кольцами.
Прочее
IORING_SETUP_IOPOLL — включает активное ожидание для I/O операций, положительно влияет на latency и отрицательно на throughput. Не поддерживается для операций на сокетах.
IORING_SETUP_CQSIZE — устанавливает размер CQ буфера, который стандартно равен size (SQ)*2.
IORING_SETUP_CLAMP — защита от дурака неверно заданного размера буферов SQ и CQ.
IORING_SETUP_R_DISABLED — позволяет создать выключенное кольцо.
Флаги SQE.
Поговорим о флагах, которые устанавливаются для SQE перед вызовом io_uring_enter. Все так же разбираемся с ними в контексте socket I/O. Полный список флагов и способы их установки можно подсмотреть в:
man io_uring_enter
IOSQE_ASYNC
Установка этого флага для SQE позволяет сразу обработать SQE в ассинхронном режиме. Для сетевых запросов это значит сразу перейти к fallback механизму — отправить операцию в work pool.
Мнение автора: если вы по какой-то причине хотите работать с одним инстансом io_uring, но при этом не против параллельно запущенных worker’ов, то включение этой опции дает значительный прирост производительности. Другое дело, что работать с несколькими экземплярами гораздо лучше, впрочем, об этом позже.
IOSQE_BUFFER_SELECT
Если экземпляр io_uring берет в обработку SQE с установленным флагом IOSQE_BUFFER_SELECT то будет использован один из заранее аллоцированных буферов. Набор буферов регистрируется при помощи операции IORING_OP_PROVIDE_BUFFERS.
Мнение автора: теоретически выглядит очень интересно, так как позволяет расшарить участки памяти между приложением и ядром. К сожалению, на практике эффект [не слишком впечатляющий](https://twitter.com/hielkedv/status/1255492941960949760? s=21), ждем изменений.
IOSQE_IO_LINK
Позволяет связать несколько SQE в цепочку, каждое SQE в цепочке будет выполняться последовательно, после завершения предыдущей. В случае возникновения ошибки в одном из SQE вся цепочка инвладириуется. Эта опция актуальна в связке с операцией IORING_OP_LINK_TIMEOUT которая несколько меняет семантику: когда эта операция будет связана с SQE то они будут работать в паре, то есть либо SQE завершится успешно либо сработает timeout. Такая опция пригодится для выполнения сетевых запросов, поскольку без таймаутов — никак.
Прочее
IOSQE_FIXED_FILE — разрешает пользоваться файловыми дескрипторами, которые были заранее «прибиты» к экземпляру io_uring. В принципе, тут подходят те же выводы, что и с механизмом предопределенных буферов, теоретически интересно, практически, пока что, разница незаметна.
IOSQE_IO_DRAIN — своеобразный барьер, SQE с таким флагом будет запущен после завершения всех текущих операций, новые операции также будут ждать окончания обработки данного SQE.
IOSQE_IO_HARDLINK — используется в связке с IOSQE_IO_LINK, в случае установки данного флага цепочка не будет разорвана, если одна из операций завершилась с ошибкой.
Нужно больше колец
При рассмотрении настроек красной нитью проходит простая мысль: можно создавать сколько угодно экземпляров io_uring. И действительно, если обычно кольцо выполняет net операции в том же потоке на котором был выполнен системный вызов io_uring_enter, то почему бы не создать несколько колец и не расположить их на разных потоках?
Такой подход действительно дает отличный буст производительности и позволяет по полной нагрузить процессор. Но есть несколько моментов о которых не стоит забывать:
потокобезопасность — да, liburing и прочие обертки защищают несинхронизированного доступа к SQ и CQ между user space’ом и ядром. Но, никакой коробочной синхронизации при доступе нескольких пользовательских потоков к CQ или SQ нет. К примеру, добавлять операции в SQ буфер и делать submit из него же параллельно в нескольких потоках — не безопасно;
оптимальное количество колец — будет варьироваться от системы к системе и зависит от того, как вы настроили экземпляры. Например, на моем тестовом стенде оптимальным оказалось (количество ядер * 2) — 2.
Оптимизация reactor
Наконец не будем забывать и об оптимизации кода приложения. Давайте разберем пару оптимизаций на примере компонента reactor из прошлой статьи.
Поддержка нескольких экземпляров io_uring
Как мы решили выше — для полной утилизации ресурсов системы необходимо несколько колец io_uring. Так что reactor должен уметь работать с несколькими экземплярами.
Распределяем запросы между кольцами
Необходим способ распределения операции между кольцами. И здесь очевидное решение маршрутизировать операции по файловому дескриптору. Получается некая функция роутинга/шардирования от файлового дескриптора сокета. Возникает небольшая проблема — если в качестве такой функции выбрать (FD % количество колец) то при малом количестве соединений мы получим очень большое снижение производительности. Дело в том, что event-loop на основе кольца, да и сам io_uring работает довольно медленно при низкой частоте операций. Хочется чтобы при небольшом количестве сокетов нагрузка не размазывалась между всеми кольцами, а оседала на одном — двух экземплярах. Поэтому функция роутинга выглядит вот так:
const granSize= 75
func (r *NetworkReactor) loopForFd(fd int) *ringNetEventLoop {
// n - количество колец с которыми работает reactor
n := len(r.loops)
// h - номер "гранулы" к которой принадлежит файловый дескриптор
h := fd / granSize
// h%n - номер кольца который отвечатает за обработку гранулы
return r.loops[h%n]
}
Регистр обработчиков
В наивной реализации реактора использовался единый регистр обработчиков CQE — reactor.callbacks. Он представлял собой goroutine-safe мапу в которой ключом был уникальный номер SQE, а значением замыкание, вызываемое при появлении CQE. Такое решение крайне не оптимально так как оно использует единый lock для всех операций — добавления, вызова и удаления обработчиков. Да и учитывая специфику нашей области, хотелось бы предалоцировать место для обработчиков, ибо мы знаем, что одновременных соединений будет много и для каждого сокета в кольце может единовременно находиться две операции: чтения и записи в сокет. Получаем следующий компонент, решающий эти проблемы:
cbRegistry
package reactor
import (
"sync"
"sync/atomic"
)
type (
// cbMap - обработчики привязанные к файловому дескриптору, ключ - id операции для файлового дескриптора, Callback - замыкание которое обработает CQE
cbMap map[uint32]Callback
// shard - шард обработчиков, отвечающий за хранение колбеков к операциям относящимся к определенным файловым дескрипторам
shard struct {
// nonce - id операции в контексте файлового дескриптора
// noncec - массив идентификаторов, где индекс массива - файловый дескриптор, значение - последний выданный nonce
nonces []uint32
// callbacks - преалоцированный массив для обработчиков CQE
callbacks []cbMap
// boundary - граница после которой обработчик попадает в медленное хранилище slowCallbacks
boundary int
// медленные хранилища для обработчиков и последних выданных nonce'ов
slowCallbacks map[int]cbMap
slowNonces map[int]uint32
sync.Mutex
}
// cbRegistry - общий регист для обработчиков CQE
cbRegistry struct {
shards []*shard
granularity int
shardCnt int
}
)
func newShard(fCap int) *shard {
buff := make([]cbMap, fCap)
for i := range buff {
buff[i] = make(cbMap, 10)
}
return &shard{
callbacks: buff,
nonces: make([]uint32, fCap),
boundary: fCap,
slowCallbacks: make(map[int]cbMap),
slowNonces: make(map[int]uint32),
}
}
func (sh *shard) add(idx int, cb Callback) (n uint32) {
if idx < sh.boundary {
n = atomic.AddUint32(&sh.nonces[idx], 1)
sh.Lock()
sh.callbacks[idx][n] = cb
sh.Unlock()
return n
}
//slow path, for big fd values
sh.Lock()
sh.slowNonces[idx]++
n = sh.slowNonces[idx]
if _, exists := sh.slowCallbacks[idx]; !exists {
sh.slowCallbacks[idx] = make(cbMap, 10)
}
sh.slowCallbacks[idx][n] = cb
sh.Unlock()
return n
}
func (sh *shard) pop(idx int, nonce uint32) Callback {
if idx < sh.boundary {
sh.Lock()
cb := sh.callbacks[idx][nonce]
delete(sh.callbacks[idx], nonce)
sh.Unlock()
return cb
}
sh.Lock()
cb := sh.slowCallbacks[idx][nonce]
delete(sh.slowCallbacks[idx], nonce)
sh.Unlock()
return cb
}
func newCbRegistry(shardCount int, granularity int) *cbRegistry {
shards := make([]*shard, shardCount)
for i := 0; i < shardCount; i++ {
shards[i] = newShard((1 << 16) / shardCount)
}
return &cbRegistry{
granularity: granularity,
shardCnt: shardCount,
shards: shards,
}
}
func (r *cbRegistry) shardNumAndFlattenIdx(fd int) (int, int) {
// fd / r.granularity - granule number
gNum := fd / r.granularity
// gNum/r.shardCnt - granule number in shard
// granule number in shard *r.granularity - index of first el in granule
// index of granule start + fd % r.granularity - index of file descriptor in shard
return gNum % r.shardCnt, (gNum/r.shardCnt)*r.granularity + (fd % r.granularity)
}
func (r *cbRegistry) add(fd int, cb Callback) uint32 {
shardNum, idx := r.shardNumAndFlattenIdx(fd)
return r.shards[shardNum].
add(idx, cb)
}
func (r *cbRegistry) pop(fd int, nonce uint32) Callback {
shardNum, idx := r.shardNumAndFlattenIdx(fd)
return r.shards[shardNum].
pop(idx, nonce)
}
Управление потоками
Так как одна горутина в языке GO в разные моменты времени может выполняться на разных тредах, то может возникнуть ситуация, когда на одном из тредов будет выполняться сразу два и более кольца (то есть два и более конкурентных вызовов io_uring_enter). Это не лучший расклад, так как в свою очередь это означает, что могут быть треды не занятые socket I/O. Чтобы избежать подобных ситуаций, можно воспользоваться функцией:
runtime.LockOSThread()
Таким образом, можно быть уверенным, что каждый экземпляр io_uring обрабатывает запросы в своем треде.
Переиспользуем память
Кроме специфичных оптимизаций не забываем и о стандартных вещах, например, вместо подобного кода внутри (net.Conn).Read:
// помещаем Recv операцию в реактор
op := uring.Recv(uintptr(c.Fd), b, 0)
c.reactor.Queue(op, func(event uring.CQEvent) {
c.readChan <- event
})
Лучше переиспользовать единожды созданную операцию во избежание повторных аллокаций:
op := c.readOp // создаем readOp единожды, при создании connection
op.SetBuffer(b)
c.reactor.Queue(op, func(event uring.CQEvent) {
c.readChan <- event
})
В общем следим за тем чтобы максимально переиспользовать доступные ресурсы: буфера, каналы, горутины.
Осада netpoller
Используем описанные выше идеи, добавляем щепотку микрооптимизаций и получаем новый NetReactor, готовый соперничать с nepoller’ом GO:
NetReactor
//go:build linux
package reactor
import (
"context"
"errors"
"github.com/godzie44/go-uring/uring"
"math"
"runtime"
"sync/atomic"
"syscall"
"time"
)
const (
timeoutNonce = math.MaxUint64
cancelNonce = math.MaxUint64 - 1
cqeBuffSize = 1 << 7
)
//RequestID identifier of operation queued into NetworkReactor.
type RequestID uint64
func packRequestID(fd int, nonce uint32) RequestID {
return RequestID(uint64(fd) | uint64(nonce)<<32)
}
func (ud RequestID) fd() int {
var mask = uint64(math.MaxUint32)
return int(uint64(ud) & mask)
}
func (ud RequestID) nonce() uint32 {
return uint32(ud >> 32)
}
//NetworkReactor is event loop's manager with main responsibility - handling client requests and return responses asynchronously.
//NetworkReactor optimized for network operations like Accept, Recv, Send.
type NetworkReactor struct {
loops []*ringNetEventLoop
registry *cbRegistry
config *configuration
}
//NewNet create NetworkReactor instance.
func NewNet(rings []*uring.Ring, opts ...Option) (*NetworkReactor, error) {
for _, ring := range rings {
if err := checkRingReq(ring, true); err != nil {
return nil, err
}
}
r := &NetworkReactor{
config: &configuration{
tickDuration: time.Millisecond * 1,
logger: &nopLogger{},
},
}
r.registry = newCbRegistry(len(rings), fdPerGranule)
for _, opt := range opts {
opt(r.config)
}
for _, ring := range rings {
loop := newRingNetEventLoop(ring, r.config.logger, r.registry)
r.loops = append(r.loops, loop)
}
return r, nil
}
//Run start NetworkReactor.
func (r *NetworkReactor) Run(ctx context.Context) {
for _, loop := range r.loops {
go loop.runConsumer(r.config.tickDuration)
go loop.runPublisher()
}
<-ctx.Done()
for _, loop := range r.loops {
loop.stopConsumer()
loop.stopPublisher()
}
}
//NetOperation must be implemented by NetworkReactor supported operations.
type NetOperation interface {
uring.Operation
Fd() int
}
type subSqeRequest struct {
op uring.Operation
flags uint8
userData uint64
timeout time.Duration
}
func (r *NetworkReactor) queue(op NetOperation, cb Callback, timeout time.Duration) RequestID {
ud := packRequestID(op.Fd(), r.registry.add(op.Fd(), cb))
loop := r.loopForFd(op.Fd())
loop.reqBuss <- subSqeRequest{op, 0, uint64(ud), timeout}
return ud
}
const fdPerGranule = 75
func (r *NetworkReactor) loopForFd(fd int) *ringNetEventLoop {
n := len(r.loops)
h := fd / fdPerGranule
return r.loops[h%n]
}
//Queue io_uring operation.
//Return RequestID which can be used as the SQE identifier.
func (r *NetworkReactor) Queue(op NetOperation, cb Callback) RequestID {
return r.queue(op, cb, time.Duration(0))
}
//QueueWithDeadline io_uring operation.
//After a deadline time, a CQE with the error ECANCELED will be placed in the callback function.
func (r *NetworkReactor) QueueWithDeadline(op NetOperation, cb Callback, deadline time.Time) RequestID {
if deadline.IsZero() {
return r.Queue(op, cb)
}
return r.queue(op, cb, time.Until(deadline))
}
//Cancel queued operation.
//id - SQE id returned by Queue method.
func (r *NetworkReactor) Cancel(id RequestID) {
loop := r.loopForFd(id.fd())
loop.cancel(id)
}
type ringNetEventLoop struct {
ring *uring.Ring
registry *cbRegistry
reqBuss chan subSqeRequest
submitSignal chan struct{}
stopConsumerCh chan struct{}
stopPublisherCh chan struct{}
submitAllowed uint32
log Logger
}
func newRingNetEventLoop(ring *uring.Ring, logger Logger, registry *cbRegistry) *ringNetEventLoop {
return &ringNetEventLoop{
ring: ring,
reqBuss: make(chan subSqeRequest, 1<<8),
submitSignal: make(chan struct{}),
stopConsumerCh: make(chan struct{}),
stopPublisherCh: make(chan struct{}),
registry: registry,
log: logger,
}
}
func (loop *ringNetEventLoop) runConsumer(tickDuration time.Duration) {
cqeBuff := make([]*uring.CQEvent, cqeBuffSize)
for {
loop.submitSignal <- struct{}{}
_, err := loop.ring.WaitCQEventsWithTimeout(1, tickDuration)
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR) || errors.Is(err, syscall.ETIME) {
runtime.Gosched()
goto CheckCtxAndContinue
}
if err != nil {
loop.log.Log("io_uring", loop.ring.Fd(), "wait cqe", err)
goto CheckCtxAndContinue
}
loop.submitSignal <- struct{}{}
for n := loop.ring.PeekCQEventBatch(cqeBuff); n > 0; n = loop.ring.PeekCQEventBatch(cqeBuff) {
for i := 0; i < n; i++ {
cqe := cqeBuff[i]
if cqe.UserData == timeoutNonce || cqe.UserData == cancelNonce {
continue
}
id := RequestID(cqe.UserData)
cb := loop.registry.pop(id.fd(), id.nonce())
cb(uring.CQEvent{
UserData: cqe.UserData,
Res: cqe.Res,
Flags: cqe.Flags,
})
}
loop.ring.AdvanceCQ(uint32(n))
}
CheckCtxAndContinue:
select {
case <-loop.stopConsumerCh:
close(loop.stopConsumerCh)
return
default:
continue
}
}
}
func (loop *ringNetEventLoop) stopConsumer() {
loop.stopConsumerCh <- struct{}{}
<-loop.stopConsumerCh
}
func (loop *ringNetEventLoop) stopPublisher() {
loop.stopPublisherCh <- struct{}{}
<-loop.stopPublisherCh
}
func (loop *ringNetEventLoop) cancel(id RequestID) {
op := uring.Cancel(uint64(id), 0)
loop.reqBuss <- subSqeRequest{
op: op,
userData: cancelNonce,
}
}
func (loop *ringNetEventLoop) runPublisher() {
runtime.LockOSThread()
defer close(loop.reqBuss)
defer close(loop.submitSignal)
var err error
for {
select {
case req := <-loop.reqBuss:
atomic.StoreUint32(&loop.submitAllowed, 1)
if req.timeout == 0 {
err = loop.ring.QueueSQE(req.op, req.flags, req.userData)
} else {
err = loop.ring.QueueSQE(req.op, req.flags|uring.SqeIOLinkFlag, req.userData)
if err == nil {
err = loop.ring.QueueSQE(uring.LinkTimeout(req.timeout), 0, timeoutNonce)
}
}
if err != nil {
id := RequestID(req.userData)
loop.registry.pop(id.fd(), id.nonce())
loop.log.Log("io_uring", loop.ring.Fd(), "queue operation", err)
}
case <-loop.submitSignal:
if atomic.CompareAndSwapUint32(&loop.submitAllowed, 1, 0) {
_, err = loop.ring.Submit()
if err != nil {
if errors.Is(err, syscall.EBUSY) || errors.Is(err, syscall.EAGAIN) {
atomic.StoreUint32(&loop.submitAllowed, 1)
} else {
loop.log.Log("io_uring", loop.ring.Fd(), "submit", err)
}
}
}
case <-loop.stopPublisherCh:
close(loop.stopPublisherCh)
return
}
}
}
Пора в последний раз написать tcp-echo сервер и сделать бенчмарки. Полное описание процесса тестирования есть здесь, а результаты представлены ниже:
c: 100 bytes: 128 | c: 50 bytes: 1024 | c: 500 bytes: 128 | c: 1000 bytes: 128 | c: 1000 bytes: 128 | c: 1000 bytes: 1024 | |
net/http | 132664 | 139206 | 133039 | 139171 | 133480 | 139617 |
go-uring | 34202 | 33159 | 147362 | 139313 | 158483 | 154194 |
go-uring SQ_POLL mode | 24406 | 22847 | 134863 | 130668 | 127896 | 122601 |
При более-менее большом количестве соединений получилось выжать производительность даже лучше, чем у привычного runtime. И это при том, что runtime использует недоступные в user-space’е низкоуровневые примитивы синхронизации (например, напрямую жонглирует горутинами), тогда как reactor для этих же целей использует общедоступные вещи — каналы, мьютексы, атомики. С другой стороны, получить достойный rps на небольшом количестве коннектов не вышло. Получилось так, что либо в системе много потоков со слабо нагруженными кольцами (в этом случае теряются преимущества io_uring) либо один-два потока с нагруженными кольцами (что лучше, но не получается полностью утилизировать ресурс CPU).
Снова дома
И наконец-то эта серия закончена. Для меня это первый опыт в подобном формате. Не скрою, что каждый следующий шаг давался сложнее, чем предыдущий, поэтому я действительно рад довести дело до конца. Надеюсь, мне удалось выполнить поставленную цель — продемонстрировать, что io_uring перспективная технология, которая как минимум достойна внимания. Напоследок полезные ссылки:
Дата-центр ITSOFT — размещение и аренда серверов и стоек в двух дата-центрах в Москве. За последние годы UPTIME 100%. Размещение GPU-ферм и ASIC-майнеров, аренда GPU-серверов, лицензии связи, SSL-сертификаты, администрирование серверов и поддержка сайтов.