Linux进程概念
1、冯诺依曼体系结构
1.1、先谈硬件

先来看冯诺伊曼体系结构,我们的电脑:内存、显示器、键盘、鼠标、硬盘、CPU等硬件组装起来就是电脑吗?并不是这样的,它们实际上是需要按一定的结构组织起来的,并不是毫无章法的放在一起就行了。输入设备比如我们的键盘,输入数据后经由存储器,然后运算器可以到存储器中取数据进行运算,运算结束后再将数据写回存储器,存储器再把数据输出到输出设备,输出设备如显示器,这样我们就能看到数据了。而运算器和控制器我们称为中央处理器——CPU。
a.存储器指的是什么?——内存
b.输入设备:鼠标、键盘、摄像头、话筒、磁盘、网卡…
c.输出设备:显示器、播放器硬件、磁盘、网卡…
输入设备和输出设备统称为外设
有的设备是纯输入设备、纯输出设备,有的既是输入设备也是输出设备。例如磁盘,我们可能从磁盘中读取数据,然后也可能将数据写回磁盘中,所以磁盘即使输入设备也是输出设备。
d.运算器:对我们的数据进行计算任务(算术运算、逻辑运算)
e.控制器:对我们的计算硬件流程进行一定的控制
运算器和控制器统称为中央处理器
这些都是独立的个体,设备和设备之间数据要流动,那么必定要把各个硬件单元用“线”链接起来。这里的“线”就是总线。那么CPU和内存之间的就是系统总线,内存和输入输出设备之间的就是IO总线。
另外存储也是分级的,CPU中有寄存器,寄存器的速度非常快但是存储容量很低,而内存存储容量还行,比如有4G、8G、16G等,速度也还可以,而硬盘的容量就很大了,但是它的速度就比较慢。所以存储也是分级的,如下图:

越往上,它的速度就越快,容量越低,但是价格也越高,越往下,它的速度就越慢,容量越大,价格越低。
那么为什么需要有内存呢?CPU不能直接和输入输出设备交互吗?
我们要知道输入和输出设备是很慢的,内存比输入输出设别要快一些,CPU就更快了,基本上它们的差距就在毫秒、微秒、纳秒的级别。如果直接让CPU和输入输出设备交互,由于输入输出设备相比CPU的速度慢的多,那么必定会导致CPU的效率不够高,因为输入输出设备整体拖慢了CPU。因此就需要有内存了。
木桶原理:这个木桶能装多少水取决于它最短的那块板。

那么对于电脑来说也是一样的,输入输出设备太慢,如果直接跟CPU交互就会导致整体都很慢,效率不高。
那么有了内存,输入设备将数据读入内存中,CPU从内存中获取数据就稍微快一些,CPU在处理数据的时候,其他输入设备就可以先把数据加载到内存中,CPU处理完之后再从内存读取再继续处理。这样就能提高CPU处理的效率。
一个程序要运行,必须先加载到内存中,为什么?——冯诺依曼体系结构规定的
下面举个你和你同学使用QQ聊天的例子:不考虑网络的情况

你向同学发送消息:在吗?,首先输入设备是键盘,然后将数据读取到内存中,CPU对数据进行处理,然后再到输出设备,这里的输出设备包括网卡和显示器,因为你显示器也能看到发送的消息,然后网卡将数据发送到网络中,对方网卡作为输入设备接受,然后加载到内存,CPU对数据进行处理,然后流向输出设备显示器,对方显示器就能看到你发送的消息。
1.2、再谈软件
操作系统是什么?——操作系统是一款进行管理的软件。

底层这么多硬件,如硬盘、网卡、键盘等,这些都需要被管理起来,那么这些硬件是如何被管理的呢?是通过底层硬件的上面一层——驱动程序,同时驱动程序也是需要被管理起来的,所以操作系统对下:管理好软硬件资源。那么我使用一个操作系统看重的是什么?这个操作系统好不好用,稳不稳定。因此操作系统对上:给用户提供一个良好(稳定、高效、安全)的运行环境。
操作系统里面会有各种数据,但是操作系统并不信任用户!一个是操作系统难度很大,用户无法直接去操作,另外一个是为了保证操作系统的安全,不能直接让用户去访问操作系统。所以操作系统为了保证自己数据安全,也为了保证给用户提供服务,操作系统以接口的方式给用户提供调用入口,来获取操作系统内部的数据。而Linux内核是用C语言写的,那么也就注定了操作系统提供的接口就是用C实现的,自己内部函数的调用——系统调用。由此可见,shell外壳可以解析我们的命令,包括有些人写的库,那么它的实现肯定要包含系统调用。就拿C语言来说,我们printf可以将数据输出到显示器上,底层肯定要封装系统调用。
那么也就是说,我们用户所有访问操作系统的行为,都只能通过系统调用完成。
操作系统为什么?——1、操作系统帮助用户,管理好下面的软硬件资源。2、给用户提供一个良好(安全、稳定、高效)的运行环境。
下面再来谈谈操作系统怎么做?——如何管理??
从大学来说,校长就是典型的管理者,学生就是典型的被管理者,我们做一件事情分为:决策、执行。校长就是决策者,那么辅导员呢?辅导员是执行者,辅导员其实并不属于管理者。校长管理学生需要见面吗?并不需要,校长只要拿到你的数据就可以对你进行管理,比如某某同学一学期10门课挂了9门,那么校长就可以根据这个数据来管理你,校长把你开除,辅导员就会来通知你收拾东西回家。
所以:
1、管理者和被管理者是不需要见面的。
2、管理者在不见被管理者的情况下,如何做好管理呢?只要能够得到管理信息,就可以在未来进行管理决策——管理的本质:是通过对数据的管理达到对人的管理。
3、管理者和被管理者不见面,怎么拿到数据?——通过执行者。
辅导员来获取你的数据,然后将这些一张张纸质数据交给校长,校长就可以通过这些数据来对你进行管理。但是当人数很多的时候,管理起来还是很麻烦,会有很大一沓纸,所以校长这时候建一个excel表格,输入一些数据:身高、体重、学院、专业、姓名、电话等等信息,然后通知辅导员去收集学生信息。这个通过excel表格输入这一些描述学生信息的过程,这就是先描述的过程。
但是这样还是麻烦,因为收集之后还需要自己来查看。校长刚好是个程序员,他就想通过创建一个struct student的结构体,这个结构体里面有学生姓名、电话等等信息,然后通过学生的信息创建出一个个的student对象,然后再通过struct student* next指针将这些对象都链接起来,这个过程就是组织的过程。那么这时候需要获取学生信息,直接交给计算机就好了。比如要获取身高最高的学生,本质就是遍历链表找出身高最高的学生。当某个学生挂科太多,我们要让他退学,只需要将这个链表中该学生所在节点删除即可。所以对学生的管理就转换成了对链表的增删查改。
而校长就相当于操作系统,辅导员就相当于驱动程序,学生就相当于软硬件资源。操作系统要管理这么多软硬件资源,肯定也是要先用struct xxx将他们描述起来,然后通过某种数据结构再将它们组织起来,这就是先描述再组织的过程。在操作系统中管理任何对象都可以转换为对某种数据结构的增删查改。
操作系统如何管理?——先描述,再组织。
库函数和系统调用的关系?
我们平时写的C语言printf可以将字符串输出到显示器上,而我们可以直接访问底层硬件吗?答案肯定是不行的,上面我们也说了,操作系统对下管理好软硬件资源,所以我们是需要通过操作系统来访问的,而操作系统又不信任用户,但是为了给用户提供服务,对上提供了系统调用接口。所以printf要访问底层的显示器,就必须通过操作系统提供的系统调用接口来实现,所以C语言库函数有的肯定要封装系统调用。
那么库函数和系统调用就是上下层的调用和被调用的关系。
1.3、再谈进程
什么叫做进程?
一个已经加载到内存中的程序,叫做进程。正在运行的程序,叫做进程。
根据冯诺依曼体系结构,一个进程运行起来,它的数据一定要被加载到内存中。它的数据包括:程序的代码和程序的数据。那么这就是进程了吗?一个操作系统中不仅仅有一个进程在运行,可能有多个进程在运行,那么对于这么多的进程是不是也要管理起来呢?是的,必须管理起来。那么如何管理呢?——先描述,再组织。
需要给进程创建一个struct xxx对象,然后里面有进程的id,进程的状态,优先级等等信息。那么进程的代码和数据呢,可以通过一个指针指向它们,这样就可以获取到代码和数据了。这就是先描述的过程。然后通过指针将一个一个进程链接起来形成单链表,这就是再组织的过程。那么在操作系统中,对进程的管理变成了对单链表的增删查改。
任何一个进程在加载到内存的时候,形成真正进程时,操作系统要先创建描述进程的结构体对象——PCB:process control block——进程控制块
所以什么叫做进程?
进程 = 内核PCB数据结构对象 + 代码和数据
那么Linux具体是怎么做的?
Linux中的PCB是task_struct结构体,里面包含了进程的所有属性。
Linux中如何组织进程?——Linux内核中,最基本组织task_struct的方式采用双向链表组织的。
task_struct中有什么?
标示符:描述本进程的唯一标示符,用来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执行的下一条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据:进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
I/ O状态信息:包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息…
2、Linux下查看进程
在Linux中如何查看进程呢?
2.1、ps命令:
ps axj:可以查看所有进程信息

ps axj | head -1 && ps axj | grep xxxx:配合行过滤工具查看某个进程信息

ps axj | head -1就是将上面红色方框这一圈信息打印出来。后面配合行过滤工具找到对应进程myprocess。
另外,我们本身使用grep行过滤工具也会创建进程执行,因为我们给的字符串中含有myprocess,所以也显示了。如果不想让它显示,可以再通过管道再次过滤:ps axj | head -1 && ps axj | grep myprocess | grep -v grep。
2.2、top命令查看进程:在命令行直接输入top

可以按q退出。
2.3、查看/proc目录下的文件:

首先通过ps命令可以看到我们当前进程的PID为3941911
然后我们查看/proc下的文件:

可以看到存在3941911这个名字的目录,这就是操作系统通过进程PID在/proc下创建对应的目录。
查看该目录下的所有文件:

主要看两个:exe是个软链接文件,指向了可执行程序存。 cwd——current work directory:表示进程当前工作目录。
为什么我们C语言使用fopen打开文件test.txt默认会在当前路径下创建test.txt呢?这个当前路径又是什么?这个当前路径就是进程的当前工作目录。
3、进程PID
操作系统要把进程管理起来,创建对应的PCB结构体。那么就需要标识每个进程,那么这是通过进程PID来实现——process identify。进程pid本质上是个有符号的整数。
上面我们可以看到运行起来的进程PID为4002519,PPID表示的就是该进程父进程的PID——parent PID。
我们可以查看一下该进程的父进程是谁:

可以看到,该进程的父进程是bash,也就是命令行解释器。
那么我们可以通过ps命令来查看进程的PID,那如果我想在进程./proc中获取当前进程pid呢?
我们要获取进程pid,就是要获取操作系统的数据,那么必定就需要通过系统调用接口来获取。
下面介绍两个系统调用接口:pid_t getpid() —— 获取当前进程的pid
man 2 getpid:可以使用man手册查看该接口的介绍

getpid用于获取当前进程的pid,getppid用于获取当前进程的父进程的pid。
我们可以使用以下命令监控进程:
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep; sleep 1; done


编译后运行,我们可以看到,getpid确实获取到了当前进程的pid和它父进程的pid。

并且通过该进程的PPID去查看,可以看到它的父进程就是bash。
4、fork创建子进程


fork()函数用于创建子进程,包含于头文件<unistd.h>,创建成功给父进程返回子进程的pid,给子进程返回0。如果创建失败,返回-1。
先来看使用:


我们可以看到,fork之后,打印语句fork after被执行了两次,那么这肯定和创建子进程有关系。
下面再看:


可以看到,子进程获取到的父进程pid为4017463,而父进程的pid就是4017463。而父进程的PPID是3940996,毫无疑问这肯定就是bash。
那么就有以下几个问题了:
1、为什么fork有两个返回值?为什么fork要给子进程返回0,给父进程返回子进程pid?
首先我们要思考,创建出子进程是为了什么?一般都不是为了执行和父进程相同的代码,否则我创建子进程还有什么意义呢?所以创建出子进程是为了执行不同的流程。那么返回不同的返回值,就是为了区分不同的执行流,让它们执行不同的代码块。而之所以给父进程返回子进程的pid,是为了让父进程控制子进程。
并且可以看到第一个代码,创建出子进程后,父子进程都执行了打印语句。所以创建子进程后,父子进程代码是共享的。
2、fork函数在干什么?

fork函数创建子进程,那么肯定要在内存中创建一个task_struct结构体对象,这个就是以父进程为模板创建的,然后修改对应的pid、ppid等属性。还有个指针指向它们运行的代码,而父进程和子进程代码是共享的,所以它们都指向同一份代码。
3、一个函数是如何做到返回两次的?
在调用了fork函数,会跳转到fork函数内部执行,那么在fork函数返回之前,fork函数的功能是不是必定已经完成了。也就是说,fork函数返回之前子进程一定是被创建出来了,而父子进程代码共享,那么就会返回两次。
4、一个变量怎么会有不同的内容?如何理解?
先来了解一个概念——进程的独立性,进程和进程之间的运行是具有独立性的,比如我现在在使用浏览器,那么QQ崩了会影响浏览器吗?并不会。那么对于父进程和子进程来说也是一样,子进程和父进程返回时都要修改id,那么肯定不能让子进程影响父进程,所以必定要拷贝一份数据。那么是将父进程的所有数据都拷贝一份吗?并不是,因为子进程并不一定会访问父进程的数据,就算访问了父进程的数据也不一定会修改,所以这里的策略是当子进程会修改父进程的数据时会发生写时拷贝,你写多少就拷多少。因为全部拷贝也必定会影响操作系统的资源。
fork之后,父子进程代码共享,数据写时拷贝。
父子进程创建好,fork之后,谁先运行?
不确定,由调度器决定。
那么现在创建进程有两种方式:
1、./运行程序——指令级别创建进程。
2、fork()——代码层面创建子进程。
之前我们发现运行的程序父进程都是bash,这是因为bash内部创建了子进程去执行我们程序。另外对于我们输入的一些命令,bash也会创建子进程去执行,然后bash再去显示命令行获取我们输入字符串进行解析。
5、进程状态
5.1、先来谈谈操作系统学科的进程状态:运行、阻塞、挂起。
运行状态:

操作系统需要管理进程,那么就需要先描述,先创建struct task_struct结构体,然后再组织,将它们用双链表的形式组织起来,然后task_struct中有一个指针指向它们对应的数据和代码。一个CPU维护一个运行队列,所有进程要到CPU上执行都需要排队,运行队列头尾指针分别指向第一个task_struct和最后一个task_struct。CPU执行进程是需要调度的,所以存在调度器,调度器通过调度算法可以让每个进程尽可能的获得公平的调度。
那么,处于运行队列的进程就说明进程已经准备好了,可以随时被调度,我们称此时进程的状态为运行态R。
一个进程放到CPU上执行,是不是要一直到执行完毕才会把自己放下来?并不是,否则如果你写一个程序while(1)死循环,那么该进程一直放在CPU上执行,就会导致其他进程无法执行。每个进程都有一个叫做时间片的概念。 每个进程执行完时间片的时间比如10ms,就会换下一个进程。那么有了时间片的概念,必然会存在大量的把进程放到CPU上,再从CPU上拿下来,这就叫做进程切换。那么在一段时间内,所有进程的代码都会被执行,这就叫做并发执行。
阻塞状态:

操作系统对于底层这些硬件要不要管理?要,如何管理?——先描述再组织
通过struct dev结构体描述起来,然后它们通过某种数据结构再组织起来。现在这个进程假设我需要等待键盘输入某些数据,也就是scanf,那么如果我一直不输入,这个进程难道会在CPU上跑或者在运行队列吗。答案是肯定不会的,所以设备也会维护一个等待队列,这时候这个进程就会在键盘设备的等待队列中,等待键盘资源就绪。
我们把处于等待队列的进程称为阻塞状态。
挂起状态:

现在当操作系统内部资源严重不足了,而此时处于等待队列的进程它还需要等待键盘资源就绪,那么这时候它的代码和数据就会被换出到磁盘中,以节省操作系统内存资源,然后等键盘资源就绪又会将代码和数据换入内存中。
我们把处于等待队列的进程,它的代码和数据被换出到磁盘中,称此时进程处于挂起状态。
5.2、再来看Linux中的进程状态:
Linux中进程有七种状态:
R运行状态(running):进程可能在运行,也可能处于运行队列中。
S睡眠状态(sleeping):进程在等待某种资源就绪,处于等待队列中。又称为可中断睡眠状态。
D磁盘休眠状态(disk sleep):处于这个状态的进程要等待IO结束。又称不可中断睡眠状态。
T暂停状态(stopped):可以给进程发送SIGSTOP信号让进程处于暂停状态,再给进程发送SIGCONT恢复运行态。
t暂停状态:处于调试状态下的进程就是t状态。
X死亡状态(dead):进程死亡后就是X状态,不过速度很快一瞬之间,无法捕捉到。
Z僵尸状态(zombie):当父进程没有对子进程资源回收,子进程就处于僵尸Z状态。
我们可以使用ps命令查看进程的状态

PPID表示父进程PID,PID表示当前进程PID,STAT表示的就是进程的状态,UID表示启动该进程的用户UID,实际上Linux会给每个用户分配一个唯一标识的整数UID。
5.2.1、先来看处于R状态的进程:


我们直接写了一个死循环,运行程序可以看到该进程处于R状态,也就是运行态。后面的+表示该进程是前台进程。
那么如果我想让进程在后台运行呢?需要给./myproc后面加上&:

可以看到,后台进程运行起来后,会给我们显示进程的PID,查看该进程状态发现就没有+了。后台进程不会影响命令行的输入,但是就无法使用ctrl c终止进程了,如果想要终止进程,需要使用kill命令:

kill命令是用于给进程发送信号的,这里给进程发送9号信号,杀死进程。关于进程信号我们后面会有一篇专门来介绍。
5.2.2、下面来看看进程的S状态:


可以看到,此时进程一直在打印hello linux,查看进程状态为S,阻塞状态。
有人可能会有疑问,进程不是在一直打印吗,不应该是运行状态吗?怎么会是S呢?
实际上进程打印需要访问显示器设备,也就是IO的过程,那么访问显示器设备就需要等待显示器资源就绪,所以大部分时间进程是处于阻塞状态的。
5.2.3、什么是进程D状态?深度睡眠?

现在有一个进程需要将数据写入磁盘中,假设有1G的数据,那么磁盘就需要找到一块空间,然后将进程交给我的数据写入,所以进程现在就闲在那里等着。但是这时候操作系统内部资源严重不足了,操作系统就过来发现这个进程在这闲着,操作系统很生气,我现在内部资源严重不足了,你这个进程还在这闲着。操作系统直接把这个进程杀掉了。那么对于磁盘来说,它写完数据需要将写好的结果返回给进程,但是它现在找不到进程了。而且如果磁盘空间也不足,导致写入数据失败,也需要将结果告诉进程,但是现在找不到进程了。如果这个数据是交易流水呢,那么这其中的损失该怎么办?所以现在来了个法官,法官问操作系统你怎么把进程给杀了?操作系统说,我当时内部资源严重不足,我只是在执行操作系统该有的职责,况且如果不杀了,到时候操作系统崩了,这数据照样丢失。法官想了想你说的有道理,转头问向磁盘。磁盘说,我只是在完成进程交给我的任务,但是磁盘空间不足了,我想回过头来告诉进程,结果找不到进程了,这也不能怪我。法官想了想磁盘说的也有道理,最后问向进程。进程说我可是受害者,操作系统过来直接把我杀了,这怎么能怪我呢?最终,三方其实说的都有道理。所以说明是制度有问题,那么就让进程在等待磁盘写入完毕的期间,不能被任何人杀掉,这样不就解决问题了吗。
所以我们把进程在等待磁盘写入完毕,此时进程不能被任何人杀掉,包括操作系统也不行。此时进程处于D状态,也就是深度睡眠,不可被唤醒。而S是浅度睡眠,是可以被唤醒的。
5.2.4、下面看进程T状态:

当我们给进程发送19号信号后,进程变成了T状态,暂停状态,我们可以继续发送18号信号恢复。
5.2.5、给gcc加上-g选项,在gdb调试下查看进程的t状态:

gdb进入调试,打上断点后运行,然后查看进程的状态,可以发现此时进程处于t状态,暂停状态。
5.2.6、通过创建子进程查看Z状态:


运行后查看进程状态,可以看到刚开始两个进程都是S状态,并且子进程的PPID就是父进程的PID,然后当子进程执行完之后就变成了Z状态。我们称此时子进程为僵尸进程。
为什么子进程运行完之后不会直接为X状态,而会维持Z状态呢?父进程创建子进程执行任务,子进程执行完之后谁最关心子进程执行的结果?当然是父进程了,你执行得如何是不是应该告诉父进程?
所以一般进程退出的时候,如果父进程没有主动回收子进程信息,那么子进程就会一直处于Z状态,进程的相关资源由于是task_struct不能被释放!
同时侧面也反映了一个问题,如果系统存在大量的僵尸进程,那么这些僵尸进程就会占着系统的资源不释放,就会导致内存泄漏。

现在我ctrl c结束掉进程,我们发现父子进程都不在了。这是为什么呢?
我们当前所起的进程它的父进程是bash进程,那么bash进程肯定会对该进程回收信息,所以该进程不会处于僵尸状态。
但是子进程呢?我们当前进程并没有对子进程回收,为什么子进程也没掉了呢?
下面换份代码再看看:我们先让父进程运行结束退出,然后子进程死循环打印

可以看到,父进程运行完退出后,并不会进入僵尸状态,因为bash会回收父进程的信息。而在父进程退出之前,子进程PPID就是父进程的PID,在父进程退出之后,我们发现子进程的PID变为了1。
这里的1号进程是什么?实际上1号进程就是操作系统:

也就是说,父子进程运行,父进程先退出,那么子进程的父进程就会变成1号进程(操作系统)。
我们把父进程是1号进程的称为孤儿进程。
也就是子进程会被操作系统领养。为什么要被领养?因为子进程未来也会退出,也要被释放。
所以上面的现象,当子进程运行结束后,子进程处于僵尸状态,父进程退出后会被bash回收,而子进程会被1号进程领养,然后被操作系统回收。
一个进程可能创建很多子进程,所以也就注定了linux下进程PCB的结构一定是多叉树的样子。
之前我们不是说进程PCB结构是以双链表形式组织起来的吗?没错,但是PCB并不一定只是以一种结构来组织。它可以是链表中的某个节点,同时又是多叉树中的某个节点。可以吗?当然可以。
下面介绍linux中PCB结构组织形式:

我们以前的写法都是在struct node定义一个数据对象,但是linux中并不是这样的。而是定义了一个struct node节点,但是这个节点只有指针信息,并没有数据,而在task_struct中包含了这个节点,然后每个task_struct就通过里面的成员struct node链接起来。那么现在就有个struct node* start指针指向链表的头节点,如何访问task_struct中其他的属性呢?比如PID/状态等。
通过:

将0这个地址转换成task_struct*,然后访问link1取地址,这样算出来的就是该struct node在task_struct中的偏移量,然后用start减去该偏移量就能算出task_struct的起始地址,而由于指针-指针计算出来的数据个数,所以需要将他们强转成int类型进行计算,然后计算结果强转成task_struct*,计算出来的地址就是start所指向node所在的task_struct的起始地址,然后就能通过这个指针去访问task_struct中的所有成员。
那么task_struct中只有一个节点吗?并不是,可以有很多节点,然后不同的节点组织成不同的数据结构。这样就能既是链表结构,也可以是多叉树结构。
并且:node中的next链接的一定是task_struct吗?并不是,也可以是其他类型。
6、进程优先级
优先级 VS 权限:
优先级和权限是两个不同的概念,权限是一件事情允不允许被做,而优先级是谁先做谁后做的概念。
在操作系统中,就是对于资源谁先访问,谁后访问的问题。
为什么?因为操作系统资源有限,那么就注定了进程之间要相互竞争,所以进程具有竞争性。
而操作系统为了保证大家良性竞争,需要确认优先级。如果进程恶性竞争,导致某个进程一直无法获得CPU资源,该进程的代码就长时间无法得到推进,我们称为进程的饥饿问题。
在Linux中,可以使用ps命令来查看进程的优先级:

ps -l:查看当前终端所起的所有进程。ps -al:查看当前用户所起的所有进程。我们可以看到默认进程的优先级就是80。PRI(priority)表示的就是进程优先级,NI(nice)表示进程优先级的修正数据。PRI(new)=PRI(old)+nice。并且PRI(old)=80,也就是说不管你怎么修改nice值,都是用PRI(old)=80去加上nice值,计算出的PRI(new)就是修改的值。优先级越小,那么该进程越快被执行,优先级越大,该进程越慢被执行。所以要提高进程优先级,就需要修改nice值为负数。
那么进程优先级可以大量的大大的修改吗?不行。Linux并不想让用户过多参与优先级的调整,所以nice的取值返回是:[-20,19]。对应40个级别。那么进程优先级对应的就是:[60,99]
1、使用top命令修改nice值:



sudo top,然后输入r,紧接着输入进程pid,再输入要修改的nice值回车即可。可以看到我们输入的是-30,但是修改后是60,nice值为-20,所以说nice值得修改是有范围限制的,超出范围就是取极值。
2、使用renice命令修改进程优先级:renice 优先级 -p pid

3、使用nice命令修改进程优先级:nice -n 优先级 命令

Linux内核O(1)调度算法

CPU要维护一个运行队列,运行队列里面有一个running指针数组,共有140个元素,其中0-99是其他种类的进程用的,而100-139就是我们讲的对应进程优先级60-99。然后一个一个进程通过指针链接起来,这个结构就相当于是哈希表的拉链法。另外有两个二级指针run和wait分别指向运行的数组和等待数组。那么CPU调度就先从下标100开始,调度完当前链表之后再去找后面优先级低的、不为空的链表,然后继续调度。那么如果这时候突然来了某个进程怎么办?并不是放入运行队列中,而是放入等待队列中,找到对应优先级的链表插入。那么对于调度完的进程也是如此,放到waiting中。那么当run所指向的数组被调度完了之后,直接将run和wait的值交换,再次调度即可。那么如何查找不为空的链表呢?如果一个一个查找就需要遍历数组,所以这里维护了一个位图bitmap,通过位图直接快速对应下标是否有进程。通过这种方式,CPU调度的时间复杂度近乎O(1),这就是Linux内核的O(1)调度算法。
进程具有竞争性:系统进程数目众多,而CPU资源只有少量,甚至只有一个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争资源,便有了优先级。
进程具有独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。
并行:多个进程在多个CPU下同时运行,称之为并行。
并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内,让多个进程的代码都得以推进,称之为并发。
进程切换

CPU调度有个时间片的概念,每个进程并不是一直在CPU上面跑,当时间片到了就会把当前进程放入等待队列中,然后从运行队列再拿一个进程到CPU上继续跑,我们把这称为进程切换。
我们把这种调度称为:基于进程切换基于时间片轮转的调度算法。
我们进程在执行某个函数的代码时,最后需要返回某个值,比如一个加法函数Add,int c = a + b; return c;
返回c是如何被外部拿到的呢? 实际上return c;转换成汇编就是move eax 10;会先把这个值放到寄存器中,然后再从寄存器获取这个值赋值给外部调用的接受值。所以,返回值是通过CPU寄存器让外部获取到的。
另外,系统如何得知我们当前的程序执行到哪里了呢?这是由CPU中的另一个寄存器——程序计数器pc/eip来判断的,程序计数器会记录当前进程正在执行指令的下一条指令的地址。
也就是说,CPU中会存在很多寄存器:
通过寄存器:eax、ebx、ecx、edx
形成栈帧结构的寄存器:ebp、esp、eip
状态寄存器:status
那么这些寄存器扮演者什么角色呢?进程中必定有很多数据,那么肯定会有一些高频数据需要被CPU访问,那么我们就可以把这些数据放入CPU的寄存器中,可以提高效率。
那么也就是说CPU寄存器中保存的是进程相关的数据,并且这些数据可能随时被访问和修改。
CPU寄存器里面保存的是进程的临时数据,又称为进程的上下文。
那么当进程时间片到了,这些数据要怎么办呢?如果直接切换下一个进程,那么下一个进程就会把当前进程的上下文数据覆盖,那么当下次调度轮到这个进程的时候,它的数据就丢失了,那么就不知道该从哪里继续往下执行——对应的pc数据丢失。所以必定要保存进程的上下文。保存在哪里呢?当然是在task_struct中,可以定义一个struct reg_info的对象,里面保存了进程的上下文数据。我们现在就先这样理解。
所以进程在从CPU上离开的时候,需要把自己的上下文数据保存好并带走,那么保存的目的就是为了将来调度再次轮到该进程的时候,可以将上下文恢复。
那么进程切换的时候就有两个步骤:1、保存上下文。 2、恢复上下文。
7、环境变量
先来看看环境变量:

使用echo $PATH可以看到系统中的环境变量PATH。实际上环境变量是一组Key/Value形式的值。可以看到存在很多的路径,它们之间通过:间隔。
之前我们说过,指令本质上是一个可运行程序,它存储在系统特定的目录下,也就是在/usr/bin目录下面。
而我们还说过,我们自己写好的程序编译好之后运行需要通过./的方式指明路径运行, 而执行指令是不需要的。
这是因为PATH环境变量是系统指令的默认搜索路径,我们执行的指令默认会在PATH中的路径进行搜索,找到对应的可执行程序然后执行。而我们写好的编译好的可执行程序是在用户家目录下的,但是环境变量PATH中并没有对应的路径,所以并不会到家目录下去搜索,因此我们需要加上./指明路径。
那么如果现在我想让我的可执行程序可以像指令一样不添加路径就可以直接执行呢?
方式一:将我们的可执行程序拷贝到/usr/bin路径下,这个我们之前已经做过实验了。
方式二:修改环境变量PATH,给PATH添加上我们可执行程序所在路径。

首先是不能直接这样修改PATH的,这样会直接把PATH覆盖了,我们应该要在原来的基础上添加。
所以正确做法应该是:PATH=$PATH:/xxxxx
使用:作为路径分割。并且修改的环境变量只在本次登录有效,下次你再登录就没有了,因为当你登录需要给你分配一个bash,然后bash是从系统的配置文件中读取环境变量的。

可以看到,给PATH添加上我们当前目录后,可执行程序就不需要加./了,可以直接运行。

系统中还存在其他环境变量,比如HOME,保存了我们用户的家目录,而USER则是表明了当前用户是谁。当我们切换为其他用户时,对应的环境变量HOME和USER都会发生变化。
使用env指令查看所有环境变量:

使用env查看所有环境变量,我们看到当前用户是zzy,用户家目录是/home/zzy,下面我们切换成root再看看。

可以看到对应的USER和HOME环境变量发生了变化。
那么我们对权限的理解就可以更进一步了,当我们访问修改文件的时候,就可以通过环境变量USER和文件对应的拥有者和所属组对比来判断你是谁,从而判断你是否有读写执行的权限。
LS_COLORS是ls指令的一些配色方案。PWD保存了用户当前路径,如果我们使用cd指令进行路径切换,然后再次查看PWD,应该要看到它发生变化:


这里也还有一个OLDPWD的环境变量,所以我们说的执行cd -返回用户上一次访问的路径,本质上就是cd $OLDPWD。如果我们进行路径切换,也可以看到它发生变化,大家可以自行验证。
我们还可以通过系统调用接口:getenv获取环境变量



所以什么是环境变量?——环境变量是系统提供的一组name=value形式的变量,不同用户有不同的环境变量,通常具有全局属性。
命令行参数

不知道大家有没有见过别人写代码,在main函数的参数列表加上了argc和argv。那么我们都说main函数是程序的入口,但是其实main函数也是要被其他函数调用的。那么被其他函数调用是不是也可以传参呢。
这里的argv是个字符指针数组,那么它里面的字符指针指向的就是一个一个的字符串。而argc表示的是argv中含有字符指针的个数。
下面我们先看现象:使用for循环遍历argv数组,查看argv数组内容


我们输入的指令本质上就是字符串,这里会把我们输入的整个字符串进行分解,分解成一个一个字串,然后通过字符指针指向它们,在argv中存储起来,后面不存在的就指向NULL,我们把argv称为向量表。
那么为什么要这么做呢?——为指令、工具、软件等提供命令行选项的支持。
那么实际上main函数并不只有这两个参数,还有一个参数:char* env[]。看名字我们就知道,这就是环境变量的字符指针数据,我们遍历这个数组看看它的数据。


可以看到,通过main函数的第三个参数,我们也可以获取环境变量。
所以C/C++有两张核心向量表:1、命令行参数表。2、环境变量表。
我们运行的程序,是bash通过创建子进程来执行的,而bash本身在启动的时候,会从系统的配置文件中读取环境变量信息,而子进程会继承父进程的环境变量。因此我们可以看到上面打印的一大堆环境变量。
子进程继承父进程的环境变量,这就是为什么环境变量具有全局属性。
如何验证呢?

我们不能通过这种方式添加环境变量,这样本质上是给bash添加了本地变量。当然我们使用echo $MY_VALUE还是可以查看到它的值的。也就是echo $既可以查看本地变量也可以查看环境变量。
我们可以通过export命令导入环境变量:export name=value

使用env查看所有环境变量,我们可以看到导入成功了,所以在bash中有了MY_VALUE=12345这个环境变量。
现在我们编写代码,通过main函数参数的第三个参数char* env[]获取环境变量,然后在main函数体内遍历这个数组打印所有环境变量信息,而我们运行这个程序是bash创建子进程来执行的,如果说打印的环境变量中有MY_VALUE我们刚才添加的,就说明子进程继承了父进程的环境变量。

运行程序,我们确实看到了MY_VALUE这个环境变量,说明子进程继承了父进程的环境变量。
现在我不想要MY_VALUE这个环境变量了,可以使用unset MY_VALUE取消这个环境变量:

可以看到刚才在HOME上面的MY_VALUE环境变量就没了。
我们可以使用set查看所有变量:包括本地变量和环境变量。

我们可以使用export直接将本地变量导成环境变量:

本地变量和内建命令:
本地变量只会在本bash内部有效,不会被继承。

我们前面说了,bash会创建子进程来执行你的命令或程序。那么我在echo $MY_VAL的时候,在MY_VAL还没有被导为环境变量的时候,MY_VAL还是本地变量。而执行echo命令需要创建子进程来执行,而子进程会继承父进程的环境变量,但不会继承本地变量。那么子进程执行echo的时候如何获取到MY_VAL的值呢?怎么会获取到MY_VAL的值并输出呢?
实际上bash并不会对所有命令都创建子进程来执行,像执行echo这种没有太大风险的命令bash本身直接就执行了。所以执行echo命令并没有创建子进程,而是由bash来执行的,那么bash当然可以获取到MY_VAL的数据输出。
Linux中命令分为两批:
1、常规命令——通过创建子进程完成。
2、内建命令——bash不创建子进程,而是由自己亲自执行。类似于bash调用了自己写的或系统提供的函数。
那么你在使用cd指令的时候,你以为创建了子进程,实际上并没有,因为如果创建子进程来执行那你当前所在路径压根就不会变化,而只有bash亲自来执行你的路径才会发生变化。
下面再介绍一个切换进程当前工作目录的接口:chdir

那么在bash解析命令的时候就是类似下面这样:


运行myproc程序,还没执行chdir之前,当前工作目录cwd为/home/zzy/test,当使用了chdir修改了工作目录后,cwd就变成了/。
通过第三方变量environ获取环境变量:



获取环境变量的三种方式:
1、getenv获取指定环境变量。
2、main函数第三个参数char* env[]。
3、导入第三方变量:extern char** environ。
8、进程地址空间第一讲
历史核心问题:fork创建子进程后,父子进程代码共享,数据写时拷贝。但是一个变量id怎么会有两个不同的值呢?怎么做到id既可以等于零又大于零呢。我们还是不太理解。

过去C/C++,我们讲过有:栈区、堆区、常量区、代码区、全局区,也就是我上面画的这张图,已初始化和未初始化全局变量统称全局数据区。
那么这个是什么东西呢?是内存吗?
下面我们通过一段代码来验证这个结构是否是正确的:


通过程序运行结果,我们可以看到代码区的地址最低,然后只读常量区在代码区之上,然后就是初始化全局数据区,未初始化全局数据区,在往上就是堆区和栈区。经过代码验证,我们上面那张图是正确的。
并且我们注意到:堆区的地址是向上增长的,栈区的地址是向下增长的,堆栈相对而生。
在C语言中,我们使用static修饰的局部变量会在全局区存储,并且第一次调用局部变量所在函数才会创建对应变量,当函数栈帧销毁后,该局部变量还是存在,并不会随着栈帧的销毁而释放。现在我们通过代码验证:


可以看到static修饰的局部变量,编译时已经被编译到全局数据区。
下面通过一份代码来看现象:


这份代码我们通过fork创建子进程,然后子进程打印三次后将g_val的值修改为200。
通过运行结果我们看到,在子进程还未修改g_val的值时,父子进程g_val的值都是100,并且它们获取g_val的地址也是一样的。在子进程将g_val的值修改为200之后,父进程g_val的值不变还是100,子进程g_val的值变成了200。但是!我们发现父子进程取g_val的地址还是相同的。
我们前面说过,fork创建子进程,父子进程代码共享,数据写时拷贝,这里没问题,子进程对g_val写入的时候,发生了写时拷贝,所以父进程的g_val并没有发生变化。但是为什么父子进程获取g_val的地址是一样的?怎么可能同一个变量,同一个地址,父子进程同时读取却读到不同的内容!!
首先不管什么原因,这个g_val的地址绝对不是物理地址!!因为如果是物理地址绝对不会出现上面这种情况。
实际上这是线性地址或虚拟地址。 我们平时写的C/C++代码用的指针,里面的地址全都不是物理地址。
引入进程地址空间的概念:
进程地址空间又称虚拟内存/虚拟地址空间

程序运行,首先操作系统要给该进程创建一个task_struct的结构体对象,并且还会给该进程创建进程地址空间,task_struct也必定会有指针指向进程地址空间,而进程里面的地址都是进程地址空间的虚拟地址,以32位系统为例,地址就是从0x00000000到0xffffffff。进程地址空间从下到上依次为:正文代码、初始化数据、未初始化数据、堆、共享区、栈、命令行参数和环境变量。其中初始化数据和未初始化数据统称为全局区,堆栈相对而生。还要给该进程创建页表,进程地址空间通过页表映射到物理内存。页表我们理解为key/value值,左侧为虚拟地址,右侧是物理地址。我们上面g_val的地址就是0x601054,这就是页表中的虚拟地址,然后实际内存中的地址我们假设是0x11223344,那么该地址就是页表中对应的物理地址。同理,进程运行的代码由左侧进程地址空间中的正文代码地址和右侧物理内存中实际代码的地址通过页表进行映射。那么进程先找到进程地址空间中的地址,然后通过页表的映射关系,就可以找到实际物理内存中的地址,就可以找到对应的代码和数据了。
下面再看创建子进程的过程:

创建子进程,那么操作系统也要为该进程创建一个task_struct对象,子进程的task_struct是以父进程为模板来初始化的,同时也要为子进程创建进程地址空间,直接将父进程的地址空间拷贝一份给子进程,页表也是如此,那么现在父子进程通过页表映射到物理内存,实现了数据和代码共享。那么如何理解环境变量具有全局属性呢?父进程在创建子进程之前对应命令行参数和环境变量都已经在页表中做好映射了,那么子进程拷贝创建地址空间和页表自然而然就把环境变量继承下去了,所以哪怕不传env子进程也是可以继承环境变量的。
然后子进程修改g_val=200,发生了写时拷贝:

子进程对数据进行修改,先经过写时拷贝——是有操作系统自动完成的。重新开辟一块空间假设为0x44332211,存储g_val的值为200,同时修改页表中g_val虚拟地址映射到物理地址的值,将子进程页表中的物理地址改为0x44332211,那么此时父子进程g_val各自私有一份。
但是在这个过程中,左侧的虚拟地址是零感知的,不关心,不会影响它。
下面再来谈地址空间究竟是什么?
1、什么叫做地址空间?
我们应该知道,计算机只能识别01二进制数,所以本质上我们的可执行程序都是01组成的。在32位计算机中,有32位的地址和数组总线。在上面冯诺伊曼体系结构中,CPU和内存交互,内存和外设交互,它们肯定要进行数据的流通,那么必定就要用“线”连接起来,这里的线就是总线。我们把CPU和内存连接的线称为系统总线,而内存和外设连接的叫做IO总线。
而计算机只能识别01这种理解还不够。CPU和内存通过一根一根的总线连接起来,32位下就是32根,那么内存里面我们可以认为有一个32位的地址寄存器,然后CPU会对这一根一根的总线进行充放电,就是发送高脉冲低脉冲信号,高脉冲就代表1,低脉冲就代表0。那么最终就可以根据每根总线的信号组成一个01序列,那么这个序列转换成十六进程不就是地址了吗?同理,内存对CPU发送高脉冲低脉冲的信号,就可以向寄存器中写入数据。所以,数据拷贝本质上就是进行充放电的过程。
32根地址总线,每根地址总线只有0/1,排列组合一下就有2^32种,对应的就是4GB。
所以地址空间就是你的地址总线排列组合形成的地址范围:[0,2^32)
2、如何理解地址空间上区域划分?
举个例子,这里有两个小孩,小胖和小花,小胖和小花是同桌,它们的桌子有100cm的范围,一人一半区域。但是小胖小时候很调皮,而且很不讲卫生,鼻涕留着不擦,手也不洗,还经常欺负小花。那么小花就在桌子上画了一条线,并告诉小胖不要超过这条线,否则小花就要揍他,而这条线就是三八线。那么这个画线本质就叫做区域划分。
那么在计算机语言中就是:

定义个struct area的结构体,然后里面有两个成员变量start和end。然后在destop_area种包含了xiaopang和xiaohua两个area对象,刚开始两个人各占一般区域,初始化为:小胖:1->50,小花:51->100。而后面小花画了三八线,那就是让小胖的end -= 10,小花的start += 10。当然也可以只定义destop,里面包含四个int变量。也就是说,区域划分本质上不就是对1-100进行start、end吗。小胖其实还有强迫症,他把铅笔放在2这个位置,然后书包放在6这个位置… 同时需要注意到,在范围内,连续的空间中,每一个最小单位都可以有地址,每一个最小单位都可以被小胖使用。
所以什么是地址空间呢?
所谓的地址空间,本质是一个描述进程可视范围的大小。地址空间一定要存在各种区域的划分,对线性地址进行start、end即可。
地址空间本质是操作系统内核的一个数据结构对象,类似PCB,它也需要被操作系统管理,如何管理?——先描述,再组织。

那么进程地址空间对应数据结构体就应该类似我们上面图的实现方式。通过对线性地址start、end实现区域的划分。在Linux内核中,进程地址空间名为:mm_struct。


查看Linux内核源代码,我们可以发现task_struct确实有一个struct mm_struct* mm的指针,而在mm_struct结构体种有start_code、end_code等等区域的划分。
为什么要有进程地址空间?
下面先讲个故事,有个老美(米国人),是个富豪,他有十个亿(美元),他有四个私生子,对应私生子1-4。老美对每个私生子说,以后他老了会把家产给他。但是每个私生子都不知道对方的存在,每个私生子始终认为自己才是唯一继承人。而每个私生子不管由于什么原因找他老爹要钱,老美都会给,因为老美有十个亿,根本不缺钱。因此每个私生子都认为自己以后肯定会继承老美这十个亿。假设有一天私生子3由于某种原因找老美要五万美元,老美不给,说你天天找我要,自己去想办法。而哪怕老美不给私生子3钱,但是私生子3也依旧会认为自己以后肯定可以继承老美的十亿美元。那么老美实际上是给四个私生子每个人都画了一个大饼。老美就相当于操作系统,私生子就是一个一个的进程,画的大饼就是进程地址空间。 所以虽然给了进程地址空间4GB,但是真的有4GB吗,你一下申请4GB操作系统就会给你吗?当然是不会的。
所以为什么要有进程地址空间:
1、让进程以统一的视角看待内存。
2、增加进程虚拟地址空间,我们在访问内存的时候就多了一个转换的过程,在这个转换的过程中,可以对我们的寻址请求进行审查,一旦异常访问直接拦截,该请求不会到到达物理内存,保护了物理内存。
3、因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
再谈页表:

操作系统为进程创建task_struct、进程地址空间、页表。而进程可以通过task_struct中的struct mm_struct* mm指针找到地址空间,那如何找到页表呢。实际上在CPU中有一个CR3寄存器,里面保存了当前进程页表的地址。那么进行进程切换的时候,担不担心数据丢失呢?不担心,因为它本质上属于进程的硬件上下文,进程切换时,进程会保存上下文,所以会被作为上下文带走。
那么CPU要访问数据的时候,就通过CR3寄存器找到页表地址,然后根据虚拟地址到物理地址的映射,就可以找到物理内存中存储的数据和代码。
那怎么知道我当前访问的数据是可读可写的呢?在页表实际上还有一列,存储了对应读写权限。那么当访问正文段的时候,通过CR3寄存器找到页表,根据虚拟地址找到那一行,可以找到物理地址和它的对应权限r——只读。那假设今天你对只读的数据进行写入,操作系统根据页表中的权限发现这是只读的,不可修改,那么操作系统就会直接把你这个进程干掉。所以页表可以给我们提供很好的权限管理。
那么我们平时在玩游戏的时候,游戏可能有几个G,几十个G,甚至上百G都有。那么玩游戏本质上也是要把游戏的数据和代码加载到内存中,但是我们的内存是有限的,不可能把整个游戏的数据和代码都加载到内存中。实际上是先加载一部分到内存中的,并不是把全部代码数据都加载到内存中。
所以操作系统对大文件实现分批加载。
并且我们要认识到:现代操作系统,几乎不做任何浪费空间和时间的事情。
那么对于一个进程,会先加载500M的数据到内存中吗?如果先加载500M到内存中,但是你并不会访问后面的很多数据,那么就会造成内存浪费。
所以进程的代码和数据采用的是惰性加载的方式,一次先加载一点点,后面需要再加载到内存中。
那么如何判断进程对应的代码和数据是否已经加载到内存中呢?进程也是可能被挂起的!
所以页表还有一列标志位,该标志位就是用来判断对应的代码和数据是否被加载到内存。可以认为该标志位就是0/1,1表示一加载到内存中,0表示未加载到内存中。
当CPU访问进程地址空间中的数据,通过CR3寄存器找到页表地址,根据虚拟地址查找对应标志位,发现还未加载到内存中,触发缺页中断,在内存中开辟一块空间,然后把进程对应的数据加载到内存中,同时设置页表的物理地址,这一系列操作都是由操作系统自动完成的。然后再去访问对应物理地址的数据。那么在这个过程中,左侧的进程和地址空间完全是零感知的,不关心。

右侧我们称为Linux的内存管理模块,左侧我们称为Linux的进程管理模块。有了进程地址空间的存在,实现了Linux内存管理和进程管理的解耦合。
而对于之前父子进程代码共享,数据发生写时拷贝,子进程在写入数据的时候,本质上也是触发了缺页中断,在内存开辟新空间,将值写入,然后地址填到页表中。
那么进程被创建的时候,是先创建内核数据结构还是先加载可执行程序呢?当然是先创建内核数据结构,极端一点我代码和数据一点都不加载是不是也是可以的呢?当CPU去访问的时候发现页表对应标志位没有加载到内存中,就触发缺页中断。
再次回答前面说过的进程具有独立性,为什么?
1、每个进程都有自己的进程地址空间和页表,task_struct中有指针指向进程地址空间,而页表的地址则是存储在CR3寄存器中,CR3寄存器本质属于进程的硬件上下文,当进行进程切换的时候,进程会把对应的数据都带走。下一个进程再上来通过task_struct里面的struct mm_struct* mm指针可以找到它自己的进程地址空间,并且它自己的上下文也会加载到CPU中,那么也能找到他自己的页表。
2、每个进程通过页表映射到物理内存,而它们的代码和数据不管在内存中如何存储,哪怕是乱序的也无所谓,因为它们不会相互影响。进程以统一的视角看待内存。就算是父子进程数据刚开始共享,后面写入也会触发缺页中断开辟新空间。所以实现了进程间的独立性。
所以什么是进程?
进程 = 内核数据结构(task_struct && mm_struct && 页表) + 程序的代码和数据。
最后验证命令行参数和环境变量:

可以看到命令行参数和环境变量的地址是在栈区之上的。
24万+

被折叠的 条评论
为什么被折叠?



