Вы сегодня увидите yet another решение Фундаментальной проблемы мусорщика и посмеетесь над теми, кто убежал раньше времени.
Если что, то смеяться после слова лопата...
С уважением, Сергей Деревяго.
В недалеких кругах княжит твердое убеждение, что возможно объекты создать и забыть. Мусорщик уберет.
Но при этом нельзя, например, забывать ожидать горутины, закрывать сокеты, разблокировать мьютексы... Это другое!
Все равны по Закону, но у наших парней есть с рождения некий деструктор. А раз так, мы повинны его вызывать!
Гмм... Но не так уж и видно в примере, что положено нам вызывать.
А в ders/lib все понятно, как грабли: если есть у объекта Close() error (да, такой же, как в io.Closer) -- вы ОБЯЗАНЫ его использовать! Точка.
Люди делают это через defer. Каковой кроме массы забавных особенностей, таит и печальную: он работает только по выходу!
А кому-то потребно иначе??
Ну, смотрите. Допустим, есть функция:
func NewService() (Service, error) {
ret := &secretService{ /* ... */ }
if err := registry.AddComponent("Secret Service", ret); err != nil {
panic(err)
}
// кто вызовет RemoveComponent() ?!
return ret, nil
}
|
Мы создаем секретный Сервис и регистрируем его как Компонент. Проблема в том, что надо бы потом и удалить -- а как?!
Нам фактически нужен defer RemoveComponent() из внешнего scope, но про registry не должен знать ни Сервис, ни пользователь...
И тут с ноги ворота открывает наш lib.Closer:
| lib\closer.go |
|---|
type Closer struct {
_ NoCopy
cfs []func() error
}
func (my *Closer) Defer(close func() error) {
Assert(close != nil)
my.cfs = append(my.cfs, close)
}
func (my *Closer) Close() error {
cfs := my.cfs
my.cfs = nil
var errs []error
for i := len(cfs) - 1; i >= 0; i-- {
errs = append(errs, cfs[i]())
}
return errors.Join(errs...)
}
|
Ай, красавец! И теперь вместо старого:
func NewService(cr *lib.Closer) (Service, error) {
ret := &secretService{ /* ... */ }
if err := registry.AddComponent("Secret Service", ret); err != nil {
panic(err)
}
cr.Defer(func() error { return registry.RemoveComponent("Secret Service") })
return ret, nil
}
|
NewService() указатель на lib.Closer. И потом закрывает в укромном месте.
NewService() откладывает "деструкторы". От души!
Но lib.Closer и сам ведь по сути имеет деструктор. А нельзя ли lib.Closer в lib.Closer??
Сегодня нам можно все!
| examples\closer\main.go |
|---|
type Named struct {
name string
bad bool
}
func (my *Named) Close() error {
if my.bad {
return errors.New("error from " + my.name)
}
return nil
}
func main() {
cr := lib.Closer{}
n1 := Named{"bad 1", true}
cr.Defer(n1.Close)
n2 := Named{"good 2", false}
cr.Defer(n2.Close)
n3 := Named{"bad 3", true}
cr.Defer(n3.Close)
{
cr2 := lib.Closer{}
n4 := Named{"nested bad 4", true}
cr2.Defer(n4.Close)
n5 := Named{"nested good 5", false}
cr2.Defer(n5.Close)
n6 := Named{"nested bad 6", true}
cr2.Defer(n6.Close)
cr.Defer(cr2.Close)
}
n7 := Named{"bad 7", true}
cr.Defer(n7.Close)
fmt.Println(cr.Close())
}
|
Аккуратный удар лопатой, и cr2.Close заходит в cr.Defer()!
Гмм, это что ж это получается? Ну, как-то так:
error from bad 7 error from nested bad 6 error from nested bad 4 error from bad 3 error from bad 1 |
И ведь что интересно:
cr.Close() вызывает деструкторы, отложенные в cr.Defer().
Named{"good", false} на экран не попали, т.к. ошибку не генерируют.
В интернетах бывают УЖАСНЫЕ вещи! Мы на них сейчас храбро посмотрим:
// ByteSliceToString is used when you really want to convert a slice
// of bytes to a string without incurring overhead. It is only safe
// to use if you really know the byte slice is not going to change
// in the lifetime of the string
func ByteSliceToString(bs []byte) string {
// This is copied from runtime. It relies on the string
// header being a prefix of the slice header!
return *(*string)(unsafe.Pointer(&bs))
}
|
Ой! Хомячкам так нельзя!!
А как делают люди?
| go\src\runtime\string.go |
|---|
ptr := unsafe.Pointer(s)
safeLen := int(pageSize - uintptr(ptr)%pageSize)
for {
t := *(*string)(unsafe.Pointer(&stringStruct{ptr, safeLen}))
// ...
}
type stringStruct struct {
str unsafe.Pointer
len int
}
|
И сравним теперь с нашим:
| lib\ptrsz.go |
|---|
type Ptrsz struct {
ptr unsafe.Pointer
sz int
}
|
Ага, оно самое! То есть полный аналог stringStruct от Google.
Не, ну можно, конечно, не есть. Но это не умно, так как:
lib.Ptrsz.
reinterpret_cast<>) указатель на строку, как указатель на lib.Ptrsz (и обратно).
lib.Ptrsz, но не наоборот! Т.к. у слайса еще есть cap int, и ему без него не жить.
| lib\ptrsz.go |
|---|
func PtrszAsString(pp *Ptrsz) *string {
return (*string)(unsafe.Pointer(pp))
}
func StringAsPtrsz(ps *string) *Ptrsz {
return (*Ptrsz)(unsafe.Pointer(ps))
}
func BytesAsPtrsz(pb *[]byte) *Ptrsz {
return (*Ptrsz)(unsafe.Pointer(pb))
}
func PtrszToBytes(p Ptrsz) []byte {
return unsafe.Slice((*byte)(p.ptr), p.sz)
}
|
Обратите внимание, что конвертация из lib.Ptrsz в []byte называется ptrszTObytes, т.к. мы создаем новый объект слайса, а не реинтерпретируем указатели.
И несомненно нужно понимать, что *string из *lib.Ptrsz -- это другое! Нет, ну все просто прекрасно... Пока не изменятся байты, на которые эта строка указывает! Еще более тонкая ситуация, когда вы уверены, что строки больше нет, но ее уже кто-то "скопировал"...
В общем, кроме абсолютно очевидных случаев, следует использовать ptrszTOstring, которая вам сделает deep copy:
| lib\ptrsz.go |
|---|
func PtrszToString(p Ptrsz) string {
sz := uintptr(p.sz)
if sz == 0 {
return ""
}
ptr := MemAlloc(sz)
MemMove(ptr, p.ptr, sz)
return unsafe.String((*byte)(ptr), sz)
}
|
Может вам интересно, что такое MemMove() и MemAlloc()? Даже не спрашивайте!
My first choice for a book title was Boring Go because, properly written, Go is boring... Ща я быстро развею скуку!
| lib\mem.go |
|---|
func MemAlloc(n uintptr) unsafe.Pointer {
return rt_mallocgc(n, nil, false)
}
func MemClr(p unsafe.Pointer, n uintptr) {
rt_memclr(p, n)
}
func MemEqual(x, y unsafe.Pointer, n uintptr) bool {
return rt_memequal(x, y, n)
}
func MemHash(p unsafe.Pointer, n uintptr) uintptr {
return rt_memhash(p, uintptr(20250709), uintptr(n))
}
func MemMove(to, from unsafe.Pointer, n uintptr) {
rt_memmove(to, from, n)
}
//go:linkname rt_mallocgc runtime.mallocgc
//go:noescape
func rt_mallocgc(size uintptr, typ unsafe.Pointer, needzero bool) unsafe.Pointer
//go:linkname rt_memclr runtime.memclrNoHeapPointers
//go:noescape
func rt_memclr(ptr unsafe.Pointer, n uintptr)
//go:linkname rt_memequal runtime.memequal
//go:noescape
func rt_memequal(x, y unsafe.Pointer, n uintptr) bool
//go:linkname rt_memhash runtime.memhash
//go:noescape
func rt_memhash(p unsafe.Pointer, seed, n uintptr) uintptr
//go:linkname rt_memmove runtime.memmove
//go:noescape
func rt_memmove(to, from unsafe.Pointer, n uintptr)
|
Ай-яй-яй!!! Савсэм нэльзя, да!!
Это Google такое сказал? А давайте судить по делам:
| go\src\runtime\malloc.go |
|---|
// mallocgc should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
// - github.com/bytedance/gopkg
// - github.com/bytedance/sonic
// - github.com/cloudwego/frugal
// - github.com/cockroachdb/cockroach
// - github.com/cockroachdb/pebble
// - github.com/ugorji/go/codec
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname mallocgc
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
}
|
И если отбросить сопли, то внезапно окажется, что:
Прошу любить и жаловать, на сцене появляется mdb.BlobMap! Особая сестра своих подружек:
maps.BytesMap.
lib.Ptrsz.
keyBuf/valBuf. Ввиду чего, все делают deep copy:
| bench\bench2\copybuf.go |
|---|
var keyBuf = []byte("12345678key")
var valBuf = []byte("12345678val")
var keyPtr = (*int)(unsafe.Pointer(&keyBuf[0]))
var valPtr = (*int)(unsafe.Pointer(&valBuf[0]))
func MyCopyBufBlobMap() {
const N = cbN
//R0| bm := mdb.NewBlobMap(1)
bm := mdb.NewBlobMap(N) //R1|
for i := 0; i < N; i++ {
*keyPtr = i
*valPtr = i
bm.Insert(*lib.BytesAsPtrsz(&keyBuf), *lib.BytesAsPtrsz(&valBuf), 0)
}
lib.Assert(bm.Size() == N)
cnt := 0
for i := 0; i < N; i++ {
*keyPtr = i
*valPtr = i
p := bm.Val(bm.Find(*lib.BytesAsPtrsz(&keyBuf)))
if bytes.Equal(lib.PtrszToBytes(p), valBuf) {
cnt++
}
if bm.Find(*lib.BytesAsPtrsz(&valBuf)) == 0 {
cnt++
}
}
lib.Assert(cnt == N*2)
for i := 0; i < N; i++ {
*keyPtr = i
bm.Delete(*lib.BytesAsPtrsz(&keyBuf))
}
lib.Assert(bm.Size() == 0)
lib.AssertNil(bm.Close())
}
func MyCopyBufBytesMap() {
const N = cbN
//R0| bm := maps.NewBytesMap(0)
bm := maps.NewBytesMap(N) //R1|
for i := 0; i < N; i++ {
*keyPtr = i
*valPtr = i
bm.Findsert(slices.Clone(keyBuf), slices.Clone(valBuf))
}
lib.Assert(bm.Size() == N)
cnt := 0
for i := 0; i < N; i++ {
*keyPtr = i
*valPtr = i
if bytes.Equal(*bm.Val(bm.Find(keyBuf)), valBuf) {
cnt++
}
if bm.Find(valBuf) == -1 {
cnt++
}
}
lib.Assert(cnt == N*2)
for i := 0; i < N; i++ {
*keyPtr = i
bm.Delete(keyBuf)
}
lib.Assert(bm.Size() == 0)
}
func GoCopyBufMap() {
const N = cbN
//R0| m := make(map[string][]byte)
m := make(map[string][]byte, N) //R1|
for i := 0; i < N; i++ {
*keyPtr = i
*valPtr = i
m[string(keyBuf)] = slices.Clone(valBuf)
}
lib.Assert(len(m) == N)
cnt := 0
for i := 0; i < N; i++ {
*keyPtr = i
*valPtr = i
if bytes.Equal(m[string(keyBuf)], valBuf) {
cnt++
}
if _, ok := m[string(valBuf)]; !ok {
cnt++
}
}
lib.Assert(cnt == N*2)
for i := 0; i < N; i++ {
*keyPtr = i
delete(m, string(keyBuf))
}
lib.Assert(len(m) == 0)
}
|
А теперь поднимите руки, кто заметил bm.Close(). Спасибо, можно опускать.
Cпециально для вас есть вопросик с нежданчиком: что делает вызов defer lib.AssertNil(bm.Close())?
Этот Google -- такие забавники! Но вернемся к баранам.
Стартуем go test -bench=CopyBuf -benchmem и подозрительно щуримся:
| R0 | R1 | |||||
| ns/op | B/op | allocs/op | ns/op | B/op | allocs/op | |
| GoCopyBufMap | 2797259 | 1887972 | 20079 | 2114018 | 1107074 | 20033 |
| MyCopyBufBytesMap | 2567803 | 2247424 | 20014 | 2073255 | 1073674 | 20002 |
| MyCopyBufBlobMap | 2104179 | 2093112 | 11 | 1604242 | 1007679 | 3 |
| Go/MyBytes | 1.09 | 0.84 | 1.00 | 1.02 | 1.03 | 1.00 |
| Go/MyBlob | 1.33 | 0.90 | 1825 | 1.32 | 1.10 | 6678 |
| MyBytes/MyBlob | 1.22 | 1.07 | 1819 | 1.29 | 1.07 | 6667 |
Ага! Сказал я не таясь.
mdb.BlobMap на треть быстрее ЖУТКО оптимизированной нативной map[string][]byte! Что невозможно, но я привык.
mdb.BlobMap ВООБЩЕ не мусорит!!! Там, где привычные карты работают через объекты, mdb.BlobMap просто копирует байты в свой mdb.OffPool.
Суть в том, что привычные всем решения создают (и выбрасывают) N*2 объектов, а mdb.BlobMap жонглирует байтами массива uint64...
Вы когда-то страдали от мусорщика, не успевающего подметать за гигабайтами? Такой проблемы больше нет.
Как нет-то?! Ну уж точно не так: Why Discord is switching from Go to Rust!
Статей подобных множество за баней. Но этой удалось зело прославиться:
|
With the Go implementation, the Read States service was not supporting its product requirements. It was fast most of the time, but every few minutes we saw large latency spikes that were bad for user experience. After investigating, we determined the spikes were due to core Go features: its memory model and garbage collector (GC).
In the picture below, you can see the response time and system cpu for a peak sample time frame for the Go service. As you might notice, there are latency and CPU spikes roughly every 2 minutes.
![]()
These latency spikes definitely smelled like garbage collection performance impact, but we had written the Go code very efficiently and had very few allocations. We were not creating a lot of garbage. We kept digging and learned the spikes were huge not because of a massive amount of ready-to-free memory, but because the garbage collector needed to scan the entire LRU cache in order to determine if the memory was truly free from references. Thus, we figured a smaller LRU cache would be faster because the garbage collector would have less to scan. So we added another setting to the service to change the size of the LRU cache and changed the architecture to have many partitioned LRU caches per server. We were right. With the LRU cache smaller, garbage collection resulted in smaller spikes. Unfortunately, the trade off of making the LRU cache smaller resulted in higher 99th latency times. This is because if the cache is smaller it’s less likely for a user’s Read State to be in the cache. If it’s not in the cache then we have to do a database load. |
Понимаете суть проблемы? Живая структура данных с большим количеством указателей НЕИЗБЕЖНО замедляет мусорщик! А значит весь Сервис. И решения тупо НЕТ!
А по-умному, с точки зрения мусорщика, mdb.OffPool -- это data []uint64. Ни единого указателя! Такой массив он просто перепрыгивает и машет дальше...
И на выходе у работника резвая хеш-таблица любого размера. Да хоть сто гигабайт! А у мусорщика нет проблем... Ну разве не песня?!
Ах да, чуть не забыл! Как вам видится, если б в Discord-е ведали, что их БЕДА -- это минут 15 с перекурами... Не говорите! В хламину расстроятся.
BlobMap создается с помощь функции NewBlobMap(rsrv int) *BlobMap, а потом обязательно закрывается с помощью Close() error!
И error там совсем не для галочки, а для ОЧЕНЬ коварных ошибок!!
Беспременно установите пристрастие проверять результаты закрытия! Т.к. проблемы на этом шаге способны довести до исступления даже острого хомячка.
А теперь пробежимся по функциям:
BlobMap struct |
|
|---|---|
All() []uint64 | позиции всех элементов |
Capacity() int | вместимость |
Close() error | деструктор |
Delete(key Ptrsz) bool | удаляет элемент |
Find(key Ptrsz) uint64 | возвращает позицию элемента или 0 |
Info() MemInfo | используемая память |
Insert(key Ptrsz, val Ptrsz, tag int16) bool | вставляет, если не существует |
Key(pos uint64) Ptrsz | ключ по позиции |
MergeFreed() uint64 | объединяет неиспользуемые блоки памяти |
Reserve(rsrv int) bool | резервирует вместимость |
Set(key Ptrsz, val Ptrsz, tag int16, | присваивает значение и тэг с учетом флагов |
Size() int | размер |
Tag(pos uint64) int16 | тэг по позиции |
Update(key Ptrsz, val Ptrsz, tag int16) bool | изменяет, если существует |
Upsert(key Ptrsz, val Ptrsz, tag int16) BmResult | изменяет или вставляет |
Val(pos uint64) Ptrsz | значение по позиции |
Хорошо, что все ясно. За работу, товарищи!
А вступили мы в Новую Эру:
mdb.BlobMap, когда подходит.
mdb.OffPool! Можно в несколько, если удобно.
В общем и целом, гибридные Go приложения (с ручным управлением памятью для отдельных структур) -- это выход из Ступора!
Knok, Knok, Neo.
Никакая часть данного материала не может быть использована в коммерческих целях без письменного разрешения автора.