Как мы оптимизировали наш DNS-сервер с помощью инструментов GO

Автор оригинала: Arash Cordi
  • Перевод
В преддверии старта нового потока по курсу «Разработчик Golang» подготовили перевод интересного материала.




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

dnsflood — это небольшой инструмент, способный генерировать огромное количество udp запросов.

# timeout 20s ./dnsflood example.com 127.0.0.1 -p 2053

Мониторинг систем показал, что использование памяти нашим сервисом росло так быстро, что нам пришлось остановить его, иначе мы столкнулись бы с OOM ошибками. Это было похоже на проблему утечки памяти; существуют различные причины «похожих на» и «реальных» утечек памяти в go:

  • висящие горутины
  • неправильное использование defer и finalizer
  • подстроки и подсрезы
  • глобальные переменные

Этот пост содержит подробное объяснение различных случаев утечек.

Прежде чем делать какие-либо выводы, давайте сначала проведем профилирование.

GODEBUG


Различные средства отладки можно включить с помощью переменной среды GODEBUG, передав список разделенных запятыми пар name=value.

Трассировка планировщика


Трассировка планировщика (scheduler trace) может предоставить информацию о поведении горутин во время выполнения. Чтобы включить трассировку планировщика, запустите программу с GODEBUG=schedtrace=100, значение определяет период вывода в мс.

$ GODEBUG=schedtrace=100 ./redins -c config.json
SCHED 2952ms: ... runqueue=3 [26 11 7 18 13 30 6 3 24 25 11 0]
SCHED 3053ms: ... runqueue=3 [0 0 0 0 0 0 0 0 4 0 21 0]
SCHED 3154ms: ... runqueue=0 [0 6 2 4 0 30 0 5 0 11 2 5]
SCHED 3255ms: ... runqueue=1 [0 0 0 0 0 0 0 0 0 0 0 0]
SCHED 3355ms: ... runqueue=0 [1 0 0 0 0 0 0 0 0 0 0 0]
SCHED 3456ms: ... runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0]
SCHED 3557ms: ... runqueue=0 [13 0 3 0 3 33 2 0 10 8 10 14]
SCHED 3657ms: ...runqueue=3 [14 1 0 5 19 54 9 1 0 1 29 0]
SCHED 3758ms: ... runqueue=0 [67 1 5 0 0 1 0 0 87 4 0 0]
SCHED 3859ms: ... runqueue=6 [0 0 3 6 0 0 0 0 3 2 2 19]
SCHED 3960ms: ... runqueue=0 [0 0 1 0 1 0 0 1 0 1 0 0]
SCHED 4060ms: ... runqueue=5 [4 0 5 0 1 0 0 0 0 0 0 0]
SCHED 4161ms: ... runqueue=0 [0 0 0 0 0 0 0 1 0 0 0 0]
SCHED 4262ms: ... runqueue=4 [0 128 21 172 1 19 8 2 43 5 139 37]
SCHED 4362ms: ... runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0]
SCHED 4463ms: ... runqueue=6 [0 28 23 39 4 11 4 11 25 0 25 0]
SCHED 4564ms: ... runqueue=354 [51 45 33 32 15 20 8 7 5 42 6 0]

runqueue — это длина глобальной очереди запускаемых горутин. Числа в скобках — это длина очереди процессов.

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

SCHED 2449ms: gomaxprocs=12 idleprocs=0 threads=40 spinningthreads=1 idlethreads=1 runqueue=20 [20 20 20 20 20 20 20 20 20 20 20]

Глядя на вывод schedtrace, мы можем видеть периоды времени, когда почти все процессы простаивают. Это означает, что мы не используем процессор на полную мощность.

Трассировка сборщика мусора


Чтобы включить трассировку сборщика мусора (GC), запустите программу с переменной среды GODEBUG=gctrace=1:

GODEBUG=gctrace=1 ./redins -c config1.json
.
.
.
gc 30 @3.727s 1%: 0.066+21+0.093 ms clock, 0.79+128/59/0+1.1 ms cpu, 67->71->45 MB, 76 MB goal, 12 P
gc 31 @3.784s 2%: 0.030+27+0.053 ms clock, 0.36+177/81/7.8+0.63 ms cpu, 79->84->55 MB, 90 MB goal, 12 P
gc 32 @3.858s 3%: 0.026+34+0.024 ms clock, 0.32+234/104/0+0.29 ms cpu, 96->100->65 MB, 110 MB goal, 12 P
gc 33 @3.954s 3%: 0.026+44+0.13 ms clock, 0.32+191/131/57+1.6 ms cpu, 117->123->79 MB, 131 MB goal, 12 P
gc 34 @4.077s 4%: 0.010+53+0.024 ms clock, 0.12+241/159/69+0.29 ms cpu, 142->147->91 MB, 158 MB goal, 12 P
gc 35 @4.228s 5%: 0.017+61+0.12 ms clock, 0.20+296/179/94+1.5 ms cpu, 166->174->105 MB, 182 MB goal, 12 P
gc 36 @4.391s 6%: 0.017+73+0.086 ms clock, 0.21+492/216/4.0+1.0 ms cpu, 191->198->122 MB, 210 MB goal, 12 P
gc 37 @4.590s 7%: 0.049+85+0.095 ms clock, 0.59+618/253/0+1.1 ms cpu, 222->230->140 MB, 244 MB goal, 12 P
.
.
.

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

Больше о GODEBUG и некоторых других переменных среды golang можно узнать здесь.

Включение профилировщика


go tool pprof — инструмент для анализа и профилирования данных. Есть два способа настроить pprof: либо напрямую вызывать функции runtime/pprof в вашем коде, например pprof.StartCPUProfile(), либо установить net/http/pprof http-листнер и получать оттуда данные, что мы и сделали. У pprof очень маленькое ресурсопотребление, поэтому его можно безопасно использовать в разработке, но эндпоинт профиля не должен быть публично доступен, поскольку могут быть раскрыты конфиденциальные данные.

Все, что нам нужно сделать для второго варианта, это импортировать пакет «net/http/pprof»:

import (
  _ "net/http/pprof"
)

затем добавить http-листнер:

go func() {
  log.Println(http.ListenAndServe("localhost:6060", nil))
}()

У pprof есть несколько профилей по умолчанию:

  • allocs: выборка всех прошлых распределений памяти
  • block: трассировка стека, которая привела к блокировке примитивов синхронизации
  • goroutine: трассировка стека всех текущих горутин
  • heap: выборка выделений памяти живых объектов.
  • mutex: трассировка стека держателей конфликтующих мьютексов
  • profile: профиль процессора.
  • threadcreate: трассировка стека, которая привела к созданию новых потоков в ОС
  • trace: трассировка выполнения текущей программы.

Примечание: эндпоинт трассировки, в отличие от всех других эндпоинтов, является профилем трассировки, а не pprof, вы можете просмотреть его, используя go tool trace вместо go tool pprof.
Теперь, когда все подготовлено, мы можем посмотреть на доступные инструменты.

Профилировщик процессора


‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10


Профилировщик процессора по умолчанию работает в течение 30 секунд (мы можем изменить это, задав параметр seconds) и собирает выборки каждые 100 миллисекунд, после чего он переходит в интерактивный режим. Наиболее распространенными из доступных команд являются top, list, web.

Используйте top n для просмотра самых горячих записей в текстовом формате, также есть две опции для сортировки вывода, -cum для кумулятивного порядка и -flat.

(pprof) top 10 -cum
Showing nodes accounting for 1.50s, 6.19% of 24.23s total
Dropped 347 nodes (cum <= 0.12s)
Showing top 10 nodes out of 186
flat  flat%   sum%  cum   cum%
0.03s 0.12% 0.12% 16.7s 69.13% (*Server).serveUDPPacket
0.05s 0.21% 0.33% 15.6s 64.51% (*Server).serveDNS
0     0%    0.33% 14.3s 59.10% (*ServeMux).ServeDNS
0     0%    0.33% 14.2s 58.73% HandlerFunc.ServeDNS
0.01s 0.04% 0.37% 14.2s 58.73% main.handleRequest
0.07s 0.29% 0.66% 13.5s 56.00% (*DnsRequestHandler).HandleRequest
0.99s 4.09% 4.75% 7.56s 31.20% runtime.gentraceback
0.02s 0.08% 4.83% 7.02s 28.97% runtime.systemstack
0.31s 1.28% 6.11% 6.62s 27.32% runtime.mallocgc
0.02s 0.08% 6.19% 6.35s 26.21% (*DnsRequestHandler).FindANAME
(pprof)

используйте list, чтобы исследовать функцию.

(pprof) list handleRequest
Total: 24.23s
ROUTINE ======================== main.handleRequest in /home/arash/go/src/arvancloud/redins/redins.go
     10ms     14.23s (flat, cum) 58.73% of Total
        .          .     35: l          *handler.RateLimiter
        .          .     36: configFile string
        .          .     37:)
        .          .     38:
        .          .     39:func handleRequest(w dns.ResponseWriter, r *dns.Msg) {
     10ms      610ms     40: context := handler.NewRequestContext(w, r)
        .       50ms     41: logger.Default.Debugf("handle request: [%d] %s %s", r.Id, context.RawName(), context.Type())
        .          .     42:
        .          .     43: if l.CanHandle(context.IP()) {
        .     13.57s     44:  h.HandleRequest(context)
        .          .     45: } else {
        .          .     46:  context.Response(dns.RcodeRefused)
        .          .     47: }
        .          .     48:}
        .          .     49:
(pprof)

Команда web генерирует SVG-график критических участков и открывает его в браузере.

(pprof)web handleReques



Большое количество времени, проводимого в функциях GC, таких как runtime.mallocgc, часто приводит к значительному выделению памяти, что может добавить нагрузки на сборщик мусора и увеличить задержку.

Большое количество времени, затрачиваемое на механизмы синхронизации, такие как runtime.chansend или runtime.lock, может быть признаком конфликта.

Большое количество времени, затрачиваемое на syscall.Read/Write означает чрезмерное использование операций ввода-вывода.

Профилировщик памяти


$ go tool pprof http://localhost:6060/debug/pprof/allocs


По умолчанию он показывает время жизни выделенной памяти. Мы можем увидеть количество выделенных объектов, используя -alloc_objects, другие полезные опции: -iuse_objects и -inuse_space для проверки живой памяти.

Обычно, если вы хотите уменьшить потребление памяти, вам нужно посмотреть на -inuse_space, но если вы хотите уменьшить задержку, посмотрите на -alloc_objects после достаточного времени выполнения/загрузки.

Выявление узкого места


Важно сначала определить тип узкого места (процессор, ввод/вывод, память), с которым мы имеем дело. Помимо профилировщиков, есть еще один вид инструментов.

go tool trace может показать, что горутины делают в деталях. Чтобы собрать пример трассировки, нам нужно отправить http запрос на эндпоинт трассировки:

$ curl http://localhost:6060/debug/pprof/trace?seconds=5 --output trace.out

Сгенерированный файл можно просмотреть с помощью инструмента трассировки:

$ go tool trace trace.out
2019/12/25 15:30:50 Parsing trace...
2019/12/25 15:30:59 Splitting trace...
2019/12/25 15:31:10 Opening browser. Trace viewer is listening on http://127.0.0.1:42703

Go tool trace — это веб-приложение, которое использует протокол Chrome DevTools и совместимо только с браузерами Chrome. Главная страница выглядит примерно так:

View trace (0s-409.575266ms)
View trace (411.075559ms-747.252311ms)
View trace (747.252311ms-1.234968945s)
View trace (1.234968945s-1.774245108s)
View trace (1.774245484s-2.111339514s)
View trace (2.111339514s-2.674030898s)
View trace (2.674031362s-3.044145798s)
View trace (3.044145798s-3.458795252s)
View trace (3.43953778s-4.075080634s)
View trace (4.075081098s-4.439271287s)
View trace (4.439271635s-4.814869651s)
View trace (4.814869651s-5.253597835s)

Goroutine analysis
Network blocking profile ()
Synchronization blocking profile ()
Syscall blocking profile ()
Scheduler latency profile ()
User-defined tasks
User-defined regions
Minimum mutator utilization

Трассировка разделяет время трассировки, чтобы ваш браузер мог справиться с этим



Здесь огромное множество данных, что делает их практически нечитаемыми, если мы не знаем что ищем. Давайте пока оставим это.

Следующая ссылка на главной странице — «goroutine analysis», который показывает различные виды работающих горутин в программе в течение периода трассировки:

Goroutines:
github.com/miekg/dns.(*Server).serveUDPPacket N=441703
runtime.gcBgMarkWorker N=12
github.com/karlseguin/ccache.(*Cache).worker N=2
main.Start.func1 N=1
runtime.bgsweep N=1
arvancloud/redins/handler.NewHandler.func2 N=1
runtime/trace.Start.func1 N=1
net/http.(*conn).serve N=1
runtime.timerproc N=3
net/http.(*connReader).backgroundRead N=1
N=40

Нажмите на первый элемент с N = 441703, вот что мы получим:


анализ горутин

Это очень интересно. Большинство операций практически не тратят времени на выполнение, а большую часть времени проводят в блоке Sync. Давайте подробнее рассмотрим одну из них:


схема трассировки горутин

Похоже, наша программа почти всегда неактивна. Отсюда мы можем перейти непосредственно к инструменту блокировки; профилирование блокировки по умолчанию отключено, нам нужно сначала включить его в нашем коде:

runtime.SetBlockProfileRate(1)

Теперь мы можем получить выборку блокировок:

$ go tool pprof http://localhost:6060/debug/pprof/block
(pprof) top
Showing nodes accounting for 16.03wks, 99.75% of 16.07wks total
Dropped 87 nodes (cum <= 0.08wks)
Showing top 10 nodes out of 27
     flat  flat%   sum%        cum   cum%
 10.78wks 67.08% 67.08%   10.78wks 67.08%  internal/poll.(*fdMutex).rwlock
  5.25wks 32.67% 99.75%    5.25wks 32.67%  sync.(*Mutex).Lock
        0     0% 99.75%    5.25wks 32.67%  arvancloud/redins/handler.(*DnsRequestHandler).Filter
        0     0% 99.75%    5.25wks 32.68%  arvancloud/redins/handler.(*DnsRequestHandler).FindANAME
        0     0% 99.75%   16.04wks 99.81%  arvancloud/redins/handler.(*DnsRequestHandler).HandleRequest
        0     0% 99.75%   10.78wks 67.08%  arvancloud/redins/handler.(*DnsRequestHandler).Response
        0     0% 99.75%   10.78wks 67.08%  arvancloud/redins/handler.(*RequestContext).Response
        0     0% 99.75%    5.25wks 32.67%  arvancloud/redins/handler.ChooseIp
        0     0% 99.75%   16.04wks 99.81%  github.com/miekg/dns.(*ServeMux).ServeDNS
        0     0% 99.75%   16.04wks 99.81%  github.com/miekg/dns.(*Server).serveDNS
(pprof)

Здесь у нас есть две разные блокировки (poll.fdMutex и sync.Mutex), отвечающие почти за 100% блокировок. Это подтверждает наше предположение о конфликте блокировок, теперь нам нужно только найти, где это происходит:

(pprof) svg lock

Эта команда создает векторный граф всех узлов, учитывающих конфликты, с упором на функции блокировки:


svg-график эндпонта блокировки

мы можем получить тот же результат из эндпоинта горутины:

$ go tool pprof http://localhost:6060/debug/pprof/goroutine

и затем:

(pprof) top
Showing nodes accounting for 294412, 100% of 294424 total
Dropped 84 nodes (cum <= 1472)
Showing top 10 nodes out of 32
     flat  flat%   sum%        cum   cum%
   294404   100%   100%     294404   100%  runtime.gopark
        8 0.0027%   100%     294405   100%  github.com/miekg/dns.(*Server).serveUDPPacket
        0     0%   100%      58257 19.79%  arvancloud/redins/handler.(*DnsRequestHandler).Filter
        0     0%   100%      58259 19.79%  arvancloud/redins/handler.(*DnsRequestHandler).FindANAME
        0     0%   100%     293852 99.81%  arvancloud/redins/handler.(*DnsRequestHandler).HandleRequest
        0     0%   100%     235406 79.95%  arvancloud/redins/handler.(*DnsRequestHandler).Response
        0     0%   100%     235406 79.95%  arvancloud/redins/handler.(*RequestContext).Response
        0     0%   100%      58140 19.75%  arvancloud/redins/handler.ChooseIp
        0     0%   100%     293852 99.81%  github.com/miekg/dns.(*ServeMux).ServeDNS
        0     0%   100%     293900 99.82%  github.com/miekg/dns.(*Server).serveDNS
(pprof)

почти все наши программы находятся в runtime.gopark, это планировщик go, усыпляющий горутины; очень распространенная причина этого — конфликт блокировок

(pprof) svg gopark


svg-график конечной точки горутин

Здесь мы видим два источника конфликтов:

UDPConn.WriteMsg()


Похоже, что все ответы заканчиваются записью на один и тот же FD (отсюда и блокировка), это имеет смысл, поскольку все они имеют один и тот же адрес источника.

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

Rand()


Похоже, что в обычных math/rand функциях есть блокировка (подробнее об этом здесь). Это можно легко исправить с помощью Rand.New(), который создает генератор случайных чисел без блокировочной обертки

rg := rand.New(rand.NewSource(int64(time.Now().Nanosecond())))

Это немного лучше, но создавать новый источник каждый раз дорого. Можно ли сделать еще лучше? В нашем случае нам действительно не нужно случайное число. Нам просто нужно равномерное распределение для балансировки нагрузки, и получается, что Time.Nanoseconds() может нам подойти.

Теперь, когда мы устранили все лишние блокировки, давайте посмотрим на результаты:


анализ горутин

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


график времени блокировки синхронизации

Давайте взглянем на функцию повышения ccache с конечной точки блокировки pprof:

(pprof) list promote
ROUTINE ======================== github.com/karlseguin/ccache.(*Cache).promote in ...
        0   9.66days (flat, cum) 99.70% of Total
        .          .    155: h.Write([]byte(key))
        .          .    156: return c.buckets[h.Sum32()&c.bucketMask]
        .          .    157:}
        .          .    158:
        .          .    159:func (c *Cache) promote(item *Item) {
        .   9.66days    160: c.promotables <- item
        .          .    161:}
        .          .    162:
        .          .    163:func (c *Cache) worker() {
        .          .    164: defer close(c.donec)
        .          .    165:

Все вызовы ccache.Get() заканчиваются отправкой на один канал c.promotables. Кэширование является важной частью нашего сервиса, мы должны рассмотреть другие варианты; У Dgraph есть отличная статья о состоянии кэша в go, у них также есть отличный модуль кэширования под названием ristretto. К сожалению, ristretto пока не поддерживает высвобождение на основе Ttl, мы могли бы обойти эту проблему, используя очень большой MaxCost и сохранив значение тайм-аута в нашей структуре кэша (мы хотим сохранить устаревшие данные в кэше). Давайте посмотрим результат при использовании ristretto:


анализ горутин

Отлично!

Нам удалось сократить максимальное время выполнения горутины с 5000 мс до 22 мс. Тем не менее, большая часть времени выполнения делится между «блокировкой синхронизации» и «ожиданием планировщика». Давайте посмотрим, сможем ли мы что-нибудь с этим сделать:


график времени блокировки синхронизации

Мы мало что можем сделать с fdMutex.rwlock, теперь давайте сосредоточимся на другом: gcMarkDone, который отвечает за 53% времени блока. Эта функция является частью процесса сбора мусора. Их наличие в критическом участке часто является признаком того, что мы перегружаем gc.

оптимизация аллокаций


На этом этапе может быть полезно посмотреть, как работает сборка мусора; Go использует mark-and-sweep сборщик. Он отслеживает все, что выделено, и как только он достигает в два раза большего (или любого другого значения, на которое установлено GOGC) размера от предыдущего размера, запускается очистка GC. Оценка происходит в три этапа:

  • Установка маркера (STW)
  • Маркировка (параллельно)
  • Окончание маркировки (STW)

Фазы «Остановить мир» (Stop The World, STW) останавливают весь процесс, хотя они, как правило, очень короткие, сплошные циклы могут увеличить продолжительность. Это потому, что в настоящее время (go v1.13) горутины являются вытесняемыми только в точках вызова функции, поэтому для сплошного цикла возможно произвольно большое время паузы, поскольку GC ожидает остановки всех горутин.

Во время маркировки gc использует около 25% GOMAXPROCS, но дополнительные вспомогательные функции могут быть принудительно добавлены в mark assist, это происходит, когда быстро выделяющаяся горутина опережает фоновый маркер, чтобы уменьшить задержку, вызванную gc, нам нужно минимизировать использование кучи.

Следует отметить две вещи:

  • количество выделений имеет большее значение, чем размер (например, 1000 выделений памяти из 20-байтовой структуры создают гораздо больше нагрузки на кучу, чем одно выделение 20000 байтов);
  • в отличие от таких языков, как C/C++, не все выделения памяти оказываются в куче. Компилятор go решает, будет ли переменная перемещаться в кучу или она может быть размещена внутри фрейма стека. В отличие от переменных, размещаемых в куче, переменные, выделенные в стеке, не нагружают gc.

Для получения дополнительной информации о модели памяти Go и архитектуры GC смотрите эту презентацию.

для оптимизации выделения памяти мы используем набор инструментов go:

  • профилировщик процессора, чтобы найти горячие распределения;
  • профилировщик памяти для отслеживания выделений;
  • трассировщик для паттернов; GC
  • escape-анализ, чтобы выяснить, почему происходит аллокация.

Давайте начнем с профилировщика процессора:

$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=20
(pprof) top 20 -cum
Showing nodes accounting for 7.27s, 29.10% of 24.98s total
Dropped 315 nodes (cum <= 0.12s)
Showing top 20 nodes out of 201
     flat  flat%   sum%        cum   cum%
        0     0%     0%     16.42s 65.73%  github.com/miekg/dns.(*Server).serveUDPPacket
    0.02s  0.08%  0.08%     16.02s 64.13%  github.com/miekg/dns.(*Server).serveDNS
    0.02s  0.08%  0.16%     13.69s 54.80%  github.com/miekg/dns.(*ServeMux).ServeDNS
    0.01s  0.04%   0.2%     13.48s 53.96%  github.com/miekg/dns.HandlerFunc.ServeDNS
    0.02s  0.08%  0.28%     13.47s 53.92%  main.handleRequest
    0.24s  0.96%  1.24%     12.50s 50.04%  arvancloud/redins/handler.(*DnsRequestHandler).HandleRequest
    0.81s  3.24%  4.48%      6.91s 27.66%  runtime.gentraceback
    3.82s 15.29% 19.78%      5.48s 21.94%  syscall.Syscall
    0.02s  0.08% 19.86%      5.44s 21.78%  arvancloud/redins/handler.(*DnsRequestHandler).Response
    0.06s  0.24% 20.10%      5.25s 21.02%  arvancloud/redins/handler.(*RequestContext).Response
    0.03s  0.12% 20.22%      4.97s 19.90%  arvancloud/redins/handler.(*DnsRequestHandler).FindANAME
    0.56s  2.24% 22.46%      4.92s 19.70%  runtime.mallocgc
    0.07s  0.28% 22.74%      4.90s 19.62%  github.com/miekg/dns.(*response).WriteMsg
    0.04s  0.16% 22.90%      4.40s 17.61%  github.com/miekg/dns.(*response).Write
    0.01s  0.04% 22.94%      4.36s 17.45%  github.com/miekg/dns.WriteToSessionUDP
    1.43s  5.72% 28.66%      4.30s 17.21%  runtime.pcvalue
    0.01s  0.04% 28.70%      4.15s 16.61%  runtime.newstack
    0.06s  0.24% 28.94%      4.09s 16.37%  runtime.copystack
    0.01s  0.04% 28.98%      4.05s 16.21%  net.(*UDPConn).WriteMsgUDP
    0.03s  0.12% 29.10%      4.04s 16.17%  net.(*UDPConn).writeMsg

Нас особенно интересуют функции, связанные с mallocgc, именно здесь происходит помощь с метками

(pprof) svg mallocgc



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

$ go tool pprof -alloc_objects http://localhost:6060/debug/pprof/allocs
(pprof) top -cum
Active filters:
  show=handler
Showing nodes accounting for 58464353, 59.78% of 97803168 total
Dropped 1 node (cum <= 489015)
Showing top 10 nodes out of 19
     flat  flat%   sum%        cum   cum%
 15401215 15.75% 15.75%   70279955 71.86%  arvancloud/redins/handler.(*DnsRequestHandler).HandleRequest
  2392100  2.45% 18.19%   27198697 27.81%  arvancloud/redins/handler.(*DnsRequestHandler).FindANAME
   711174  0.73% 18.92%   14936976 15.27%  arvancloud/redins/handler.(*DnsRequestHandler).Filter
        0     0% 18.92%   14161410 14.48%  arvancloud/redins/handler.(*DnsRequestHandler).Response
 14161410 14.48% 33.40%   14161410 14.48%  arvancloud/redins/handler.(*RequestContext).Response
  7284487  7.45% 40.85%   11118401 11.37%  arvancloud/redins/handler.NewRequestContext
 10439697 10.67% 51.52%   10439697 10.67%  arvancloud/redins/handler.reverseZone
        0     0% 51.52%   10371430 10.60%  arvancloud/redins/handler.(*DnsRequestHandler).FindZone
  2680723  2.74% 54.26%    8022046  8.20%  arvancloud/redins/handler.(*GeoIp).GetSameCountry
  5393547  5.51% 59.78%    5393547  5.51%  arvancloud/redins/handler.(*DnsRequestHandler).LoadLocation

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

printf-подобные функции

(pprof) list handleRequest
Total: 97803168
ROUTINE ======================== main.handleRequest in /home/arash/go/src/arvancloud/redins/redins.go
  2555943   83954299 (flat, cum) 85.84% of Total
        .          .     35: l          *handler.RateLimiter
        .          .     36: configFile string
        .          .     37:)
        .          .     38:
        .          .     39:func handleRequest(w dns.ResponseWriter, r *dns.Msg) {
        .   11118401     40: context := handler.NewRequestContext(w, r)
  2555943    2555943     41: logger.Default.Debugf("handle request: [%d] %s %s", r.Id, context.RawName(), context.Type())
        .          .     42:
        .          .     43: if l.CanHandle(context.IP()) {
        .   70279955     44:  h.HandleRequest(context)
        .          .     45: } else {
        .          .     46:  context.Response(dns.RcodeRefused)
        .          .     47: }
        .          .     48:}
        .          .     49:

Строка 41 особенно интересна, даже когда отладка отключена, там все еще выделяется память, мы можем использовать escape-анализ, чтобы исследовать ее подробнее.

Инструмент Escape-анализа Go на самом деле флаг компилятора

$ go build -gcflags '-m'

Вы можете добавить другой -m для получения дополнительной информации

$ go build -gcflags '-m '

для более удобного интерфейса используйте view-annotated-file.

$ go build -gcflags '-m'
.
.
.
../redins.go:39:20: leaking param: w
./redins.go:39:42: leaking param: r
./redins.go:41:55: r.MsgHdr.Id escapes to heap
./redins.go:41:75: context.RawName() escapes to heap
./redins.go:41:91: context.Request.Type() escapes to heap
./redins.go:41:23: handleRequest []interface {} literal does not escape
./redins.go:219:17: leaking param: path
.
.
.

Здесь все параметры Debugf уходят в кучу. Это происходит из-за способа определения Debugf:

func (l *EventLogger) Debugf(format string, args ...interface{})

Все параметры args преобразуются в тип interface{}, который всегда сбрасывается в кучу. Мы можем либо удалить журналы отладки, либо использовать библиотеку журналов с нулевым распределением, например, zerolog.

Для получения дополнительной информации об escape- анализе см. «allocation eficiency in golang».

Работа со строками

(pprof) list reverseZone
Total: 100817064
ROUTINE ======================== arvancloud/redins/handler.reverseZone in /home/arash/go/src/arvancloud/redins/handler/handler.go
  6127746   10379086 (flat, cum) 10.29% of Total
        .          .    396:  logger.Default.Warning("log queue is full")
        .          .    397: }
        .          .    398:}
        .          .    399:
        .          .    400:func reverseZone(zone string) string {
        .    4251340    401: x := strings.Split(zone, ".")
        .          .    402: var y string
        .          .    403: for i := len(x) - 1; i >= 0; i-- {
  6127746    6127746    404:  y += x[i] + "."
        .          .    405: }
        .          .    406: return y
        .          .    407:}
        .          .    408:
        .          .    409:func (h *DnsRequestHandler) LoadZones() {
(pprof)

Поскольку строка в Go является неизменяемой, создание временной строки вызывает выделение памяти. Начиная с Go 1.10, можно использовать strings.Builder для создания строки.

(pprof) list reverseZone
Total: 93437002
ROUTINE ======================== arvancloud/redins/handler.reverseZone in /home/arash/go/src/arvancloud/redins/handler/handler.go
        0    7580611 (flat, cum)  8.11% of Total
        .          .    396:  logger.Default.Warning("log queue is full")
        .          .    397: }
        .          .    398:}
        .          .    399:
        .          .    400:func reverseZone(zone string) string {
        .    3681140    401: x := strings.Split(zone, ".")
        .          .    402: var sb strings.Builder
        .    3899471    403: sb.Grow(len(zone)+1)
        .          .    404: for i := len(x) - 1; i >= 0; i-- {
        .          .    405:  sb.WriteString(x[i])
        .          .    406:  sb.WriteByte('.')
        .          .    407: }
        .          .    408: return sb.String()

Поскольку нас не волнует значение перевернутой строки, мы можем устранить Split(), просто перевернув всю строку.

(pprof) list reverseZone
Total: 89094296
ROUTINE ======================== arvancloud/redins/handler.reverseZone in /home/arash/go/src/arvancloud/redins/handler/handler.go
  3801168    3801168 (flat, cum)  4.27% of Total
        .          .    400:func reverseZone(zone string) []byte {
        .          .    401: runes := []rune("." + zone)
        .          .    402: for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        .          .    403:  runes[i], runes[j] = runes[j], runes[i]
        .          .    404: }
  3801168    3801168    405: return []byte(string(runes))
        .          .    406:}
        .          .    407:
        .          .    408:func (h *DnsRequestHandler) LoadZones() {
        .          .    409: h.LastZoneUpdate = time.Now()
        .          .    410: zones, err := h.Redis.SMembers("redins:zones")

Прочитать подробнее о работе со строками можно здесь.

sync.Pool


(pprof) list GetASN
Total: 69005282
ROUTINE ======================== arvancloud/redins/handler.(*GeoIp).GetASN in /home/arash/go/src/arvancloud/redins/handler/geoip.go
  1146897    1146897 (flat, cum)  1.66% of Total
        .          .    231:func (g *GeoIp) GetASN(ip net.IP) (uint, error) {
  1146897    1146897    232: var record struct {
        .          .    233:  AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
        .          .    234: }
        .          .    235: err := g.ASNDB.Lookup(ip, &record)
        .          .    236: if err != nil {
        .          .    237:  logger.Default.Errorf("lookup failed : %s", err)
(pprof) list GetGeoLocation
Total: 69005282
ROUTINE ======================== arvancloud/redins/handler.(*GeoIp).GetGeoLocation in /home/arash/go/src/arvancloud/redins/handler/geoip.go
  1376298    3604572 (flat, cum)  5.22% of Total
        .          .    207:
        .          .    208:func (g *GeoIp) GetGeoLocation(ip net.IP) (latitude float64, longitude float64, country string, err error) {
        .          .    209: if !g.Enable || g.CountryDB == nil {
        .          .    210:  return
        .          .    211: }
  1376298    1376298    212: var record struct {
        .          .    213:  Location struct {
        .          .    214:   Latitude        float64 `maxminddb:"latitude"`
        .          .    215:   LongitudeOffset uintptr `maxminddb:"longitude"`
        .          .    216:  } `maxminddb:"location"`
        .          .    217:  Country struct {
        .          .    218:   ISOCode string `maxminddb:"iso_code"`
        .          .    219:  } `maxminddb:"country"`
        .          .    220: }
        .          .    221: // logger.Default.Debugf("ip : %s", ip)
        .          .    222: if err := g.CountryDB.Lookup(ip, &record); err != nil {
        .          .    223:  logger.Default.Errorf("lookup failed : %s", err)
        .          .    224:  return 0, 0, "", err
        .          .    225: }
        .    2228274    226: _ = g.CountryDB.Decode(record.Location.LongitudeOffset, &longitude)
        .          .    227: // logger.Default.Debug("lat = ", record.Location.Latitude, " lang = ", longitude, " country = ", record.Country.ISOCode)
        .          .    228: return record.Location.Latitude, longitude, record.Country.ISOCode, nil
        .          .    229:}
        .          .    230:
        .          .    231:func (g *GeoIp) GetASN(ip net.IP) (uint, error) {

Мы используем функции maxmiddb для получения данных геолокации. Эти функции принимают interface{} в качестве параметров, которые, как мы видели ранее, могут вызывать побеги кучи.

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

type MMDBGeoLocation struct {
  Coordinations struct {
     Latitude        float64 `maxminddb:"latitude"`
     Longitude       float64
     LongitudeOffset uintptr `maxminddb:"longitude"`
  } `maxminddb:"location"`
  Country struct {
     ISOCode string `maxminddb:"iso_code"`
  } `maxminddb:"country"`
}

type MMDBASN struct {
  AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
}
func (g *GeoIp) GetGeoLocation(ip net.IP) (*MMDBGeoLocation, error) {
  if !g.Enable || g.CountryDB == nil {
     return nil, EMMDBNotAvailable
  }
  var record *MMDBGeoLocation = g.LocationPool.Get().(*MMDBGeoLocation)
  logger.Default.Debugf("ip : %s", ip)
  if err := g.CountryDB.Lookup(ip, &record); err != nil {
     logger.Default.Errorf("lookup failed : %s", err)
     return nil, err
  }
  _ = g.CountryDB.Decode(record.Coordinations.LongitudeOffset, &record.Coordinations.Longitude)
  logger.Default.Debug("lat = ", record.Coordinations.Latitude, " lang = ", record.Coordinations.Longitude, " country = ", record.Country.ISOCode)
  return record, nil
}

func (g *GeoIp) GetASN(ip net.IP) (uint, error) {
  var record *MMDBASN = g.AsnPool.Get().(*MMDBASN)
  err := g.ASNDB.Lookup(ip, record)
  if err != nil {
     logger.Default.Errorf("lookup failed : %s", err)
     return 0, err
  }
  logger.Default.Debug("asn = ", record.AutonomousSystemNumber)
  return record.AutonomousSystemNumber, nil
}

Больше о sync.Pool здесь.

Есть много других возможных оптимизаций, но на данный момент, кажется, мы уже сделали достаточно. Для получения дополнительной информации о методах оптимизации памяти, можете прочитать Allocation efficiency in high-performance Go services.

Результаты


Чтобы визуализировать результаты оптимизации памяти, мы используем диаграмму в go tool trace под названием «Minimum Mutator Utilization», здесь Mutator означает не gc.

До оптимизации:



Здесь у нас есть окно около 500 миллисекунд без эффективного использования (gc потребляет все ресурсы), и мы никогда не получим 80% эффективного использования в долгосрочной перспективе. Мы хотим, чтобы окно нулевого эффективного использования было как можно меньше, и максимально быстро достигать 100% использования, как-то так:

после оптимизации:



Вывод


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

Вы можете посмотреть наш исходный код на GitHub. Вот неоптимизированная версия и вот оптимизированная.

Также будет полезно



На этом все. До встречи на курсе!
OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Похожие публикации

Комментарии 10

    +1
    Наш авторитетный DNS-сервер

    Авторитативный.

      0
      Считаете, так правильнее?

        0
        Древняя статья от 2002-го года говорит, что да.
        info.nic.ru/st/8/out_256.shtml
        На вики аналогично: ru.wikipedia.org/wiki/DNS-%D1%81%D0%B5%D1%80%D0%B2%D0%B5%D1%80
          0
          Спасибо. Поправлю
          +1

          Ложные друзья переводчика. Конечно, authoritative имеет близкое значение к "авторитетному", но это слово используется совсем иначе в русском языке. Так же, как authorities — это "власти", а не "авторитеты". Наиболее близким вариантом к "authoritative" мог бы быть "уполномоченный" или "полномочный", но это уже чересчур :) Авторитативный — прекрасное заимствование вместе с контекстом.

        0

        Прошу прощения, но в чем его преимущества по сравнению с Bind9?

          0
          А редис зачем? Только куча оверхеда лишнего. Ложите все в хешмапу в го процессе и будет вам счастье.
            +2
            побеги кучи

            шикарно, записал в блокнот
              +2
              Безотносительно всего, хорошая статья с демонстрацией профилирование Go приложений.
              Спасибо) Точно будет полезно многим (Если догадаются зайти в статью. А то могут подумать, что DNS сервера их не касаются)
                0
                Спасибо за хорошую статью по оптимизации Go.

                Сам был сильно удивлен сколько можно выжать из Go если заняться оптимизацией, благо инструменты все необходимые — всегда под рукой.

                А главное, проделав такие оптимизации несколько раз уже сам начинаешь писать оптимально.

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

                Самое читаемое