目录
我们下面聊聊数据结构的事情。在内核中,常见的数据结构如下:
链表(双向循环链表)
队列
图
二叉树
链表
在Linux内核中这玩意最常见了。比起来要求是连续分配的数组,它则可以更加零散的分散在内存和磁盘当中。那么,我们如何指代它的下一个呢?答案是使用一个标识来指代下一个:
struct list_elements /*Simple list's elements*/ { void* data; struct list_elements* next; };
不知道你想象到了吗:
当然,我们可以扩展,构成一个更加完备的节点::也就是标识之既有前驱又有后驱的节点:
struct list_elements /*Simple list's elements*/ { void* data; struct list_elements* next; struct list_elements* prev; };
可以理解为这个节点存储着前一个节点的地址和后一个节点的地址。在特殊些:对于第一个节点,我们看可以引导它的前驱指向最后一个节点,引导最后一个节点的后驱指向第一个节点,这样的话,我们就可以发现,这个链表循环起来:
好了这大概就是一个双循环链表
Linux实现
我们重点关心的是Linux如何实现这个骚操作的。它的实现很抽象:他将链表嵌入了结构体内:
struct list_head { struct list_head* prev; struct list_head* next; } struct MyData{ void* welp; int someDatas; struct list_head lists; }
如何访问的呢:答案是使用Linux自己的宏实现:比如说这里给出了一个叫做container_of
的宏:即通过成员反向定位这个结构体(顺藤摸瓜摸出来地址):
container_of需要传入三个参数,第一个参数是一个指针,第二个参数是结构体类型,第三个是对应第二个参数里面的结构体里面的成员。
container_of(ptr, type, member)
#define container_of(ptr, type, member) ({ const typeof( ((type*)0)->member )* _mptr = (ptr); (type*)( (char*)_mptr - offsetof(type, member) ); })
ptr:表示结构体中member的地址
type:表示结构体类型
member:表示结构体中的成员
返回结构体的首地址
所以,这个链表的入口就是在封装一下container_of
:
#define list_entry(ptr, type, member) container_of(ptr, type, member)
想要初始化一个链表,你需要使用的宏是:INIT_LIST_HEAD
表示初始化
操作!
添加一个节点:list_add(struct list_head* new. struct list_head* head)
MyData a; MyData head; ... list_add(&a->list, &head);
删除一个节点:list_del(struct list_head* entry)
这个API将会删除我们上一次加入的节点。实际上就是通过调整前驱和后驱的指针
移动到给定节点的后头:list_move(struct list_head* list. struct list_head* head)
这个将会将lists移除添加到head的后端。
可以查看是否为空:list_empty(struct list_head* entry)
遍历
我们创建一个链表的核心目的就是管理了这些数据,以便我们进行访问
#define list_for_each(pos, head) \ for (pos = (head)->next; pos != (head); pos = pos->next)
你会发现我们拿到的就是list_head没啥意义,所以有一个更加好的版本:
#define list_for_each_entry(pos, head, member) \ for (pos = list_entry((head)->next, typeof(*pos), member);&pos->member != (head); \
它可以直接被拿来用:
static struct inotify_watch* inode_find_handle(struct inode* inode, struct inotify_handle* ih){ struct inotify_watch* watch; list_for_each_entry(watch, &inode->inootify_watches, i_list){ if(watch->ih == ih) return ih; } return NULL; }
还有一个安全一点的版本:list_for_each_entry_safe这段代码是一个宏定义,用于遍历一个链表中所有的元素,并且在遍历过程中可以安全地删除元素。具体来说,这个宏定义的功能是:遍历链表中所有的元素,从头节点开始,直到尾节点结束。对于每个元素,使用给定的结构体成员变量名找到它所属的结构体对象,并且将该对象的指针赋值给给定的变量名。在遍历过程中,可以安全地删除当前元素,因为它在删除前会先保存下一个元素的指针,保证不会影响遍历的正确性。
队列
队列(Queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。允许插入的端是队尾,允许删除的端是队头。所以说队列是一个先进先出的线性表,相应的也有顺序存储和链式存储两种方式。
(1)顺序存储就是用数组实现,比如有一个n个元素的队列,数组下标0的一端是队头,入队操作就是通过数组下标一个个顺序追加,不需要移动元素,但是如果删除队头元素,后面的元素就要往前移动,对应的时间复杂度就是O(n),性能自然不高。
(2)为了提高出队的性能,就有了循环队列,什么是循环队列呢?就是有两个指针,front指向队头,rear指向对尾元素的下一个位置,元素出队时front往后移动,如果到了对尾则转到头部,同理入队时rear后移,如果到了对尾则转到头部,这样通过下标front出队时,就不需要移动元素了。
这里常见的应用就是生产者和消费者了
kfifo是linux内核的对队列功能的实现。在内核中,它被称为无锁环形队列。所谓无锁,就是当只有一个生产者和只有一个消费者时,操作fifo不需要加锁。这是因为kfifo出队和入队时,不会改动到相同的变量。
kfifo使用了in和out两个变量分别作为入队和出队的索引:
in&out索引
-
入队n个数据时,in变量就+n
-
出队k个数据时,out变量就+k
-
out不允许大于in(out等于in时表示fifo为空)
-
in不允许比out大超过fifo空间(比如上图,in最多比out多8,此时表示fifo已满)
如果in和out大于fifo空间了,比如上图中的8,会减去8后重新开始吗?
不,这两个索引会一直往前加,不轻易回头,为出入队操作省下了几个指令周期。
那入队和出队的数据从哪里开始存储/读取呢,我们第一时间会想到,把 in/out 用“%”对fifo大小取余就行了,是吧?
不,取余这种耗费资源的运算,内核开发者怎会轻易采用呢,kfifo的办法是,把 in/out 与上fifo->mask。这个mask等于fifo的空间大小减一(其要求fifo的空间必须是2的次方大小)。这个“与”操作可比取余操作快得多了。由此,kfifo就实现了“无锁”“环形”队列。
了解了上述原理,我们就能意识到,这个无锁只是针对“单生产者-单消费者”而言的。“多生产者”时,则需要对入队操作进行加锁;同样的,“多消费者”时需要对出队操作进行加锁。
API
使用 kfifo_alloc
申请内存空间;分别使用kfifo_in、kfifo_out
执行入队、出队的操作;不再使用kfifo
时,使用 kfifo_free
释放申请的内存。想要查看fifo大小就是使用kfifo_size
图
我们的图在系统架构中的一个代表性应用就是用来映射和分配UID。这里先给出使用办法:
struct idr id_huh; // 图 // 使用API:idr_init来初始化一个图 idr_init(&id_huh);
之后就是一些API:
idr.h - include/linux/idr.h - Linux source code (v6.8.7) - Bootlin
void *idr_find_slowpath(struct idr *idp, int id); void idr_preload(gfp_t gfp_mask); int idr_alloc(struct idr *idp, void *ptr, int start, int end, gfp_t gfp_mask); int idr_alloc_cyclic(struct idr *idr, void *ptr, int start, int end, gfp_t gfp_mask); // 遍历 int idr_for_each(struct idr *idp, int (*fn)(int id, void *p, void *data), void *data); void *idr_get_next(struct idr *idp, int *nextid); void *idr_replace(struct idr *idp, void *ptr, int id); void idr_remove(struct idr *idp, int id); void idr_destroy(struct idr *idp); bool idr_is_empty(struct idr *idp);
二叉树
在内核中更多使用的是红黑树和二叉搜索树。二叉搜索树(Binary Search Tree, BST)保证了节点按照中序遍历是有序的,其中,为了保证二叉搜索树的平衡我们引入了红黑树,也就是现在Linux使用的二叉树。
一棵合法的红黑树必须遵循以下四条性质:
-
节点为红色或黑色
-
NIL 节点(空叶子节点)为黑色
-
红色节点的子节点为黑色
-
从根节点到 NIL 节点的每条路径上的黑色节点数量相同
关于红黑树的一个漫画:漫画:什么是红黑树? - 知乎 (zhihu.com)
创建一颗红黑树
红黑树中的数据结点是包含rb_node结构体成员的结构体:
struct mytype { struct rb_node node; char *keystring; };
当处理一个指向内嵌rb_node结构体的指针时,包住rb_node
的结构体可用标准的container_of()
宏访问。此外,个体成员可直接用rb_entry(node, type, member)
访问。
每颗红黑树的根是一个rb_root数据结构,它由以下方式初始化为空:
struct rb_root mytree = RB_ROOT;
在一颗红黑树中搜索值
撸一个搜索函数:从树根开始,比较每个值,然后根据需要继续前往左边或 右边的分支。
示例:
struct mytype *my_search(struct rb_root *root, char *string) { struct rb_node *node = root->rb_node; while (node) { struct mytype *data = container_of(node, struct mytype, node); int result; result = strcmp(string, data->keystring); // 有序的,搜索的时候决定向哪个根寻找 if (result < 0) node = node->rb_left; else if (result > 0) node = node->rb_right; else return data; } return NULL; }
在一颗红黑树中插入数据
在树中插入数据的步骤包括:首先搜索插入新结点的位置,然后插入结点并对树再平衡 (”recoloring”)。
插入的搜索和上文的搜索不同,它要找到嫁接新结点的位置。新结点也需要一个指向它的父节点 的链接,以达到再平衡的目的。
示例:
int my_insert(struct rb_root *root, struct mytype *data) { struct rb_node **new = &(root->rb_node), *parent = NULL; /* Figure out where to put new node */ while (*new) { struct mytype *this = container_of(*new, struct mytype, node); int result = strcmp(data->keystring, this->keystring); parent = *new; if (result < 0) new = &((*new)->rb_left); else if (result > 0) new = &((*new)->rb_right); else return FALSE; } /* Add new node and rebalance tree. */ rb_link_node(&data->node, parent, new); rb_insert_color(&data->node, root); return TRUE; }
在一颗红黑树中删除或替换已经存在的数据
若要从树中删除一个已经存在的结点,调用:
void rb_erase(struct rb_node *victim, struct rb_root *tree);
示例:
struct mytype *data = mysearch(&mytree, "walrus"); if (data) { rb_erase(&data->node, &mytree); myfree(data); }
若要用一个新结点替换树中一个已经存在的键值相同的结点,调用:
void rb_replace_node(struct rb_node *old, struct rb_node *new, struct rb_root *tree);
通过这种方式替换结点不会对树做重排序:如果新结点的键值和旧结点不同,红黑树可能被 破坏。
(按排序的顺序)遍历存储在红黑树中的元素
我们提供了四个函数,用于以排序的方式遍历一颗红黑树的内容。这些函数可以在任意红黑树 上工作,并且不需要被修改或包装(除非加锁的目的):
struct rb_node *rb_first(struct rb_root *tree); struct rb_node *rb_last(struct rb_root *tree); struct rb_node *rb_next(struct rb_node *node); struct rb_node *rb_prev(struct rb_node *node);
要开始迭代,需要使用一个指向树根的指针调用rb_first()或rb_last(),它将返回一个指向 树中第一个或最后一个元素所包含的节点结构的指针。要继续的话,可以在当前结点上调用 rb_next()或rb_prev()来获取下一个或上一个结点。当没有剩余的结点时,将返回NULL。
迭代器函数返回一个指向被嵌入的rb_node结构体的指针,由此,包住rb_node的结构体可用 标准的container_of()宏访问。此外,个体成员可直接用rb_entry(node, type, member) 访问。
示例:
struct rb_node *node; for (node = rb_first(&mytree); node; node = rb_next(node)) printk("key=%s\n", rb_entry(node, struct mytype, node)->keystring);
带缓存的红黑树
计算最左边(最小的)结点是二叉搜索树的一个相当常见的任务,例如用于遍历,或用户根据 他们自己的逻辑依赖一个特定的顺序。为此,用户可以使用’struct rb_root_cached’来优化 时间复杂度为O(logN)的rb_first()的调用,以简单地获取指针,避免了潜在的昂贵的树迭代。 维护操作的额外运行时间开销可忽略,不过内存占用较大。
和rb_root结构体类似,带缓存的红黑树由以下方式初始化为空:
struct rb_root_cached mytree = RB_ROOT_CACHED;
带缓存的红黑树只是一个常规的rb_root,加上一个额外的指针来缓存最左边的节点。这使得 rb_root_cached可以存在于rb_root存在的任何地方,并且只需增加几个接口来支持带缓存的 树:
struct rb_node *rb_first_cached(struct rb_root_cached *tree); void rb_insert_color_cached(struct rb_node *, struct rb_root_cached *, bool); void rb_erase_cached(struct rb_node *node, struct rb_root_cached *);
操作和删除也有对应的带缓存的树的调用:
void rb_insert_augmented_cached(struct rb_node *node, struct rb_root_cached *, bool, struct rb_augment_callbacks *); void rb_erase_augmented_cached(struct rb_node *, struct rb_root_cached *, struct rb_augment_callbacks *);
Reference
kfifo(linux kernel 无锁队列) - 知乎 (zhihu.com)