进程与线程

进程与线程总结

Posted by 袁平 on November 15, 2018

前言

操作系统之进程与线程总结


正文


一. 进程

定义: 一个运行中的程序

进程和程序的区别: 一个进程是某种类型的活动, 它有程序, 输入, 输出以及状态, 如果一个程序运行了两遍, 则算作两个进程

进程地址空间见下图:

进程地址空间

内核模式和用户模式的实现: 通常是用某个控制寄存器中的一个模式位实现的

进程有三种状态: 运行态, 就绪态, 阻塞态; 其相互之间的切换见下

进程状态切换

子进程的回收: 当一个进程由于某种原因终止时, 内核并不是立即把它从系统中清除, 相反, 进程被保持在已终止的状态, 知道被它的父进程回收, 一个终止但还未被回收的进程称为僵死进程, 即使僵死进程没有运行, 他们仍然消耗系统的内存资源; 如果一个父进程终止了, 内核会安排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

注意理解上述程序:

  1. 调用一次, 返回两次
  2. 并发执行
  3. 相同但是独立的地址空间: 写时复制; 子进程共享父进程的所有内存, 但这块内存通过写时复制共享, 这意味着一旦两者之一想要修改部分内存, 则这块内存首先被明确的复制, 以确保修改发生在私有内存区域
  4. 共享文件: 当运行这个程序时, 我们注意到父进程和子进程都把他们的输出显示到屏幕上, 原因是子进程继承了父进程所有的打开文件, 当父进程调用fork时, stdout文件是打开的, 并指向屏幕, 子进程继承了这个文件, 因此它的输出也是指向屏幕的(注意理解此处)

1.2 进程间通信

1.2.1 如何保证临界区互斥

  1. 屏蔽中断: 在单处理器系统中, 最简单的方法是使每个进程在刚刚进入临界区后立即屏蔽所有中断, 并在要离开之前再打开中断

  2. 锁变量

  3. 严格的轮换法: 自旋锁

  4. Peterson解法

  5. TSL指令: 测试并加锁; 读字和写字操作保证是不可分割的, 即该指令结束之前其他处理器均不允许访问该内存字; 执行TSL指令的CPU锁住内存总线, 以禁止其他CPU在本指令结束之前访问内存(原子操作的更底层解释)

1.2.2 生产者消费者问题

  1. 信号量: 用一个整型变量来累计唤醒次数
#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. 互斥量: 没有计数能力, 可以处于两态之一: 解锁和加锁

  2. 管程: 一个管程是一个由过程, 变量以及数据等组成的一个集合, 它们组成一个特殊的模块或软件包, 进程可以在任何需要的时候调用管程中的过程, 但是不能在管程之外声明的过程中直接访问管程内的数据结构

管程

进入管程的互斥由编译器负责, 但通常的做法是用一个互斥量或二元信号量

  1. 避免锁: 读-复制-更新

1.3 调度

进程切换做的工作: 首先用户态切换到内核态, 然后保存当前进程的状态, 包括在进程表中存储器值; 在许多系统中, 内存映像(如: 页表内内存访问位)也必须保存, 接着, 通过运行调度程序选择一个新进程, 之后, 将该新进程的内存映像装进MMU, 最后新进程开始运行; 除此之外, 进程切换还要使整个内存高速缓存失效, 强迫缓存从内存中动态重新装入两次(进入内核一次, 离开内核一次)

上下文切换

1.3.1 调度时机

什么时候调度

  1. 创建一个新进程之后, 决定是运行子进程还是父进程

  2. 在一个进程退出时

  3. 当一个进程阻塞在I/O和信号量上或由于其他原因阻塞时

  4. I/O中断时

1.3.2 调度策略

有三种环境:

  1. 批处理
  2. 交互式
  3. 实时
  1. 批处理
  1. 先来先服务
  2. 最短作业优先
  3. 最短剩余时间优先
  1. 交互式
  1. 轮转调度: 时间分片; 维护一个进程队列, 用完时间片后, 排到队列末尾
  2. 优先级调度
  3. 多级队列
  4. 最短进程优先
  5. 保证调度
  6. 彩票调度
  7. 公平分享调度

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); // 释放互斥访问
    }
}

二. 线程

为什么要使用线程:

  1. 并行实体拥有共享同一个地址空间和所有可用数据的能力, 这是多进程无法表达的
  2. 线程比进程更轻量级, 它们比进程更容易创建, 也更容易撤销
  3. 若多个线程都是CPU密集型的, 那么并不能获得性能上的增强(考虑调度消耗时间), 但是如果存在大量的计算和大量的I/O处理, 拥有多个线程允许这些活动彼此重叠进行, 从而会加快应用程序执行的速度

在线程中有一个程序计数器, 用来记录接着要执行哪一条指令, 线程拥有寄存器, 用来保存线程当前的工作变量, 线程还拥有一个堆栈, 其中每一帧保存了一个已调用的但是还没有从中返回的过程

线程与进程

进程用于把资源集中到一起, 而线程则是在CPU上被调度执行的实体(资源管理的单位是进程)