前言
操作系统之进程与线程总结
正文
一. 进程
定义: 一个运行中的程序
进程和程序的区别: 一个进程是某种类型的活动, 它有程序, 输入, 输出以及状态, 如果一个程序运行了两遍, 则算作两个进程
进程地址空间见下图:
内核模式和用户模式的实现: 通常是用某个控制寄存器中的一个模式位实现的
进程有三种状态: 运行态, 就绪态, 阻塞态; 其相互之间的切换见下
子进程的回收: 当一个进程由于某种原因终止时, 内核并不是立即把它从系统中清除, 相反, 进程被保持在已终止的状态, 知道被它的父进程回收, 一个终止但还未被回收的进程称为僵死进程, 即使僵死进程没有运行, 他们仍然消耗系统的内存资源; 如果一个父进程终止了, 内核会安排init
进程成为它的孤儿进程的养父
1.1 进程创建–fork()
通过fork
可以创建进程, 新创建的子进程与父进程几乎但不是完全相同的, 子进程得到与父进程用户级虚拟地址空间相同(但是独立)的副本, 包括代码和数据段, 堆, 共享库以及用户栈, 子进程还获得与父进程任何打开文件描述符相同的副本, 这就意味着当父进程调用fork
时, 子进程可以读写父进程中打开的任何文件; 其最大的区别就是具有不同的UID
通常子进程会执行execve
或一个相同的系统调用以修改其内存映像并运行一个新的程序
fork
函数调用一次但是返回两次, 一次是在父进程中, 另一次是在新创建的子进程中, 参见一下代码
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0) // 子进程
{
printf("Child: x = %d\n", ++x);
exit(0);
}
printf("Parent: x = %d\n", --x); // 父进程
exit(0);
}
输出(注意由于并发不定性, 所以输出顺序不定):
Parent: x = 0
Child: x = 2
注意理解上述程序:
- 调用一次, 返回两次
- 并发执行
- 相同但是独立的地址空间: 写时复制; 子进程共享父进程的所有内存, 但这块内存通过写时复制共享, 这意味着一旦两者之一想要修改部分内存, 则这块内存首先被明确的复制, 以确保修改发生在私有内存区域
- 共享文件: 当运行这个程序时, 我们注意到父进程和子进程都把他们的输出显示到屏幕上, 原因是子进程继承了父进程所有的打开文件, 当父进程调用
fork
时,stdout
文件是打开的, 并指向屏幕, 子进程继承了这个文件, 因此它的输出也是指向屏幕的(注意理解此处)
1.2 进程间通信
1.2.1 如何保证临界区互斥
-
屏蔽中断: 在单处理器系统中, 最简单的方法是使每个进程在刚刚进入临界区后立即屏蔽所有中断, 并在要离开之前再打开中断
-
锁变量
-
严格的轮换法: 自旋锁
-
Peterson
解法 -
TSL
指令: 测试并加锁; 读字和写字操作保证是不可分割的, 即该指令结束之前其他处理器均不允许访问该内存字; 执行TSL
指令的CPU
锁住内存总线, 以禁止其他CPU
在本指令结束之前访问内存(原子操作的更底层解释)
1.2.2 生产者消费者问题
- 信号量: 用一个整型变量来累计唤醒次数
#define N 100 // 缓冲区的槽数目
typedef int semaphore; // 信号量是一种特殊类型的整型数据
semaphore mutex = 1; // 控制对临界区的访问
semaphore empty = N; // 计数缓冲区的空槽数目
semaphore full = 0; // 计数缓冲区的满槽数目
void producer(void)
{
int item;
while(true)
{
item = produce_item();
down(&empty); // 将空槽数目减一; 注意理解, 若交换这里两个down操作的顺序, 可能会造成死锁
down(&mutex); // 进入临界区
insert_item(item);
up(&mutex); // 离开临界区
up(&full); // 将满槽数目加一
}
}
void consumer(void)
{
int item;
while(true)
{
down(&full);
down(&mutex);
item = remove_item();
up(&mutex);
up(&empty);
comsume_item(item);
}
}
-
互斥量: 没有计数能力, 可以处于两态之一: 解锁和加锁
-
管程: 一个管程是一个由过程, 变量以及数据等组成的一个集合, 它们组成一个特殊的模块或软件包, 进程可以在任何需要的时候调用管程中的过程, 但是不能在管程之外声明的过程中直接访问管程内的数据结构
进入管程的互斥由编译器负责, 但通常的做法是用一个互斥量或二元信号量
- 避免锁: 读-复制-更新
1.3 调度
进程切换做的工作: 首先用户态切换到内核态, 然后保存当前进程的状态, 包括在进程表中存储器值; 在许多系统中, 内存映像(如: 页表内内存访问位)也必须保存, 接着, 通过运行调度程序选择一个新进程, 之后, 将该新进程的内存映像装进MMU
, 最后新进程开始运行; 除此之外, 进程切换还要使整个内存高速缓存失效, 强迫缓存从内存中动态重新装入两次(进入内核一次, 离开内核一次)
1.3.1 调度时机
什么时候调度
-
创建一个新进程之后, 决定是运行子进程还是父进程
-
在一个进程退出时
-
当一个进程阻塞在
I/O
和信号量上或由于其他原因阻塞时 -
I/O
中断时
1.3.2 调度策略
有三种环境:
- 批处理
- 交互式
- 实时
- 批处理
- 先来先服务
- 最短作业优先
- 最短剩余时间优先
- 交互式
- 轮转调度: 时间分片; 维护一个进程队列, 用完时间片后, 排到队列末尾
- 优先级调度
- 多级队列
- 最短进程优先
- 保证调度
- 彩票调度
- 公平分享调度
1.3.3 读者-写者问题
实现目标是: 可以多个读者
以数据库为例
typedef int semaphore;
semaphore mutex = 1;
semaphore db = 1; // 控制对数据库的访问
int rc = 0; // 正在读或者即将读的进程数目
void reader(void)
{
while(true)
{
down(&mutex); // 获得对rc的互斥访问权
rc = rc + 1; // 多了一个读者
if (rc == 1) // 如果是第一个读者
down(&db);
up(&mutex);
read_data_base(); // 访问数据
down(&mutex);
rc = rc - 1; // 减少了一个读者
if (rc == 0) // 如果是最后一个读者
up(&db);
up(&mutex);
use_data_read(); // 非临界区
}
}
void writer(void)
{
while(true)
{
think_up_data(); // 非临界区
down(&db); // 获取互斥访问
write_data_basae(); // 更新数据
up(&db); // 释放互斥访问
}
}
二. 线程
为什么要使用线程:
- 并行实体拥有共享同一个地址空间和所有可用数据的能力, 这是多进程无法表达的
- 线程比进程更轻量级, 它们比进程更容易创建, 也更容易撤销
- 若多个线程都是
CPU
密集型的, 那么并不能获得性能上的增强(考虑调度消耗时间), 但是如果存在大量的计算和大量的I/O
处理, 拥有多个线程允许这些活动彼此重叠进行, 从而会加快应用程序执行的速度
在线程中有一个程序计数器, 用来记录接着要执行哪一条指令, 线程拥有寄存器, 用来保存线程当前的工作变量, 线程还拥有一个堆栈, 其中每一帧保存了一个已调用的但是还没有从中返回的过程
进程用于把资源集中到一起, 而线程则是在CPU
上被调度执行的实体(资源管理的单位是进程)