SDS:一個簡易動態字串庫

cjpan發表於2014-05-24

SDS(Simple Dynamic Strings)是一個C語言字串庫,設計中增加了從堆上分配記憶體的字串,來擴充有限的libc字元處理的功能,使得:

  • 使用更簡便
  • 二進位制安全
  • 計算更有效率
  • 而且仍舊…相容一般的C字串功能

它使用另一種設計來實現,不用C結構體來表現一個字串,而是使用一個二進位制的字首(prefix),儲存在實際的指向字串的指標之前,SDS將其返回給使用者。

因為後設資料作為一個字首被儲存於實際的返回指標之前,還因為不論字串的實際內容,每個SDS字串都隱含地在字串的末尾追加一個空項(null term)。SDS字串能夠和C字串一起使用,使用者能夠使用以只讀方式訪問字串的函式,自由地交替使用它們。

SDS以前是C字串的庫,是我為自己每天的C程式設計的需要而開發的,後來它被遷移到Redis,那裡它得到了擴充套件使用,並且為了適應高效能的操作而修改。現在它被從Redis分離出來,成了一個獨立的專案。

因為它在Redis裡存在了好幾年,SDS不僅提供了能夠簡單操作C字串的上層函式,還有一系列的底層函式,使得避免因使用上層字串庫造成的損失而寫出高效能的程式碼變為可能。

SDS的優缺點
通常動態C字串庫使用一個定義字串的結構體來實現。此結構體有一個指標域,由字串函式管理,看起來像這樣:

SDS字串,已經提過了,不遵從這樣的模式,而是對在實際返回字串地址之前的字首給予一個單獨分配(single allocation)的空間。
相比傳統的方式,這種方法也有其優缺點:

缺點#1:
很多函式以值的形式返回新字串,由於有時SDS要求建立一個佔用更多空間的新字串,所以大多數SDS的API呼叫像這樣:

你可以看到s被用來作為sdscat的輸入,但也被設為SDS API呼叫返回的值,因為我們不知道此呼叫是否會改變了我們傳遞的SDS字串,還是會重新分配一個新的字串。忘記將sdscat或者類似函式的返回值賦回到存有SDS字串的變數的話,就會引起bug。

缺點#2:
如果一個SDS字串在你的程式中多個地方共享,當你修改字串的時候,你必須修改所有的引用。但是,大多數時候,當你需要共享SDS字串時,將字串封裝成一個結構體,並使用一個引用計數會更好,否則很容易導致記憶體洩露。

優點#1:
你無需訪問結構體成員或者呼叫一個函式就可以把SDS字串傳遞給C函式,就像這樣:

而在大多數其它庫中,這將是像這樣的:

或者:

優點#2:
直接訪問單個字元。C是個底層的語言,所以在很多程式中這是一個重要的操作。 用SDS字串來訪問單個字元是很輕鬆的:

用其他庫的話,你最有可能分配string->buf(或者呼叫函式來取得字串指標)到一個字元指標,並操作此指標。然而,每次你呼叫一個函式,可能修改了字串,其它的庫可能隱式地重新分配快取,你必須再次取得一個記憶體區的引用。

優點#3:
單次分配有更好的快取區域性性。通常當你訪問一個由使用結構體的字串庫所建立字串時,你會有兩塊不同的記憶體分配:用結構體來表現字串的分配,和實際的記憶體區裡儲存著字串。過了一段時間後,記憶體重新分配,很可能終結在一個與結構體本身地址完全不同的記憶體部分。因為現代的程式效能經常由快取未命中次數所決定的,SDS可以在大工作量下表現得更好。

SDS基礎

SDS字串的型別只是字元指標char *。 然而,SDS在標頭檔案裡定義了一個sds型別作為char*的別名:你應該用sds型別,來保證你能記住你程式裡的一個變數儲存了一個SDS字串而不是C字串,當然這不是硬性規定的。
這是你可以寫的能做些事情的最簡單的SDS程式

上面的小程式已經展示了一些關於SDS的重要內容:

  • SDS字串由sdsnew()函式或者其它類似的函式建立、從堆上分配記憶體,稍後你將看到。
  • SDS字串可以被傳遞到printf()像任何其它的C字串。
  • SDS字串要求由sdsfree()釋放,因為它們是堆上分配的。

建立SDS字串

建立SDS字串有很多方法:

  • sdsnew()函式建立一個以C的空字元結尾的SDS字串?。我們已經在上述例子中看到它如何工作的。
  • sdsnewlen()函式類似於sdsnew(),但不同於建立一個輸入是以空字元結尾的字串,它取另一個長度的引數。用這個方法,你可以建立一個二進位制資料的字串:

    注意:sdslen的返回值被轉換成int,因為它返回一個size_t型別。你可以使用正確的printf識別符號而不是型別轉換。
  • sdsempty()函式建立一個空的零長度的字串:
  • sdsup()函式複製一個已存在的SDS字串:

獲得字串長度

在上述例子中,我們已經用了sdslen()函式來獲得字串長度。這個函式的運作方式類似於libc中的strlen,不同點在於:

  • 能以常數時間執行,因為長度被存在SDS字串的字首,所以即使是非常大的字串,呼叫sdslen的花費不昂貴。
  • 這個函式是二進位制安全的,就像其它的SDS字串函式一樣,所以長度是真正的字串長度,而不用考慮字串的內容,字串中間包含空字元也沒有問題。

我們可以執行下面的程式碼,來看一個SDS字串二進位制安全的例子:

注意,SDS字串在末尾總是以空字元終結,所以哪怕在樣例中s[4]也會有一個空字元終結,然而用printf列印字串的話,最終只有“A”被列印出來,因為libc會把SDS字串當作一般的C字串那樣處理。

銷燬字串

要銷燬一個SDS字串,只需要呼叫sdsfree()函式,並將字串指標作為引數。但需要注意的是,由sdsempty()建立的空字串也需要被銷燬,否則它們會造成記憶體洩漏。
如果不是SDS字串指標,而是NULL指標被傳遞過來,sdsfree()函式將不執行任何操作。因此在呼叫它之前你不需要顯式地檢查NULL指標:

連線字串

把字串和另外的字元連線起來,也許會是你最可能放棄動態C字串庫的操作了。SDS提供了不同的函式來把字串和已存在的字串連線起來。

主要的字串連線函式是sdscatlen()sdscat(),它們基本是一樣的,唯一的區別是sdscat()沒有一個顯式的長度引數,因為它要求一個以空字元結尾的字串。

有時,你需要連線一個SDS字串到另一個SDS字串,你不需要指定長度,但同時字串不需要以空字元結尾,但可以包含任何二進位制資料。為此有個特別的函式:

用法很直接:

有時你不想給字串新增任何特殊資料,但你想確定整個字串至少包含了給定數量的位元組。

如果現在的字串長度已經是len位元組了的話,sdsgrowzero()函式不做任何事情;如果不是,它需要用0位元組補齊,把字串增長到len

字串的格式

有個特殊的字串連線函式,它接收類似printf格式識別符號,並且將格式化字串連線到指定的字串。

樣例:

經常地,你需要直接從printf的格式識別符號中建立SDS字串。因為sdscatprintf()實際上是一個連線字串的函式,你需要做的只是將你的字串連線到一個空字串:

你可以用sdscatprintf()來把數字轉換成SDS字串:

但是這很慢,然而我們有個特殊函式來提高效率。

數字到字串快速轉換操作

從一個整數建立一個SDS字串在特定型別的程式中可能是一個普通的操作,當你能用sdscatprintf()來完成的時候,會有很大的效能下降,所以SDS提供了一個專用的函式。

用起來像這樣:

裁剪字串和取得區間

字串裁剪是一個通常的操作,一系列字元被從字串的左邊或右邊去除。另一個有用的對字元操作是從一個大字串中只取出一個區間。

SDS提供dstrim()sdsrange()函式來完成這兩個操作。但是,留意,兩個函式工作方式都不同於大多數修改SDS字串的函式,因為它們的返回值為空:基本上那些函式總是破壞性地修改了傳遞過來的SDS字串,從來不分配一個新的。因為裁剪和取得區間從不需要更多的空間:所以這兩個操作可以只從原來的字串中去除字元。
因為這個行為,這兩個函式速度很快,並且不涉及到記憶體的重新分配。
這是一個字串裁剪的例子,裡面換行和空格從SDS字串中被去除了。

基本上,sdstrim()把要裁剪的SDS字串作為第一個引數,並且帶有一個以空字元終結的字符集, 它們會被從字串的左邊或右邊去除。字元只要不被裁剪字元列表以外的字元隔開,就會被去除:這是為什麼“my”和“string”中間的空格在上面的例子中被保留。

取得區間也類似,但不是取得一組字元,而是在字串內取得表示開始和結束的索引,由0起始,來取得將被保留的區間。

索引可以為負,來指定一個起始於字串末尾的位置,因此-1表示最後一個字元,-2表示倒數第二的,以此類推。

當實現網路伺服器處理一個協議或者傳送訊息時,sdsrange()會非常有用。例如,下面的程式碼用來實現節點間的Redis Cluster訊息匯流排的寫處理:

每當我們需要傳送訊息的目標節點的socket是可寫的時候,我們嘗試寫入儘可能多的位元組,我們用?sdsrange()從緩衝中移除已經傳送的部分。
給傳送到某個叢集節點的新訊息排隊的函式就只是用sdscatlen()來把更多的資料放到傳送緩衝中去。
注意,Redis Cluster匯流排實現了一個二進位制的協議,因為SDS是二進位制安全,所以這不會造成問題。所以SDS的目標不僅是為C程式設計師提供一個高層字串API,還提供了易於管理的動態分配緩衝。

字串複製

最危險、最惡名的C標準庫函式可能就是strcpy了。所以可能有些有趣的是,在更好設計的動態字串庫的環境中,複製字串的概念幾乎是無關緊要的。通常你做的就是建立一個字串,內容由你定,或者根據需要連線更多內容。

然而,SDS以一個有利於高效重要的程式碼段的字串複製函式為特性。但是我猜它的實用性是有限的,因為這個函式從來沒在50千行程式碼所組成的Redis程式碼庫中被呼叫。

SDS字串複製函式叫sdscpylen,像這樣呼叫:

正如你能看到的,這個函式接收SDS字串s作為輸入,但也返回一個SDS字串。這對很多修改字串的SDS函式來說很普遍,用這個方法,返回的SDS字串可能是基於原來的那個修改的,或者一個新分配的(比如舊的SDS字串沒有足夠空間時)。

sdscpylen只是用由指標和長度引數傳遞的新資料,替換掉在舊SDS字串裡的內容。還有一個類似的函式叫sdscpy,不需要長度引數,但是要求帶有空字元終結的字串。

你可能會想,為什麼SDS庫需要一個字串複製函式,你可以簡單地從零開始建立一個新的SDS字串,用新的值,而非複製一個存在的SDS字串的值。理由是效率:sdsnewlen()總是分配一個新的字串,而sdscplylen()會盡量重用已存在的字串,如果有足夠的空間,就用使用者指定的新內容,只在必要時分配新的。

引用字串(Quoted String)

為了給程式使用者提供的輸出,或者為了除錯的目的,將一個可能包含二進位制資料或者特殊字元的字串轉換成引用的字串通常是很重要的。這裡的引用字串,意思是程式程式碼裡的字串文字上的一般形式。然而今天,這個形式也是著名的序列化格式的一部分,如JSON和CSV,所以它顯然偏離了在程式的原始碼中表現文字上的字串這一簡單的目標。

下面是一個引用字串文面的例子:

第一個位元組是一個0位元組,當最後一個位元組是一個換行,所以共有兩個非字母的字元在這個字串裡。
SDS使用一個連線函式,把表示輸入字串的引用字串,連線到一個已存在的字串,來達到這個目的。

scscatrepr()repr表示representation)遵從通常的SDS字串函式規則,接收一個字元指標和一個長度引數,所以你可以用它來處理SDS字串,或者一般的使用strlen()作為len引數的C字串,或者二進位制資料。下面是一個使用例子:

這是sdscatrepr()使用的規則:

  • \和“用backslash引用。
  • 能引用特殊字元’\n’, ‘\r’, ‘\t’, ‘\a’以及’\b’
  • 所有其他不能通過isprint測試的不可列印字元在\x..格式裡被引用,就是backslash後跟x,後跟2位十六進位制數字表示字串的位元組數值。
  • 這個函式總是加上初始的和最後的雙引號字元。

有一個SDS函式能處理逆轉換,在下面的語彙單元化(Tokenization)的篇幅裡有記述

語彙單元化(Tokenization)

語彙單元化是一個把大字串分割成小字串的過程。在這個特定的例子中,指定另一個字串作為分隔符來執行分割。例如,在下面的字串,有兩個子字串被|-|分割符分割:

一個更常用的由一個字元組成的分割符是逗號:

處理一行內容來獲得組成它的子字串在許多程式中是很有用的,所以SDS提供了一個函式,給定一個字串和一個分割符,返回一個SDS字串的陣列。

和往常一樣,這個函式可以處理SDS字串和普通的C字串。頭兩個引數slen指定了要單元化的字串,另兩個字串sepseplen是在單元化過程中用到的分割符。最後的引數count是一個整數指標,會被設為返回的單元(子字串)的數目。

返回值是一個在堆上分配的SDS字串陣列。

返回的陣列是在堆上分配的,並且陣列的單個元素是普通的SDS字串。在例子中,你可以通過呼叫sdsfreesplitres()釋放所有資源。你也可以選擇用free函式自行釋放陣列,或者像通常那樣釋放單獨的SDS字串。

合理的方法是用某種方式將你會重用的陣列元素設定為NULL,並且用sdsfreesplitres()來釋放其餘所有的陣列。

面向命令列的單元化

用分割符分割字串是很有用的操作,但是對於執行最常見的涉及到重要的字串操作,即為程式實現命令列介面來說,通常還是不夠的。
這是為什麼SDS也提供一個額外的函式,允許你將使用者由鍵盤互動式輸入,或者通過一個檔案、網路或者其他任何方式的引數,分割成單元。

sdssplitargs函式返回一個SDS字串陣列,就像sdssplitlen()一樣。釋放結構的函式sdsfreesplitres(),也是一樣的。不同在於執行單元化的方式。
例如,如果輸入下面一行:

函式會返回下面的標記(token):

  • “call”
  • “Sabrina”
  • “and”
  • “Mark Smith\n”

基本上,不同的標記要被一個或多個空格分割,每一個標記也可以是一個sdscatrepr()可以發出的相同格式的引用字串。

字串結合(Joining)

有兩個函式做與單元化相反的工作,將字串結合成一個。

這兩個函式取一個長度為argc的字串陣列,一個分割符及其長度作為輸入,產生一個由所有被輸入分割符分割的輸入字串所組成的SDS字串。
sdsjoin()sdsjoinsds()不同點在於前者接收C空字元終結的字串作為輸入,而後者要求所有陣列裡的字串須為SDS字串。但是也因為這個原因,只有sdsjoinsds()能夠處理二進位制資料。

錯誤處理

所有返回SDS指標的SDS函式,在記憶體不足的情況下,也有可能返回NULL,基本上這是唯一需要你進行檢查的地方。
但是許多現代的C程式處理記憶體不足時,只會簡單地中止程式,所以可能你也會需要通過包裝malloc,直接呼叫其他相關的記憶體分配函式來處理這種情況。

SDS本質和進階用法

在本篇開始時,解釋了SDS字串是如何被分配的,但是隻涉及到儲存在返回使用者的指標之前的字首,被當作一個字串頭(header)而已,沒有更深入的細節。為了瞭解進階的用法,最好挖掘更多SDS的本質,看看實現它所用到的結構體:

如你所見,這個結構體可能與某個傳統的字串庫類似,但是結構體的buf域是不同的,因為它不是一個指標,而是一個沒有宣告任何長度的陣列,所以buf實際上指向了緊跟叫free的整數後的第一個位元組。所以為了建立一個SDS字串,我們只要分配一片記憶體,其大小為sdshdr結構體加上我們的字串長度,外加一個額外的位元組,這是為了所有SDS字串硬性需要的空字元。

結構體的len域顯而易見,就是當前的SDS字串的長度,每當字串被通過SDS函式呼叫修改時,總是會被重新計算。而free域表示了在當前分配空間中的空閒記憶體的數量,可以被用來儲存更多的字元。

所以實際的SDS記憶體分佈是這個:

你可能要問,為什麼在字串末尾會有一些空閒空間,這看上去是浪費。實際上,在一個新的SDS字串建立後,之後是沒有任何空閒空間的:分配空間小到只需要儲存字串頭、字串和空終結符。然而,其他的訪問模式會在末尾建立一些額外的空閒空間,如下面的程式:

因為SDS致力於高效,它負擔不起在每次新增新資料時,重新分配字串,因為這會非常的低效,所以會使用每次你擴大字串時,預分配一些空閒空間

所使用的預分配演算法如下:每次字串為了儲存更多的位元組而被重新分配時,實際進行分配的大小是最小需求的兩倍。例如,如果字串現在儲存了30個位元組,我們多連線2個位元組,SDS總共會分配64個位元組,而非32個。

然而,可進行分配的空間有一個硬性限制,被定義為SDS_MAX_PREALLOC。SDS絕不會分配超過1MB的額外空間(預設的,你可以修改這個預設值)。

縮減字串

有時,有一類程式要求使用非常少的記憶體。字串連線、裁剪、取得區間後,字串可能最終會有非常巨大的額外空間。
可以用函式sdsRemoveFreeSpace()改變字串大小,回到可以儲存現在內容的最小尺寸。

也可以用另一個函式,來取得給定字串的總的分配空間大小,叫做sdsAllocSize()

注意:SDS底層API使用cammelCase,這可以警告你,你在玩火。

手動修改SDS字串

有時你會想手動修改一個SDS字串,而不是用SDS函式。在下面的例子中,我們隱式地修改字串的長度,當然,我們還想要邏輯上的長度來表示以空字元終結的C字串。
函式sdsupdatelen()正好做了那些工作,把指定字串的內部長度資訊更新成通過strlen得到的長度。

共享SDS字串

如果你在寫一個程式,在其中,不同的資料結構間分享同一個SDS字串會有好處的話,強烈建議,把SDS字串封裝到一個結構體中,結構體中包含記錄引用字串的數目,還有增減引用數目的函式。

這個方法是一個記憶體管理技術,叫做引用計數,在SDS的情景下有兩個優點:

  • 降低了因沒有釋放SDS字串或者釋放已被釋放了的字串,而造成記憶體洩露或者bug的可能性。
  • 當你修改SDS字串時,你不需要更新每一個它的引用。(因為新的SDS字串可以指向不同的記憶體位置)

儘管這顯然已經是一項很常用的程式設計技術了,我還是把它的基本思路概述一下。像這樣,你建立一個結構體:

當新的字串被建立出來,分配了這個結構體並且返回refcount設成1。然後你有兩個函式修改共享字串的引用數目:

  • incrementStringRefCount會簡單地將結構體裡的為1的refcount增加。每當你在新的資料結構、變數或者隨便什麼中加了一個字串的引用,它就會被呼叫。
  • decrementStringRefCount被用於刪減一個引用。然而這個函式有點特殊,因為當refcount減到0時,它會自動釋放SDS字串,以及mySharedString結構體。

對堆檢查工具(Heap Checker)的影響

因為SDS返回一個指向由malloc分配的記憶體塊的中間,堆檢查工具可能會有些問題,但是:

  • 常用的Valgrind程式會發現SDS字串是可能丟失的記憶體,但並不是確定丟失,所以還是可以容易知道是否有洩露。我用Valgrind和Redis許多年,每一個真的洩露都會被檢測為“確定丟失”。
  • OSX工具不會把SDS字串檢測為洩露,並能夠正確操作指向記憶體塊中間的指標。

系統呼叫的零複製

此時,通過閱讀程式碼,你應該已經擁有所有挖掘更多SDS庫內幕的工具了。然而,還有一個有趣的模式,你可以應用匯出的底層API,它在Redis內部使用過,用來改進網路程式碼效能。
sdsIncrLen()sdsMakeRoomFor(),可以應用下面的模式,來把從核心而來的位元組連線到一個sds字串的末尾,而無需複製到一箇中間的記憶體緩衝區域。

sdsIncrLen記述於sds.c的程式碼中。

在你的專案中嵌入SDS

這和在你的專案中拷貝sds.c和sds.h檔案一樣簡單。程式碼很小,每個C99編譯器應該都不帶任何問題地搞定。

開發人員和許可

SDS由Salvatore Sanfilippo開發,在BDS的兩個條款許可下發布。詳情參見軟體釋出中的LICENSE檔案。

相關文章