〔池化纲领〕也及链表
转载: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内核链表在链表的通用性上走得更远,尽管第一眼看上去不一定会习惯:
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;
#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这成员的对象的地址。这样,用户就可以将链表结构嵌入到数据节点当中,并随意取得链表结构对应的数据节点。
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.
这样,我们很容易使用一个头文件来定义上面的链表结构和操作。
Last but not least, 另外一个值得一提的地方是Linux链表由于删除时并不去配,使得在多线程访问对象池时的互斥操作很可能可以使用一个自旋锁完成,假如对象池本身的访问冲突并不严重的话,这节约了一点锁操作上的开销,但对于对象池来说锁操作不应该是瓶颈,否则选择直接分配/去配是更优的方案。
fix:宏函数的方案其实倒也可以解决柔性数组成员的问题……