Pull to refresh

О плюсах и минусах Go

Reading time 16 min
Views 50K
В данной статье я хочу поделиться опытом, полученным в ходе переписывания одного проекта с Perl на Go. В ней будет больше о минусах, чем о плюсах, ибо о достоинствах Go и так поведано немало, а вот о подводных камнях, ожидающих новых разработчиков, узнать зачастую, кроме как от собственных шишек — неоткуда. Пост никоим образом не преследует цели охаять язык Go, хотя, признаться, некоторые вещи я был бы рад не писать. Также в нем охвачено сравнительно небольшой срез всей платформы, в частности, не будет ничего о шаблонах, регекспах, распаковке/запаковке данных и подобного, часто используемого в веб-программировании, функционала.

Поскольку пост не состоит в хабе «я пиарюсь» — обрисую особенности проекта лишь вкратце. Это высоконагруженное веб-приложение, обрабатывающее сейчас около 600М хитов в сутки (пиковая загрузка больше 10к запросов в секунду). Около 80% запросов можно отдать из кеша, а остальные надо полностью обрабатывать. Рабочие данные в основном лежат в базе на PostgreSQL, часть в бинарных файлах с плоской структурой (т.е. фактически массив, но не в памяти, а в файле). Кластер на Perl-е состоял из восьми 24-х ядерных машин с практических исчерпаным запасом по производительности, кластер на Go будет уже из шести с подтвержденным более чем трехкратным запасом. Причем узкое место уже не столько процессор, сколько ОС и остальное железо с софтом — обработать 10к нетривиальных запросов за одну секунду на одной машине физически не просто, каким бы ни производительным был бэкенд-софт.

Скорость разработки

Мой опыт работы с Go до начала рефакторинга был минимальным. Я уже больше года присматривался к языку, успел проштудировать спецификацию от корки до корки, изучил полезные материалы на оффициальном сайте и за его пределами и ощущал себя готовым засучить рукава и браться за работу. Первоначальная оценка сроков по работам была — 3-6 недель. Рабочая бета была готова как раз к концу 6-й недели, хотя ближе к завершению я уже начал думать, что не успею. Зачистка багов и оптимизация производительности заняли ещё целый месяц.

Вначале было особенно сложно, но с течением времени в спецификации приходилось заглядывать все реже, а код получался все чище. Если сперва мне приходилось на тот функционал, который я на Perl-е мог закодить за час, тратить на Go целый день, то потом этот разрыв значительно сократился. Но все равно на Go программировать ощутимо дольше, чем на Perl-е — приходится продумывать нужные для работы структуры, типы данных и интерфейсы, прописывать все это в коде, заботиться об инициализации слайсов, мапов и каналов, прописывать проверки на nil… В Perl-е с этим все сильно проще: для структур приходится использовать хеши, там не надо предварительно обьявлять поля, и сильно больше синтаксического сахара для программистов. Сравнить хотя бы сортировку — в Go нет возможности указать замыкание для сравнения данных, нужно прописывать отдельные функции для получения длины, и кроме функции сравнения по индексам необходимо ещё прописать отдельную функцию обмена элементов местами в массиве. А все почему? Потому что нет генериков, и функции сортировки проще вызвать специально задекларированный Swap(i, j) чем разбираться, что там ей подсунули и по каким смещениям надо делать этот обмен значений.

Кроме сортировки, мне ещё в глаза бросилось отсутствие Perl-ой конструкции for/while() {… } continue {… } (блок continue будет выполняться даже при раннем прерывании текущей итерации через оператор next). В Go для этого приходится использовать некошерный goto, который к тому же принуждает прописывать все декларации переменных перед ним, даже тех, которые не используются после метки перехода:
var cnt int
for ;; {
        goto NEXT
        a := int(0) // ./main.go:16: goto NEXT jumps over declaration of a at ./main.go:17
        cnt += a
NEXT:
        cnt ++
}

Также не до конца работает парадигма обьединения синтаксиса для указателей и не указателей — в случае использования стуктур компилятор нам дает возможность использовать один и тот же синтаксис, а для мап — уже нужно разыменовывать и использовать скобки, хотя компилятор мог бы и сам все определить:
type T struct { cnt int }
s := T{}
p := new(T)
s.cnt ++
p.cnt ++
но
m := make(map[int]T)
p := new(map[int]T)
*p = make(map[int]T)

m[1] = T{}
(*p)[1] = T{}
p[1] = T{}  // ./main.go:13: invalid operation: p[1] (type *map[int]T does not support indexing)

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

Кстати, итоговый объем кода в символах практически совпал (только для выравнивания кода в Perl использовалось два пробела, а в Go — один таб), а вот строк в Go получилось на 20% больше. Правда, функционал немного различается, в Go, например, добавлена работа с GC, зато в Perl ещё учитывается отдельная библиотека для кеширования SQL запросов во внеший файловый кеш (с доступом через mmap()). В общем, обьем кода почти равен, но у Perl все же немного компактнее. Зато в Go меньше скобок и точек с запятой — код выглядит лакониченее и легче читается.

В целом, код на Go пишется вполне быстро и аккуратно, куда быстрее чем, скажем, на C/C++, но для простых задач без особых требований к производительности я буду продолжать использовать Perl.

Производительность

Скажем прямо, особых претензий к Go в плане производительности у меня нет, но я ожидал большего. Разница с Perl (много зависит от типа вычислений, в арифметике, например, Perl совсем уж не блистает) составляет около 5-10 раз. У меня не было возможности попробовать gccgo, т.к. на FreeBSD он упорно не собирается, а жаль. Но сейчас бэкенд-софт перестал быть узким местом, потребление им cpu составляет порядка 50% одного ядра, а при росте загрузки проблемы первыми начнутся у Nginx, PostgreSQL и ОС.

В процессе оптимизации производительности профайлер показал, что, кроме моего кода, активную часть CPU потребляет и runtime (речь идет не только о пакете runtime).
Вот один из примеров top10 --cum:
Total: 1945 samples
       0   0.0%   0.0%     1309  67.3% runtime.gosched0
       1   0.1%   0.1%     1152  59.2% bitbucket.org/mjl/scgi.func·002
       1   0.1%   0.1%     1151  59.2% bitbucket.org/mjl/scgi.serve
       0   0.0%   0.1%      953  49.0% net/http.HandlerFunc.ServeHTTP
       3   0.2%   0.3%      952  48.9% main.ProcessHttpRequest
       1   0.1%   0.3%      535  27.5% main.ProcessHttpRequestFromCache
       0   0.0%   0.3%      418  21.5% main.ProcessHttpRequestFromDb
      16   0.8%   1.1%      387  19.9% main.(*RequestRecord).SelectServerInDc
       0   0.0%   1.1%      367  18.9% System
       0   0.0%   1.1%      268  13.8% GC

Как видим, на обработку собственно scgi запроса хендлером тратится всего 49% потребляемого cpu, а целых 33% тратится на System+GC

А вот просто top20 из этого же профайла:
Total: 1945 samples
     179   9.2%   9.2%      186   9.6% syscall.Syscall
     117   6.0%  15.2%      117   6.0% runtime.MSpan_Sweep
     114   5.9%  21.1%      114   5.9% runtime.kevent
      93   4.8%  25.9%       96   4.9% runtime.cgocall
      93   4.8%  30.6%       93   4.8% runtime.sys_umtx_op
      67   3.4%  34.1%      152   7.8% runtime.mallocgc
      63   3.2%  37.3%       63   3.2% runtime.duffcopy
      56   2.9%  40.2%       99   5.1% hash_insert
      56   2.9%  43.1%       56   2.9% scanblock
      53   2.7%  45.8%       53   2.7% runtime.usleep
      39   2.0%  47.8%       39   2.0% markonly
      36   1.9%  49.7%       41   2.1% runtime.mapaccess2_fast32
      28   1.4%  51.1%       28   1.4% runtime.casp
      25   1.3%  52.4%       34   1.7% hash_init
      23   1.2%  53.6%       23   1.2% hash_next
      22   1.1%  54.7%       22   1.1% flushptrbuf
      22   1.1%  55.8%       22   1.1% runtime.xchg
      21   1.1%  56.9%       29   1.5% runtime.mapaccess1_fast32
      21   1.1%  58.0%       21   1.1% settype
      20   1.0%  59.0%       31   1.6% runtime.mapaccess1_faststr

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

Имхо, ещё есть огромный резерв для оптимизации компилятора и библиотек. К примеру, я не заметил inlining-а — все мои мьютексы отлично видны в развертках стека горутин. Процесс оптимизации компилятора на месте не стоит (не так давно Дмитрий Вьюков представил существенно ускоренную реализацию каналов, например), но кардинальных подвижек пока не часто заметно. Например, после перехода с Go 1.2 на Go 1.3 я разницы в производительности практически не увидел вообще.

Ещё в ходе оптимизации мне пришлось отказаться от пакета math/rand. Дело в том, что по ходу обработки запроса часто нужны были псевдослучайные числа, но с привязкой к данным, а rand.Seed() использовал слишком много CPU (профайлер показывал 13% от общего числа). Кому надо, тот нагуглит метод генерации пвсевдослучайных чисел с быстрым Seed(), но все равно — для криптографических целей есть пакет crypto/rand, а в math/rand могли бы так сильно не заморачиваться на качественное перемешивание бит при инициализации.
Кстати, я в итоге остановился на следующем алгоритме:
func RandFloat64(seed uint64) float64 {
        seed ^= seed >> 12
        seed ^= seed << 25
        seed ^= seed >> 27
        return float64((seed*2685821657736338717)&0x7fffffffffffffff) / (1 << 63)
}


Очень удобно, что все вычисления происходят в одном процессе, на Perl-е использовались отдельные процессы-воркеры и мне приходилось организовавать общий кеш — что-то через memcached, что-то через файл. На Go с этим все гораздо проще и естественней. Но теперь при отсутствии внешнего кеша встает проблема холодного старта, тут пришлось немного повозиться — сначала пробовал на nginx-е ограничить (что-бы не запустилось одновременно сто тысяч горутин и не встало всё колом) количество одновременных запросов на upstream через модуль https://github.com/cfsego/nginx-limit-upstream, но что-то он не сильно стабильно работал (когда забивался пул соединений, то вернуться к нормальному режиму ему было почему-то непросто, даже после снятия нагрузки). В итоге я немного пропатчил scgi модуль и добавил ограничитель на кол-во одновременно исполняемых запросов — пока не закончится обработка какого-то из текущих запросов — новый не будет принят Accept()-ом:
func ServeLimited(l net.Listener, handler http.Handler, limit int) error {
        if limit <= 0 {
                Serve(l, handler)
        }

        if l == nil {
                var err error
                l, err = net.FileListener(os.Stdin)
                if err != nil {
                        return err
                }
                defer l.Close()
        }
        if handler == nil {
                handler = http.DefaultServeMux
        }
        sem := make(chan struct{}, limit)
        for {
                sem <- struct{}{}
                rw, err := l.Accept()
                if err != nil {
                        return err
                }
                go func(rw net.Conn) {
                        serve(rw, handler)
                        <-sem
                }(rw)
        }
}

Модуль scgi был выбран тоже из соображений производительности — net/http/fcgi почему-то был в итоге медленнее просто net/http (и не поддерживает persistent connection), а net/http дополнительно грузил OS генерацией tcp-пакетов и поддержкой внутренних tcp-соединений (хотя технически и возможно его запустить слушать на unix-сокете) — а раз можно было от этого избавиться, то почему бы и не избавиться? Использование nginx в качестве фронтэнда дает свои плюсы — контроль таймаутов, логирование, пробрасывание failed запросов на другие сервера из кластера — и все это с минимальной дополнительной нагрузкой на сервер. Ещё один плюс такого подхода — по netstat -Lan видно, когда на сокете scgi растет заполненность очереди Accept — значит где-то у нас перегруз и надо что-то делать.

Качество кода и отладка

Пакет net/http/pprof — волшебная вещь! Это что-то типа модуля server-status от Apache, но для Go демона. И, кстати, я бы не рекомендовал включать его в продакшне, если у Вас вместо выделенного хендлера http используется DefaultServeMux — так как пакет становится доступным всем по ссылке /debug/pprof/. У меня такой проблемы нет, наоборот, чтобы получить доступ к функциям пакета через http, нужно запускать отдельный мини-сервер на localhost:
go func() {
        log.Println(http.ListenAndServe("127.0.0.1:8081", nil))
}()

Кроме получения профайла по процессору и памяти этот модуль дает возможность просмотреть по стеку список всех запущенных в данный момент горутин, всю цепочку функций которые в них в данный момент выполняются и в каком состоянии находятся: /debug/pprof/goroutine\?debug=1 дает список разных горутин и их состояний, а /debug/pprof/goroutine\?debug=2 дает список всех запущеныхгорутин, в т.ч. и дублирующихся (т.е. в полностью одинаковых состояниях). Вот пример одной из них:
goroutine 85 [IO wait]:
net.runtime_pollWait(0x800c71b38, 0x72, 0x0)
        /usr/local/go/src/pkg/runtime/netpoll.goc:146 +0x66
net.(*pollDesc).Wait(0xc20848daa0, 0x72, 0x0, 0x0)
        /usr/local/go/src/pkg/net/fd_poll_runtime.go:84 +0x46
net.(*pollDesc).WaitRead(0xc20848daa0, 0x0, 0x0)
        /usr/local/go/src/pkg/net/fd_poll_runtime.go:89 +0x42
net.(*netFD).accept(0xc20848da40, 0x8df378, 0x0, 0x800c6c518, 0x23)
        /usr/local/go/src/pkg/net/fd_unix.go:409 +0x343
net.(*UnixListener).AcceptUnix(0xc208273880, 0x8019acea8, 0x0, 0x0)
        /usr/local/go/src/pkg/net/unixsock_posix.go:293 +0x73
net.(*UnixListener).Accept(0xc208273880, 0x0, 0x0, 0x0, 0x0)
        /usr/local/go/src/pkg/net/unixsock_posix.go:304 +0x4b
bitbucket.org/mjl/scgi.ServeLimited(0x800c7ec58, 0xc208273880, 0x800c6c898, 0x8df178, 0x1f4, 0x0, 0x0)
        /home/user/go/src/bitbucket.org/mjl/scgi/scgi.go:177 +0x20d
main.func<C2><B7>008()
        /home/user/repo/main.go:264 +0x90
created by main.main
        /home/user/repo/main.go:265 +0x1f5c

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

Профайл CPU тоже неплох, рекомендую ставить gv (ghostview) и смотреть в Xorg диаграмму переходов между функциями со счетчиками — видно на что стоит обратить внимание и пооптимизировать.

go vet хоть и полезная утилита, но у меня её основная польза свелась к предупреждениям о пропущенных спецификаторах формата в всяких printf() — компилятор такое обнаружить не в состоянии. На явно плохой код
if UintValue < 0 {
        DoSomething()
}
vet никак не реагирует.

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

Стоит быть аккуратным с оператором :=. У меня был случай, когда надо было посчитать разницу двух uint, в т.ч. корректно учитывать отрицательную разницу как отрицательную, а код
  var a, b uint
 ...
  diff := a - b
посчитает не то, что Вы ожидаете — нужно использовать приведение к знаковому типу (либо не пользоваться беззнаковыми).

Ещё есть полезная практика именовать одни и те же типы данных с разным предназначением разными именами. Например, так:
type ServerIdType uint32
type CustomerIdType uint32

var ServerId ServerIdType
var CustomerId CustomerIdType
И теперь для переменной CustomerId компилятор не даст просто так записать значение ServerId (без приведения типа), несмотря на то что и там и там внутри uint32. Помогает от разного рода опечаток, хотя теперь приходится часто использовать приведение типов, особенно при инициализации переменных.

Пакеты, библиотеки и связка с C

Немаловажную роль в популярности Go сыграл эффективный (увы, не в плане производительности, с этим пока некоторые проблемы) механизм взаимодействия с C-библиотеками. По большому счету — значительная часть Go-библиотек — это просто обертки над их C аналогами. Например, пакеты github.com/abh/geoip и github.com/jbarham/gopgsqldriver компилируются с -lGeoIP и -lpq соотв (правдя, я использую нативный Go PostgreSQL driver — github.com/lib/pq).

Для примера рассмотрим практически стандартную функцию crypt() из unistd.h — эта функция есть из коробки во многих языках, например, в Perl-овом модуле Nginx-а её можно использовать без подгрузки дополнительных модулей, что бывает полезно. Но не в Go, здесь её надо пробрасывать в C самостоятельно. Это делается элементарно (в примере соль отрезается из результата):
// #cgo LDFLAGS: -lcrypt
// #include <unistd.h>
// #include <stdlib.h>
import "C"
import (
        "sync"
        "unsafe"
)

var cryptMutex sync.Mutex

func Crypt(str, salt string) string {
        cryptStr := C.CString(str)
        cryptSalt := C.CString(salt)

        cryptMutex.Lock()
        key := C.GoString(C.crypt(cryptStr, cryptSalt))[len(salt):]
        cryptMutex.Unlock()

        C.free(unsafe.Pointer(cryptStr))
        C.free(unsafe.Pointer(cryptSalt))
        return key
}
Блокировка нужна т.к. crypt() возвращает один и тот же char* на внутреннее состояние, полученную строку надо скопировать, иначе она будет перезаписана при следующем вызове, т.е. функция не является thread-safe.

database/sql

Для каждого используемого хендлера Db я рекомендую вызывать прописывать максимальный лимит коннектов и указывать какой-то ненулевой лимит idle-коннектов:
db.SetMaxOpenConns(30)
db.SetMaxIdleConns(8)
Первое позволит избежать перегруза базы и использовать её в режиме максимальной производительности (с ростом кол-ва одновременных соединений производительность баз данных начинает падать с какого-то момента, есть оптимальное значение кол-ва одновременных запросов), а второе — уберет необходимость открывать новое соединение на каждый запрос, для PostgreSQL с его fork() режимом это особенно важно. Конечно, для PostgreSQL можно ещё использовать pgpool или pgbouncer, но это всё лишний оверхед на пересылку данных ядром и дополнительные задержки — так что лучше обеспечить постоянность соединений прямо на уровне приложения.

Для исключения оверхеда на разбор запроса и построения плана стоит использовать prepared statements вместо непосредственных запросов. Но нужно иметь ввиду — в некоторых случаях планировщик выполнения запроса может использовать не самый оптимальный план, так как он строится на этапе разбора запроса (а не его выполнения) и планировщик не всегда имеет достаточно данных, чтобы знать какой индекс предпочтительнее использовать. Кстати, плейсхолдерами для переменных в Go драйвере PostgreSQL используются '$1', '$2' и т.д., вместо '?', как в Perl.

sql.(Rows).Scan() имеет одну особенность — он не понимает переименнованные строковые типы, например type DomainNameType string. Приходится заводить временную переменную типа string и в неё делать загрузку данных из базы, а потом делать присвоение с конвертацией типа. С переименованными числовыми типами почему-то такой проблемы нет.

Каналы и синхронизация

Бытует несколько ошибочное мнение, что раз у нас есть каналы в Go, то стоит использовать их и только их. Это не совсем так — каждой задаче — свой инструмент. Каналы отлично подходят для передачи различного рода сообщений, но для работы с общими ресурсами, например sql-кешем, вполне легально использовать мютексы. Для работы с кешем через каналы нам придется написать диспетчер запросов, который в итоге ограничит производительность доступа к кешу одним ядром, добавит ещё больше работы шедулеру горутин и добавит оверхеда на копирование и чтения данных в канал, плюс нужно каждый раз создать временный канал для передачи данных обратно вызывающей функции. Код с использованием каналов также зачастую становится в разы больше и сложнее кода с мютексами (как ни странно). Зато с мютексами надо быть предельно аккуратными, чтобы не попасть в дедлок.

В Go есть такая хитрая фича, как struct{}. Т.е. полностью пустая структура, без полей. Она занимает ноль места, массив любого размера таких структур тоже занимает ноль места, ну и буферизированый канал пустых структур тоже занимает ноль места (плюс внутренние данные, разумеется). Собственно, этот буферизированый канал пустых структур является семафором, в компиляторе для него даже отдельный обработчик сделан — если нужен семафор с Go синтаксисом — можно использовать chan struct{}.

Немного печалит куцость пакета sync. Например, нет спинлоков, хотя они очень полезны, так как быстры (хотя с GC использование спинлоков становится рискованным делом). Да ещё и сами операции с мютексами не инлайнятся (насколько я могу судить). Ещё больше расстраивает невозможность проапгрейдить блокировку RWMutex — если блокировка в статусе RLock и обнаружилось, что необходимо внести изменения — извольте делать RUnlock(), затем Lock() и ещё раз проверять, есть ли все ещё неоходимость делать эти изменения или какая-то горутина уже всё успела сделать. Также нет неблокирующей функции TryLock(), опять же непонятно почему — для некоторых случаев она крайне необходима. Тут разработчики языка с их «нам лучше знать, как вам нужно программировать», имхо, уже перегнули палку.

В некоторых случаях избежать использования мютексов помогает пакет sync/atomic с его атомарными операциями. Например, у меня в коде часто используется текущий uint32 timestamp — я его держу в глобальной переменной и в начале каждого запроса просто атомарно сохраняю в неё актуальное значение. Немного грязный подход, знаю, можно было и функцию-хелпер написать, но в борьбе за производительность иногда приходится идти на такие жертвы — я теперь могу использовать эту переменную в арифметических выражениях без особых ограничений.

Есть ещё один хороший метод оптимизации для случая, когда какие-то общие данные обновляются только в одном месте (например, периодически), а в остальных случаях используются в режиме «только чтение». Суть в том, что нет необходимости делать RLock()/RUnlock() на операции чтения (и Lock()/Unlock() на обновление) — функция обновления может загрузить данные в новую область памяти, а затем атомарно подменить указатель на старые данные указателем на новые. Правда, в Go функция атомарной записи указателя требует тип unsafe.Pointer, и приходится городить такую конструкцию:
atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&Data)), unsafe.Pointer(&newData))
Зато можно использовать эти данные в любых выражениях, не заботясь о блокировках. Это имеет особое значение для Go, т.к. кажущиеся короткими блокировки на самом деле могут быть очень даже долгими — а все из-за GC.

GC (garbage collector)

Испил он моей кровушки изрядно ;(. Представьте себе ситуацию — запускаете нагрузочный тест — все ок. Пускаете живой трафик — тоже все замечательно. А потом бац — и все становится плохо либо очень-очень плохо — старые запросы висят, новые все прибывают и прибывают (несколько тысяч в секунду), приходится делать рестарт приложения, после которого все опять же тупит, т.к. кешу приходится заполняться заново, но хоть как-то работает и через какое-то время возвращается в норму. Я сделал замер времени выполнения каждой стадии обработки запроса — и вижу, что периодически время выполнения всех стадий скачет до трех секунд и выше, даже тех из них, которые не используют блокировки, не используют доступ к базе и файлам, а делают только локальные расчеты и обычно укладываются в микросекунды. Стало понятно, что источником проблемы был не какой-то внешний фактор, а сама платформа. Точнее — сборщик мусора.

Хорошо, что в Go можно посмотреть статистику работы GC через runtime/debug.ReadGCStats() — там есть чему поудивляться. В моем случае на самом незагруженном сервере GC работал в следующем режиме:
0.06
0.30
2.00
0.06
0.30
2.00

Порядок величин сохранен, хотя сами числа немного варьировались. Это длительности засыпания приложения на время работы GC, вверху самые свежие. Пауза всей работы на 2 секунды — каково? Боюсь даже представить, что творилось на самых загруженных серверах, но их я уже не трогал, чтобы не создавать лишних даунтаймов.

Решение — запускать GC() почаще, для надежности лучше самостоятельно из программы. Можно даже просто периодически, я же немного заморочился и сделал счетчик запросов, а также форсированный запуск GC() после крупных очисток устаревших данных. В итоге GC() стал запускаться каждые десять-двадцать секунд вместо раз в несколько минут, зато каждый проход занимает стабильно около 0.1с — совсем другое дело! И потребление памяти демоном заодно упало процентов на 20. Есть возможность отключить сборщик мусора вовсе, но это подойдет разве что короткоживущим программам, ни никак не для демонов. Разрабочикам языка стоит добавить настройку к GC, чтобы он не останавливал приложение дольше указаного лимита, а вместо этого начал почаще запускаться — это избавило бы многих пользователей от проблем при высокой нагрузке.

maps

Никто не будет спорить, что мапы (хеши в терминах Perl) — крайне полезная штука. Но у меня есть серьезные претензии к разработчикам языка по поводу способа их реализации и использования. Грубо говоря, для работы с мапами компилятор использует следующие три функции:
valueType, ok := map_fetch(keyType)
map_store(keyType, valueType)
map_delete(keyType)
И это накладывает ряд значительных ограничений. Пока мапы состоят из базовых типов — все отлично, но с мапой структур или типов с ссылочными методами (т.е. методами, которые работают по ссылке на данные, а не по копии данных) уже начинаются проблемы — мы не можем например написать
type T struct { cnt int }
m := make(map[int]T)
m[0] = T{}
m[0].cnt++  // ./main.go:9: cannot assign to m[0].cnt
так как компилятор не может получить адрес значения m[0], чтобы по смещению cnt сделать инкремент.

Можно либо сделать мапу ссылок на структуру
m := make(map[int]*T)
m[0] = new(T)
m[0].cnt++
либо выгружать и сохранять всю структуру целиком
m := make(map[int]T)
tmp := m[0]
tmp.cnt++
m[0] = tmp
Первый вариант добавит много лишней работы сборщику мусора, а второй — процессору (особенно если структура немаленькая)

По моему мнению, вопрос можно решить, если при работе с мапой компилятор вместо map_store будет использовать функцию
*valueType = map_allocate(keyType)
и добавить дополнительное ограничение, что однажды добавленное значение в мапу не будет перемещаться в памяти.

Функция map_allocate должна использоваться для получения указателей не только на новосозданные элементы, но и на существующие, если они будут модифицироваться. Этот указатель можно использовать для выдачи программисту, для обновления значения, для вызова ссылочного метода — и пока значение лежит на месте, все работает отлично.

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

Проблемы могут возникнуть только в случае, если удалить элемент и продолжить пользоваться ссылкой на неиспользуемую память. Это из той же области, что и одновременное использование мапы из разных горутин без блокировки — если программист сам себе буратино, то кто ему доктор? А если будет возможность адаптировать сборщик мусора к этому случаю, чтобы память после удаления не освобождалась до тех пор, пока в программе есть живая ссылка на удаленный элемент, тогда вообще всё замечательно и нет никаких проблем с безопасностью.

Итог

Увы, нет в мире совершенства. Но было бы наивно ожидать, что новый язык сразу родится идеальным. Да, у Go есть некоторые недостатки, но они, в принципе, все исправимы, было бы желание. Зато Go продвигает развитие языков программирования на следующую ступень, подстраиваясь под современные реалии многоядерных компьютерных архитектур и предлагая соответствующие парадигмы.

Уже очень длительное время я не изучал новых языков программирования. В свое время я немного освоил C (на уровне чуток пропатчить ядро FreeBSD), Perl и Shell-scripting (для повсеместных задач). Погружаться в изучение Python, Ruby или JS у меня не было ни времени, ни стремления — эти языки не могли предложить мне ничего принципиально нового, а менять шило на мыло желания не было. Go же сумел существенно дополнить мой набор инструментов, чему я только рад. При всех его недочетах я ни капли не сожалею о потраченном времени на его изучение — это действительно того стоит.
Tags:
Hubs:
+90
Comments 66
Comments Comments 66

Articles