一、进程概念
课本概念:进程是程序的一个执行实例,是正在执行的程序。
内核观点:进程是承担系统资源(CPU时间、内存)的实体。
当我们写完代码之后,编译连接就形成一个可执行程序.exe,本质是二进制文件,在磁盘上存放着。双击这个.exe文件把程序运行起来就是把程序从磁盘加载到内存,然后CPU才能执行其代码语句。当把程序加载到内存后,这个程序就叫做进程。所有启动程序的过程,本质都是在系统上创建进程,双击.exe文件也不例外:
二、PCB
1.什么是PCB
根据操作系统管理是先描述再组织,那么操作系统是如何描述进程的呢?先预想一下,肯定是先描述进程信息,然后再把这些信息用数据结构组织起来进行管理。那么进程都有哪些信息呢?使用
ps axj
命令查看系统当中的进程,也就是正在运行的程序:
看到进程的属性至少有PPID、PID、PGID、SID、TTY、TPGID、STAT、UID、TIME、COMMAND。
进程信息被放在一个叫做进程控制块PCB(Process Control Block)的数据结构中,它是进程属性的集合。
操作系统创建进程时,除了把磁盘上的代码和数据加载到内存以外,还要在系统内部为进程创建一个task_struct,是一个struct。
2.什么是task_struct
Linux操作系统的下的PCB就是task_struct,所以task_struct是PCB的一种,在其他操作系统中的PCB就不一定叫task_struct。
创建进程不仅仅把代码和数据加载到内存,还要为进程创建task_struct,所以进程不仅仅是运行起来的程序,更准确的来说,进程是程序文件内容和操作系统自动创建的与进程相关的数据结构,其实进程还包括其他内容,今天先说这两个。
操作系统对每一个进程进行了描述,这就有了一个一个的PCB,Linux中的PCB就是task_struct,这个struct会有next、prev指针,可以用双向链表把进程链接起来,task_struct结构体的部分指针也可以指向进程的代码和数据:
所有运行在系统里的进程,都以task_struct作为链表节点的形式存储在内核里,这样就把对进程的管理变成了对链表的增删改查操作。
增:当生成一个可执行程序时,将.exe文件存放到磁盘上,双击运行这个.exe程序时,操作系统会将该进程的代码和数据加载到内存,并创建一个进程,对进程描述以后形成task_struct,并把插入到双向链表中。
删:进程退出就是将该进程的task_struct节点从双向链表中删除,操作系统把内存中该进程的代码和数据进行释放。
3.task_struct包含内容
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程信息。那么task_struct具体包含哪些信息呢?
标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据: 进程执行时处理器的寄存器中的数据。
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
还有一些其他信息。下面解释task_struct包含内容的具体含义。
三、task_struct内容详解
1.查看进程
(1)通过系统目录查看
proc是一个系统文件夹,在根目录下,通过ls可以看到该文件夹:
可以通过
ls /proc
命令查看进程的信息,数字是PID:
如果想查看进程信息,比如查看PID为989的进程信息,使用命令
ls /proc/PID
查看:
(2)通过ps命令查看
使用
ps aux
命令查看进程,可以看到所有进程:
如果结合grep可以查看某一个进程:
比如想查看包含proc的进程,可以使用如下命令:
ps aux | head -1 && ps aux | grep proc | grep -v grep
(3)通过top命令查看
也可以通过
top
命令查看:
(4)通过系统调用获取进程PID和父进程PPID
获取进程ID和获取父进程ID可以通过以下方式进行获取,其中pid_t是short类型变量:
1. #include <sys/types.h> 2. #include <unistd.h> 3. 4. pid_t getpid(void);//获取当前进程ID 5. pid_t getppid(void);//获取当前进程的父进程ID
获取当前进程,process.c
1. #include<sys/types.h> 2. #include<stdio.h> 3. #include<unistd.h> 4. 5. int main() 6. { 7. while(1) 8. { 9. printf("hello linux!:pid:%d\n",getpid());//获取当前进程ID 10. sleep(1); 11. } 12. 13. return 0; 14. }
Makefile:
1. process:process.c 2. gcc -o $@ $? 3. .PHONY:clean 4. clean: 5. rm -f process
运行之后,就获取到了当前进程的PID,即进程号:
关闭进程可以通过ctrl+c或者来关闭进程。另开一个窗口,现在通过ps来查看进程:
这也就验证了getpid获取到的是PID。
1. #include<sys/types.h> 2. #include<stdio.h> 3. #include<unistd.h> 4. 5. int main() 6. { 7. while(1) 8. { 9. printf("hello linux!:pid:%d,ppid:%d\n",getpid(),getppid()); 10. sleep(1); 11. } 12. 13. return 0; 14. }
使用ps命令查看,发现父进程的ID是11081,但是11081同时也是bash的子进程:
这是因为,运行命令行的命令有风险,命令行出错了,不能影响命令行解释,因此在命令行上运行的命令,基本上父进程都是bash。
使用如下命令可查看到进程内部的所有属性信息:
ls /proc/当前进程ID -al
当进程退出时,就没有/proc/18448这个文件夹了,ctrl c后,再去查看文件夹,已经不存在了:
2.状态
之前写代码的返回值是0 ,这个0是进程退出时的退出码,这个退出码是要被父进程拿到的,返回给系统,父进程通过系统拿到。比如以下代码的退出码是0
1. #include<stdio.h> 2. int main() 3. { 4. printf("hello linux!\n"); 5. return 0; 6. }
那么使用
echo $?
就可以查看到进程退出码为0:
假如将退出码改为99 :
那么程序运行后的退出码也变成了99:
所以,状态的作用是输出最近执行的命令的退出码。
3.优先级
权限指的是能不能,而优先级指的是已经能了,有权限了,但是至于什么时候执行得先排队 ,这就像在餐馆点餐结帐出小票之后,已经可以拿到餐食了,但是什么时候能拿到呢?需要排队,在这个过程中,是否出小票就代表是否有权限,排队取餐就代表的是优先级。
4.程序计数器
当CPU执行程序时,执行当前行指令时,怎么知道下一行指令是什么呢?程序计数器pc中存放下一条指令的地址,当操作系统执行完当前行指令后,pc自动会++,直接执行下一行命令。
内存指针可以通过task_struct中的内存指针,通过PCB 找到进程的代码和数据。
5.上下文数据
当操作系统维护进程队列时,由于进程代码可能不会在很短时间就能执行完毕,假如操作系统也不会在执行一个进程时,让其他进程一直等待,直到当前进程执行完毕,那可能当前进程需要执行很久才执行完毕,其他进程会一直处于等待状态,这不合理。那么操作系统在实际执行进程调度时,按时间片分配执行时间,时间片一到,就切换下一个进程。时间片是一个进程单次运行的最长时间。
比如有4个进程,在40ms之内先让第一个进程运行10ms,时间一到就算没有运行完毕,就把第一个进程从队列头移动到队列尾,再让第二个进程运行10ms。40ms后,使得用户感知到这4个进程都推进了,其实本质上是通过CPU的快速切换完成的。
有可能在一个进程的生命周期内被调度成百上千次。比如CPU有5个寄存器,进程A正在运行时时间片到了,被切走的时候,会把CPU里和进程A相关的保存到寄存器里面的临时数据带走。当进程B调度完后,再次调度进程A的时候,会把进程A里面保存的临时数据再恢复到CPU的寄存器当中,继续上次切走时的状态继续运行,因此保护上下文能够保证多个进程切换时共享CPU。
6.I/O状态信息
文件操作有fopen、fclose、fread、fwrite等函数,其实是进程在操作文件,因为在把代码写完之后,程序运行起来时,操作系统会找到这个进程,进程打开文件进行IO操作,其实IO都是进程在进行IO,所以操作系统需要维护进程和IO信息。
7.记账信息
记录历史上一个进程所享受过的软硬件资源的结合。
四、通过系统调用创建进程
1.使用fork创建子进程
fork用来创建子进程:
1. #include <unistd.h> 2. pid_t fork(void);//通过复制调用进程创建一个新进程。新进程称为子进程。调用进程称为父进程。
先看一个奇奇怪怪的代码:
forkProcess_getpid.c
1. #include<unistd.h> 2. #include<stdio.h> 3. 4. int main() 5. { 6. int ret = fork(); 7. 8. if(ret > 0) 9. { 10. printf("I am here\n"); 11. } 12. else 13. { 14. printf("I am here,too\n"); 15. } 16. 17. sleep(1); 18. return 10; 19. }
按道理来说,要么打印I am here,要么打印I am here,too。但是请看执行结果,发现两句话都打印了,也就是既执行了if又执行了else:
再看代码:
1. #include<stdio.h> 2. #include<unistd.h> 3. 4. int main() 5. { 6. int ret = fork(); 7. 8. while(1) 9. { 10. printf("I am here,pid = %d,ppid = %d\n",getpid(),getppid()); 11. sleep(1); 12. } 13. 14. return 10; 15. }
发现有两个pid和ppid:
这说明执行while死循环不只一个执行流在执行, 而是两个执行流在执行,每一行两个id都是父子关系。这是因为fork之后有两个执行流同时执行while循环。
可以看到bash 16202创建了子进程 16705,子进程又创建了子进程 16706:
2.理解fork创建子进程
再来说为什么if和else都执行了。
./可执行程序、命令行、fork,站在操作系统角度,创建进程的方式没有差别,都是系统中多了个进程。fork创建出来的子进程,和父进程不一样,父进程在磁盘上是有可执行程序的,运行可执行程序时会把对应的代码和数据加载到内存中去运行。
但是子进程只是被创建出来的,没有进程的代码和数据,默认情况下,子进程会继承父进程的代码和数据,子进程的数据结构task_struct也会以父进程的task_struct为模板来初始化子进程的task_struct。因此子进程会执行父进程fork之后的代码,来访问父进程的数据。
总结:当fork创建子进程时,系统里面多了个进程,实际上是多了个以父进程为模板的描述进程的数据结构task_struct和以父进程为模板的代码和数据。因此fork之后,if和else中的代码都执行了。如果把task_struct比作基因,把代码和数据比作事业,那么子进程既继承了父进程的基因,又继承了父进程的事业。
3.fork后的数据修改
代码是不可以被修改的。 那么数据呢?子进程和父进程共享数据,当父进程修改数据时,子进程看到的数据也被修改了,那么父进程就会影响子进程。那这两个进程还具有独立性吗?
当父子进程都只读不写数据时,数据是共享的。但是这两个进程中的任何一个进程要修改数据,都会对对方造成影响,这时候作为进程管理者同时也是内存管理者的操作系统就要站出来干涉了。修改时,操作系统会在内存中重新开辟一块空间,把这部分数据拷贝过去之后再做修改,而不是在原数据上做修改,这叫做写时拷贝。
写时拷贝是为了维护进程独立性,为了防止多个进程运行时互相干扰。而在创建子进程时不会让子进程把父进程的所有数据全部都拷贝一份,因为并不是所有情况下都可能产生数据写入,所以这就避免了fork时的效率降低和浪费更多空间的问题。因此只有写入数据时再开辟空间才是合理的。
4.fork的返回值
(1)fork返回值含义
fork出子进程后,一般会让子进程和父进程去干不同的事情,这时候如何区分父子进程呢?fork函数的返回值如下:
打印一下fork的返回值:
forkProcess_getpid.c
1. #include<stdio.h> 2. #include<unistd.h> 3. 4. iint main() 5. { 6. pid_t ret = fork(); 7. 8. while(1) 9. { 10. printf("Hello forkProcess,pid = %d,ppid = %d,ret = %d\n",getpid(),getppid(),ret); 11. sleep(1); 12. } 13. 14. return 10; 15. }
打印结果如下:
这说明:
- fork准备return的时候子进程被创建出来了。
- 这里有两个返回值,由于函数的返回值是通过寄存器写入的, 函数返回时把变量值写入到保存数据的空间。所以当父子执行流执行完毕以后,有两次返回,就有两个不同的返回值,就要进行写入,谁先返回谁就先写入,即发生写时拷贝。
- 给父进程返回子进程的pid的原因是,一个父进程可能有多个子进程,子进程必须得用pid来进行标识区分,所以一般给父进程返回子进程的pid来控制子进程。子进程想知道父进程pid可以通过get_ppid( )来获取。这样就可以维护父子进程了。
(2)根据fork返回值让父子进程执行不同的功能
通过返回值来让父子进程分流,去执行不同的功能:
1. #include<stdio.h> 2. #include<unistd.h> 3. 4. int main() 5. { 6. pid_t ret = fork(); 7. 8. //通过if else来分流 9. if(ret == 0)//child 10. { 11. while(1) 12. { 13. printf("I am child, pid = %d,ppid = %d\n",getpid(),getppid()); 14. sleep(1); 15. } 16. } 17. else if(ret > 0)//parent 18. { 19. while(1) 20. { 21. printf("I am parent, pid = %d,ppid = %d\n",getpid(),getppid()); 22. sleep(3); 23. } 24. } 25. else 26. { 27. } 28. 29. return 0; 30. }
这就让父子进程执行了不同的功能,上述代码父进程每隔3秒打印一次,子进程每隔1秒打印一次:
可以查看到父进程和子进程:
通过fork创建出进程,再通过if else分离,从而让父和子各自执行不同的代码段,实现不同的功能。至于父子进程谁先运行,是由调度器决定的。