操作系统_进程
- 1 冯•诺依曼体系结构
- 2 操作系统(Operator System)
- 2.1 设计OS的目的
- 2.2 OS的定位
- 3 进程
- 3.1 什么是进程?
- 3.2 查看进程
- 3.3 通过fork创建进程
- 使用 if 进行分流
- 如何杀死一个进程
- 3.4 进程状态
- R - 运行状态
- S - 浅睡眠状态
- D - 磁盘休眠状态
- T - 停止状态
- X - 死亡状态
- Z - 僵尸状态
- 模拟僵尸进程
- 孤儿进程
- T and t
- 退出状态
- 销毁僵尸进程
- 僵尸进程和孤儿进程区别
- 3.5 进程优先级
- PRI and NI
- 4 其他概念
1 冯•诺依曼体系结构
所谓冯•诺依曼体系结构,它是一个我们对应的模板。就相当于以后大家在做计算机可以按照我的这个方式来做。它的硬件构成分为输入设备、输出设备,还有中央处理器。中央处理器又分为运算器和控制器。
注:这里的存储器指的就是内存
。
常见的输入输出设备和中央处理器:
-
输入设备:键盘、鼠标、网卡、磁盘、话筒、摄像头、扫描仪等。
-
输出设备:显示器、音响、网卡、磁盘、打印机等。
-
中央处理器 (CPU) :
运算器:算数运算、逻辑运算
控制器:CPU是可以响应外部事件的,可以协调外部就绪事件,如拷贝数据时,CPU要对硬件中断做出响应
注意: 同种设备在不同场景下可能属于输入设备,也可能属于输入设备。
我们经常说CPU当中有寄存器,实际上寄存器不仅仅在CPU当中存在,在其他外设当中也是有寄存器的。例如,当我们敲击键盘时,键盘是先将获取到的内容存储在寄存器当中,然后再通过寄存器将数据刷新到内存当中。
根据冯•诺依曼体系结构图,我们可以知道,站在硬件角度或是数据层面上,CPU只和内存打交道,外设也只和内存打交道。到这里我们有一个问题:为什么程序运行之前必须先加载到内存?输入设备直接把数据给CPU处理,处理完直接输出不是更快吗?
- 技术角度
CPU的运算速度 > 寄存器的速度> Cache > 内存 >> 外设 >> 光盘磁带
木桶原理都知道,一套系统的快慢是由最慢的设备决定的。如果没有内存,外设直接和CPU交互,那么这些设备就会拖慢整个系 统的运算速度。所以内存在我们看来就是起到缓存的作用,目的是适配外设和 CPU运算速度不均的问题。
我们的操作系统就可以把磁盘上的数据提前加载到内存,CPU就可以直接到内存拿数据,而不是磁盘。木桶的短板从外设变成了内 存,这样就可以在一定程度大大缓解整机的效率问题。
- 成本角度
寄存器 >> 内存 >> 磁盘
内存是断电失效的,硬盘不是。
CPU读取数据(数据+代码),都是从内存读取。CPU要处理数据,操作系统或者软件就会先将外设中的数据加载到内存。所以,站在数据的角度,CPU和外设都只与内存打交道。
从输入设备输入数据到内存的过程叫做Input
,从内存读到输出设备的过程叫做Output
,整体就是一次IO
过程。比如:scanf 和 printf就是从键盘输入到内存,再从内存输出到显示器。(有些数据被处理完不会直接打印到屏幕,而是加载到缓冲区,再定时或强制刷新到屏幕)
我们所写的C/C++程序,运行之前要先加载到内存,这是由硬件结构所决定的。
2 操作系统(Operator System)
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库, shell程序等等)
2.1 设计OS的目的
对下与硬件交互,管理所有的软硬件资源 – 手段
对上为用户程序(应用程序)提供一个良好的执行环境 – 目的
对下与硬件交互
此时这里有一个问题:难道操作系统直接和底层硬件打交道吗?
举个例子,如果操作系统自己来完成键盘的读取操作,那么只要你的键盘读取方式进行了改变,那么操作系统的内核源代码就需要进行重新编译,
这对操作系统来说维护成本太高了。
于是我们又在操作系统与底层硬件之间增加了一层驱动层,驱动层的主要工作就是单独去控制底层硬件的。例如,键盘有键盘驱动,网卡有网卡驱动,硬盘有硬盘驱动,磁盘有磁盘驱动。驱动简单来说就是去访问某个硬件,访问这个硬件的读、写以及硬件当前的状态等等,驱动层就是直接和硬件打交道的。而驱动一般是由硬件制造厂商提供的,或是由操作系统相关的模块进行开发的(例如网卡)。
此时操作系统就只需关心何时读取数据,而不用关心数据是如何读取的了,也就是完成了操作系统与硬件之间的解耦。
那操作系统究竟管理些什么呢?操作系统主要进行以下四项管理:
- 内存管理:内存分配、内存共享、内存保护以及内存扩张等等。
- 驱动管理:对计算机设备驱动驱动程序的分类、更新、删除等操作。
- 文件管理:文件存储空间的管理、目录管理、文件操作管理以及文件保护等等。
- 进程管理:其工作主要是进程的调度。
对上与软件交互
而操作系统再往上就是我们所处的位置,在这里我们就可以用命令行或是图形化界面进行各种操作,这一层被称为用户层。
但操作系统为了保护自己,对上只暴露了一些接口,而不会让用户直接访问操作系统,这一系列接口被称为系统调用接口。
但这些系统调用接口对我们普通用户来说使用成本又太高了,因为要使用系统调用前提条件是你得对系统有一定了解。所以在系统调用接口之上又构建出了一批库,例如libc和libc++。实际上在语言级别上使用的各种库,就是封装了系统调用接口的,我们就是通过调用这些库当中的各种函数(例如printf和scanf)进行各种程序的编写。
2.2 OS的定位
在整个计算机软硬件架构中,操作系统就是一款进行软硬件
资源管理的软件。
管理的本质就是对数据进行管理
管理的核心理念:先描述,再组织(先定义结构,再用数据结构和算法进行管理)
如何做到管理呢?
只要拿到被管理者的核心数据,来进行支持管理的决策,就可以了进行管理和决策了。
由于Linux操作系统是用C语言写的,所以我们可以肯定在Linux内核代码中有大量的
struct
结构体,链表或其他数据结构。
系统调用和库函数概念
- 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
- 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
3 进程
在Windows下,我们双击启动一个软件或者程序,本质就是启动了一个进程!
在Linux下,运行一条命令(如:./xxx),本质就是在系统上创建了一个进程!
我们加载到内存的C/C++程序就是一个进程!
当内存中存在大量的进程是吗,操作系统就需要对进程进行管理。
3.1 什么是进程?
进程 = 代码和数据 + 该进程对应的内核数据结构(PCB)
有了进程我们就要进行管理(也就是上面的内核数据结构)
就是我们熟悉的 PCB (进程控制块)在Linux下就是 task_struct
对进程的管理,就变成了对进程PCB结构体链表的增删查改。
来一个进程,就会新建一个PCB,当进程结束的时候,对应的PCB也会销毁。
怎么多进程在内存中,该先执行哪个呢?
在PCB中是有进程优先级的,找到优先级高的那个进程,然后将该进程的代码和数据加载到CPU就可以了。
在内核中,操作系统将进程PCB按照
链表
的形式串接起来,对进程的管理实际上就是对PCB双向链表的增删查改。
task_ struct 内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
3.2 查看进程
-
大多数进程信息同样可以使用 top 和 ps 这些用户级工具来获取
在Linux中,想查看一个进程信息的命令叫做
ps
,,默认只能查看你自己终端下的进程;
可以用命令查看所有进程。
ps axj
我们先写一个C++程序为“myproc”,可以使用grep命令来查看。
加上头部信息
PID | 进程的ID |
---|---|
PPID | 父进程 |
TTY | 终端ID 正在等待的进程资源 |
STAT | 进程状态 |
PGID | 进程组ID |
TPGID | tty进程组负责人的进程组ID |
SID | 会话负责人的会话id |
-
进程的信息可以通过
/proc
系统文件夹查看proc:内存文件系统(当前系统实时的进程信息)
我们可以查看一个进程的相关属性
3.3 通过fork创建进程
fork是系统调用的函数。作用是创建一个子进程
man 2 getpid
返回值:
创建成功:
父进程返回子进程的PID,是因为父进程要管理子进程,所以要知道子进程的 ID。
子进程返回0,是因为我们只需要知道子进程创建成功没有
创建失败:返回 -1
我们创建一个子进程,并打印信息。
#include <stdio.h>
#include <unistd.h>
int main()
{
fork();
while(1)
{
printf("I am a process PID : %d, PPID : %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
我们看到程序运行起来会循环打印两条信息,在fork() 之后 创建了一个子进程,第一条是父进程打印的,第二条是子进程打印的。
PPID就是当前进程的父进程的PID。子进程的PPID是6970,也就是父进程,但是父进程的PPID是20225,是哪个进程呢?
我们可以看看20225。
也就是说,我们在命令上所执行的所有的指令,都是bash的子进程。
使用 if 进行分流
像上面的代码,创建一个子进程与父进程执行同一份代码,没有如何意义。我们可以通过返回值不同,使用if语句进行分流就可以让父子进程做不同的事。
fork函数的返回值:
- 如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
- 如果子进程创建失败,则在父进程中返回 -1。
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0) // child
{
while(1)
{
printf("I am child process PID : %d\n", getpid());
sleep(1);
}
}
else if(id > 0) //parent
{
while(1)
{
printf("I am parent process PID : %d\n", getpid());
sleep(1);
}
}
else {
printf("create child process failed!");
}
return 0;
}
父子进程会循环打印信息,我们将打印语句换成两个不同的任务,就可以同时执行两个任务。
如何杀死一个进程
-
直接等他执行完毕
-
通过发送信号 如:kill -9
3.4 进程状态
一个进程就像一个人一样,不同时刻有不同的状态。从创建到结束的整个生命期间,有有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。
所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。
虚拟内存管理 - 换入换出
那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。
另外,挂起状态可以分为两种:
- 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
- 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁。
在Linux内核源码中对于进程状态的定义:
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
R - 运行状态
就是在运行或者在running 队列里面排队,都叫做运行状态,正在被调度或者已经准备好了随时可以被调度。所以可以同时存在多个R状态的进程。
S - 浅睡眠状态
该进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠)。处于浅睡眠状态的进程可以随时被唤醒,也可以随时被杀死。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("I am a process\n");
sleep(1000);
return 0;
}
通过命令查看该进程的状态
ps aux | head -1 && ps aux | grep test | grep -v grep
可以通过Ctrl + C 杀掉该进程
D - 磁盘休眠状态
该状态也叫不可中断睡眠状态,处于该状态的进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒。
对磁盘进行写入或者读取期间,该进程就处于深度睡眠状态,是不会被杀掉的。
T - 停止状态
可以对一个进程发送SIGSTOP信号,该进程就进入到了暂停状态。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
X - 死亡状态
这个状态只是一个返回状态,你不会在任务列表里看到这个状态
该进程还在!,只不过是永远不运行了,随时等待被释放!
当一个进程被标记为“X”状态时,并不是说该进程仍然在运行。相反,这意味着进程已经被完全删除,包括其PCB和在内存中的所有数据结构都已经被操作系统回收。但是,这个进程仍然存在于操作系统的进程管理器中,以便操作系统可以记录并跟踪所有进程的状态。因此,虽然X状态的进程已经不存在于内存中,但是它仍然存在于操作系统的进程列表中。
Z - 僵尸状态
一个进程退出的时候,不会直接进入X状态,而是进入Z状态,等待操作系统回收该进程。
当进程退出的时候,PCB里的进程状态会被标记为Z状态,等OS回收,回收以后就变成X状态,表示该进程已经被完全删除。
模拟僵尸进程
如果创建子进程,子进程退出了,父进程不退出,也不等待子进程,子进程退出后所处的状态就是Z。它不会自动退出释放所有资源,也不会被kill命令再次杀死。避免僵尸进程的产生采用进程等待(wait/waitpid)方式完成。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0){ //child
int count = 5;
while(count){
printf("I am child process PID : %d, PPID : %d, count : %d\n", getpid(), getppid(), count);
sleep(1);
count --;
}
printf("child quit !\n");
exit(1);
}
else if(id > 0){ //father
while(1){
printf("I am father process PID : %d, PPID : %d\n", getpid(), getppid());
sleep(1);
}
}
else{
printf("create child process failed!\n");
}
return 0;
}
我们可以通过监控脚本查看进程状态的变化。此时子进程就是僵尸进程。
while :; do ps axj | head -1 && ps axj | grep test | grep -v grep;echo "######################";sleep 1;done
长时间Z有什么问题?
如果僵尸状态一直不退出,那么PCB就一直需要进行维护。
如果一个父进程创建了很多子进程,都不回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
如果僵尸进程越多,实际可用的资源就越少,所以僵尸进程可能会导致内存泄漏。
孤儿进程
如果父进程提前退出,子进程还在运行,子进程会被1号进程领养!这个1号进程就是操作系统!孤儿进程运行在系统后台。
孤儿进程的产生一般都会带有目的性,比如我们需要一个程序运行在后台,或者我们不想一个进程退出后成为僵尸进程之类的需要。
守护进程&精灵进程
这两种是同一种进程的不同翻译,是特殊的孤儿进程,不但运行在后台,最主要的是脱离了与终端和登录会话的所有联系,也就是默默的运行在后台不想受到任何影响。精灵进程其实和守护进程是一样的,不同的翻译叫法而已,它的父进程是1号进程,退出后不会成为僵尸进程。
注意:状态后面跟 +号说明是前台进程,可以 Ctrl+C 杀掉,但是没有 +号就是后台进程,Ctrl+C杀不到,我们可以kill -9 n
杀掉。
T and t
都是暂停的功能,比如追剧的暂停等,这两个其实是一样的,唯一的区别就是进程被调试的时候,遇到断点所处的状态,就是t。
退出状态
一个进程在执行系统调用 exit 函数结束自己的生命的时候,并没有真正的被销毁, 而是留下一个称为僵尸进程(Zombie)的数据结构(系统调用exit,它的作用是使进程退出,但也仅仅限于将一个正常的进程变成一个僵尸进程,并不能将其完全销毁)。在这个退出过程中,进程占有的所有资源,除了task_struct结构(以及少数资源)以外将被回收。于是只剩下task_struct这么个空壳,故称为僵尸。
保留 task_struct 是因为 task_struct 里面保存了进程的退出码以及一些统计信息,其父进程很可能会关心这些信息。比如在 shell 中,$?变量就保存了最后一个退出的前台进程的退出码。内核也可以将这些信息保存在别的地方,而将task_struct结构释放掉,以节省一些空间。但是使用task_struct结构更为方便,因为在内核中已经建立了从 PID 到task_struct查找关系,还有进程间的父子关系。释放掉task_struct,则需要建立一些新的数据结构,以便让父进程找到它的子进程的退出信息。
在子进程中调用 exit/return 可以终结子进程,但是这种终结不是销毁 ,子进程此时变成僵尸态。
如果父进程一直没有去主动获取子进程的结束状态值,那么子进程就一直保持僵尸状态。通过 wait/waitpid 函数就可以获取退出状态值从而回收僵尸进程。一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。该进程的父进程可以调用 wait/waitpid 获取这些信息,然后清除掉这个进程。
一个进程的退出状态可以在Shell中用特殊变量 $? 查看,因为Shell是它的父进程,当它终止时Shell调用wait/waitpid得到它的退出状态同时彻底清除掉这个进程。如果一个进程已经终止,但是它的父进程尚未调用wait/waitpid对它进行清理,这时的进程状态称为僵尸进程。任何进程在刚终止时都是僵尸进程。
销毁僵尸进程
父进程可以通过wait系列的系统调用(如wait、waitpid)来等待某个或某些子进程的退出,并获取它的退出信息。然后wait系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。父进程通过wait/waitpid等函数等待子进程结束,这会导致父进程挂起,相关用法可以通过man命令查看。
子进程在退出的过程中,内核会给其父进程发送一个信号,通知父进程来“收尸”。这个信号默认是SIGCHLD
,通过clone系统调用创建子进程时,可以设置这个信号。可以用signal函数为SIGCHLD安装handler回调函数,子进程结束后父进程会收到该信号,可以在handler中调用wait回收。
如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIG_IGN) 通知内核,自己对子进程的结束不感兴趣,那么子进程结束后内核会回收, 并不再给父进程发送信号。
fork两次,父进程fork一个子进程,然后继续工作,子进程fork一个孙进程后退出,那么孙进程被init接管,孙进程结束后init会回收。不过子进程的回收还要自己做。
僵尸进程和孤儿进程区别
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程没有调用wait/waitpid获取子进程的状态,那么子进程的进程描述符仍然保存在系统中,这种进程称为僵尸进程。
孤儿进程: 父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为1号进程(init进程),称为init进程领养孤儿进程。子进程的死亡需要父进程来处理,当父进程先于子进程死亡时,子进程死亡没有父进程处理,这个死亡的子进程就是孤儿进程。
僵尸进程占用一个进程ID号,占用资源,危害系统。孤儿进程与僵尸进程不同的是,由于父进程已经死亡,系统会帮助父进程回收处理孤儿进程。所以孤儿进程实际上是不占用资源的,因为它最终是被系统回收了,不会像僵尸进程那样占用ID。
3.5 进程优先级
为什么会存在优先级?
是因为资源不够,进程要竞争资源
可以使用命令列出当前系统所有进程的详细信息
ps -al
PRI and NI
- PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
- 那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
- 所以,调整进程优先级,在Linux下,就是调整进程nice值
- nice其取值范围是-20至19,一共40个级别
- PRI(old)默认为80,即PRI = 80 + NI
可以使用top命令更改已存在进程的nice
top命令就相当于Windows操作系统中的任务管理器,用于实时监控系统的资源使用情况。
top,进入top后按“r”–>输入进程PID–>输入nice值,按“q”即可退出。
这个优先级只是用户建议的,至于操作系统按不按照这个优先级执行就不确定了。
4 其他概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行性:多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发性:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发