〔池化纲领〕也及链表

转载:http://tieba.baidu.com/p/1393147869

 

考虑某个需要长时间运行的程序,在它的运行期间可能频繁创建和销毁某种类型的对象(比如用于小额内存分配的内存池,或者某些具有已知长度缓冲区的对象)。然而频繁地malloc/free完成内存块的分配/去配显得并不合适:涉及到的brk/mmap, 或者不定长对象(容易想到,程序中的很多部分都会用到malloc/free, 使用的堆则是进程的用户堆)的连续分配/去配产生的碎片(其实这点也不必过分担心,毕竟ptmalloc之类的实现还是足够强大的)都可能是影响对象管理性能的因素。当然对于完全不能确定尺寸的内存块分配,我们可以直接申请一块足够大的内存之后用dlmalloc在用户态下管理,相当于模拟一个省去了陷入和malloc实现本身花在线程安全上开销的堆,但其实对于更规则的对象,如果分配-初始化-销毁-去配确实是更为耗时的操作,可以考虑引入一个对象池。

有些说法认为对象池是特殊的Factory Pattern, 不过总之是预生成一批构造完成的对象存储在一个Pool中,在产生对象的请求到来时首先尝试从Pool取出一个空闲对象,同等地,在释放对象的请求到来时也尝试将对象加到池中以供下一次产生请求使用。Pool的实现比较多样,这里举一个简单的情况,也即我们使用一个链表来存储对象,并提供尽可能快速轻巧的插入/取出操作。当然,更喜欢c++的读者这里也可以直接用list之类的东西。

或许出于方便或者可信的考虑,读者会选用某个库提供的ADT来解决问题,比如这里举例所用的GQueue. GQueue内部实际上使用了GLib提供的双向链表GList:

struct GList {
  gpointer data;
  GList *next;
  GList *prev;
};

 

上面是一个常见的链表写法,如同一个单纯的c实现使用void *和特定上下文中的类型转换使得链表节点可以访问任意类型的数据,而数据本身并没有存储在链表节点当中,库的用户可以使用自己的算法和数据结构来管理数据对象本身,链表ADT只负责将存在于某处的数据组织成表。这是值得效仿的做法。
有一点数据结构基础的读者很容易实现这个链表结构需要的操作,包括插入、删除、查找、遍历和其他认为应当提供的例程,这里略去不表。

 

当然有些时候大量的强制转换看起来并不舒服,规矩通用的实现也未必那么简明。另一个实现来自Linux内核,这里展示的结构做了一些删改。
Linux内核链表在链表的通用性上走得更远,尽管第一眼看上去不一定会习惯:

struct list_ctl_struct {
    struct list_ctl_struct *prev, *next;
};
typedef struct list_ctl_struct list_ctl_t;

链表结构本身只包含了控制信息。这时的链表操作已经完全独立于数据,换言之,连回调函数也不必注册了。但是取到数据的方式就需要稍微花一点力气了。这样的链表结构,需要作为控制信息域包含在数据节点内,比如

struct test_node_struct {
    int val;
    list_ctl_t lctl;
};
typedef struct test_node_struct test_node_t;

 

为了取得包含了链表结构的数据节点,需要稍微整理一下思路。在所有链表操作当中,所涉及到的只可能是list_ctl_t *(这里我们不考虑传一个list_ctl_t对象作为参数的情况),所以应当通过成员lctl在test_node_t内的偏移获得test_node_t对象的首地址。首先考虑取得类型内成员偏移的方法:

#define offset_of(type, member) ((size_t) &((type *) 0)->member)

这个写法比较常见,尤其是在结构的柔性数组成员普及之前,也会用到这样的宏来确定结构中缓冲区成员的偏移。在这个前提下,考虑如何反过来确定包含了成员的对象的首地址:

1. 我们需要一个指向成员m的指针p,这个指针至少能够确定对象的大致范围。
2. 我们需要知道成员m在结构内的偏移off.
3. 显而易见,p - off就得到了对象的首地址。

如果读者坚持编写可移植的程序,这并不是一件容易实现的事情。因此这里Linux内核的实现直接使用了两种GCC扩展:typeof和语句表达式。

#define    container_of(ptr, type, member) ({ \
     const typeof(((type *) 0)->member) *__mptr = ptr; \
     (type *) ((char *) __mptr - offset_of(type, member)); })

其中ptr是指向成员member的指针,type则是需要取得的对象的类型。有了前面的解释,应该比较容易理解这个宏最后返回了包含了*ptr这成员的对象的地址。这样,用户就可以将链表结构嵌入到数据节点当中,并随意取得链表结构对应的数据节点。
为了使用方便,Linux内核把链表组织成环形,并要求链表具有一个头结点。我们可以用下面的例程初始化一个空表:

static inline void init_list_head(list_ctl_t *h) {
    h->next = h;
    h->prev = h;
}


头结点本身是无意义的。当然,它也不必具有数据节点。插入例程也高度简单:

static inline void __list_add(list_ctl_t *e, list_ctl_t *p, list_ctl_t *n) {
    n->prev = e;
    e->next = n;
    e->prev = p;
    p->next = e;
}

这里只涉及到了将n链接在前驱p和后缀e之间的指针操作。从而容易实现方便的前插/后插:

static inline void list_add_head(list_ctl_t *e, list_ctl_t *h) {
    __list_add(e, h, h->next);
}

static inline void list_add_tail(list_ctl_t *e, list_ctl_t *h) {
    __list_add(e, h->prev, h);
}


表头h的next方向是后继,而prev方向则是链表尾。环形链表的结构使得两种操作具有统一的内部实现。

 

删除则更为简单,根本不涉及到链表结构的去配:

static inline void __list_del(list_ctl_t *p, list_ctl_t *n) {
    n->prev = p;
    p->next = n;
}

__list_del完成一个快速的断链操作。下面出于实际应用的考虑,封装成删除某个具体节点的形式:

static inline void list_del(list_ctl_t *e) {
    __list_del(e->prev, e->next);
    e->next = e->prev = NULL;
}

对于待删除的节点e, 指定其前驱和后缀调用__list_del并稍作善后即可。出于安全考虑,Linux内核将next和prev两个域置成了两个Poisoned Address, 以便在试图访问已删除节点的前驱/后缀时引发错误;在用户态下,我们置为NULL就可以达到相近的效果(但这里我们没法直接区分错误的访问来自前驱还是后继,这是NULL的一个缺点)。

最后,我们试图为链表添加遍历操作。这里我们使用比较适合用户态程序的实现方式:

#define    list_foreach(pos, head) \
    for (pos = (head)->next; pos != (head); pos = pos->next)

list_foreach使用pos遍历表头为head的链表(注意到这里我们保证了head本身不会被访问到)。这个遍历要求遍历过程中链表的结构不能变化:容易想象如果我们在list_foreach的过程中执行list_del(pos), 在实际被展开的for循环中执行pos = pos->next之后,pos已经变成了NULL(或者其他的POISON)。因此这里再给出一个并不好看但勉强能支持结构更改的遍历方式:

#define    list_foreach_remove(pos, head, tmp) \
    for (pos = (head)->next; \
        ((tmp = pos) != (head)) && (pos = pos->next);)

用户对链表节点的操作应该通过指针tmp完成,而不是单纯用于迭代的pos.

这样,我们很容易使用一个头文件来定义上面的链表结构和操作。

 

稍微回到之前的对象池上来。前面介绍的两类链表结构都很容易实现对象池,插入和删除的时间开销也都不大。然而GLib式的链表实现仍然有一点不彻底的地方:链表的控制结构仍然按照池对象被申请/释放的频率分配/去配,而Linux式的链表能完全避免这一点。当然,如果在定义对象的时候就为它附加一个用于形成链表的指针域同样可以避免控制结构的频繁分配/去配,但这样的节约是以牺牲通用性为代价的,当然也可以通过一些宏替换可以在预编译时自动生成不同类型池化对象的操作,不过如果读者稍微尝试一下的话,会发现生成的池化结构或者因为包含一个指针而落入了GList的境地,或者因为包含了一个对象实体而难以解决柔性数组成员的分配问题。具体如何实现,还请斟酌。

Last but not least, 另外一个值得一提的地方是Linux链表由于删除时并不去配,使得在多线程访问对象池时的互斥操作很可能可以使用一个自旋锁完成,假如对象池本身的访问冲突并不严重的话,这节约了一点锁操作上的开销,但对于对象池来说锁操作不应该是瓶颈,否则选择直接分配/去配是更优的方案。

 

fix:宏函数的方案其实倒也可以解决柔性数组成员的问题……

 

 

 

 

 

你可能感兴趣的:(链表)