Сергей Деревяго

Мое решение для Нерешаемой Проблемы



1. Введение

Go не всесилен. В отдельных случаях приходится менять язык.

Вы сегодня увидите yet another решение Фундаментальной проблемы мусорщика и посмеетесь над теми, кто убежал раньше времени.

Если что, то смеяться после слова лопата...

С уважением, Сергей Деревяго.


2. Уважайте труд уборщиц!

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

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

Но при этом нельзя, например, забывать ожидать горутины, закрывать сокеты, разблокировать мьютексы... Это другое!

Все равны по Закону, но у наших парней есть с рождения некий деструктор. А раз так, мы повинны его вызывать!

Гмм... Но не так уж и видно в примере, что положено нам вызывать.

А в 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
}

От души, конечно, интересно...

Но 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

И ведь что интересно:

Пользуйтесь на здоровье!

3. Удивительный lib.Ptrsz

Об этом было страшно даже подумать!
Но она все же храбро подумала.

В интернетах бывают УЖАСНЫЕ вещи! Мы на них сейчас храбро посмотрим:

// 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.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()? Даже не спрашивайте!


4. Что-то с памятью моей стало...

Скучно вам, серые, щас я накапаю
Правду на смирные ваши мозги!

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 {
    // ...
}

И если отбросить сопли, то внезапно окажется, что:


5. Невозможная mdb.BlobMap

OffPool... как много в этом звуке
Для счастья нашего сошлось!


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

Прогрев закончен, начинается концерт.

Прошу любить и жаловать, на сцене появляется mdb.BlobMap! Особая сестра своих подружек:

Бенчмарк проверенный, только ключи/значения из одного места: 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 и подозрительно щуримся:

R0R1
ns/opB/opallocs/opns/opB/opallocs/op
GoCopyBufMap27972591887972200792114018110707420033
MyCopyBufBytesMap25678032247424200142073255107367420002
MyCopyBufBlobMap2104179209311211160424210076793
Go/MyBytes1.090.841.001.021.031.00
Go/MyBlob1.330.9018251.321.106678
MyBytes/MyBlob1.221.0718191.291.076667

Ага! Сказал я не таясь.


5.2. Фундаментальная проблема

Не зря прищурились: вы смотрите на ОСЛЕПИТЕЛЬНОЕ ЧУДО!

Суть в том, что привычные всем решения создают (и выбрасывают) 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 с перекурами... Не говорите! В хламину расстроятся.


5.3. Руководство пользователя

Как все уже хоть раз заметили, 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,
insert bool, update bool) BmResult
присваивает значение и тэг с учетом флагов
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значение по позиции

Хорошо, что все ясно. За работу, товарищи!


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

Почитал и как будто вступил...

А вступили мы в Новую Эру:

Вся прогрессивная общественность давным-давно утилизирует off-heap для тех же целей, но мне достаточно обычного массива! Syscall не нужен, родной.

В общем и целом, гибридные Go приложения (с ручным управлением памятью для отдельных структур) -- это выход из Ступора!

Knok, Knok, Neo.


Copyright © С. Деревяго, 2025

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