Linux进程调度
1.调度方式
Linux系统的调度方式基本上采用“ 抢占式优先级 ”方式,当进程在用户模式下运行时,不管它是否自愿,核心在一定条件下(如该进程的时间片用完或等待I/O)可以暂时中止其运行,而调度其他进程运行。一旦进程切换到内核模式下运行时,就不受以上限制,而一直运行下去,仅在重新回到用户模式之前才会发生进程调度。
Linux系统中的调度基本上继承了UNIX系统的 以优先级为基础 的调度。也就是说,核心为系统中每个进程计算出一个优先级,该优先级反映了一个进程获得CPU使用权的资格,即高优先级的进程优先得到运行。核心从进程就绪队列中挑选一个优先级最高的进程,为其分配一个CPU时间片,令其投入运行。在运行过程中,当前进程的优先级随时间递减,这样就实现了“负反馈”作用,即经过一段时间之后,原来级别较低的进程就相对“提升”了级别,从而有机会得到运行。当所有进程的优先级都变为0(最低)时,就重新计算一次所有进程的优先级。
2.调度策略
Linux系统针对不同类别的进程提供了3种不同的调度策略,即SCHED_FIFO、SCHED_RR及SCHED_OTHER。其中,SCHED_FIFO适合于 短实时进程 ,它们对时间性要求比较强,而每次运行所需的时间比较短。一旦这种进程被调度且开始运行,就一直运行到自愿让出CPU或被优先级更高的进程抢占其执行权为止。
SCHED_RR对应“时间片轮转法”,适合于每次运行需要 较长时间的实时进程 。一个运行进程分配一个时间片(200 ms),当时间片用完后,CPU被另外进程抢占,而该进程被送回相同优先级队列的末尾,核心动态调整用户态进程的优先级。这样,一个进程从创建到完成任务后终止,需要经历多次反馈循环。当进程再次被调度运行时,它就从上次断点处开始继续执行。
SCHED_OTHER是传统的UNIX调度策略,适合于交互式的 分时进程 。这类进程的优先级取决于两个因素:一个是进程剩余时间配额,如果进程用完了配给的时间,则相应优先级降到0;另一个是进程的优先数nice,这是从UNIX系统沿袭下来的方法,优先数越小,其优先级越高。nice的取值范围是-20 19。用户可以利用nice命令设定进程的nice值。但一般用户只能设定正值,从而主动降低其优先级;只有特权用户才能把nice的值设置为负数。进程的优先级就是以上二者之和。
后台命令对应后台进程(又称后台作业)。后台进程的优先级低于任何交互(前台)进程的优先级。所以,只有当系统中当前不存在可运行的交互进程时,才调度后台进程运行。后台进程往往按批处理方式调度运行。
3.调度时机
核心进行进程调度的时机有以下5种情况:
(1)当前进程调用系统调用nanosleep( )或者pause( ),使自己进入睡眠状态,主动让出一段时间的CPU的使用权。
(2)进程终止,永久地放弃对CPU的使用。
(3)在时钟中断处理程序执行过程中,发现当前进程连续运行的时间过长。
(4)当唤醒一个睡眠进程时,发现被唤醒的进程比当前进程更有资格运行。
(5)一个进程通过执行系统调用来改变调度策略或者降低自身的优先级(如nice命令),从而引起立即调度。
4.调度算法
进程调度的算法应该比较简单,以便减少频繁调度时的系统开销。Linux执行进程调度时,首先查找所有在就绪队列中的进程,从中选出优先级最高且在内存的一个进程。如果队列中有实时进程,那么实时进程将优先运行。如果最需要运行的进程不是当前进程,那么当前进程就被挂起,并且保存它的现场—— 所涉及的一切机器状态,包括程序计数器和CPU寄存器等,然后为选中的进程恢复运行现场。
(二)Linux常用调度命令
· nohup命令
nohup命令的功能是以忽略挂起和退出的方式执行指定的命令。其命令格式是:
nohup command [arguments]
其中,command是所要执行的命令,arguments是指定命令的参数。
nohup命令告诉系统,command所代表的命令在执行过程中不受任何结束运行的信号(hangup和quit)的影响。例如,
$ nohup find / -name exam.txt -print>f1 &
find命令在后台运行。在用户注销后,它会继续运行:从根目录开始,查找名字是exam.txt的文件,结果被定向到文件f1中。
如果用户没有对输出进行重定向,则输出被附加到当前目录的nohup.out文件中。如果用户在当前目录中不具备写权限,则输出被定向到$HOME/nohup.out 中。
· at命令
at命令允许指定命令执行的时间。at命令的常用形式是:
at time command
其中,time是指定命令command在将来执行时的时间和日期。时间的指定方法有多种,用户可以使用绝对时间,也可以用相对时间。该指定命令将以作业形式在后台运行。例如:
$ at 15:00 Oct 20
回车后进入接收方式,接着键入以下命令:
mail -s "Happy Birthday!" liuzheny
按下D键,屏幕显示:
job 862960800.a at Wed Oct 20 15:00:00 CST 1999
$
表明建立了一个作业,其作业ID号是862960800.a,运行作业的时间是1999年10月20日下午3:00,给liuzheny发一条标题为“Happy Birthday!”(生日快乐)的空白邮件。
利用 at -l 可以列出当前at队列中所有的作业。
利用 at -r 可以删除指定的作业。这些作业以前由at或batch命令调度。例如,
at -r 862960797.a
将删除作业ID号是862960797.a的作业。其一般使用形式是:
at -r job_id
注意,结尾是.a的作业ID号,表示这个作业是由at命令提交的;结尾是.b的作业ID号,表示这个作业是由batch命令提交的。
· batch命令
batch命令不带任何参数,它提交的作业的优先级比at命令提交的作业的优先级低。batch无法指定作业运行的时间。实际运行时间要看系统中已经提交的作业数量。如果系统中优先级较高的作业比较多,那么,batch提交的作业则需要等待;如果系统空闲,则运行batch提交的作业。例如,
$ batch
回车后进入接收方式,接着键入命令:
find / -name exam.txt -print
按下D。退出接收方式,屏幕显示:
job 862961540.b at Thu Nov 18 14:30:00 CST 1999
表示find命令被batch作为一个作业提交给系统,作业ID号是862961540.b。如果系统当前空闲,这个作业被立即执行,其结果同样作为邮件发送给用户。
· jobs命令
jobs命令用来显示当前shell下正在运行哪些作业(即后台作业)。例如:
$ jobs
[2] + Running tar tv3 *&
[1] - Running find / -name README -print >logfile &
$
其中,第一列方括号中的数字表示作业序号,它是由当前运行的shell分配的,而不是由 *** 作系统统一分配的。在当前shell环境下,第一个后台作业的作业号为1,第二个作业的作业号为2,等等。
第二列中的“ ”号表示相应作业的优先级比“-”号对应作业的优先级高。
第三列表明作业状态,是否为运行、中断、等待输入或停止等。
最后列出的是创建当前这个作业所对应的命令行。
利用 jobs -l 形式,可以在作业号后显示出相应进程的PID。如果想只显示相应进程的PID,不显示其它信息,则使用 jobs -p 形式。
· fg命令
fg命令把指定的后台作业移到前台。其使用格式是:
fg [job…]
其中,参数job是一个或多个进程的PID,或者是命令名称或者作业号(前面要带有一个“%”号)。例如:
$ jobs
[2] + Running tar tv3 *&
[1] - Running find / -name README -print >logfile&
$ fg %find
find / -name README -print >logfile
注意,显示的命令行末尾没有“&”符号。下面命令能产生同样的效果:
$ fg %1
这样,find命令对应的进程就在前台执行。当后台只有一个作业时,键入不带参数的fg命令,就能使相应进程移到前台。当有两个或更多的后台作业时,键入不带参数的fg,就把最后进入后台的进程首先移到前台。
· bg命令
bg命令可以把前台进程换到后台执行。其使用格式是:
bg [job…]
其中,job是一个或多个进程的PID、命令名称或者作业号,在参数前要带“%”号。例如,在cc(C编译命令)命令执行过程中,按下Z键,使这个作业挂起。然后键入以下命令:
$ bg %cc
该挂起的作业在后台重新开始执行。
Linux的调度策略区分实时进程和普通进程,实时进程的调度策略是SCHED_FIFO和SCHED_RR,普通的,非实时进程的调度策略是SCHED_NORMAL(SCHED_OTHER)。
实时调度策略被实时调度器管理,普通调度策略被完全公平调度器来管理。实时进程的优先级要高于普通进程(nice越小优先级越高)。
SCHED_FIFO实现了一种简单的先入先出的调度算法,它不使用时间片,但支持抢占,只有优先级更高的SCHED_FIFO或者SCHED_RR进程才能抢占它,否则它会一直执行下去,低优先级的进程不能抢占它,直到它受阻塞或自己主动释放处理器。
SCHED_RR是带有时间片的一种实时轮流调度算法,当SCHED_RR进程耗尽它的时间片时,同一优先级的其它实时进程被轮流调度,时间片只用来重新调用同一优先级的进程,低优先级的进程决不能抢占SCHED_RR任务,即使它的时间片耗尽。SCHED_RR是带时间片的SCHED_FIFO。
Linux的实时调度算法提供了一种软实时工作方式,软实时的含义是尽力调度进程,尽力使进程在它的限定时间到来前运行,但内核不保证总能满足这些进程的要求,相反,硬实时系统保证在一定的条件下,可以满足任何调度的要求。
SCHED_NORMAL使用完全公平调度算法(CFS),之前的算法直接将nice值对应时间片的长度,而在CFS中,nice值只作为进程获取处理器运行比的权重,每个进程都有一个权重,nice优先级越高,权重越大,表示应该运行更长的时间。Linux的实现中,每个进程都有一个vruntime字段,vruntime是经过量化的进程运行时间,也就是实际运行时间除以权重,所以每个量化后的vruntime应该相等,这就体现了公平性。
CFS当然也支持抢占,但与实时调度算法不同,实时调度算法是根据优先级进行抢占,CFS是根据vruntime进行抢占,vruntime小就拥有优先被运行的权利。
为了计算时间片,CFS算法需要为完美多任务中的无限小调度周期设定近似值,这个近似值也称作目标延迟,指每个可运行进程在目标延迟内都会调度一次,如果进程数量太多,则时间粒度太小,所以约定时间片的默认最小粒度是1ms。
进程可以分为I/O消耗型和处理器消耗型,这两种进程的调度策略应该不同,I/O消耗型应该更加实时,给对端的感觉是响应很快,同时它一般又不会消耗太多的处理器,因而I/O消耗型需要调度频繁。相对来说,处理器消耗型不需要特别实时,应该尽量降低它的调度频度,延长其运行时间。
参考: linux内核分析——CFS(完全公平调度算法) - 一路向北你好 - 博客园
1.调度器的概述
多任务 *** 作系统分为非抢占式多任务和抢占式多任务。与大多数现代 *** 作系统一样,Linux采用的是抢占式多任务模式。这表示对CPU的占用时间由 *** 作系统决定的,具体为 *** 作系统中的调度器。调度器决定了什么时候停止一个进程以便让其他进程有机会运行,同时挑选出一个其他的进程开始运行。
2.调度策略
在Linux上调度策略决定了调度器是如何选择一个新进程的时间。调度策略与进程的类型有关,内核现有的调度策略如下:
#define SCHED_NORMAL 0#define SCHED_FIFO 1#define SCHED_RR 2#define SCHED_BATCH 3/* SCHED_ISO: reserved but not implemented yet */#define SCHED_IDLE 5
0: 默认的调度策略,针对的是普通进程。
1:针对实时进程的先进先出调度。适合对时间性要求比较高但每次运行时间比较短的进程。
2:针对的是实时进程的时间片轮转调度。适合每次运行时间比较长得进程。
3:针对批处理进程的调度,适合那些非交互性且对cpu使用密集的进程。
SCHED_ISO:是内核的一个预留字段,目前还没有使用
5:适用于优先级较低的后台进程。
注:每个进程的调度策略保存在进程描述符task_struct中的policy字段
3.调度器中的机制
内核引入调度类(struct sched_class)说明了调度器应该具有哪些功能。内核中每种调度策略都有该调度类的一个实例。(比如:基于公平调度类为:fair_sched_class,基于实时进程的调度类实例为:rt_sched_class),该实例也是针对每种调度策略的具体实现。调度类封装了不同调度策略的具体实现,屏蔽了各种调度策略的细节实现。
调度器核心函数schedule()只需要调用调度类中的接口,完成进程的调度,完全不需要考虑调度策略的具体实现。调度类连接了调度函数和具体的调度策略。
武特师兄关于sche_class和sche_entity的解释,一语中的。
调度类就是代表的各种调度策略,调度实体就是调度单位,这个实体通常是一个进程,但是自从引入了cgroup后,这个调度实体可能就不是一个进程了,而是一个组
4.schedule()函数
linux 支持两种类型的进程调度,实时进程和普通进程。实时进程采用SCHED_FIFO 和SCHED_RR调度策略,普通进程采用SCHED_NORMAL策略。
preempt_disable():禁止内核抢占
cpu_rq():获取当前cpu对应的就绪队列。
prev = rq->curr获取当前进程的描述符prev
switch_count = &prev->nivcsw获取当前进程的切换次数。
update_rq_clock() :更新就绪队列上的时钟
clear_tsk_need_resched()清楚当前进程prev的重新调度标志。
deactive_task():将当前进程从就绪队列中删除。
put_prev_task() :将当前进程重新放入就绪队列
pick_next_task():在就绪队列中挑选下一个将被执行的进程。
context_switch():进行prev和next两个进程的切换。具体的切换代码与体系架构有关,在switch_to()中通过一段汇编代码实现。
post_schedule():进行进程切换后的后期处理工作。
5.pick_next_task函数
选择下一个将要被执行的进程无疑是一个很重要的过程,我们来看一下内核中代码的实现
对以下这段代码说明:
1.当rq中的运行队列的个数(nr_running)和cfs中的nr_runing相等的时候,表示现在所有的都是普通进程,这时候就会调用cfs算法中的pick_next_task(其实是pick_next_task_fair函数),当不相等的时候,则调用sched_class_highest(这是一个宏,指向的是实时进程),这下面的这个for()循环中,首先是会在实时进程中选取要调度的程序(p = class->pick_next_task(rq))。如果没有选取到,会执行class=class->next在class这个链表中有三种类型(fair,idle,rt).也就是说会调用到下一个调度类。
static inline struct task_struct *pick_next_task(struct rq *rq){ const struct sched_class *class struct task_struct *p /** Optimization: we know that if all tasks are in
* the fair class we can call that function directly:
*///基于公平调度的普通进程
if (likely(rq->nr_running == rq->cfs.nr_running)) {
p = fair_sched_class.pick_next_task(rq) if (likely(p)) return p
}//基于实时调度的实时进程
class = sched_class_highest for ( ) {
p = class->pick_next_task(rq) //实时进程的类
if (p) return p /*
* Will never be NULL as the idle class always
* returns a non-NULL p:
*/
class = class->next //rt->next = fair fair->next = idle
}
}
在这段代码中体现了Linux所支持的两种类型的进程,实时进程和普通进程。回顾下:实时进程可以采用SCHED_FIFO 和SCHED_RR调度策略,普通进程采用SCHED_NORMAL调度策略。
在这里首先说明一个结构体struct rq,这个结构体是调度器管理可运行状态进程的最主要的数据结构。每个cpu上都有一个可运行的就绪队列。刚才在pick_next_task函数中看到了在选择下一个将要被执行的进程时实际上用的是struct rq上的普通进程的调度或者实时进程的调度,那么具体是如何调度的呢?在实时调度中,为了实现O(1)的调度算法,内核为每个优先级维护一个运行队列和一个DECLARE_BITMAP,内核根据DECLARE_BITMAP的bit数值找出非空的最高级优先队列的编号,从而可以从非空的最高级优先队列中取出进程进行运行。
我们来看下内核的实现
struct rt_prio_array {DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1)/* include 1 bit for delimiter */
struct list_head queue[MAX_RT_PRIO]
}
数组queue[i]里面存放的是优先级为i的进程队列的链表头。在结构体rt_prio_array 中有一个重要的数据构DECLARE_BITMAP,它在内核中的第一如下:
define DECLARE_BITMAP(name,bits) \unsigned long name[BITS_TO_LONGS(bits)]
5.1对于实时进程的O(1)算法
这个数据是用来作为进程队列queue[MAX_PRIO]的索引位图。bitmap中的每一位与queue[i]对应,当queue[i]的进程队列不为空时,Bitmap的相应位就为1,否则为0,这样就只需要通过汇编指令从进程优先级由高到低的方向找到第一个为1的位置,则这个位置就是就绪队列中最高的优先级(函数sched_find_first_bit()就是用来实现该目的的)。那么queue[index]->next就是要找的候选进程。
如果还是不懂,那就来看两个图
注:在每个队列上的任务一般基于先进先出的原则进行调度(并且为每个进程分配时间片)
在内核中的实现为:
static struct sched_rt_entity *pick_next_rt_entity(struct rq *rq, struct rt_rq *rt_rq){ struct rt_prio_array *array = &rt_rq->active struct sched_rt_entity *next = NULL struct list_head *queue int idxidx = sched_find_first_bit(array->bitmap)//找到优先级最高的位
BUG_ON(idx >= MAX_RT_PRIO) queue = array->queue + idx//然后找到对应的queue的起始地址
next = list_entry(queue->next, struct sched_rt_entity, run_list) //按先进先出拿任务
return next
}
那么当同一优先级的任务比较多的时候,内核会根据
位图:
将对应的位置为1,每次取出最大的被置为1的位,表示优先级最高:
5.2 关于普通进程的CFS算法:
我们知道,普通进程在选取下一个需要被调度的进程时,是调用的pick_next_task_fair函数。在这个函数中是以调度实体为单位进行调度的。其最主要的函数是:pick_next_entity,在这个函数中会调用wakeup_preempt_entity函数,这个函数的主要作用是根据进程的虚拟时间以及权重的结算进程的粒度,以判断其是否需要抢占。看一下内核是怎么实现的:
wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se){
s64 gran, vdiff = curr->vruntime - se->vruntime//计算两个虚拟时间差//如果se的虚拟时间比curr还大,说明本该curr执行,无需抢占
if (vdiff <= 0) return -1
gran = wakeup_gran(curr, se) if (vdiff >gran) return 1 return 0
}
gran为需要抢占的时间差,只有两个时间差大于需要抢占的时间差,才需要抢占,这里避免太频繁的抢占
wakeup_gran(struct sched_entity *curr, struct sched_entity *se){
unsigned long gran = sysctl_sched_wakeup_granularity if (cfs_rq_of(curr)->curr &&sched_feat(ADAPTIVE_GRAN))
gran = adaptive_gran(curr, se)
/*
* Since its curr running now, convert the gran from real-time
* to virtual-time in his units.
*/ if (sched_feat(ASYM_GRAN)) {
/*
* By using 'se' instead of 'curr' we penalize light tasks, so
* they get preempted easier. That is, if 'se' <'curr' then
* the resulting gran will be larger, therefore penalizing the
* lighter, if otoh 'se' >'curr' then the resulting gran will
* be smaller, again penalizing the lighter task.
*
* This is especially important for buddies when the leftmost
* task is higher priority than the buddy.
*/ if (unlikely(se->load.weight != NICE_0_LOAD))
gran = calc_delta_fair(gran, se)
} else { if (unlikely(curr->load.weight != NICE_0_LOAD))
gran = calc_delta_fair(gran, curr)
} return gran
}
6.调度中的nice值
首先需要明确的是:nice的值不是进程的优先级,他们不是一个概念,但是进程的Nice值会影响到进程的优先级的变化。
通过命令ps -el可以看到进程的nice值为NI列。PRI表示的是进程的优先级,其实进程的优先级只是一个整数,它是调度器选择进程运行的基础。
普通进程有:静态优先级和动态优先级。
静态优先级:之所有称为静态优先级是因为它不会随着时间而改变,内核不会修改它,只能通过系统调用nice去修改,静态优先级用进程描述符中的static_prio来表示。在内核中/kernel/sched.c中,nice和静态优先级的关系为:
#define NICE_TO_PRIO(nice) (MAX_RT_PRIO + (nice) + 20)#define PRIO_TO_NICE(prio) ((prio) - MAX_RT_PRIO - 20)
动态优先级:调度程序通过增加或者减小进程静态优先级的值来奖励IO小的进程或者惩罚cpu消耗型的进程。调整后的优先级称为动态优先级。在进程描述中用prio来表示,通常所说的优先级指的是动态优先级。
由上面分析可知,我们可以通过系统调用nice函数来改变进程的优先级。
#include <stdlib.h>#include <stdio.h>#include <math.h>#include <unistd.h>#include <sys/time.h>#define JMAX (400*100000)#define GET_ELAPSED_TIME(tv1,tv2) ( \(double)( (tv2.tv_sec - tv1.tv_sec) \
+ .000001 * (tv2.tv_usec - tv1.tv_usec)))//做一个延迟的计算double do_something (void){ int j double x = 0.0 struct timeval tv1, tv2
gettimeofday (&tv1, NULL)//获取时区
for (j = 0j <JMAXj++)
x += 1.0 / (exp ((1 + x * x) / (2 + x * x)))
gettimeofday (&tv2, NULL) return GET_ELAPSED_TIME (tv1, tv2)//求差值}int main (int argc, char *argv[]){ int niceval = 0, nsched /* for kernels less than 2.6.21, this is HZ
for tickless kernels this must be the MHZ rate
e.g, for 2.6 GZ scale = 2600000000 */
long scale = 1000 long ticks_cpu, ticks_sleep pid_t pid
FILE *fp char fname[256] double elapsed_time, timeslice, t_cpu, t_sleep if (argc >1)
niceval = atoi (argv[1])
pid = getpid () if (argc >2)
scale = atoi (argv[2]) /* give a chance for other tasks to queue up */
sleep (3) sprintf (fname, "/proc/%d/schedstat", pid)//读取进程的调度状态
/*
在schedstat中的数字是什么意思呢?:
*/
/* printf ("Fname = %s\n", fname)*/
if (!(fp = fopen (fname, "r"))) { printf ("Failed to open stat file\n") exit (-1)
} //nice系统调用
if (nice (niceval) == -1 &&niceval != -1) { printf ("Failed to set nice to %d\n", niceval) exit (-1)
}
elapsed_time = do_something ()//for 循环执行了多长时间
fscanf (fp, "%ld %ld %d", &ticks_cpu, &ticks_sleep, &nsched)//nsched表示调度的次数
t_cpu = (float)ticks_cpu / scale//震动的次数除以1000,就是时间
t_sleep = (float)ticks_sleep / scale
timeslice = t_cpu / (double)nsched//除以调度的次数,就是每次调度的时间(时间片)
printf ("\nnice=%3d time=%8g secs pid=%5d"
" t_cpu=%8g t_sleep=%8g nsched=%5d"
" avg timeslice = %8g\n",
niceval, elapsed_time, pid, t_cpu, t_sleep, nsched, timeslice)
fclose (fp) exit (0)
}
说明: 首先说明的是/proc/[pid]/schedstat:在这个文件下放着3个变量,他们分别代表什么意思呢?
第一个:该进程拥有的cpu的时间
第二个:在对列上的等待时间,即睡眠时间
第三个:被调度的次数
由结果可以看出当nice的值越小的时候,其睡眠时间越短,则表示其优先级升高了。
7.关于获取和设置优先级的系统调用:sched_getscheduler()和sched_setscheduler
#include <sched.h>#include <stdlib.h>#include <stdio.h>#include <errno.h>#define DEATH(mess) { perror(mess)exit(errno)}void printpolicy (int policy){ /* SCHED_NORMAL = SCHED_OTHER in user-space */if (policy == SCHED_OTHER) printf ("policy = SCHED_OTHER = %d\n", policy) if (policy == SCHED_FIFO) printf ("policy = SCHED_FIFO = %d\n", policy) if (policy == SCHED_RR) printf ("policy = SCHED_RR = %d\n", policy)
}int main (int argc, char **argv){ int policy struct sched_param p /* obtain current scheduling policy for this process */
//获取进程调度的策略
policy = sched_getscheduler (0)
printpolicy (policy) /* reset scheduling policy */
printf ("\nTrying sched_setscheduler...\n")
policy = SCHED_FIFO
printpolicy (policy)
p.sched_priority = 50 //设置优先级为50
if (sched_setscheduler (0, policy, &p))
DEATH ("sched_setscheduler:") printf ("p.sched_priority = %d\n", p.sched_priority) exit (0)
}
输出结果:
[root@wang schedule]# ./get_schedule_policy policy = SCHED_OTHER = 0Trying sched_setscheduler...
policy = SCHED_FIFO = 1
p.sched_priority = 50
可以看出进程的优先级已经被改变。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)