更多文章欢迎关注个人微信公众号:极客熊猫
引言
从今天开始,会陆续开更系列文章《一起学操作系统》,在这里说一下整个系列的写作思路。
操作系统的功能有两个:
- 提供抽象
- 管理资源
对于提供抽象这个功能,我总结为:操作系统提供了三个抽象
- CPU 抽象为 进程;
- 物理内存 抽象为 虚拟地址空间;
- IO设备(包括磁盘) 抽象为 文件系统。
对于管理资源这个功能,操作系统的任务是在相互竞争的程序间有序地控制对资源的分配,这里,我关注一点,即:
- 死锁。
资源就是随着时间的推移,必须能获得、使用以及释放的任何东西。可以是硬件,也可以是一组信息(如数据库中的记录)。
我会按照这个思路写这系列文章。今天先写第一部分,即将CPU抽象为进程,进程这一部分目前计划为四篇文章:
- 进程与线程基础(本篇)
- 进程间通信问题:管道(PIPE和FIFO)、消息队列、信号量、共享存储、套接字
- 线程同步:互斥锁、读写锁、自旋锁、条件变量、屏障
- 进/线程调度
进程
为什么需要进程?
我们早已习惯了一台计算机“同时”做很多事情,我们举一个例子,有一台Web服务器,它接收到一个网页请求,服务器检查后发现该请求需要的网页不在其缓存中,所以服务器会启动一个磁盘请求去获取该网页。但是,对CPU而言,磁盘请求是非常慢的,在这个过程中,会有其他请求到达服务器。如果存在多个磁盘,那么可以在响应第一个请求之前就向其他磁盘发出后续的请求。
很明显,我们需要一种方法来模拟并控制这种并发,进程(尤其是线程)就是解决这个问题的。
严格来讲,在某一个瞬间,一个CPU只能运行一个进程。所以对单核CPU来讲,我们看到的计算机“同时”运行多个进程其实是一种假象,是一种伪并发,这种伪并发是由于CPU在多个进程之间快速地切换,导致看上去就像多个进程同时运行一样。
什么是进程?
进程是一个正在执行的程序的实例。每个进程拥有自己的虚拟CPU,即拥有自己的寄存器、程序计数器(PC)等。
进程和程序的区别:
- 程序是一个静态概念,进程是一个动态概念;
- 我们写的程序经过编译链接生成可执行文件,到此为止,它都可以称为程序;而当把可执行文件跑起来,即装载到内存开始运行之后,它才变成一个进程;
- 自然,一个程序执行两次,算作两个进程。
进程组成
前面一直说的比较抽象,本小节将把进程这一概念落在实处。
操作系统内核维护着一张进程表,每个进程占用其中一个表项,成为进程控制块(PCB)。在Linux内核下,PCB就是task_struct结构体,进程表就是一个由很多个task_struct结构体组成的链表。
PCB即描述进程的数据结构。PCB是进程存在的唯一标志。
PCB通常包含以下内容:
进程描述信息 | 进程控制和管理信息 | 资源分配清单 | CPU相关信息 |
---|---|---|---|
进程ID(PID) | 进程当前状态 | 代码段指针 | 通用寄存器值 |
用户ID(UID) | 进程优先级 | 数据段指针 | 地址寄存器值(如堆栈指针寄存器ESP) |
代码运行入口地址 | 堆栈段指针 | 控制寄存器值(如程序计数器PC) | |
程序的外存地址 | 文件描述符 | 标志寄存器值 | |
进入内存时间 | 键盘 | 状态字(PSW) | |
CPU占用时间 | 鼠标 | ||
信号量使用 |
暂且可以这么说,进程由代码段、数据段、PCB三部分组成。
进程状态
进程有下图所示三种状态:
- 运行态:该时刻进程实际占用CPU;
- 就绪态:可运行,但因为其他进程正在占用CPU而暂时不运行;
- 阻塞态:正在等待某种外部事件(如磁盘IO)发生,否则进程无法运行;
三种状态之间有四种可能的转换关系:
- 运行态至阻塞态:进程因等待外部事件发生而不能继续运行下去;
- 运行态至就绪态:由进程调度程序引起,进程的时间片耗尽,调度程序选择其他进程运行;
- 就绪态至运行态:由进程调度程序引起,调度程序选择该进程运行;
- 阻塞态至就绪态:等待的外部事件发生,由阻塞态转为就绪态,等待调度程序的调度。
创建进程:fork
归根结底,新进程的创建都是由于一个已存在的进程执行了一个用于创建进程的系统调用而创建的。
在UNIX系统中,有且仅有一个系统调用可以用来创建进程:fork。
#include <unistd.h>
pid_t fork(void);
//返回值:子进程返回0,父进程返回子进程PID;若出错,返回-1。
由fork创建的新进程称为子进程。fork函数被调用一次,但返回两次:
- 子进程的返回值是0;
- 父进程的返回值是新建子进程的进程ID。
为什么要把子进程的PID返回给父进程?
- 因为一个进程可以有多个子进程,且没有函数可以使一个进程获得其所有子进程的PID。
为什么子进程中fork返回值为0?
- 因为一个进程只会有一个父进程,子进程可以通过调用getppid来获得其父进程的PID;
- 且进程ID 0 总是由内核交换进程使用,所以一个子进程的进程ID不可能是0。
子进程和父进程继续执行fork之后的指令。子进程获得其父进程的虚拟地址空间中的数据段、堆、栈的副本,而代码段是父进程和子进程共享的。
写时复制技术
由于fork之后经常跟随着exec,所以现在很多实现并不真的给子进程一个父进程的数据段、堆、栈的副本,而采用写时复制技术作为替代。
在该技术中,fork之后,父进程的数据段、堆、栈也是同子进程共享的,只是内核会把它们的访问权限变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟内存的一个页。
注:Windows中没有父子进程这种层次概念,所有进程地位相同。
孤儿进程与僵尸进程
进程是一个动态概念,有创建就有终止。父进程为了决定下一步的对策,需要知道其子进程的死亡原因(终止状态)。具体是通过wait和waitpid两个函数来实现。
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
//两个函数返回值:若成功则返回进程ID;若出错则返回0或-1。
进程的终止状态存放在statloc指针所指向的内存单元内。
调用wait或waitpid时可能发生什么:
- 若其所有子进程都还在运行,则阻塞;
- 若一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态后立即返回;
- 若它没有任何子进程,则立即出错返回。
wait和waitpid的区别如下:
- 若子进程都还在运行,wait使父进程阻塞;而waitpid有一选项,可使父进程不阻塞;
- wait等待的是其被调用之后第一个终止的子进程;而waitpid可以指定等待哪个进程的终止;
为了使父进程能够获得子进程的终止状态,一个进程终止之后并非完全消失,内核仍为每个终止子进程保存了一定量的信息。
僵尸进程:一个已经终止,但是其父进程尚未对其进行善后处理(获取其终止信息、释放其仍占用的资源)的进程。
僵尸进程的危害
如果父进程不调用 wait/waitpid的话, 那么内核为每个终止子进程保存的一定量的信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程, 此即为僵尸进程的危害,应当避免 。
解决僵尸进程
僵尸进程的出现,追其根本原因,是其父进程出现了问题,在子进程终止后没有回收子进程的资源,而不是Linux系统的问题;此时运行的程序代码逻辑应该是有问题的,需要整改,如果出现僵尸进程,可以通过以下方法解决:
直接杀掉其父进程,将此进程变成孤儿进程,交给 init 进程管理,init 进程回收此进程的资源;
kill -9 + 父进程号
孤儿进程:在子进程终止之前,父进程先终止了,子进程则成为孤儿进程。
孤儿进程会被init进程收养。操作过程大概是:在一个进程终止时,内核逐个检查所有活着的进程,以判断它是否是正要终止的进程的子进程,如果是,则该进程的父进程ID就更改为1(init进程)。
线程
为什么需要线程?
进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点:
进程在同一时间只能干一件事,如果进程在执行的过程中阻塞,整个进程就会挂起,即使有些工作不依赖于等待的资源,进程仍然不会执行。因此,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性。
什么是线程?
线程可以理解为进程中的一条执行流程。
线程是CPU调度的最小单位。
传统的进程只包含一个线程。线程是依赖进程而存在的。
进程负责把资源集中到一起,而线程则是被CPU调度执行的实体。
线程内容
同一进程内的所有线程共享该进程的虚拟地址空间,但每个线程拥有自己的栈。
进程的虚拟地址空间如下图所示:
PCB在上图中的Kernel Space中。
线程共享的内容有:
- 代码段、数据段;
- 堆(Heap);
- PCB中的文件描述符,即线程共享进程的打开文件;
- PCB中的其他一些内容,我们暂时不关注。
线程私有的内容有:
- 栈,即每个线程有自己的栈;
- PCB中的寄存器;
- PCB中某些其他内容,我们暂时不关注。
POSIX线程
#include <pthread.h>
int pthread_create(pthread_t *restrict tidp,
const pthread_attr_t *restrict attr,
void *(*start_rtn)(void *),
void *restrict arg);
//若成功则返回0;若出错则返回错误编号。
pthread_create用于创建线程:
- 新创建线程的线程ID会被设置为tidp指向的内存单元;
- attr参数用于定制线程属性,默认为NULL;
- 新建的线程从start_rtn函数的地址开始执行,该函数只有一个void*类型的参数arg;
- 若要传给start_rtn函数的参数超过一个,那么需要把参数放在一个结构体中,然后把该结构体的地址作为arg参数传给pthread_create函数。
restrict是C99中新增的关键字,它只用于修饰指针;该关键字用于告知编译器,所有修改该指针所指向内容的操作都必须基于该指针,即不存在其它进行修改操作的途径。
#include <pthread.h>
void pthread_exit(void *rval_ptr);
int pthread_join(pthread_t thread, void **rval_ptr); //若成功则返回0;若出错则返回错误编号。
pthread_exit用于在不终止进程的情况下终止线程:
- rval_ptr指向的内存单元存放线程的终止状态。
pthread_join用于等待特定线程的终止,在此之前,调用该函数的线程将一直阻塞:
- thread用于指定所等待线程的线程ID;
- rval_ptr用于获取所等待线程的终止状态,若不感兴趣,可以设其为NULL。
多进程与多线程
多进程与多线程的优缺点
多进程的优点:
-
编程相对容易:通常不需要考虑锁和同步资源的问题;
-
更强的容错性:比起多线程的一个好处是一个进程崩溃了不会影响其他进程。;
-
有内核保证的隔离:数据和错误隔离。 对于使用如C/C++这些语言编写的本地代码,错误隔离是非常有用的:采用多进程架构的程序一般可以做到一定程度的自恢复;(master守护进程监控所有worker进程,发现进程挂掉后将其重启)。
chrome浏览器采用多进程方式。
原因:1. 可能存在一些网页不符合编程规范,容易崩溃,采用多进程一个网页崩溃不会影响其他网页;而采用多线程会。
2.网页之间互相隔离,保证安全,不必担心某个网页中的恶意代码会取得存放在其他网页中的敏感信息。
多线程的优点:
-
创建速度快,方便高效的数据共享;
-
较轻的上下文切换开销。