SLUB分配器

SLUB分配器

linux 内核的 slab 分配器有三种不同的实现,它们分别是 slab、slub和 slob。每种实现都有不同的设计侧重点,以适应不同的系统需求。本文基于 4.14.7 版本分析下其实现,内核代码里面很多地方都用 slab 来统一称呼,本文也保持一致。

分配器 典型场景 现状
SLAB 早期的高性能服务器 已弃用(Linux 6.5 开始)
SLUB 通用(桌面、服务器、现代嵌入式) 绝对主流/默认
SLOB 极小内存嵌入式(<32MB) 已移除(自 Linux 6.4)

slab 分配器基于伙伴系统来实现,用来解决伙伴系统存在的一些问题。比如内存碎片、性能开销等。

1. 概述

slab cache 是一个管理器,用来管理特定的对象,比如进程描述符 task struct、文件节点 inode 等,

所有的 slab cache 通过全局链表管理起来。

根据 slab 中对象的分配情况,slab 分为了3种:

  • partial slab:有已分配对象,又有空闲对象的页面。
  • full slab:对象都被分配出去的页面,slab cache 不会对其跟踪,当需要释放对象时,内核根据对象地址找到 page 结构,然后将其挂回到 partial slab 中。
  • empty slab:对象都没被分配出去,基本上大部分都还给了伙伴系统,少部分挂载到 partial slab 的末尾,作为缓冲。

2. 结构体定义

2.1 slab cache

slab cache 为每个 cpu 设置了一个本地缓存cpu_slab,只有绑定的 cpu 才能访问他,用于无锁的快速分配和释放对象;为了防止频繁的向伙伴系统申请和释放页面,设置了一个成员min_partial,当node 节点的 partial slab 页面数量 < min_partial 时,即使是一个 empty slab,也会把它留在 partial 链表中,反之则将其归还给伙伴系统。

其他变量含义见注释。

c 复制代码
/* include/linux/slub_def.h */
struct kmem_cache {
	struct kmem_cache_cpu __percpu *cpu_slab; /* cpu本地缓存 */
	unsigned long flags; /* 标志位,用于设置对象的一些特性,比如对齐、填充等 */
	unsigned long min_partial; /* node节点partial slab数量现职 */
	int size;		/* 实际分配大小 */
	int object_size;	/* 用户申请的原始对象大小 */
	/* 空闲指针(freelist pointer)在对象内的偏移量
	 * 在对象没有分配前,内核会复用对象的内存当作指针指向下一个空闲对象
	 */
	int offset;
#ifdef CONFIG_SLUB_CPU_PARTIAL
  /* per-cpu partial链表中所有slab可供分配的空闲对象数量
   * 多的将会放入node->partial供其他cpu使用
   */
	int cpu_partial; /*  */
#endif
  /* 高位为申请页面的阶数、低位为该slab容纳的对象总数 */
	struct kmem_cache_order_objects oo;  /* 正常情况配置 */
	struct kmem_cache_order_objects max; /* 最大尝试配置 */ 
	struct kmem_cache_order_objects min; /* 最小保底配置 */
	gfp_t allocflags;	/* 向伙伴系统申请内存时的分配标识 */
	int refcount;		/* 当计数为0时,可以释放内存到伙伴系统 */
	void (*ctor)(void *); /* 构造函数,用于创建slab池中的对象 */
	int inuse;		/* Offset to metadata */
	int align;		/* Alignment */
	int reserved;		/* Reserved bytes at the end of slabs */
	int red_left_pad;	/* Left redzone padding size */
	const char *name;	/* 名称,会在/proc/slabinfo下面展示 */
	struct list_head list;	/* 链接系统中所有类型的slab caches */

	struct kmem_cache_node *node[MAX_NUMNODES]; /* 指向内存结点对对应的partial cache */
};

2.2 kmem_cache_cpu

cpu 本地缓存使用kmem_cache_cpu 结构体来表示,变量含义见注释。

c 复制代码
/* include/linux/slub_def.h */
struct kmem_cache_cpu {
	void **freelist;	/* 指向cpu本地缓存中的第一个空闲对象 */
	unsigned long tid;	/* 确保申请的cpu和cpu本地缓存是一致的 */
	struct page *page;	/* 使用page表示slab */
#ifdef CONFIG_SLUB_CPU_PARTIAL
  /* cpu本地缓存的备用slab
   * 当cpu本地缓存无空闲对象时,内核会从partial列表中查找空闲对象
   */
	struct page *partial;	
#endif
#ifdef CONFIG_SLUB_STATS
	unsigned stat[NR_SLUB_STAT_ITEMS]; /* 记录一些状态信息 */
#endif
};

2.3 page

当前版本内核代码,slab 使用struct page来表示,变量含义见注释。

c 复制代码
/* include/linux/mm_types.h */
struct page {
	union {
		void *s_mem;			/* 指向slab页中第一个对象的起始虚拟地址 */
	};

	union {
		void *freelist;		/* 指向slab页中的第一个空闲对象  */
	};

	union {
    /* 
     * counters相当于是 _refcount (32bit) + SLUB metadata (32bit)
     * 让cpu用一条原子指令操作这些关键字段
     */
		unsigned long counters;
		struct {
			union {
				struct {
					unsigned inuse:16; /* slab已分配对象的数量 */
					unsigned objects:15; /* slab包含的对象总数 */
					unsigned frozen:1; /* slab冻结标志,为1表示在cpu本地缓存中 */
				};
			};
		};
		atomic_t _refcount; /* slab对应物理页面的引用计数 */
	};

	union {
	  /* 当slab位于node->partial时,用于挂入链表 */
		struct list_head lru;
    /* slab位于cpu partial list时使用 */
		struct {
			struct page *next;	/* 指向partial链表的下一个slab */
			int pages;	/* 当前slab开始,链表后面还剩多少个slab */
			int pobjects;	/* 当前slab开始,链表后面所有slab中空闲对象的总数 */
		};
	};

	union {
		struct kmem_cache *slab_cache;	/* 指向该slab所属的slab cache */
	};
}

2.4 kmem_cache_node

为了支持 NUMA 架构(UMA 架构就一个node 0),减少跨内存节点的访问延迟和锁竞争,每个 slab 缓存会为系中的每个内存节点维护一个 kmem_cache_node结构。

c 复制代码
struct kmem_cache_node {
	spinlock_t list_lock;

#ifdef CONFIG_SLUB
	unsigned long nr_partial; /* 记录partial链表中的slab数量 */
	struct list_head partial; /* partial slab的链表头 */
#endif
};

3. slab cache创建

cache 创建的入口函数是kmem_cache_create,流程如下。

3.1 init_kmem_cache_nodes

init_kmem_cache_nodes函数用于为指定 slab cache 在各个内存结点上面分配并初始化管理结构。static struct kmem_cache *kmem_cache_node是一个全局变量,专门管理 kmem_cache_node 对象的 slab cache 。它和struct kmem_cache_node区别就是全局变量它是一个管理员,而结构体是被管理的对象。

c 复制代码
static int init_kmem_cache_nodes(struct kmem_cache *s)
{
	int node;
  /* 遍历内存结点 */
	for_each_node_state(node, N_NORMAL_MEMORY) {
		struct kmem_cache_node *n;

		if (slab_state == DOWN) {
		  /* 系统处于早期启动阶段,使用早期分配函数 */
			early_kmem_cache_node_alloc(node);
			continue;
		}
		/* 正常分配:从专用缓存中分配 */
		n = kmem_cache_alloc_node(kmem_cache_node,
						GFP_KERNEL, node);

		if (!n) {
			free_kmem_cache_nodes(s);
			return 0;
		}
    /* 初始化刚分配的kmem_cache_node结构 */
		init_kmem_cache_node(n);
		/* 将分配好的结点管理结构挂载到kmem_cache的node数组 */
		s->node[node] = n;
	}
	return 1;
}

对于 NUMA 系统来说会有多个节点,而 UMA 只有一个节点 node 0 。

3.2 alloc_kmem_cache_cpus

alloc_kmem_cache_cpus是为指定 cache 分配并初始化一个 cpu 本地缓存。

c 复制代码
static inline int alloc_kmem_cache_cpus(struct kmem_cache *s)
{
	BUILD_BUG_ON(PERCPU_DYNAMIC_EARLY_SIZE <
			KMALLOC_SHIFT_HIGH * sizeof(struct kmem_cache_cpu));

  /* 分配per-cpu内存 */
	s->cpu_slab = __alloc_percpu(sizeof(struct kmem_cache_cpu),
				     2 * sizeof(void *));

	if (!s->cpu_slab)
		return 0;
  /* 初始化分配的结构 */
	init_kmem_cache_cpus(s);

	return 1;
}

4. slab 内存分配

内存分配的入口函数是kmem_cache_alloc,函数执行路径为kmem_cache_alloc->slab_alloc→slab_alloc_node ,从最后一个函数开始分析。

4.1 slab_alloc_node

slab_alloc_node会决定当前执行快速分配路径还是慢速分配路径。它判断cpu_slab→freelist,如果不为空,代表 cpu 本地缓存还有空闲对象。 除了有空闲对象以外,cpu_slab 要和请求分配的节点保持一致,比如当前 cpu_slab 属于node 0,那么请求的内存节点也是node 0,这样才可以走快速路径,实现无锁快速分配。对于 UMA 架构来说,只有一个 node 0 。

c 复制代码
static __always_inline void *slab_alloc_node(struct kmem_cache *s,
		gfp_t gfpflags, int node, unsigned long addr)
{
	void *object;
	struct kmem_cache_cpu *c;
	struct page *page;
	unsigned long tid;
  /* 分配前的检查和准备工作 */
	s = slab_pre_alloc_hook(s, gfpflags);
	if (!s)
		return NULL;
redo:
	/*
   * 获取cpu_slab指针和任务ID
   * 由于内核抢占,tid和c可能不属于同一个cpu,则重新获取
	 */
	do {
		tid = this_cpu_read(s->cpu_slab->tid);
		c = raw_cpu_ptr(s->cpu_slab);
	} while (IS_ENABLED(CONFIG_PREEMPT) &&
		 unlikely(tid != READ_ONCE(c->tid)));

	barrier();

  /*
   * 快速路径:从cpu的本地缓存获取对象
   * 获取空闲链表的第一个object对象
   * 获取缓存对应的物理页page
  */
	object = c->freelist;
	page = c->page;
	if (unlikely(!object || !node_match(page, node))) {
	  /* 如果没有空闲对象或者
	   * 当前cpu缓存的页面所在的numa节点和请求的node不一致
	   * 则进入慢速路径
	   */
		object = __slab_alloc(s, gfpflags, node, addr, c);
		stat(s, ALLOC_SLOWPATH);
	} else {
	  /* 获取下一个对象 */
		void *next_object = get_freepointer_safe(s, object);
    /* 让freelist指向下一个空闲对象 */
		if (unlikely(!this_cpu_cmpxchg_double(
				s->cpu_slab->freelist, s->cpu_slab->tid,
				object, tid,
				next_object, next_tid(tid)))) {

			note_cmpxchg_failure("slab_alloc", s, tid);
			goto redo;
		}
		/* 预取下一个对象到cpu高速缓存 */
		prefetch_freepointer(s, next_object);
		stat(s, ALLOC_FASTPATH);
	}
  /* 如果设置了ZERO标志,则将内存清0 */
	if (unlikely(gfpflags & __GFP_ZERO) && object)
		memset(object, 0, s->object_size);
  /* 解除毒化 标记内存可访问 */
	slab_post_alloc_hook(s, gfpflags, 1, &object);

	return object;
}

4.2 ___slab_alloc

当 cpu 本地缓存无空闲对象,或者和申请的内存节点不匹配时,就会进入到慢速路径,调用函数__slab_alloc ,函数会将中断关闭然后调用___slab_alloc

函数首先检查cpu_slab→page是否为空,如果freelist 为空,page 不为空,说明虽然缓存链表空了,但是这个页面本身可能还有未被提取到 freelist 的空闲对象,比如在page→freelist中。关于这两个 freelist ,可以看作是同一个池子的两本账单:

  • cpu_slab→freelist:本地账单,记录的是当前 cpu 可无锁直接拿走的对象。
  • cpu_slab→page→freelist:共享账单,记录的是该 slab 被当前 cpu 占用占用期间,其他 cpu 异步释放回来的对象。

如果 page 也为空,代表 cpu 本地资源已耗尽,需要跳转到new_slab处寻找新的 slab 。在new_slab中,依次完成以下几件事:

  1. 检查当前 cpu 是否有备用的 partial slab。如果有,则取出一个作为新的cpu_slab→page,跳回到redo重新尝试分配。
  2. 如果 cpu 本地没有,就去查全局node节点的 partial 链表,需要加锁。
  3. 如果 node partial 链表也是空,则需要向伙伴系统申请了。

redo流程中,如果用户明确要求在特定结点分配内存,而当前 cpu 缓存的 slab 不在该节点中,则会去寻找或分配符合要求的内存,避免远程内存访问带来的性能开销。

redo会重新检查下c→freelist是否是空,虽然__slab_alloc调用___slab_alloc 时关闭了中断,但是在读到c->freelist == NULL到关中断这个时间窗口内可能出现释放操作。

c 复制代码
static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,
			  unsigned long addr, struct kmem_cache_cpu *c)
{
	void *freelist;
	struct page *page;
  /* 检查c->page是否为空 */
	page = c->page;
	if (!page)
		goto new_slab;
redo:
  /* 检查当前slab和node节点是否匹配 */
	if (unlikely(!node_match(page, node))) {
		int searchnode = node; /* searchnode为用户请求的节点 */

    /* 当numa节点没有内存时,将目标节点修正为最近的有内存结点 */
		if (node != NUMA_NO_NODE && !node_present_pages(node))
			searchnode = node_to_mem_node(node);
    /* 二次确认 */
		if (unlikely(!node_match(page, searchnode))) {
			stat(s, ALLOC_NODE_MISMATCH);
			deactivate_slab(s, page, c->freelist, c);
			goto new_slab;
		}
	}

  /* 检查当前页面是否是紧急预留内存,并且当前分配请求是否允许
   * 使用这种内存,如果不匹配,则寻找新的新的slab
   */
	if (unlikely(!pfmemalloc_match(page, gfpflags))) {
		deactivate_slab(s, page, c->freelist, c);
		goto new_slab;
	}

	/* 再次检查,其他进程可能释放了一些对象到slab中 */
	freelist = c->freelist;
	if (freelist)
		goto load_freelist;

  /* 尝试获取page->freelist */
	freelist = get_freelist(s, page);
	if (!freelist) {
		c->page = NULL;
		stat(s, DEACTIVATE_BYPASS);
		goto new_slab;
	}

	stat(s, ALLOC_REFILL);

load_freelist:
	/* 这是成功路径 */
	VM_BUG_ON(!c->page->frozen);
	/* 更新c->freelist指向下一个对象 */
	c->freelist = get_freepointer(s, freelist);
	/* 更新task id */
	c->tid = next_tid(c->tid);
	return freelist;
/* 寻找新的slab */
new_slab:
  /* 慢速路径:获取c->partial */
	if (slub_percpu_partial(c)) {
	  /* 更新当前的活动slab */
		page = c->page = slub_percpu_partial(c);
		/* 更新链表头指向下一个partial slab */
		slub_set_percpu_partial(c, page);
		stat(s, CPU_PARTIAL_ALLOC);
		goto redo;
	}
  /* 走到这里,代表c->partial链表也没有空闲对象了,需要向node节点或者伙伴系统申请 */
	freelist = new_slab_objects(s, gfpflags, node, &c);

	if (unlikely(!freelist)) {
		slab_out_of_memory(s, gfpflags, node);
		return NULL;
	}

	page = c->page;
	if (likely(!kmem_cache_debug(s) && pfmemalloc_match(page, gfpflags)))
		goto load_freelist;

	/* Only entered in the debug case */
	if (kmem_cache_debug(s) &&
			!alloc_debug_processing(s, page, freelist, addr))
		goto new_slab;	/* Slab failed checks. Next slab needed */

	deactivate_slab(s, page, get_freepointer(s, freelist), c);
	return freelist;
}

4.3 new_slab_objects

当已经无法从cpu本地缓存中获取到对象的时候,就需要调用 new_slab_objects了。

c 复制代码
static inline void *new_slab_objects(struct kmem_cache *s, gfp_t flags,
			int node, struct kmem_cache_cpu **pc)
{
	void *freelist;
	struct kmem_cache_cpu *c = *pc;
	struct page *page;
  /* 从指定node节点的kmem_cache_node->partial获取 */
	freelist = get_partial(s, flags, node, c);
	if (freelist)
		return freelist;
  /* 向伙伴系统申请slab */
	page = new_slab(s, flags, node);
	if (page) {
	  /* 获取slab cache的本地cpu缓存 */
		c = raw_cpu_ptr(s->cpu_slab);
		/* 将旧的slab缓存于cpu本地缓存解绑 */
		if (c->page)
			flush_slab(s, c);

		/* 
		 * 将新申请到的slab和cpu本地缓存绑定
		 * page->freelist赋值给kmem_cache_cpu->freelist
		 */
		freelist = page->freelist;
		/* 绑定后需要将page->freelist置空 */
		page->freelist = NULL;

		stat(s, ALLOC_SLAB);
		/* 将新申请到slab赋值给kmem_cache_cpu->page */
		c->page = page;
		*pc = c;
	} else
		freelist = NULL;

	return freelist;
}

5. slab内存释放

slab 释放流程入口函数是kmem_cache_free,函数执行路径为kmem_cache_free->slab_free→do_slab_free

5.1 do_slab_free

释放流程也分为快速路径和慢速路径,do_slab_free首先会检查释放对象的 slab 是否在被当前 cpu 使用。如果是,则可以走快速路径直接释放,释放的对象会插入到 freelist 的头部。否则走慢速路径调用__slab_free

c 复制代码
static __always_inline void do_slab_free(struct kmem_cache *s,
				struct page *page, void *head, void *tail,
				int cnt, unsigned long addr)
{
	void *tail_obj = tail ? : head;
	struct kmem_cache_cpu *c;
	unsigned long tid;
redo:
	/* 获取当前cpu的本地缓存指针c和任务id */
	do {
		tid = this_cpu_read(s->cpu_slab->tid);
		c = raw_cpu_ptr(s->cpu_slab);
	} while (IS_ENABLED(CONFIG_PREEMPT) &&
		 unlikely(tid != READ_ONCE(c->tid)));

	/* 内存屏障 */
	barrier();
  /* 检查要释放对象的page是否被当前cpu正在使用 */
	if (likely(page == c->page)) {
	  /* 快速路径,无锁释放 */
	  
	  /* 将要释放的对象放到c->freelist的头部 */
		set_freepointer(s, tail_obj, c->freelist);
    /* 更新freelist和tid */
		if (unlikely(!this_cpu_cmpxchg_double(
				s->cpu_slab->freelist, s->cpu_slab->tid,
				c->freelist, tid,
				head, next_tid(tid)))) {

			note_cmpxchg_failure("slab_free", s, tid);
			goto redo;
		}
		stat(s, FREE_FASTPATH);
	} else
	  /* 慢速路径 */
		__slab_free(s, page, head, tail_obj, cnt, addr);

}

5.2 __slab_free

当释放的对象不属于当前 cpu 使用的 slab 时,就会进到__slab_free,进行慢速路径释放。函数入参head 为待释放对象链表的第一个对象,tail为待释放对象链表的最后一个对象。

c 复制代码
static void __slab_free(struct kmem_cache *s, struct page *page,
			void *head, void *tail, int cnt,
			unsigned long addr)

{
	void *prior;
	int was_frozen;
	struct page new; 
	unsigned long counters;
	struct kmem_cache_node *n = NULL;
	unsigned long uninitialized_var(flags);

	stat(s, FREE_SLOWPATH);

	if (kmem_cache_debug(s) &&
	    !free_debug_processing(s, page, head, tail, cnt, addr))
		return;

	do {
		if (unlikely(n)) {
			spin_unlock_irqrestore(&n->list_lock, flags);
			n = NULL;
		}

		prior = page->freelist; /* 获取当前的空闲链表头 */
		counters = page->counters; /* 获取当前的计数器状态 */
		set_freepointer(s, tail, prior); /* 将待释放对象链表的末尾指向旧的freelist */
		
		/* 准备新的页面状态 */
		new.counters = counters;
		was_frozen = new.frozen;
		new.inuse -= cnt; /* 减少在用对象计数 */

    /* 判断释放需要node锁或者改变页面状态
     * 条件:(slab变空了 or slab 之前是满的) and(slab当前不是冻结状态)
     */
		if ((!new.inuse || !prior) && !was_frozen) {
		  /* 优化分支:如果支持cpu partial且slab之前是满的(!prior) */
			if (kmem_cache_has_cpu_partial(s) && !prior) {
				/*
				 * 关键优化:
				 * slab之前不在任何列表上(因为它是满的),现在有了空闲对象。
				 * 我们不急着把它放回node的partial列表,而是将其"冻结"(frozen=1)。
				 * 这样可以避免获取node锁,稍后将其放入cpu partial列表。
				 */
				new.frozen = 1;
			} else {
       /* 需要从链表中移除或移动,必须获取Node锁 */
				n = get_node(s, page_to_nid(page));
				/*
				 * 推测性地获取list_lock。
				 * 如果后面的cmpxchg失败,我们会释放锁并重试。
				 */
				spin_lock_irqsave(&n->list_lock, flags);
			}
		}

	} while (!cmpxchg_double_slab(s, page,
		prior, counters,
		head, new.counters,
		"__slab_free"));
  /* 成功通过原子操作更新了状态,且没有触碰node锁 */
	if (likely(!n)) {
		/*
		 * 如果我们在上面的循环中将页面状态设置为了frozen (new.frozen = 1),
     * 且它之前不是frozen (!was_frozen),说明我们刚刚把一个满的Slab
     * 转化为了一个本地缓存的slab。
		 */
		if (new.frozen && !was_frozen) {
		  /* 将页面放入当前cpu的partial列表中,供后续快速分配使用 */
			put_cpu_partial(s, page, 1);
			stat(s, CPU_PARTIAL_FREE);
		}
		/*
		 * 如果was_frozen为真,说明页面本来就是冻结的(属于当前cpu),
     * 我们只是简单地归还了对象并更新了计数,不需要任何列表操作。
		 */
		if (was_frozen)
			stat(s, FREE_FROZEN);
		return;
	}
	/* 如果代码执行到这里,说明n不为空,我们持有Node的list_lock。这意味着需要对Node的Partial或Full列表进行操作。 */
  /* 如果对象全部释放(!new.inuse)且Node的Partial列表数量已达标 */
	if (unlikely(!new.inuse && n->nr_partial >= s->min_partial))
		goto slab_empty;

	/*
	 * slab中还有对象。如果它之前不在Partial列表中(即之前是满的),
   * 现在需要将其加入到Node的Partial列表尾部。
	*/
	if (!kmem_cache_has_cpu_partial(s) && unlikely(!prior)) {
		if (kmem_cache_debug(s))
			remove_full(s, n, page); /* 调试模式下从full列表移除 */
		add_partial(n, page, DEACTIVATE_TO_TAIL); /* 加入partial链表 */
		stat(s, FREE_ADD_PARTIAL);
	}
	spin_unlock_irqrestore(&n->list_lock, flags);
	return;

/* 实际销毁逻辑 */
slab_empty:
	if (prior) {
		/*
		 * 如果prior不为空,说明Slab之前在Partial列表中。
     * 现在它空了,需要将其从Partial列表中移除。
		 */
		remove_partial(n, page);
		stat(s, FREE_REMOVE_PARTIAL);
	} else {
		/* prior 为空说明之前是满的,从full列表(仅调试模式维护)移除 */
		remove_full(s, n, page);
	}

	spin_unlock_irqrestore(&n->list_lock, flags);
	stat(s, FREE_SLAB);
	/* 将物理页归还给页分配器(伙伴系统) */
	discard_slab(s, page);
}
相关推荐
吕司1 小时前
Linux信号产生
linux·运维·服务器
A.A呐1 小时前
【Linux第九章】程序地址空间
linux
vortex53 小时前
Linux 终端优化:Alacritty + Zellij 配置指南
linux·kali·终端模拟器
码农编程录4 小时前
【notes11】并发/IO/内存
linux
cuijiecheng20184 小时前
Linux下MyIpAdd库的使用
linux·运维·服务器
一路往蓝-Anbo4 小时前
第 12 章:Linux 侧 RPMsg 用户态驱动与数据接口
linux·运维·服务器·stm32·单片机·嵌入式硬件·网络协议
乔碧萝成都分萝5 小时前
二十六、IIO子系统 + SPI子系统 + ICM20608
linux·驱动开发·嵌入式
海盗猫鸥5 小时前
Linux权限详解
linux·c语言
cuijiecheng20185 小时前
Linux下modbuspp库的使用
linux·运维·服务器