操作系统学习笔记1--进程及进程调度

        进程(process):操作系统为正在运行的程序提供的抽象。

        进程的机器状态(machine state)是程序在运行时可以读取或更新的内容。

        内存是进程机器状态的重要组成部分,指令存在内存中,正在运行的程序读取和写入的数据也在内存中。进程可以访问的内存是该进程的一部分。

        寄存器是进程机器状态的另一个重要组成部分,许多指令明确地读取或更新寄存器。如:程序计数器(Program Counter,PC),有时称为指令指针(Instruction Pointer,IP),告诉我们程序当前正在执行哪个指令;栈指针(stack pointer)和相关的帧指针(frame pointer)用于管理函数参数栈、局部变量和返回地址。

        进程常见的3种状态:运行(running)就绪(ready)阻塞(blocked)

xv6(一个教学目的的操作系统)的进程结构如下:

// the registers xv6 will save and restore
// to stop and subsequently restart a process
struct context {
  int eip;
  int esp;
  int ebx;
  int ecx;
  int edx;
  int esi;
  int edi;
  int ebp;
};

// the different states a process can be in
enum proc_state { UNUSED, EMBRYO, SLEEPING,
                  RUNNABLE, RUNNING, ZOMBIE };

// the information xv6 tracks about each process
// including its register context and state
struct proc {
  char *mem;                   // Start of process memory
  uint sz;                     // Size of process memory
  char *kstack;                // Bottom of kernel stack
                               // for this process
  enum proc_state state;       // Process state
  int pid;                     // Process ID
  struct proc *parent;         // Parent process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  struct context context;      // Switch here to run process
  struct trapframe *tf;        // Trap frame for the
                               // current interrupt
};

1 进程API

1.1 fork()用于创建新进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main(int argc, char* argv[])
{
    printf("hello world (pid:%d)\n", (int)getpid());

    int rc = fork();
    if (rc < 0)
    {
        fprintf(stderr, "fork failed.\n");
        exit(1);
    }
    else if (rc == 0)
    {
        printf("I am child (pid:%d)\n", (int)getpid());

        char *myArgs[3];
        myArgs[0] = strdup("wc");
        myArgs[1] = strdup("test.c");
        myArgs[2] = NULL;

        execvp(myArgs[0], myArgs);
    }
    else
    {
        int wc = wait(NULL); 
        printf("I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int)getpid());
    }

    return 0;
}

        子进程并不是完全拷贝父进程。虽然它拥有自己的地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。

1.2 wait()父进程等待子进程执行完毕

        父进程调用wait(),延迟自己的执行,直到子进程执行完毕。当子进程结束时,wait()才返回父进程。代码如上。

1.3 exec() 指定子进程的执行程序

        代码如上,子进程调用execvp()来运行字符计数程序wc。它针对源代码文件test.c运行wc,从而告诉我们该文件有多少行、多少单词,以及多少字节。

        给定可执行程序的名称(如wc)及需要的参数(test.c)后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过argv传递给该进程。它并没有创建新进程,而是直接将当前运行的程序替换为不同的运行程序。对exec()的成功调用永远不会返回。

实际上,除上述接口之外,还有很多其他可与进程交互的接口。如可以通过kill()系统调用向进程发送信号(signal),包括要求进程睡眠、终止或其他有用的指令。实际上,整个信号子系统提供了一套丰富的向进程传递外部事件的途径,包括接受和执行这些信号。

2 CPU的虚拟化

        时分共享(time sharing)--受限制的直接执行(limited direct execution)

        如何执行受限制的操作?--采用受保护的控制权转移

        硬件通过提供不同的执行模式来协助操作系统。在用户模式(user mode)下,应用程序不能完全访问硬件资源。在内核模式(kernel mode)下,操作系统可以访问机器的全部资源。还提供了陷入(trap)内核和从陷阱返回(return-from-trap)到用户模式程序的特别说明,以及一些指令,让操作系统告诉硬件陷阱表(trap table)在内存中的位置。

         用户通过系统调用执行特权操作。要执行系统调用,程序必须执行特殊的陷阱(trap)指令。该指令同时跳入内核并将特权级别提升到内核模式。一旦进入内核,系统就可以执行任何需要的特权操作(如果允许),从而为调用进程执行所需的工作。完成后,操作系统调用一个特殊的从陷阱返回(return-from-trap)指令,返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。

        陷阱如何知道在OS内运行哪些代码呢?内核通过在启动时设置陷阱表(trap table)来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。

3 进程间切换

3.1 进程之间切换的方式

        1.协作(cooperative)方式:等待系统调用

        2.非协作方式:操作系统进行控制--时钟中断(timer interrupt)

3.2 操作系统怎么切换进程

       由操作系统的调度程序(scheduler)来决定运行哪个程序,不运行哪个程序。如果决定进行切换,OS就会执行一些底层代码,即所谓的上下文切换(context switch)

        上下文切换:为了保存当前正在运行的进程的上下文,操作系统会执行一些底层汇编代码,来保存通用寄存器、程序计数器,以及当前正在运行的进程的内核栈指针,然后恢复寄存器、程序计数器,并切换内核栈,供即将运行的进程使用。通过切换栈,内核在进入切换代码调用时,是一个进程(被中断的进程)的上下文,在返回时,是另一进程(即将执行的进程)的上下文。当操作系统最终执行从陷阱返回指令时,即将执行的进程变成了当前运行的进程。至此上下文切换完成。

        上下文切换时有两种类型的寄存器保存/恢复。第一种是发生时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。第二种是当操作系统决定从A切换到B。在这种情况下,内核寄存器被软件(即OS)明确地保存,但这次被存储在该进程的进程结构的内存中。后一个操作让系统从好像刚刚由A陷入内核,变成好像刚刚由B陷入内核。

4 进程调度

4.1 调度指标

        周转时间 = 完成时间 - 到达时间

        响应时间 = 首次运行时间 - 到达时间

        周转时间是一个性能(performance)指标,另一个有趣的指标是公平(fairness)。性能和公平在调度系统中往往是矛盾的。例如,调度程序可以优化性能,但代价是以阻止一些任务运行,这就降低了公平。

4.2 调度策略(sheduling policy)

1.先进先出(First In First Out,FIFO)调度

        先进先出(First In First Out,FIFO)调度,也称先到先服务(First Come First Served,FCFS)

2.最短任务优先(Shortest Job First,SJF)

3.最短完成时间优先(STCF)

        向SJF添加抢占,称为最短完成时间优先(Shortest Time-to-Completion First,STCF)或抢占式最短作业优先(Preemptive Shortest Job First ,PSJF)

4.轮转(Round-Robin,RR)

        RR在一个时间片(time slice,有时称为调度量子,scheduling quantum)内运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。它反复执行,直到所有任务完成。

        时间片长度对于RR是至关重要的。越短,RR在响应时间上表现越好。然而,时间片太短是有问题的:突然上下文切换的成本将影响整体性能。因此,系统设计者需要权衡时间片的长度,使其足够长,以便摊销(amortize)上下文切换成本,而又不会使系统不及时响应。

总结:SJF、STCF 优化周转时间,但对响应时间不利;RR 优化响应时间,但对周转时间不利。

5.多级反馈队列(Multi-level Feedback Queue,MLFQ)

        MLFQ中有许多独立的队列(queue),每个队列有不同的优先级(priority level)。任何时刻,一个工作只能存在于一个队列中。当然,每个队列中可能会有多个工作,因此具有同样的优先级。MLFQ总是优先执行较高优先级的工作(即在较高级队列中的工作)。在这种情况下,我们就对这些工作采用轮转调度。

        因此,MLFQ调度策略的关键在于如何设置优先级。MLFQ没有为每个工作指定不变的优先情级,而是根据观察到的行为调整它的优先级。

MLFQ的基本规则:

        规则1:如果A的优先级 > B的优先级,运行A(不运行B)。

        规则2:如果A的优先级 = B的优先级,轮转运行A和B。

        规则3:工作进入系统时,放在最高优先级(最上层队列)。

        规则4a:工作用完整个时间片后,降低其优先级(移入下一个队列)。

        规则4b:如果工作在其时间片以内主动释放CPU,则优先级不变。

        规则4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级(移入低一级队列)。

        规则5:经过一段时间S,就将系统中所有工作重新加入最高优先级队列。

许多系统使用某种类型的MLFQ作为自己的基础调度程序。

6.比例份额(proportional-share)(待补充)

        调度程序的最终目标,是确保每个工作获得一定比例的CPU时间,而不是优化周转时间和响应时间。

7.多处理器调度(multiprocessor scheduling)(待补充)

        多CPU与单CPU的区别核心在于对硬件缓存(cache)的使用,以及多处理器之间共享数据的方式。

        缓存是基于局部性(locality)的概念,局部性有两种,即时间局部性和空间局部性。时间局部性是指当一个数据被访问后,它很有可能会在不久的将来被再次访问,比如循环代码中的数据或指令本身。而空间局部性指的是,当程序访问地址为x的数据时,很有可能会紧接着访问x周围的数据,比如遍历数组或指令的顺序执行。由于这两种局部性存在于大多数的程序中,硬件系统可以很好地预测哪些数据可以放入缓存,从而运行得很好。

        多CPU情况下,缓存一致性(cache coherence)问题由硬件提供了基本的解决方案:通过监控内存访问,硬件可以保证获得正确的数据,并保证共享内存的唯一性。在基于总线的系统中,一种方式是使用总线窥探(bus snooping)。每个缓存都通过监听链接所有缓存和内存的总线,来发现内存访问。如果CPU发现对它放在缓存中的数据的更新,会作废(invalidate)本地副本(从缓存中移除),或更新(update)它(修改为新值)。

        虽然缓存已经做了一些工作来保证数据一致性,但应用程序在哭=跨CPU访问共享数据时,仍需要使用互斥原语(比如锁),才能保证正确性。

1.单队列多处理器调度(Single Queue Multiprocessor Scheduling,SQMS)

        简单地复用单处理器调度的基本架构,将所有需要调度的工作放入一个单独的队列中。

        此方法最大的优点是简单,它不需要太多修改,就可以将原有的策略用于多个CPU,选择最适合的工作来运行。

        然而,SQMS有几个明显的短板。第一个是缺乏可扩展性(scalability)。为了保证在多CPU上正常运行,调度程序的开发者需要在代码中通过加锁(locking)来保证原子性,如上所述。在SQMS访问单个队列时(如寻找下一个运行的工作),锁确保得到正确的结果。第二个主要问题是缓存亲和性。

2.多队列多处理器调度(Multi-Queue Multiprocessor Scheduling,MQMS)

5 进程通信及进程同步

        进程通信(InterProcess Communication,IPC)指的是不同进程之间传递和共享数据的行为;进程同步则是指确保多个进程在执行时能够有序地访问共享资源,避免数据不一致或冲突的问题。虽然进程通信和进程同步是不同的概念,但它们之间存在密切的联系。在某些情况下,进程同步机制(如信号量、互斥锁等)可以通过进程通信来实现,反之,进程通信过程中也可能需要进程同步机制来保证通信的正确性和一致性。

5.1 进程通信

1. 管道

        无名管道(内存文件):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程之间使用,如父子进程。

        有名管道(FIFO文件,借助文件系统):有名管道也是半双工的通信方式,但是允许在没有亲缘关系的进程之间使用,管道是先进先出的通信方式。

2.共享内存

        共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的IPC方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与信号量,配合使用来实现进程间的同步和通信。

3.消息队列

        消息队列是有消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

4.套接字

        适用于不同机器间进程通信,在本地也可作为两个进程通信的方式。

5.信号

        用于通知接收进程某个事件已经发生,比如按下ctrl + C就是信号。

6.信号量(Semaphore)

        虽然主要用作同步手段,但也可以用于进程间传递信号。信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,实现进程、线程的对临界区的同步及互斥访问。

5.2进程同步

1.临界区

        通过串行化对公共资源或代码段的访问来保证数据的一致性。这种方法适用于单进程中的多线程同步,速度快且简便,但不适用于跨进程同步。

2.同步与互斥

        用于协调不同线程对共享资源的单独访问,它比临界区更复杂,能在同一应用程序的不同线程或不同应用程序的线程之间实现资源共享。

3.信号量(Semaphore)

        信号量机制允许进程通过信号合作,实现同步和互斥。

        信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。

        down:如果信号量大于0执行-1操作;如果信号量等于0,进程睡眠,等待信号量大于 0;

        up:对信号量执行+1操作,唤醒睡眠的进程让其完成 down 操作。

        down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。如果信号量的取值只能为0或者1,那么就成为了 互斥量(Mutex):0表示临界区已经加锁,1表示临界区解锁。

4.管程(Moniter)

        管程是一种同步机制,其中封装了对共享资源的操作和必要的同步操作。条件变量是管程中的一种结构,用于自动阻塞和唤醒等待特定条件的线程。

        管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。管程引入了 条件变量 以及相关的操作:wait() 和 signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。

6 守护进程、孤儿进程、僵尸进程

        守护进程:指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux的大多数服务器就是用守护进程的方式实现的,如web服务器进程http等。

        孤儿进程:如果父进程先退出,子进程还没退出,那么子进程就是孤儿进程。子进程的父进程将变为init进程(进程号为1,任何进程都必须有父进程),并由init进程对它完成状态收集工作。

        僵尸进程:如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵尸状态)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值