Перевод статьи: https://go.dev/blog/unique


Стандартная библиотека Go 1.23 теперь включает новый пакет unique. Цель этого пакета — предоставить возможность канонизации сравниваемых значений. Иными словами, пакет позволяет устранить дубликаты значений так, чтобы они ссылались на единственную, каноническую и уникальную копию, при этом эффективно управляя этими каноническими копиями за кулисами. Вы, возможно, уже знакомы с этой концепцией, которая называется «интернирование». Давайте разберёмся, как это работает и почему это полезно.

Простая реализация интернирования

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

var internPool map[string]string

// Intern возвращает строку, которая равна s, но может использовать ту же область памяти,
// что и строка, переданная в Intern ранее.
func Intern(s string) string {
    pooled, ok := internPool[s]
    if !ok {
        // Клонируем строку на случай, если она является частью более длинной строки.
        // Это должно случаться редко, если интернирование используется правильно.
        pooled = strings.Clone(s)
        internPool[pooled] = pooled
    }
    return pooled
}

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

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

  • Она никогда не удаляет строки из пула.
  • Она не может безопасно использоваться несколькими горутинами одновременно.
  • Она работает только со строками, хотя сама идея более общая.

В этой реализации также упущена возможность, которая проявляется не сразу. Внутри строки — это неизменяемые структуры, состоящие из указателя и длины. При сравнении двух строк, если указатели не равны, необходимо сравнивать их содержимое для определения равенства. Однако если мы знаем, что две строки канонизированы, достаточно просто сравнить их указатели.

Встречаем пакет unique

Новый пакет unique вводит функцию, схожую с Intern, под названием Make.

Она работает примерно так же, как и Intern. Внутри также используется глобальная карта (быстрая универсальная конкурентная карта), и Make ищет переданное значение в этой карте. Но есть два важных отличия. Во-первых, она принимает значения любого сравнимого типа. Во-вторых, она возвращает обертку — значение Handle[T], из которого можно извлечь каноническое значение.

Этот Handle[T] — ключевой элемент дизайна. Handle[T] обладает свойством, что два значения Handle[T] равны, если и только если значения, использованные для их создания, равны. Более того, сравнение двух значений Handle[T] дешевое: оно сводится к сравнению указателей. По сравнению со сравнением двух длинных строк это на порядок дешевле!

До сих пор ничего из этого нельзя было сделать в обычном Go-коде.

Но у Handle[T] есть и вторая важная роль: пока существует значение Handle[T] для какого-то значения, карта будет хранить каноническую копию этого значения. Как только все значения Handle[T], связанные с определенным значением, исчезают, пакет помечает соответствующую запись в карте как подлежащую удалению, чтобы освободить память в ближайшем будущем. Это задает четкую политику удаления записей из карты: когда канонические значения больше не используются, сборщик мусора может свободно их удалить.

Если вы раньше использовали Lisp, это может показаться вам знакомым. Символы в Lisp — это интернированные строки, но не сами строки, и значения всех символов гарантированно находятся в одном пуле. Это соотношение между символами и строками аналогично отношению между Handle[string] и строкой.

Пример из реальной жизни

Как можно использовать unique.Make? Достаточно взглянуть на пакет net/netip в стандартной библиотеке, который интернирует значения типа addrDetail, являющегося частью структуры netip.Addr.

Ниже приведена сокращённая версия кода из net/netip, использующего unique.

// Addr представляет собой IPv4 или IPv6 адрес (с зоной адресации или без),
// аналогично net.IP или net.IPAddr.
type Addr struct {
    // Другие несущественные незакрытые поля...
    
    // Детали об адресе, собранные вместе и канонизированные.
    z unique.Handle[addrDetail]
}
    
// addrDetail указывает, является ли адрес IPv4 или IPv6, а для IPv6
// задаёт имя зоны.
type addrDetail struct {
    isV6   bool   // IPv4 - false, IPv6 - true.
    zoneV6 string // Может быть != "" если IsV6 равно true.
}
    
var z6noz = unique.Make(addrDetail{isV6: true})

// WithZone возвращает IP, который идентичен ip, но с заданной зоной.
// Если zone пустая, зона удаляется. Если ip является IPv4 адресом,
// WithZone не выполняет никаких действий и возвращает ip без изменений.
func (ip Addr) WithZone(zone string) Addr {
    if !ip.Is6() {
        return ip
    }
    if zone == "" {
        ip.z = z6noz
        return ip
    }
	
    ip.z = unique.Make(addrDetail{isV6: true, zoneV6: zone})
    return ip
}

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

Примечание об интернировании строк

Хотя пакет unique полезен, следует признать, что Make не совсем аналогична Intern для строк, поскольку Handle[T] необходим для того, чтобы предотвратить удаление строки из внутренней карты. Это означает, что вам нужно изменить свой код, чтобы сохранять как ручки, так и строки.

Однако строки особенные тем, что, хотя они ведут себя как значения, на самом деле они содержат указатели под капотом, как мы уже упоминали. Это означает, что мы потенциально можем канонизировать только внутреннее хранилище строки, скрывая детали Handle[T] внутри самой строки. Таким образом, в будущем всё ещё существует возможность для того, что я назову прозрачным интернированием строк, при котором строки могут интернироваться без типа Handle[T], аналогично функции Intern, но с семантикой, более тесно напоминающей Make.

В то же время, unique.Make("my string").Value() является одним из возможных обходных путей. Хотя несохранение ручки позволит строке быть удаленной из внутренней карты unique, записи в карте не удаляются немедленно. На практике записи не будут удалены по крайней мере до завершения следующего сборщика мусора, так что этот обходной путь всё же позволяет добиться некоторой степени устранения дубликатов в периоды между сборками.

Некоторая история и взгляд в будущее

На самом деле пакет net/netip интернировал строки зон с момента своего первого появления. Пакет интернирования, который он использовал, был внутренней копией пакета go4.org/intern. Как и пакет unique, он имеет тип Value (который очень похож на Handle[T], но без дженериков) и обладает заметным свойством: записи во внутренней карте удаляются, как только на их ручки больше не ссылаются.

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

Верно: при реализации пакета unique мы добавили поддержку слабых указателей в сборщик мусора. И, пройдя через минное поле неудачных проектных решений, связанных со слабыми указателями (например, должны ли слабые указатели отслеживать воскрешение объектов? Нет!), мы были поражены тем, насколько всё это оказалось простым и понятным. Поражены настолько, что слабые указатели теперь стали публичным предложением.

Эта работа также подтолкнула нас к пересмотру финализаторов, что привело к ещё одному предложению о более простом и эффективном замене финализаторов. С функцией хеширования для сравнимых значений на подходе, будущее создания экономных по памяти кэшей в Go выглядит многообещающе!