鍊錶是一種常用的組織有序資料的資料結構。它透過指標將一系列資料節點連接成一條資料鏈,是線性表的一種重要實作方式。相對於數組,鍊錶具有更好的動態性。建立鍊錶時無需預先知道資料總量,可以隨機分配空間,可以有效率地在鍊錶中的任意位置即時插入或刪除資料。鍊錶的主要開銷在於存取的順序性和組織鏈的空間損失。
通常,鍊錶資料結構至少應包含兩個域:資料域和指標域。資料域用於儲存數據,指標域用於建立與下一個節點的聯繫。依照指標域的組織以及各節點之間的聯繫形式,鍊錶又可以分為單鍊錶、雙鍊錶、循環鍊錶等多種類型。以下分別給出這幾類常見鍊錶類型的示意圖:
單鍊錶是最簡單的一類鍊錶,它的特點是僅有一個指標域指向後繼節點(next),因此,對單鍊錶的遍歷只能從頭到尾(通常是NULL空指標)順序進行。
透過設計前輪驅動和後繼兩個指標域,雙鍊錶可以從兩個方向遍歷,這是它區別於單鍊錶的地方。如果打亂前驅、後繼的依賴關係,就可以構成」二元樹」;如果再讓首節點的前驅指向鍊錶尾節點、尾節點的後繼指向首節點(如圖2中虛線部分),就構成了循環鍊錶;如果設計更多的指標域,就可以構成各種複雜的樹狀資料結構。
循環鍊錶的特徵是尾節點的後繼指向首節點。前面已經給了雙循環鍊錶的示意圖,它的特點是從任一節點出發,沿著兩個方向的任何一個,都能找到鍊錶中的任一個資料。如果去掉前驅指針,就是單循環鍊錶。
在Linux核心中使用了大量的鍊錶結構來組織數據,包括裝置清單以及各種功能模組中的資料組織。這些鍊錶大多採用在[include/linux/list.h]實現的一個相當精彩的鍊錶資料結構。本文的後繼部分將透過範例詳細介紹此資料結構的組織和使用。
儘管這裡使用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成员,例如在[include/linux/netfilter.h]中定义了一个nf_sockopt_ops结构来描述Netfilter为某一协议族准备的getsockopt/setsockopt接口,其中就有一个(struct list_head list)成员,各个协议族的nf_sockopt_ops结构都通过这个list成员组织在一个链表中,表头是定义在[net/core/netfilter.c]中的nf_sockopts(struct list_head)。从下图中我们可以看到,这种通用的链表结构避免了为每个数据项类型定义自己的链表的麻烦。Linux的简捷实用、不求完美和标准的风格,在这里体现得相当充分。
实际上Linux只定义了链表节点,并没有专门定义链表头,那么一个链表结构是如何建立起来的呢?让我们来看看LIST_HEAD()这个宏:
#define LIST_HEAD_INIT(name) { &(name), &(name) } #define LIST_HEAD(name) struct list_head name = LIST_HEAD_INIT(name)
当我们用LIST_HEAD(nf_sockopts)声明一个名为nf_sockopts的链表头时,它的next、prev指针都初始化为指向自己,这样,我们就有了一个空链表,因为Linux用头指针的next是否指向自己来判断链表是否为空:
static inline int list_empty(const struct list_head *head) { return head->next == head; }
除了用LIST_HEAD()宏在声明的时候初始化一个链表以外,Linux还提供了一个INIT_LIST_HEAD宏用于运行时初始化链表:
#define INIT_LIST_HEAD(ptr) do { / (ptr)->next = (ptr); (ptr)->prev = (ptr); / } while (0)
我们用INIT_LIST_HEAD(&nf_sockopts)来使用它。
a) 插入
对链表的插入操作有两种:在表头插入和在表尾插入。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_sockopt_ops结构变量new_sockopt需要添加到nf_sockopts链表头,我们应当这样操作:
list_add(&new_sockopt.list, &nf_sockopts);
从这里我们看出,nf_sockopts链表中记录的并不是new_sockopt的地址,而是其中的list元素的地址。如何通过链表访问到new_sockopt呢?下面会有详细介绍。
b) 删除
static inline void list_del(struct list_head *entry);
当我们需要删除nf_sockopts链表中添加的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()将节点置为空链状态。
c) 搬移
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,&nf_sockopts)会把new_sockopt从它所在的链表上删除,并将其再链入nf_sockopts的表头。
d) 合并
除了针对节点的插入、删除操作,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表的第一个节点)之间。新list2链表将以原list1表的第一个节点为首节点,而尾节点不变。如图(虚箭头为next指针):
图4 链表合并list_splice(&list1,&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设置为空链。
遍历是链表最经常的操作之一,为了方便核心应用遍历链表,Linux链表将遍历操作抽象成几个宏。在介绍遍历宏之前,我们先看看如何从链表中访问到我们真正需要的数据项。
a) 由链表节点到数据项变量
我们知道,Linux链表中仅保存了数据项结构中list_head成员变量的地址,那么我们如何通过这个list_head成员访问到作为它的所有者的节点数据呢?Linux为此提供了一个list_entry(ptr,type,member)宏,其中ptr是指向该数据中list_head成员的指针,也就是存储在链表中的地址值,type是数据项的类型,member则是数据项类型定义中list_head成员的变量名,例如,我们要访问nf_sockopts链表中首个nf_sockopt_ops变量,则如此调用:
list_entry(nf_sockopts->next, struct nf_sockopt_ops, list);
这里”list”正是nf_sockopt_ops结构中定义的用于链表操作的节点成员变量名。
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成员相对于结构变量的偏移量。
如果这么说还不好理解的话,不妨看看下面这张图:
图5 offsetof()宏的原理
对于给定一个结构,offsetof(type,member)是一个常量,list_entry()正是利用这个不变的偏移量来求得链表数据项的变量地址。
b) 遍历宏
在[net/core/netfilter.c]的nf_register_sockopt()函数中有这么一段话:
…… struct list_head *i; …… list_for_each(i, &nf_sockopts) { struct nf_sockopt_ops *ops = (struct nf_sockopt_ops *)i; …… } ……
函数首先定义一个(struct list_head *)指针变量i,然后调用list_for_each(i,&nf_sockopts)进行遍历。在[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()可以不考虑,用于预取以提高遍历速度)。
那么在nf_register_sockopt()中实际上就是遍历nf_sockopts链表。为什么能直接将获得的list_head成员变量地址当成struct nf_sockopt_ops数据项变量的地址呢?我们注意到在struct nf_sockopt_ops结构中,list是其中的第一项成员,因此,它的地址也就是结构变量的地址。更规范的获得数据变量地址的用法应该是:
struct nf_sockopt_ops *ops = list_entry(i, struct nf_sockopt_ops, list);
大多数情况下,遍历链表的时候都需要获得链表节点数据项,也就是说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_sockopt_ops *ops; list_for_each_entry(ops,&nf_sockopts,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参数,就可以满足这一要求。
在并发执行的环境下,链表操作通常都应该考虑同步安全性问题,为了方便,Linux将这一操作留给应用自己处理。Linux链表自己考虑的安全性主要有两个方面:
a) list_empty()判断
基本的list_empty()僅以頭指標的next是否指向自己來判斷鍊錶是否為空,Linux鍊錶另行提供了一個list_empty_careful()宏,它同時判斷頭指針的next和prev,僅當兩者都指向自己時才返回真。這主要是為了應付另一個cpu正在處理同一個鍊錶而造成next、prev不一致的情況。但程式碼註解也承認,這項安全保障能力有限:除非其他cpu的鍊錶操作只有list_del_init(),否則仍然不能保證安全,也就是說,還是需要加鎖保護。
b) 遍歷時節點刪除
前面介紹了用於鍊錶遍歷的幾個宏,它們都是透過移動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節點被釋放而造成的斷鍊。
#圖6 list和hlist
精益求精的Linux鍊錶設計者(因為list.h沒有署名,所以很可能就是Linus Torvalds)認為雙頭(next、prev)的雙鍊錶對於HASH表來說”過於浪費”,因而另行設計了一套用於HASH表應用的hlist資料結構–單指針錶頭雙循環鍊錶,從上圖可以看出,hlist的表頭僅有一個指向首節點的指針,而沒有指向尾節點的指針,這樣在可能是海量的HASH表中儲存的表頭就能減少一半的空間消耗。
因為表頭和節點的資料結構不同,插入操作如果發生在表頭和首節點之間,以往的方法就行不通了:表頭的first指標必須修改指向新插入的節點,卻不能使用類似list_add()這樣統一的描述。為此,hlist節點的prev不再是指向前一個節點的指針,而是指向前一個節點(可能是表頭)中的next(對於表頭則是first)指針(struct list_head **pprev),從而在表頭插入的操作可以透過一致的」*(node->pprev)」存取和修改前驅節點的next(或first)指標。
#在Linux鍊錶功能介面中還有一系列以”_rcu”結尾的宏,與以上介紹的許多函數一一對應。 RCU(Read-Copy Update)是2.5/2.6核心中引入的新技術,它透過延遲寫入操作來提高同步效能。
我們知道,系統中資料讀取操作遠多於寫入操作,而rwlock機制在smp環境下隨著處理機增多效能會迅速下降(請參閱參考資料4)。針對這個應用背景,IBM Linux技術中心的Paul E. McKenney提出了」讀拷貝更新」的技術,並將其應用於Linux核心。 RCU技術的核心是寫入操作分為寫-更新兩步,允許讀取操作在任何時候無阻訪問,當系統有寫入操作時,更新動作會延遲到對該資料的所有讀取操作完成為止。 Linux鍊錶中的RCU功能只是Linux RCU的一小部分,對於RCU的實作分析已經超出了本文所及,有興趣的讀者可以自行參閱本文的參考資料;而對RCU鍊錶的使用和基本鍊錶的使用方法基本相同。
#附件中的程式除了能正向、反向輸出檔案以外,並無實際作用,僅用於示範Linux鍊錶的使用。
為了簡便,例子採用的是用戶態程式模板,如果需要執行,可採用以下命令編譯:
gcc -D__KERNEL__ -I/usr/src/linux-2.6.7/include pfile.c -o pfile
因为内核链表限制在内核态使用,但实际上对于数据结构本身而言并非只能在核态运行,因此,在笔者的编译中使用”-D__KERNEL__”开关”欺骗”编译器。
以上是詳解分析 Linux 核心鍊錶的詳細內容。更多資訊請關注PHP中文網其他相關文章!