面试-操作系统篇:进程与线程

WX PUB:「曹当家的」

 

进程与线程是操作系统基础中的基础,也是面试中每次必问的“八股文”之一,以下准备了 18 个相关的问题,你可以先想想每个问题该怎么回答,最好能整理出自己的答案。掌握了以下这些,再遇到进程与线程相关的问题,基本就可以吊打面试官了。

 

 

1. 进程和线程的区别?

2. 什么是协程,协程有什么优点?

3. 并发和并行

4. 进程间通信有哪几种方式,都有什么特点?

5. 僵尸进程和孤儿进程的区别?

6. 进程是怎么调度的?

7. 进程有哪几种状态,他们是如何转换的?

8. 进程和线程的创建方式?

9. 子进程创建时会拷贝父进程哪些资源?

10. 进程上下文切换和线程上下文切换

11. 什么是系统调用?为什么要有系统调用?

12. 内核态和用户态是什么?

13. 如何实现进程同步?

14. 生产者消费者问题

16. 什么是信号量?如何使用信号量解决生产者消费者问题?

17. 哲学家就餐问题

18. 读者-写者问题

 

 


 

 

1. 进程和线程的区别?

这个问题不知道被问过多少遍了,但是要答好这个问题不是那么容易的,进程和线程是理解操作系统的基础,如果只是背答案就显得太没有深度了,在我看来,答好这个问题至少要包括这几个点:进程与线程的本质区别(定义),线程为什么存在,举例说明进程和线程的实现。

总的来说,进程是资源的容器,用来把资源集中到一起,而线程是在 CPU 上被实际调度的实体对象。

进程是资源分配的基本单位,进程中包括可执行的代码、打开的文件描述符、挂起的信号、进程的状态、内存地址空间、存放全局变量的数据段,以及一个或多个执行线程等。

线程是进程中活动的对象,或者说独立调度的基本单位。每个线程都拥有一个独立的程序计数器、线程堆栈和寄存器。

这里引申一个问题,有了进程为什么还要有线程?

  • 在一个进程中会存在多种活动任务,如果只有一个调度来执行这些任务,那么当某个任务被阻塞时,其他任务将得不到执行,因此需要有多个独立调度的单元来使这些任务可以并行的执行,这些单元就是线程。
  • 线程比进程更轻量,它们比进程更快的创建,也更容易撤销。线程间切换的开销也比进程小,由于进程拥有大量的资源,当切换到另一个进程的时候,需要保存当前进程的所有资源,而线程间的切换只需要保存当前堆栈和少了寄存器的内容。

值得一提的是,在 Linux 中,并不太区分进程和线程,线程只是一种特殊的进程,他们都被叫做任务,用 task_struct 结构体表示。它们的创建方式也大致相同,都是调用 fork() 函数,然后底层执行 clone() 方法创建,只不过,创建线程会在执行 clone() 的时候传递一些参数来指明需要共享的资源。

2. 什么是协程,协程有什么优点?

协程的定义与实现,在 Go 语言或 Python 中的特点,如何被应用的。如果面试 Go 语言,可能还会问 Goroutine 的调度机制,协程的同步机制,协程资源的如何限制等。

协程是一种用户态的轻量级线程,又叫微线程,但协程不是操作系统的机制,而是由用户程序自身控制的一种调度。协程的创建开销非常小,协程的栈空间占用只有 2k~4k,在一个地址空间中可以运行 10w 级别的协程。相比于进程和线程的上下文切换,协程几乎没有切换的开销,因为协程的调度完全由用户程序控制,只需要保存任务的上下文,不涉及内核资源的保存和恢复。

在 Go 语言中,协程的栈数量可以动态伸缩,不会出现栈溢出,当一个协程结束时会自动释放,不需要垃圾回收器管理。多个协程之间的同步是通过 channel 实现的,可以保证协程执行的顺序,因此不需要锁机制就可以实现安全的并发。

3. 并发和并行

同一个 CPU 在同一时刻只能执行一个任务指令。

并发是指一段时间内可以同时运行多道程序,因为时间较短,所以看起来像多个程序在同时执行,实际上是 CPU 在多个程序间进行快速的切换。

并行是同一时刻可以运行多个程序,是真正意义上的并发。并行需要多处理器的支持。

4. 进程间通信有哪几种方式,都有什么特点?

进程间通信有以下几种方式:

  • 管道:通常用在父子进程间通信。
  • 命名管道:去除了父子进程间通信的机制,通常用来汇聚多个客户端进程与服务端进程的通信。
  • 消息队列:独立于进程存在,进程间可以通过消息队列来传递数据,典型的模式是生产者-消费者模型。
  • 信号:一个进程可以给另一个进程发送信号来触发某些操作,比如挂起一个进程。Linux 通过 kill 命令来发送信号。
  • 信号量:信号量是一个计数器,用来保护临界资源。进程可以读取信号量的值,并对它进行加减操作,多个进程可以通过信号量实现进程同步。
  • 共享内存:多个进程上不同的地址空间可以映射到同一块物理内存上,实现数据的共享,因为不涉及数据的拷贝,所以这是一种高效的通信方式。需要注意的是,多个进程并行时,需要通过同步机制保护共享内存的访问。
  • Socket 通信:通过网络实现不同机器上不同进程间的通信。

5. 僵尸进程和孤儿进程的区别?

僵尸进程:一个子进程退出时会处于 ZOMBIE 状态,此时它占用的进程描述符没有被释放,只有等它的父进程调用 wait() 或 waitpid() 获取到子进程的信息后,最后由父进程决定是否将子进程资源释放。如果子进程的资源由于某种原因一直得不到释放,那么就一直处于僵死状态,变成了僵尸进程。

孤儿进程:当父进程退出了,但是它的子进程还没有退出,这些子进程就变成了孤儿进程。孤儿进程只是暂时的,系统会在父进程退出时启动寻父机制,为子进程找到一个新的父亲:首先在当前进程组中寻找,如果找不到就会返回 init (PID=1) 进程作为父进程。

系统中如果驻留大量的僵死进程是危险的,因为会一直占用系统资源,解决的直接办法就是杀死父进程,让他们变成孤儿进程,最后会被新的进程领养,新的父进程会例行调用 wait() 来检查子进程状态,清除相关的僵死进程。

6. 进程是怎么调度的?

不同的系统有不同的调度算法。

批处理系统:

需要保证吞吐量和总体响应时间,主要有以下几种调度算法:

  • 先来先服务(FCFS):最简单的原则。按照请求顺序进行调度。适合长作业,不适合短作业,因为短作业会很快执行完,长作业可以很快得到调度,但是长作业会阻塞后面的短作业。
  • 短作业优先:让短作业更快的执行完可以保证整体等待的时间最短。
  • 最短剩余时间优先:按剩余时间最短的作业优先调度。当一个新的作业到达时,把这个新的作业运行时间和当前作业的剩余时间做比较,如果小于当前作业的剩余运行时间,那么就挂起当前作业,运行新的作业。

最短剩余时间调度是一种理想的算法,因为大部分情况下无法知道一个进程还需要运行多长时间结束。

交互式系统:

与用户的交互性较强,需要保证较短的响应时间,满足用户的期望,主要有以下调度算法:

  • 时间片轮转调度:CPU 时间划分为均等的时间片段,每个片段分配给不同的进程,当一个进程的时间片用完之后就切换到下一个进程执行,依次循环。这种方式可以使所有的进程得到公平调度,但是会造成频繁的进程上下文切换,增大系统开销。
  • 优先级调度:每个进程定义一个优先级,重要的进程执行较长时间,次要的进程执行较短的时间。进程的优先级可以由系统动态的设定,可以为调度带来较大的灵活性
  • 多级队列:设立多个优先级类的队列,未执行完时间片的进程放入到下一级队列中。比如第一级运行 1 个时间片,第二级运行 2 个时间片,第三级运行 4 个时间片,依次类推。假如一个需要运行 100 个时间片的进程,如果通过一般的时间片轮转,那么需要 100 次调度才能执行完,而多级队列只需要 1 + 2 + 4 + 8 + 16 + 32 + 37(64 中的 37 个) = 100,总共只需要 7 次调度就完成了。

多级队列也可以保证短的交互进程得到优先调度,随着当前进程优先级的不断降低,当有短的进程达到高优先级的队列时,当前进程会让出 CPU 让高优先级的进程先执行。

实时系统:

实时分为软实时和硬实时。前者可以容忍一定时间的延迟,而后者需要满足绝对的截止时间。

实时系统的调度算法分为静态和动态的,静态的调度需要提前知道进程所需运行的时间等信息,从而做出调度决策;而动态的调度算法不需要这些限制,而是在运行过程中进行调度决策。

实时系统的调度算法可以选择优先级调度,也可以使用多级队列,最短进程优先等调度算法。

7. 进程有哪几种状态,他们是如何转换的?

进程主要有三种状态:运行态、就绪态、阻塞态。

运行态和就绪态可以相互转换,通常由系统的进程调度引起的。当一个处于运行态的进程遇到阻塞的代码,需要等待触发条件,或者没有足够的运行资源时,就会挂起当前进程,进入阻塞状态,而当满足了触发条件,或者系统资源又满足时,就是进入就绪状态,等待再次被调度。

8. 进程和线程的创建方式?

进程的创建发生在这么几个场景中:

  • 系统启动时,主要会初始化创建 3 个系统进程,一个 idle 空闲进程(PID=0)、一个 init 进程(PID=1)、一个页面守护进程(PID=2)
  • 正在运行的进程执行系统调用创建一个子进程(Unix/Linux 中使用 fork)
  • 用户请求创建一个新进程,如在 shell 中输入执行命令。
  • 提交一个批处理请求,会创建一个新进程来运行

Linux 中进程的创建主要是通过 fork 系统调用,线程被当做一种特殊的进程,也是用 fork 创建,不过通过传递不同的参数,指明共享父进程的地址空间,打开的文件等资源。

其他系统如 Windows 创建进程执行的是 CreateProcess,而线程创建实现的 POSIX 标准接口,POSIX 定义的线程包叫 Pthread,其中定义了许多的系统调用,如 Pthread_create、Pthread_join、Pthread_exit 等

9. 子进程创建时会拷贝父进程哪些资源?

Linux系统中,子进程的创建不会马上拷贝父进程的所有资源,而是以只读的方式共享大部分父进程的资源,当需要修改地址空间资源时,触发只读保护,这时才会拷贝一份地址空间。这种机制叫做 写时拷贝(copy-on-write)。这种优化可以避免拷贝大量根本不会使用到的数据。

fork 系统调用实际上只是为子进程创建一个唯一的进程描述符,分配了一个有效的 PID,有的 Linux 系统 fork 调用也会复制一份父进程的页面。

10. 进程上下文切换和线程上下文切换

上下文切换指的是当前任务的资源(寄存器和程序计数器等)、状态等内容保存起来,然后加载新任务的资源和状态,跳转到新的程序计数器指定的指令继续执行。

进程上下文切换不仅需要保存虚拟内存、全局变量、文件描述符等用户空间资源,还需要保存内核堆栈、寄存器、程序计数器等内核资源。

线程上下文切换只需要保存自己的线程栈和寄存器内容,比进程切换开销小很多。

11. 什么是系统调用?为什么要有系统调用?

系统调用是在一个进程中,由用户态切换到内核态,在内核中执行任务,或者申请操作系统的资源。系统调用是一种保护操作系统的机制,它提供一系列定义良好的 API 接口来和操作系统交互,避免用户程序直接对内核进行操作,保证了系统的稳定、安全、可靠。

12. 内核态和用户态是什么?

多数 CPU 都有两种模式,即用户态和内核态,通常由程序状态字(PSW) 寄存器中的一个位来控制这两种模式的切换(通过 TRAP 指令实现切换)。这两种状态其实对应着应用程序访问资源的权限:在用户态只能访问受限的资源,如虚拟内存,全局变量等,而要访问内核等资源需要通过系统调用等方式陷入到内核中;内核态可以访问操作系统的所有资源,包括内存、I/O 等资源。

13. 如何实现进程同步?

进程同步是指控制进程按照一定顺序执行。只有处于临界区(指访问共享内存的代码片段)的进程才需要同步。

进程同步通常有两种方式:

  • 忙等待互斥:当某个变量不满足条件时,会一直轮询直到变量值发送改变。用于忙等待的锁称为自旋锁。自旋锁一般用于中断处理程序中(需要禁止本地中断),因为中断程序需要安全、快速的执行,不能被打断、也不能被睡眠。
  • 信号量:是一个整型变量,用来实现计数器功能,主要提供 down 和 up 操作(即 P 和 V 操作),这两个操作都是原子性的。当执行 down 操作使信号量值变为 0 时,会导致当前进程睡眠,而执行 up 操作 +1 时,会同时唤醒一个进程。
  • 管程:管程是由一个过程、变量和数据结构组成的一个集合,把需要控制的那部分代码独立出来执行,它有一个重要的特性,同一时刻在管程中只能有一个活跃的进程。为了避免一个进程一直占用管程,引入了条件变量和 wait 和 signal 操作。当发生当前进程无法运行时,执行 wait 操作,将当前进程阻塞,同时调入在管程外等待的另一进程执行,而另一个进程满足条件变量时,会执行 signal 操作将正在睡眠的进程唤醒,然后马上退出管程。

14. 生产者消费者问题

也叫做有界缓冲区问题,两个进程共享一个公共的固定大小的缓冲区,一个进程产生数据放到缓冲区中,另一个进程从缓冲区中取走信息。这里存在对计数变量的竞争条件。

16. 什么是信号量?如何使用信号量解决生产者消费者问题?

信号量是一个整型变量,用来实现计数器功能,主要提供 down 和 up 操作(即 P 和 V 操作),这两个操作都是原子性的。当执行 down 操作使信号量值变为 0 时,会导致当前进程睡眠,而执行 up 操作 +1 时,会同时唤醒一个进程。

当信号量取值 0 和 1 时,就是一个互斥信号量,当取值大于 1 时,就是一个计数信号量。

 

 

17. 哲学家就餐问题

五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:进餐以及思考。当一个哲学家进餐时,需要先拿起自己左右两边的叉子,并且一次只能拿起一只叉子。

五个哲学家最多只能同时两个人进餐,因为只有 5 只叉子。如果五个哲学家同时拿起左边的叉子,那么都在等待邻居放下右边的叉子,导致谁都无法进餐,产生饥饿(也叫死锁)。

为了避免死锁,需要设置两个条件:

  • 必须同时拿起左右两边叉子
  • 只有在两个邻居都没有进餐的情况下才允许进餐

 

 

18. 读者-写者问题

允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。

一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。

 

 

 


 

END

这一期的面试题就先到这了,下一期将会分享操作系统内存管理相关的问题,敬请期待!欢迎关注我喔~

 

 

欢迎关注「曹当家的」,订阅最新文章推送 

posted @ 2021-01-16 22:15  曹当家的  阅读(260)  评论(0)    收藏  举报