计算机操作系统学习-进程与线程

1 进程

        从本章开始,我们将深入学习操作系统是如何设计的。操作系统最核心的概念是进程,是对正在运行程序的一个抽象

        在进程模型中,计算机上所有可运行的软件,也包括操作系统被组织成若干顺序进程。一个进程就是一个正在执行的程序的实例,例如多个网页每一个都是一个网页程序的实例。

        例如图1所示在单核cpu计算机中运行多个程序。并且每个程序都能够在某一时间内独立运行,这依赖于进程的切换。

图1

 1.1 进程的创建

        在通用操作系统中,有时候我们需要创建进程,例如登录微信,打开网页等等。

        4种主要事件会导致进程的创建

  1.   系统初始化。
  2.   正在运行的程序执行了创建进程的系统调用。(在当前网页下又打开一个网页)
  3.   用户请求创建一个新的进程。(登录微信)
  4.   一个批处理作业的初始化。(这个暂时没想到)

        这些创建进程的本质都是需要执行创建新进程的系统调用。在UNIX系统中,只有一个系统调用可以用于创建进程,fork。这个系统调用会创建一个与调用进程相同的副本。在调用了fork后,这两个进程(又称为父子进程,这个后面会讲)拥有相同的内存镜像(虚拟内存映射到物理内存是相同的)。通常子进程会执行execve或者一个类似的进程调用以修改其内存映射并运行一个新的程序。

1.2 进程的终止

        进程在创建之后,他开始运行,完成其工作之后需要被终止

  •         正常退出(自愿)
  •         出错退出(自愿)
  •         严重错误(非自愿)
  •         被其他进程杀死(非自愿)

正常退出:在UNIX中执行的exit系统调用。

出错退出:编译器要编译ccFoo.c文件,发现不存在则退出。

严重错误:例如在java中 定义3/0的计算就会非法退出。

被杀死:当其他进程执行kill系统调用。

1.3 进程与进程之间的关系

        在UNIX中,进程只有一个父进程,可以有(0个,一个或者多个)子进程。那么进程和它所有子进程以后裔称为一个进程组

1.4 进程的状态。

        尽管每个进程都是一个独立的实体,有其自己的程序计数器和状态。但是实际中进程之间是相互协作的。一个进程的输出可能作为另一个进程的输入。

cat chapter1 chapter2 chapter3 | grep tree

Linux grep (global regular expression) 命令用于查找文件里符合条件的字符串或正则表达式。

cat(英文全拼:concatenate)命令用于连接文件并打印到标准输出设备上。

因此上述命令为在chapter1、chapter2和chapter3中找到符合tree字符的行

        通过这个命令可以了解到,只有当cat命令执行后,grep命令才会执行,可以通过图2所示,进程的三个状态,就绪态,运行态和阻塞态。并且这三种状态之间有4种转换关系

图2

 1.5 进程的实现

        进程的实现也可以称为进程的描述,操作系统维护了一张表(结构数组),即进程表。每个进程占用一个表项。(好像对象数组啊),该表项描述了一个进程的全部信息。

图3

        这里说一下进程和线程的区别:进程把资源集中在一起,而线程则是cpu上被调度执行的实体。

        如图4所示,a图中每个进程都单独有一个线程,每一个线程都在不同的地址空间中运行,而图b则是一个进程中有3个线程,所有线程共进程空间。

图4

         在进程中不同线程不像不同进程之间存在很大的独立性。所有线程都有一样的地址空间。但是线程之间也有一部分是私有的,例如程序计数器寄存器(每个线程都有独立的栈)和状态等等。值得注意的是线程的栈中有栈帧,每一次方法的调用和返回都代表栈帧的入栈和出站的过程

图5

         线程的创建一般是通过库函数thread_create,线程的终止也需要调用库函数thread_exit进行退出。那么线程是创建在用户空间的呢?还是创建在内核空间呢? 这个问题其实在用户空间和内核空间都可以创建。

        如下图6a所示:如果线程创建在用户空间,那么就需要进程内部创建线程表对线程进行管理。如图B所示,如果创建在内核空间,那么就需要在内核空间中创建线程表对线程进行管理。

线程表中的每一项描述线程的全部信息

图6

2 进程调度问题

2.1 调度原则

        原则1:如果运行的程序,发生了I/O事件,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回,这样势必造成CPU资源浪费,调度程序需要从就绪进程中选一个进程来执行。

        原则2:有的程序执行某个任务花费时间比比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。

        原则3:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。

        原则4:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。

        原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。

 

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
  • 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;
  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

2.2 调度算法

        2.2.1 先来先服务调度算法

        进程从就绪队列中依次占有CPU,直到退出或者被阻塞,才会从队列中选择第一个进程继续运行。

 缺点:对长作业有利占有CPU时间长。对短作业没有利,适合CPU繁忙型。

        2.2.2最短作业优先调度算法

        最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。

        此时这个就绪队列是一个优先级队列,时间最短的应该在前面。

        缺点:长作业会一直不被执行。

        2.2.3 高响应比优先调度算法

        高响应比计算公式如下:

         缺点:要求服务时间是未知的,因此基本不能实现。

        2.2.4 时间片轮询调度算法。

        每一个进程被调度时,都会被分配一个CPU占用时间片,当时间片运行结束,CPU就会调度其他进程。

  • 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;
  • 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;

另外,时间片的长度就是一个很关键的点:

  • 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
  • 如果设得太长又可能引起对短作业进程的响应时间变长。

一般来说,时间片设为 20ms~50ms 通常是一个比较合理的折中值。

        2.2.5 最高优先级调度算法

        时间片轮询调度算法,是假设所有进程的优先级都是相同的。但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法

  • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
  • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级

该算法也有两种处理优先级高的方法,非抢占式和抢占式:

  • 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
  • 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。

但是依然有缺点,可能会导致低优先级的进程永远不会运行

        2.2.6多级反馈队列调度算法

        是「时间片轮转算法」和「最高优先级算法」的综合和发展。

 

  • 银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,各个队列优先级从高到低,同时每个队列执行时间片的长度也不同,优先级越高的时间片越短
  • 新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。
  • 当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。

可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。

参考[1]5.1 进程、线程基础知识 | 小林coding (xiaolincoding.com)

3 进程间通信问题 

        一般而言进程之间是独立的,不能相互访问的。但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核

        这里的用户空间其实是一份,表示为三份或者多份的原因是采用了虚拟内存技术。

3.1管道

我们常见的一个linux命令"|"。

cat t1 t2 | grep mysql

上面命令行里的"|"竖线就是一个管道。它的功能是将前一个命令的输出作为后一个命令的输入。从这个描述中我们可以看出,管道传输数据是单向的。如果想要双向通信需要创建两个管道才学。

同时,我们得知管道是没有名字,所以"|"称为匿名管道。用完就销毁了。

管道还有一类是命名管道,也叫作FIFO很像队列。需要用mkfifo命令创建。

mkfifo myPipe

myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思:

接下来,我们往 myPipe 这个管道写入数据:

echo "hello" > myPipe  // 将数据写进管道
                         // 停住了 ...

 你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出.

于是,我们执行另外一个命令来读取这个管道里的数据:

cat < myPipe  // 读取管道里的数据
hello

可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。

我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。

  • 对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
  • 另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

3.2 消息队列

        前文写到管道的通信方式效率低的,因此管道不适合在进程间频繁地交换数据。

        对于这个问题,消息队列的通信模式就可以解决(类似Java中的生产者消费者模式)。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此

        这样一来消息队列就好像邮件一样互动信息。但是这样也会存在一定缺点,换而言之就是它有自己适合的场景。但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点,同时也存在内核态和用户态之间的数据拷贝

3.3 共享内存

        由于消息队列是在内核中的,因此读取和写入消息都会产生用户态和内核态的数据交换。而共享内存可以很好的解决。因为共享内存时,内核给进程A和B提供了一个共享区域。但是进程A和B不知道这个区域被其他进程共享

 3.4 信号量

        用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

        信号量就是一个指示装置,表示当前共享资源可以被多少个(信号量个)进程同时访问。

        信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

信号量表示资源的数量,控制信号量的方式有两种原子操作:

  • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

        P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。可以发现当信号量为1时,表明同一时刻只能有一个进程占有资源。就代表着是互斥信号量

        这个也很像生产者和消费者模式:例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。

那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0

 

具体过程:

  • 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
  • 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
  • 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。

可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

3.5 信号

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。然而信号和信号量随一字之差却相差很多。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX 

运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;
  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程

当我们控制窗口卡住的时候,我们通常使用Ctrl + C进行终止。

        信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

        1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。

        2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

        3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程

4 Socket

        前面提到的管道,消息队列,共享内存,信号量和信号,都是同一台主机上进行进程间通信。要想进行跨主机间的通信,就需要Socket进行通信,而且也要依赖于计算机网络。

        实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。(就好比我们使用Java写了一个客户端和服务端的通信程序,绑定的ip是我们127.0.0.1 使我们自己主机的ip)我们来看看创建 socket 的系统调用:

int socket(int domain, int type, int protocal)

三个参数分别代表:

  • domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
  • type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
  • protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;

 

  • 服务端和客户端初始化 socket,得到文件描述符;
  • 服务端调用 bind,将绑定在 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端连接;
  • 客户端调用 connect,向服务器端的地址和端口发起连接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。

这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

5 总结

        由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间

Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」还有消息队列,共享内存,信号量和信号等等。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

兜兜转转m

一毛钱助力博主实现愿望

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值