1 链表设计原理
这里使用2.6内核,但实际上2.4内核中的链表结构和2.6并没有什么区别。不同之处在于2.6
扩充了两种链表数据结构:链表的读拷贝更新(rcu)和HASH链表(hlist)。这两种扩展都
是基于最基本的list结构,因此,本文主要介绍基本链表结构,然后再简要介绍一下rcu和
hlist。
链表数据结构的定义很简单(节选自[include/linux/list.h],以下所有代码,除非加以说
明,其余均取自该文件):
struct list_head
{
struct list_head *next, *prev;
};
list_head结构包含两个指向list_head结构的指针prev和next,由此可见,内核的链表具备
双链表功能,实际上,通常它都组织成双循环链表。这里的list_head没有数据域。在Linux
内核链表中,不是在链表结构中包含数据,而是在数据结构中包含链表节点。
在数据结构课本中,链表的经典定义方式通常是这样的(以单链表为例):
struct list_node
{
struct list_node *next;
ElemType data;
};
因为ElemType的缘故,对每一种数据项类型都需要定义各自的链表结构。有经验的C++程序员
应该知道,标准模板库中的采用的是C++ Template,利用模板抽象出和数据项类型无
关的链表操作接口。
在Linux内核链表中,需要用链表组织起来的数据通常会包含一个struct list_head成员,真
实代码中使用的链表几乎是不变地由几个结构类型组成, 每一个描述一个链表中的入口项.
为在你的代码中使用 Linux 列表, 你只需要嵌入一个 list_head 在构成这个链表的结构里
面. 它的声明可能看起来象这样:
struct nf_sockopts
{
/* 数据 */
struct list_head list;
/* 数据 */
};
这种通用的链表结构避免了为每个数据项类型定义自己的链表的麻烦。Linux的简捷实用、
不求完美和标准的风格,在这里体现得相当充分。
列表的头常常是一个独立的 list_head 结构. 下图显示了这个简单的 struct
list_head 是如何用来维护一个数据结构的列表的.
-----------
( 链表头 )
-----------
+----+----+ ----------- ----------- -----------
+-----+ | +--+ ( nf_sockopts ) ( nf_sockopts ) ( nf_sockopts )
| +----+----+ | ----------- ----------- -----------
| list_head | +-----------+ +-----------+ +-----------+
| | | | | | | |
| | | data | | data | | data |
| | | | | | | |
| | +-----+-----+ +-----+-----+ +-----+-----+
| +----+ | +-------+ | +------+ | +----+
| +-----------+ +-----------+ +-----------+ |
| | | | | | | |
| | data | | data | | data | |
| | | | | | | |
| +-----+-----+ +-----+-----+ +-----+-----+ |
| |
| |
+--------------------------------------------------------------------------------
-----------------
( linux链表 )
-----------------
2 由链表节点到数据项变量list_entry
我们知道,Linux链表中仅保存了数据项结构中list_head成员变量的地址,那么我们如何通
过这个list_head成员访问到作为它的所有者的节点数据呢?Linux为此提供了一个
list_entry(ptr,type,member)宏,其中ptr是指向该数据中list_head成员的指针,也就是存
储在链表中的地址值,type是数据项的类型,member则是数据项类型定义中list_head成员的
变量名,例如,我们要访问nf_sockopts链表地址,则如此调用:
list_entry(ptr, struct nf_sockopts, list);
这里"list"正是nf_sockopts结构中定义的用于链表操作的节点成员。
list_entry的使用相当简单,相比之下,它的实现则有一些难懂:
#define list_entry(ptr, type, member) container_of(ptr, type, member)
// container_of宏定义在[include/linux/kernel.h]中:
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})
// offsetof宏定义在[include/linux/stddef.h]中:
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
size_t最终定义为unsigned int(i386)。
这里使用的是一个利用编译器技术的小技巧,即先求得结构成员在与结构中的偏移量,然后
根据成员变量的地址反过来得出属主结构变量的地址。
container_of()和offsetof()并不仅用于链表操作,这里最有趣的地方是((type
*)0)->member,它将0地址强制"转换"为type结构的指针,再访问到type结构中的member成员。
在container_of宏中,它用来给typeof()提供参数(typeof()是gcc的扩展,和sizeof()类
似),以获得member成员的数据类型;在offsetof()中,这个member成员的地址实际上就是
type数据结构中member成员相对于结构变量的偏移量。
3 声明和初始化
Linux用头指针的next是否指向自己来判断链表是否为空:
static inline int list_empty(const struct list_head *head)
{
return head->next == head;
}
Linux提供了一个INIT_LIST_HEAD宏用于运行时初始化链表:
#define INIT_LIST_HEAD(ptr) do { \
(ptr)->next = (ptr); (ptr)->prev = (ptr); \
} while (0)
在阐述下面的实现前,先定义可初始化一个具体链表,后面的例子都是根据
这个具体链表展开说明的。
// 声明
struct nf_sockopts
{
/* 数据 */ struct list_head list;
/* 数据 */};
// 实例
struct nf_sockopts sockopt;
// 初始化
INIT_LIST_HEAD((&sockopt)->list);
4 插入
对链表的插入操作有两种:在表头插入和在表尾插入。Linux为此提供了两个接口:
static inline void list_add(struct list_head *new, struct list_head *head);
static inline void list_add_tail(struct list_head *new, struct list_head *head);
因为Linux链表是循环表,且表头的next、prev分别指向链表中的第一个和最末一个节点,所
以,list_add和list_add_tail的区别并不大,实际上,Linux分别用
__list_add(new, head, head->next);
__list_add(new, head->prev, head);
来实现两个接口,可见,在表头插入是插入在head之后,而在表尾插入是插入在head->prev
之后。
假设有一个新nf_sockopts结构变量new_sockopt需要添加到sockopt链表头,我们应
当这样操作:
list_add(&new_sockopt.list, &sockopt.list);
从这里我们看出,sockopt链表中记录的并不是new_sockopt的地址,而是new_sockopt的list元素的
地址。
5 删除
static inline void list_del(struct list_head *entry);
当我们需要删除sockopt链表中添加的new_sockopt项时,我们这么操作:
list_del(&new_sockopt.list);
被剔除下来的new_sockopt.list,prev、next指针分别被设为LIST_POSITION2和
LIST_POSITION1两个特殊值,这样设置是为了保证不在链表中的节点项不可访问--对
LIST_POSITION1和LIST_POSITION2的访问都将引起页故障。
与之相对应,list_del_init()函数将节点从链表中解下来之后,调用LIST_INIT_HEAD()将节点置为空链状态。
6 搬移
Linux提供了将原本属于一个链表的节点移动到另一个链表的操作,并根据插入到新链表的位
置分为两类:
static inline void list_move(struct list_head *list, struct list_head *head);
static inline void list_move_tail(struct list_head *list, struct list_head *head);
例如list_move(&new_sockopt.list,&sockopt.list)会把new_sockopt从它所在的链表上删
除,并将其再链入sockopt的表头。
7 合并
除了针对节点的插入、删除操作,Linux链表还提供了整个链表的插入功能:
static inline void list_splice(struct list_head *list, struct list_head *head);
假设当前有两个链表,表头分别是list1和list2(都是struct list_head变量),当调用
list_splice(&list1,&list2)时,只要list1非空,list1链表的内容将被挂接在list2链表
上,位于list2和list2.next(原list2表的第一个节点)之间。
当list1被挂接到list2之后,作为原表头指针的list1的next、prev仍然指向原来的节点,为
了避免引起混乱,Linux提供了一个list_splice_init()函数:
static inline void list_splice_init(struct list_head *list, struct list_head *head);
该函数在将list合并到head链表的基础上,调用INIT_LIST_HEAD(list)将list设置为空链。
8 遍历
遍历是链表最经常的操作之一,为了方便核心应用遍历链表,Linux链表将遍历操作抽象成几
个宏。在介绍遍历宏之前,我们先看看如何从链表中访问到我们真正需要的数据项。
struct list_head *i;
list_for_each(i, &(nf_sockopts.list))
{
struct nf_sockopts *ops = list_entry(i, struct nf_sockopts, list);
}
函数首先定义一个(struct list_head *)指针变量i,然后调用
list_for_each(i,&(sockopt.list))进行遍历。在
[include/linux/list.h]中,list_for_each()宏是这么定义的:
#define list_for_each(pos, head) \
for (pos = (head)->next, prefetch(pos->next); pos != (head); \
pos = pos->next, prefetch(pos->next))
它实际上是一个for循环,利用传入的pos作为循环变量,从表头head开始,逐项向后(next
方向)移动pos,直至又回到head(prefetch()可以不考虑,用于预取以提高遍历速度)。
大多数情况下,遍历链表的时候都需要获得链表节点数据项,也就是说list_for_each()和
list_entry()总是同时使用。对此Linux给出了一个list_for_each_entry()宏:
#define list_for_each_entry(pos, head, member) ……
与list_for_each()不同,这里的pos是数据项结构指针类型,而不是(struct list_head *)。
nf_register_sockopt()函数可以利用这个宏而设计得更简单:
struct nf_sockopts *ops;
list_for_each_entry(ops, &sockopt, list)
{
}
某些应用需要反向遍历链表,Linux提供了list_for_each_prev()和
list_for_each_entry_reverse()来完成这一操作,使用方法和上面介绍的list_for_each()、
list_for_each_entry()完全相同。
如果遍历不是从链表头开始,而是从已知的某个节点pos开始,则可以使用
list_for_each_entry_continue(pos,head,member)。有时还会出现这种需求,即经过一系列
计算后,如果pos有值,则从pos开始遍历,如果没有,则从链表头开始,为此,Linux专门提
供了一个list_prepare_entry(pos,head,member)宏,将它的返回值作为
list_for_each_entry_continue()的pos参数,就可以满足这一要求。
9 安全性考虑
在并发执行的环境下,链表操作通常都应该考虑同步安全性问题,为了方便,Linux将这一操
作留给应用自己处理。Linux链表自己考虑的安全性主要有两个方面:
9.1 list_empty()判断
基本的list_empty()仅以头指针的next是否指向自己来判断链表是否为空,Linux链表另行提
供了一个list_empty_careful()宏,它同时判断头指针的next和prev,仅当两者都指向自己
时才返回真。这主要是为了应付另一个cpu正在处理同一个链表而造成next、prev不一致的情
况。但代码注释也承认,这一安全保障能力有限:除非其他cpu的链表操作只有
list_del_init(),否则仍然不能保证安全,也就是说,还是需要加锁保护。
9.2 遍历时节点删除
前面介绍了用于链表遍历的几个宏,它们都是通过移动pos指针来达到遍历的目的。但如果遍
历的操作中包含删除pos指针所指向的节点,pos指针的移动就会被中断,因为
list_del(pos)将把pos的next、prev置成LIST_POSITION2和LIST_POSITION1的特殊值。
当然,调用者完全可以自己缓存next指针使遍历操作能够连贯起来,但为了编程的一致
性,Linux链表仍然提供了两个对应于基本遍历操作的"_safe"接
口:list_for_each_safe(pos, n, head)、list_for_each_entry_safe(pos, n, head,
member),它们要求调用者另外提供一个与pos同类型的指针n,在for循环中暂存pos下一个节
点的地址,避免因pos节点被释放而造成的断链。
10 hlist扩展
精益求精的Linux链表设计者(因为list.h没有署名,所以很可能就是Linus Torvalds)认为
双头(next、prev)的双链表对于HASH表来说"过于浪费",因而另行设计了一套用于HASH表
应用的hlist数据结构--单指针表头双循环链表,从上图可以看出,hlist的表头仅有一个指
向首节点的指针,而没有指向尾节点的指针,这样在可能是海量的HASH表中存储的表头就能
减少一半的空间消耗。
struct hlist_head
{
struct hlist_node *first;
};
struct hlist_node
{
struct hlist_node *next, **pprev;
};
这个数据结构与一般的hash-list数据结构定义有以下的区别:
1) 首先,hash的头节点仅存放一个指针,也就是first指针,指向的是list的头结点,没有tail
指针也就是指向list尾节点的指针,这样的考虑是为了节省空间--尤其在hash bucket很大的
情况下可以节省一半的指针空间.
2) list的节点有两个指针,但是需要注意的是pprev是指针的指针,它指向的是前一个节点的
next指针(见下图).
现在疑问来了:为什么hlist要使用pprev这样的指向前一个节点的next指针的设计呢?
+--------+ -------
| | ( hlist )
| | -------
+--------+
| | +---+----+ +---+----+ +---+----+
| +---+--->| | | +--+---->| | | +-+--->| | | |
| | | +-+-+-+--+ +-+-+--+-+ +-+-+----+
+--------+ | | | | |
| | | | | | | |
| +---+------+ +----------+ +--------+
| |
+--------+
-----------
( 散列表 )
-----------
主要是基于以下几个考虑:
1) hash-list中的list一般元素不多(如果太多了一般是设计出现了问题),即使遍历也不需要
太大的代价,同时需要得到尾结点的需求也不多.
2) 如果对于一般节点而言,prev指向的是前一个指针,而对于first也就是hash的第一个元素
而言prev指向的是list的尾结点,那么在删除一个元素的时候还需要判断该节点是不是first
节点进行处理.而在hlist提供的删除节点的API中,并没有带上hlist_head这个参数,因此做这
个判断存在难度.
3) 以上两点说明了为什么不使用prev,现在来说明为什么需要的是pprev,也就是一个指向指
针的指针来保存前一个节点的next指针--因为这样做即使在删除的节点是first节点时也可以
通过*pprev = next;直接修改指针的指向.来看删除一个节点和修改list头结点的两个API:
static inline void hlist_add_head(struct hlist_node *n, struct hlist_head *h)
{
struct hlist_node *first = h->first;
n->next = first;
if (first)
first->pprev = &n->next;
h->first = n;
n->pprev = &h->first;
// 此时n是hash的first指针,因此它的pprev指向的是hash的first指针的地址
}
static inline void __hlist_del(struct hlist_node *n)
{
struct hlist_node *next = n->next;
struct hlist_node **pprev = n->pprev;
*pprev = next;
// pprev指向的是前一个节点的next指针,
// 而当该节点是first节点时指向自己,
// 因此两种情况下不论该节点是一般的节点
// 还是头结点都可以通过这个操作删除掉所需删除的节点
if (next)
next->pprev = pprev;
}