动态

详情 返回 返回

golang unique包和字符串內部化 - 动态 详情

最近在做老系統優化,正好遇到了需要使用字符串內部化的場景,所以今天就來説説字符串內部化這種優化技巧。

什麼是字符串內部化

熟悉Java或者python的開發者應該對“內部化”這種技術不陌生。內部化指的是對於內容完全相同的字符串變量,內存中只保留一份數據,所有的變量都引用同一份數據,從而節約內存。

舉個Java的例子:

public class StringInternDemo {
    public static void main(String[] args) {
        String s1 = new String("hello");
        String s2 = "hello";

        // 使用 intern 方法
        String s3 = s1.intern();

        System.out.println(s1 == s2); // false,因為 s1 是堆中新建的對象
        System.out.println(s2 == s3); // true,因為 s3 指向字符串常量池中的 "hello"
    }
}

例子中s3s1是不同的兩個字符串變量,但它們共享同一份字符串數據。在python中可以用sys.intern(str)實現類似的功能,而且python更進一步——對於長度短且不包含特殊字符的字符串默認會自動進行內部化。

可以看到所謂內部化,其實相當於創建了一個“字符串緩存”,我們可以把字符串放進緩存裏,然後在需要的時候取出來複用,取的時候既可以用變量也可以用常量。不過這麼做需要字符串類型本身是不可變的,因為所有相同內容的字符串變量共享同一份數據,如果其中一個變量意外修改了這份數據,其他和它共享的字符串變量都將受到“污染”。好在不管Java、python還是Golang,字符串類型都是不可變的。

那麼字符串內部化的好處是什麼呢?好處在於可以減少內存分配次數且節約內存用量,我們不必為了內容相同的字符串反覆申請內存空間。不過和其他類型的緩存一樣,如果命中率不夠高那麼這個緩存不僅不會帶來任何提升反而還會浪費大量內存並加重gc負擔。

字符串內部化在處理數據的反序列化時是一種很重要的優化手段。

舉個例子,某個公司需要彙總處理每個員工的業績數據,數據中包含員工所在部門和職位等信息。我們知道一個公司的員工可能有幾千個幾萬個甚至幾十萬個,但公司裏的部分數量往往不會超過三位數,職位分類也是如此,即使數據再多它們也只會有固定數量的取值不會增多,反過來員工的姓名就很少會有重複數據,你幾乎可以總是預估姓名的數量小於等於員工總數且隨着員工數量增加而增加。如果沒有字符串內部化,每收到一條數據,我們就要重複創建部門名稱和職務頭銜這些字符串,數據量越大浪費的內存就越多;而如果我們能把這些名稱頭銜的字符串全部緩存起來,後續只要讓新變量共享這些數據,就能帶來非常可觀的內存利用率提升和性能改善。

另外除了字符串,其他符合“低基數”(取值重量有限,但整體數量很大,比如上文的部門名稱)特徵的數據都可以利用內部化進行優化。

Golang中手動實現內部化

在瞭解了什麼是內部化,並且看了Java的例子,現在我們可以講講在Golang裏如何實現這一技術了。

最原始的實現是這樣的:

type StringIntern struct {
    m map[string]string
}

func (s *StringIntern) Intern(str string) string {
    ret, ok := s.m[str]
    if !ok {
        ret = strings.Clone(str)
        s.m[str] = ret
    }
    return ret
}

var si StringIntern
s1 := "hello"
s2 := si.Intern(s1)

用法上沒有和Java有多少區別,代碼也很簡單,唯一需要解釋的是在字符串存進map的時候我們需要clone一次,這是為了避免參數str是某個長字符串的子串,因為我們的map需要長期持有str,如果是上述情況,這個長字符串就會無法釋放從而造成泄露。

如果是在反序列化場景使用,可能需要調用unsafe.String(bytes, length)來獲取字符串避免不必要的內存分配。

這個方案足夠應付大多數場景,但還有一個比較麻煩的問題——我們沒有實現淘汰機制,這會導致內部化池的規模越來越大。

想要在用户代碼層面解決這個問題往往會變得得不償失——我們需要額外的空間記錄內部化的字符是否需要淘汰,並在每次獲取字符串時處理緩存淘汰或者週期性掃描並釋放“過期”的字符串,這不僅增加實現複雜度還會降低性能。

但好在go有垃圾回收器,我們可以拜託垃圾回收器幫我們進行清理,只要沒有引用繼續指向字符串,它就會被回收。但這要求我們的map不能持有內部化的str,想實現這個效果需要費點功夫並且要用unsafe。

因為內部化的需求實在太常見,已經有人把代碼幫我們寫好了:go4.org/intern

這個包的原理和我們的StringIntern一樣,不過map的值換成了一種可以讓gc及時回收的類型,這個包我不過多解釋,因為如果你能用go1.24或者更新版本,那麼unique標準庫會提供更快更安全的替代品,那就沒必要再引入這個外部依賴了。

用法上也和我們自己實現的內部化差不多:

import "go4.org/intern"

s1 := "hello"
s2 := intern.GetByString(s1)

另外go4.org/intern不僅僅可以內部化字符串,它還可以內部化很多別的類型的數據,然而這一功能也被標準庫的unique全面替代並超越了。

所以如果你能保證需要內部化的字符串數量很少並且不會變動,那麼使用StringIntern足矣,否則就需要使用下面要講解的unique包了。

unique包和內部化

unique包並不是為了字符串內部化而被引入的。

最早需要unique的地方是net/netip這個包,確切得説是裏面的表示IPv6地址的類型需要它。

按照規範,TPv4地址可以以v6的形式進行表示,並且IPv6地址都有一個叫zone的東西來區分不同的網絡。為了符合規範也方便進行操作,netip中的每個地址類型都需要包含isV4 boolzone string這兩個字段。

isV4是標準的“低基數”變量,zone不是。但問題出在目前沒有多少人主動使用zone,而且也沒多少人愛用IPv6形式表示v4的地址,這就導致了這兩個變量的基數都很低,而且它們組合之後的基數還要更低。結構體裏帶上它們就會浪費至少20字節,因此開發者提出了一種存儲開銷和一個指針變量相同且能對低基數變量實現內部化的標準庫功能。

這就是unique包。unique裏核心的只有unique.Handle[T]unique.Make

unique.Handle[T]是前面説的只有一個指針大小的可以表示被內部化數據的東西。從類型名字上可以看出這是一個泛型類型,它可以表示任何可比較的類型的值。它有一個Value方法,可以返回被表示的類型為T的值。

unique.Make則用來創建unique.Handle[T],它的用法和我們的StringIntern.Intern是一樣的。並且Make也和我們的實現一樣,會clone自己的參數,以避免內存泄露或者其他的生命週期問題。

所以只要把上一節我們使用intern包的例子稍微修改就能使用unique了:

import "unique"

s1 := unique.Make("hello")
s2 := unique.Make("hello")
fmt.Println(s1.Value(), s2.Value())
fmt.Println(s1 == s2) // true

s3 := unique.Make("hello1")
fmt.Println(s1 == s3) // false,快速比較

api變得有些複雜我們不能之間獲取value,需要通過Handle進行中介,但作為好處,一個Handle只有8字節,而string有至少16字節,另一個好處在於通過比較Handle我們可以快速判斷兩個被內部化的值是否相同——本來我們很可能需要進行長字符串的比較,但利用Handle,我們只要對比一下8字節的數據是否相同就行。

同時unique包還可以利用gc,只要沒有Handle繼續,這些內部化的數據就會在下一次垃圾回收的時候被釋放,沒有泄露的風險。和go4.org/intern相比unique在傳參的時候利用的是泛型而不是interface,還可以避免一次額外的內存分配開銷。

unique和intern都可以在併發場景下使用,unique內部使用無鎖的hashtrie實現,而intern使用鎖來保證map的併發安全,因此unique又勝一籌。

所以在新版本的go代碼中如果有利用內部化的需求,應該優先考慮unique

性能對比

最後做一下go4.org/internunique的性能對比。測試代碼是我從文章開頭説的老系統的數據解析邏輯裏裁剪下來的,代碼會解析一串二進制數據並生成對應的結構體。數據中很大字符串的內容是重複的,因此非常適合使用內部化進行優化:

type Data struct {
	A, B, C, D string
}

type Data2 struct {
	A, B, C, D unique.Handle[string]
}

type Data3 struct {
	A, B, C, D *intern.Value
}

func main() {
	fmt.Println("Data size:", unsafe.Sizeof(Data{})) // 64
	fmt.Println("Data2 size:", unsafe.Sizeof(Data2{})) // 32
	fmt.Println("Data3 size:", unsafe.Sizeof(Data3{})) // 32
}

可以看到光是採用內部化之後結構體的存儲成本就已經節約了50%。下面再看看利用內部化之後對解析速度的影響:

var (
	pool1 = sync.Pool{
		New: func() any {
			return &Data{}
		},
	}
	pool2 = sync.Pool{
		New: func() any {
			return &Data2{}
		},
	}
	pool3 = sync.Pool{
		New: func() any {
			return &Data3{}
		},
	}
)

//go:noinline
func ParseString(b []byte) *Data {
	d := pool1.Get().(*Data)
	d.A = string(b[:16])
	d.B = string(b[16:32])
	d.C = string(b[32:48])
	d.D = string(b[48:64])
	return d
}

//go:noinline
func ParseUnique(b []byte) *Data2 {
	d := pool2.Get().(*Data2)
	s1 := unsafe.String(&b[0], 16)
	s2 := unsafe.String(&b[16], 16)
	s3 := unsafe.String(&b[32], 16)
	s4 := unsafe.String(&b[48], 16)
	d.A = unique.Make(s1)
	d.B = unique.Make(s2)
	d.C = unique.Make(s3)
	d.D = unique.Make(s4)
	return d
}

//go:noinline
func ParseIntern(b []byte) *Data3 {
	d := pool3.Get().(*Data3)
	s1 := unsafe.String(&b[0], 16)
	s2 := unsafe.String(&b[16], 16)
	s3 := unsafe.String(&b[32], 16)
	s4 := unsafe.String(&b[48], 16)
	d.A = intern.GetByString(s1)
	d.B = intern.GetByString(s2)
	d.C = intern.GetByString(s3)
	d.D = intern.GetByString(s4)
	return d
}

func BenchmarkParseString(b *testing.B) {
	data := []byte("aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd")
	for range b.N {
		d := ParseString(data)
        pool1.Put(d)
	}
}

func BenchmarkParseUnique(b *testing.B) {
	data := []byte("aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd")
	for range b.N {
		d := ParseUnique(data)
        pool2.Put(d)
	}
}

func BenchmarkParseIntern(b *testing.B) {
	data := []byte("aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd")
	for range b.N {
		d := ParseIntern(data)
        pool3.Put(d)
	}
}

給函數加上noinline是為了更加貼近項目中的實際代碼,項目中的代碼除了字符串之外還有很多整數和bool值需要解析,解析出來的值還需要經過簡單的校驗,因此編譯器解析函數裏東西太多沒法內聯,我們這裏簡化了邏輯只保留了字符串處理,但在內聯上和實際的代碼保持一致,因為內聯或者不內聯會大幅改變性能測試結果。數據對象我們也做了池化儘量減少任何環節上不必要的分配。

測試結果:

image

採用內部化的函數完全避免了內存分配,unique性能優於intern,但速度上比原先的版本慢了25%。這是因為unique需要計算字符串的hash然後去hashtrie裏取數據,比起小塊內存分配來説這個步驟會慢一些,另外性能測試運行時間比較短測試內容也比較簡單,gc壓力體現得沒有生產環境上明顯,因此看着有不小的速度差異,但實際生產環境上內部化因為幾乎不額外分配內存,不僅內存佔用少很多,速度上的差異也幾乎沒有監測到。

總結

內部化是一種專門針對“低基數”數據進行的優化方法。對於基數不低的數據使用則會收到明顯的反效果。

同時也要記住,雖然內存分配的少了,但查找並返回被內部保存的數據也是需要額外花費時間的,因此對於非熱點代碼或者運行時間較短的程序來説這種優化也有些捨近求遠了。

user avatar sunplay 头像 huaihuaidehongdou 头像 tongbo 头像 kohler21 头像 eolink 头像 java_study 头像 jianghushinian 头像 lyflexi 头像 immerse 头像 yunpan-plus 头像 javalover 头像 tiandetuoba 头像
点赞 32 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.