Redis源码(一)——字符串sds

一、简介

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。

  • 在redis中只有无需对字符串进行修改的地方会使用C字符串,如打印日志。
  • 其他如键值对,键和值都是SDS。
    eg:
redis> SET msg "hello world"
OK

键值对的键"msg"和值"hello world"底层实现都是SDS。

二、定义

1)3.2版本以前

redis3.2之前sds的定义如下:

struct sdshdr {
    unsigned int len;//sds当前的长度
    unsigned int free;//sds当前未被使用的长度
    char buf[];//sds实际存放的位置
};

注:sdshdr和sds是一一对应的关系。
根据len的类型是无符号整型可以看出,sds字符串最大长度是2^(8*sizeof(unsigned int)),即2的16次方或2的32次方。

上图是一个sds数据结构的示例。

2)3.2版本之后

3.2版本引入了5种sdshdr类型,创建时根据长度来选择使用哪种sdshdr类型。

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; //一共8位,低3位用来存放真实的flags(类型),高5位用来存放len(长度)。
    char buf[];//sds实际存放的位置
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;//表示当前sds的长度
    uint8_t alloc; //表示已为sds分配的内存大小
    unsigned char flags;//表示当前sdshdr的类型 
    char buf[];//sds实际存放的位置
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
  • sdshdr5
    • flag:无符号char类型,低3位用来存放flag类型,因为char是1字节也就是8bit,flag最大为4,用3bit足以表示了(可表示到7)。
    • 至少需要3位来表示:
      000:sdshdr5
      001:sdshdr8
      010:sdshdr16
      011:sdshdr32
      100:sdshdr64
    • 高5位用来表示长度,也就是说这种sdshdr类型最大表示的字符串长度为2^5。
    • buf[]:存放实际的字符串
  • 其他sdshdr
    • len:当前sds的长度,不包括终止符’\0’
    • alloc:已分配的内存大小,应该大于或等于len,不包括终止符’\0’
    • flags:表示当前sdshdr的类型,低三位足以表示,高5位空闲。
    • buf[]:存放实际的字符串
  • __attribute__ ((packed))
    这个关键字的作用是取消字节对齐。让我们的结构体,按照紧凑排列的方式占用内存,用来节省内存开销。

三、源码部分

1.求sds长度(sdslen)
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T)))) //s指针左移,指向sdshdr
//用sdshdr5的flags成员变量做参数返回sds的长度,就是把flags的8bit右移3bit,剩下的高5位表示的就是长度了
#define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)
#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3
static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1]; //sdshdr的flags成员变量
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:
            return SDS_HDR(8,s)->len;//取出sdshdr的len成员
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->len;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->len;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->len;
    }
    return 0;
}
  • 第一行里的双井号##的意思是在一个宏(macro)定义里连接两个子串(token),连接之后这##号两边的子串就被编译器识别为一个。
  • 要判断sds是什么类型的sdshdr,flags&SDS_TYPE_MASK的结果和SDS_TYPE_n比较即可。
    这里需要flags和掩码做按位与操作是因为sdshdr5这个特例,它的高5位是用来表示长度的,所以需要用掩码来清除掉。
  • s[-1]:这里实际上是s指针左移一位的意思,由于禁用了内存对齐,s指针指向的是buf[]数组,左移一位刚好是sdshdr的flags成员变量。
2.创建一个sds

一共有四种创建sds的方法,下面列出的是前三种:

sds sdsempty(void) {
    return sdsnewlen("",0);
}

/* Create a new sds string starting from a null terminated C string. */
sds sdsnew(const char *init) {
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    return sdsnewlen(init, initlen);
}

/* Duplicate an sds string. */
sds sdsdup(const sds s) {
    return sdsnewlen(s, sdslen(s));
}

可以看到它们都调用了sdsnewlen这个方法,来看一下这个方法是如何实现的。

//用init指针指向的内存的内容截取initlen长度来new一个sds,这个函数是二进制安全的
sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;//sdshdr的指针
    sds s; //buf[]的指针
    char type = sdsReqType(initlen);//根据需要的长度决定sdshdr的类型
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;//如果initlen为空并且sdshdr的类型为sdshdr5,则将类型设置为sdshdr8
    int hdrlen = sdsHdrSize(type);//每个sdshdr类型的大小都不一样,根据类型返回sdshdr的大小以计算需要分配的空间(这个空间不包括字符数组buf[])
    unsigned char *fp; //指向flags所在字节
 
    sh = s_malloc(hdrlen+initlen+1);//在heap里申请一段连续的空间给sdshdr和属于它的sds,+1是因为要在尾部放置'\0'
    if (!init)
        memset(sh, 0, hdrlen+initlen+1);//如果init为空,则整个sdshdr都用0初始化
    if (sh == NULL) return NULL;
    s = (char*)sh+hdrlen;//通过sdshdr指针找到sds的位置,这里可以理解为buf[]的起始位置
    fp = ((unsigned char*)s)-1;//找到flags的位置,等同于&s[-1]
    switch(type) {
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS);//initlen左移3位到高5位,给type腾出位置,和type做或运算
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);//可以理解为在switch作用域下申明了一个新的局部变量sh,类型是struct sdshdr##T,跟外面的sh值一样,变量名一样,但不是一个东西。
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;//设置flags
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen); //memcpy不会因为'\0'而停下,支持二进制数据的拷贝
    s[initlen] = '\0'; //不管是不是二进制数据,尾部都会加上'\0'
    return s;
}

static inline int sdsHdrSize(char type) {
    switch(type&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return sizeof(struct sdshdr5);//之前说的柔性数组成员不会计入struct的大小,所以这个hdrsize没有包括sds(即buf[])的长度
        case SDS_TYPE_8:
            return sizeof(struct sdshdr8);
        case SDS_TYPE_16:
            return sizeof(struct sdshdr16);
        case SDS_TYPE_32:
            return sizeof(struct sdshdr32);
        case SDS_TYPE_64:
            return sizeof(struct sdshdr64);
    }
    return 0;
}

具体流程可以抽象为下图:

3.空间预分配(sdsMakeRoomFor)

对字符串进行增长操作

#define SDS_MAX_PREALLOC (1024*1024)
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);//获取原字符串的内存空间大小
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;//获取字符串sdshdr类型
    int hdrlen;
    size_t usable;

    if (avail >= addlen) return s;//如果有足够空间直接返回

    len = sdslen(s);//获取原sds长度
    sh = (char*)s-sdsHdrSize(oldtype);//指针左移指向sdshdr
    newlen = (len+addlen);//新sds长度
    if (newlen < SDS_MAX_PREALLOC)//如果新长度小于1MB,新空间翻倍,否则新长度增加1MB
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);//获取新sds的类型

    if (type == SDS_TYPE_5) type = SDS_TYPE_8;//这里如果sds类型是SDS_TYPE_5会被转型,因为这个类型没有办法记录empty space,即sdshdr中没有相应的成员变量

    hdrlen = sdsHdrSize(type);//新类型的头长度
    if (oldtype==type) {//类型无变化
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);//它尽量在原来分配好的地址位置重新分配,如果原来的地址位置有足够的空余空间完成重新分配,那么它返回的新地址与传入的旧地址相同;否则,它分配新的地址块,并进行数据搬迁。
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;//指针右移hdrlen位,指向buf[]字符数据
    } else {//类型改变
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);//重新分配内存空间
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);//拷贝数据到新的内存地址
        s_free(sh);//释放旧的内存空间
        s = (char*)newsh+hdrlen;//指针右移hdrlen位,指向buf[]数据
        s[-1] = type;
        sdssetlen(s, len);//设置sdshdr中len
    }
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    sdssetalloc(s, usable);//设置sdshdr中alloc
    return s;
}

4.扩展字符串(sdscatlen)

进行字符串拼接操作

sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s);//当前字符串长度

    s = sdsMakeRoomFor(s,len);//扩容
    if (s == NULL) return NULL;
    memcpy(s+curlen, t, len);//字符串拷贝,此时已经扩容过,或者内存空间足够,因此不会发生缓冲区溢出问题
    sdssetlen(s, curlen+len);//设置sdshdr的len
    s[curlen+len] = '\0';//字符串末尾置为'\0'
    return s;
}
5.惰性空间释放(sdstrim)

代码如下,可以看到这个函数只是去除了左右的空白字符,改变了sdshdr中len属性,并没有进行真正的改变sds分配的内存空间大小,剩余的空间用于未来字符串的扩展使用。

sds sdstrim(sds s, const char *cset) {
    char *start, *end, *sp, *ep;
    size_t len;
    sp = start = s;
    ep = end = s+sdslen(s)-1;
    while(sp <= end && strchr(cset, *sp)) sp++;
    while(ep > sp && strchr(cset, *ep)) ep--;
    len = (sp > ep) ? 0 : ((ep-sp)+1);
    if (s != sp) memmove(s, sp, len);
    s[len] = '\0';
    sdssetlen(s,len);
    return s;
}
6.释放内存(sdsRemoveFreeSpace)

这个函数压缩内存,让alloc=len。如果type变小了,则另开一片内存复制,如果type不变,则realloc。
基本是sdsMakeRoomFor的反向操作,不再赘述。

sds sdsRemoveFreeSpace(sds s) {
    void *sh, *newsh;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen, oldhdrlen = sdsHdrSize(oldtype);
    size_t len = sdslen(s);
    size_t avail = sdsavail(s);
    sh = (char*)s-oldhdrlen;

    if (avail == 0) return s;

    type = sdsReqType(len);
    hdrlen = sdsHdrSize(type);

    if (oldtype==type || type > SDS_TYPE_8) {
        newsh = s_realloc(sh, oldhdrlen+len+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+oldhdrlen;
    } else {
        newsh = s_malloc(hdrlen+len+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    sdssetalloc(s, len);
    return s;
}

四、sds与C字符串区别

1.常数复杂度获取字符串长度
  • 因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。
    如下图所示:
  • 和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的 复杂度仅为O(1)。
2.杜绝缓冲区溢出
  • 因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。
    如下图所示,s1与s2是相邻的,如果s1要拓展,就会影响到s2字符串,这种情况是不允许发生的。
  • 与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API 需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。
3.减少修改字符串时带来的内存重分配次数
  • 因为C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组(额外的一个字符空间用于保存空字符)。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以 每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:
    • 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出。
    • 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏。
  • 为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而总字节数量就由SDS的alloc属性记录(包括当前已使用空间len,和未使用的空闲空间)。 通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。

3.1 空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需 要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。

  • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分 配和len属性同样大小的未使用空间,这时SDS len属性的值将是alloc属性得一半。
  • 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。
  • 在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配。
  • 通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低 为最多N次。

3.2 惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串 时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

  • 与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
4.二进制安全
  • C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制 使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
  • 所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数 据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
    这也是我们将SDS的buf属性称为字节数组的原因——Redis不是用这个数组来保存字符, 而是用它来保存一系列二进制数据。
5.兼容部分C字符串函数

虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些 API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一 个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。

五、参考资料

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值