带你通过字节跳动面试---操作系统复习

时间:2022-07-28
本文章向大家介绍带你通过字节跳动面试---操作系统复习,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

并发和并行

并发:

  • 同一时刻只能运行一条指令。在宏观上看起来是多个程序同时运行,但微观上是多个程序的指令交替着运行的。并发不能提高计算机的性能,只能提高效率。

并行:

  • 同一时刻可以运行多条指令。无论从宏观还是微观,都是一起执行的。比如多核
cpu

,多个程序分别运行在不同的核上,互不影响。并行确实提高了计算机的效率。

用户态和内核态区别

操作系统两种

CPU

状态:

  • 核心态:运行操作系统程序
  • 用户态:运行用户程序

操作系统的指令划分:

  • 特权指令:只能由操作系统使用,用户不能使用的指令。
  • 非特权指令:用户程序可以使用的指令。

特权级:

R0、R1、R2、R3
R0

相当于内核态,

R3

相当于用户态

  • 不同的特权级别可以运行不同的指令

区别:

  1. 内核态和用户态是操作系统的两种运行级别。用户态拥有最低的特权级,内核态拥有较高的特权级。
  2. 处于用户态时,进程能够访问到的内存空间和对象受到限制,其所占有的处理机是可以被抢占的。
  3. 处于内核态时,进程能够访问所有的内存空间和对象,且所占有的处理机是不可以被抢占的。

操作系统为什么要分内核态和用户态

为了安全。在

cpu

中,如果有些指令用错会使系统崩溃,所以用户程序是不可信的,无论程序员是否有意,都可能把系统弄崩溃。

分了内核态和用户态之后,操作系统对内核级别指令进行封装,然后为用户提供系统服务。用户进行系统调用后,操作系统进行一系列的检查验证,确保调用是安全的,在进行相应的操作。

用户态到内核态的转化

通过三种方式:系统调用、异常、外围设备的中断。

  • 系统调用
    • 这是用户主动要求从用户态切换到内核态的一种方式。用户进程通过系统调用申请使用操作系统提供的服务以完成工作。
  • 异常
    CPU

    在执行处于用户态的程序时,发生了不可预料的异常,这时当前运行的进程会切换到处理相关异常的程序中,也就转到了内核态,最常见的是缺页异常。

  • 外围设备的中断
    • 当外围设备完成用户请求的操作时之后,会向
    CPU

    发出相应的中断信号,这时

    CPU

    会停止下一条要执行的指令而转去处理中断。如果

    CPU

    先前执行的指令是用户态程序的指令,也就完成了用户态到内核态的转化。

微内核与宏内核

宏内核:除了最基本的进程、线程管理、内存管理之外,还把文件系统、驱动、网络等都集成在内核中。比如

Linux

  • 优点:效率高
  • 缺点:稳定性差,一些
bug

可能使整个系统崩溃,将修改和维护的代价提高

微内核:内核中只有最基本的进程、线程管理、内存管理,文件系统、驱动、网络等由用户态守护进程实现。比如

Windows

  • 优点:稳定性好,驱动等错误只会让相应的进程停止工作,不会使系统崩溃。
  • 缺点:效率低。

系统调用是什么,系统调用举例

系统调用由操作系统提供,运行在内核态,指运行在用户态的程序向操作系统请求更高特权级的服务。系统调用提供了用户态和内核态之间的接口。例如在程序中打开文件、向文件进行写操作都是系统调用。

进程和线程的概念

进程是具有独立功能的程序在一个数据集合上运行的过程。进程是系统进行资源分配的单位,实现的操作系统的并发。

线程是比进程更小的能独立运行的单位,是

CPU

调度的基本单位,实现了进程内部的并发。线程成为了程序执行流的最小单位。

进程状态转换图

  1. 创建状态:进程正在被创建。
  2. 就绪状态:进程已经分配到了除
CPU

之外的所有资源,只要分配到

CPU

就可以开始运行。

  1. 运行状态:进程已经获得
CPU

,正处于运行状态。

  1. 阻塞状态:正在执行的进程正在等待某一事件而暂时不能运行。
  2. 终止状态:进程运行完毕,操作系统完成撤销进程的相关工作,并将
PCB

归还给系统。

当有多个进程请求资源时,就会造成内存资源紧张,所以操作系统还存在一种挂起操作:将进程交换到外存去,使进程进入挂起状态。

  1. 活动就绪:进程在内存,处于就绪状态,还需要
CPU

  1. 静止就绪:进程在外存,处于就绪状态,还需要调入到内存和
CPU

  1. 活动阻塞:进程在内存,但由于某种原因被阻塞了。
  2. 静止阻塞:进程在外存,但由于某种原因被阻塞了。

进程切换

  1. 切换页目录,使用新进程的虚拟地址空间。
  2. 保存当前的
CPU

环境、硬件上下文,并导入新进程的的

CPU

环境、硬件上下文。

进程和线程的区别

  1. 一个线程属于一个进程,依赖于进程而存在。一个进程可以包括多个线程,但至少包括一个线程。
  2. 进程是系统资源分配的最小单位,线程是
CPU

调度的最小单位。

  1. 不同进程在执行过程中拥有独立的内存单元,而同一进程内多个线程共享进程内存。所以一个线程的意外会造成同一进程内其他线程的终止,而进程之间不会互相影响。
  2. 进程切换时,涉及整个程序的
CPU

环境保存和新调度进程的

CPU

环境配置。线程切换时,只需要保存少量内容,所以进程切换的系统开销更大。

  1. 进程通信时,由于它的空间独立性决定了它的通信需要通过操作系统。而线程通信时,由于多线程同享内存导致了线程之间的通信比较容易,不需要通过操作系统。

有了进程为什么还要线程

进程可以提高系统的并发性和资源的利用率,但还是存在一些缺点的:

  1. 比如一个进程一时间只能做一件事。比如只有进程的话
QQ

无法同时完成传输文件、视频聊天。

  1. 进程在执行过程中被阻塞时,整个进程就会挂起,那么进程中有些不依赖于等待资源的工作也不会执行。比如浏览器想要打印某个页面时,打印机被占用,那么浏览器也无法提供别的服务。

为了解决这些缺点,所以引入了线程作为进程内并发执行的更小单元,从而减少程序在并发执行过程中所付出的时空开销。

进程之间的同步方式

各个进程拥有自己独立的内存空间,为了保证安全,一个进程不可以直接访问另一个进程的内存空间。但进程之间的通信是必不可少的,所以有以下方式完成进程之间的通信:

  1. 管道通信
    • 管道通信分为普通管道和命名管道。普通管道可用于有亲缘关系进程之间的通信,命名管道还允许无亲缘关系进程之间的通信。
    • 管道上数据是单方向传输的,想要完成双向通信需要两个管道。
  2. 消息
    • 将通信的数据封装在消息中,通过消息来完成通信。消息的通信方式有两种:
    1. 直接通信方式,将消息直接发送给对方进程。
    2. 间接通信方式,双方都通过共享中间实体来完成对消息的法松和接收。
  3. 信号量
    • 信号量本质上是一个计数器,用来完成进程的互斥和同步,比如
    PV

    操作。

  4. 共享内存
    • 不同进程可以访问同一块内存,共享内存是临界资源。共享内存直接从内存中读取数据,不需要从用户态到内核态的切换,是最快的一种方式。

线程之间的同步方式

  1. 临界区:多线程访问公共资源,速度快。
  2. 互斥量:控制多个进程对他们之间共享资源的互斥访问。由于资源只有一个,所以不能被多个线程同时访问。
  3. 信号量:允许多个线程访问同一资源,但同一时刻访问该资源的线程有最大的数目限制。

线程之间哪些是共享的

  1. 堆区,堆是进程开辟出来的,多线程共享这部分资源。
  2. 全局变量和静态变量,和特定线程无关,所以也是共享的。
  3. 文件等公共资源,使用这些公共资源的线程必须同步。

线程需要保存哪些上下文

线程在切换过程中,需要保存当前线程

id

、线程状态、堆栈、寄存器状态等信息。寄存器状态主要包括:

SP

:堆栈指针,指向当前栈的栈顶指针。

PC

:程序计数器,存储下一跳将要执行的指令。

EAX

:累加寄存器,用于加法乘法的缺省寄存器。

游戏服务器应该为每个用户开辟一个线程还是一个进程

进程。因为同一进程内的线程会相互影响,所以如果一个用户的线程死掉了,其他用户的游戏也会崩溃。所以应该为每个用户开辟一个进程,使用户之间不会相互影响。

多进程和多线程的使用场景

多线程模型适用于

I/O

密集型场合。因为经常会因为

I/O

阻塞来切换线程,而线程切换的系统开销比进程切换小。

多进程模型适用于需要频繁计算的场合。

多线程是不是越多越好

并不是

  1. 因为多线程意味着需要更多的内存资源。
CPU

并不是同时运行多个线程,而是轮流执行了,如果线程过多,

CPU

就要在不同的线程之间快速切换,那么

CPU

的利用率就会降低。

C

++ 线程中锁机制

  • 互斥锁。互斥锁用于控制多个线程对他们之间共享资源的互斥访问的一个信号量。避免了多个对象同时访问一个共享资源。此时它会被放到等待队列里,然后去处理其他任务。
  • 条件锁。当共享数据达到某个值时,唤醒正在等待这个数据的线程,若没有共享数据分配时,向申请的线程挂起。
  • 自旋锁。当一个线程想要请求一个资源,但这个资源被别的线程占用时,它会一直处于请求状态而不能去做其他事情(忙等),所以自旋锁容易造成死锁。只有内核是可抢占式的且当临界区资源很快可以被分配到的时候,才考虑采用自旋锁。
  • 读写锁。分为读锁和写锁。允许多个线程同时进行读操作,但同一时间只允许一个线程进行写操作。

单核机器上写多线程程序,是否需要考虑加锁

仍然需要线程锁。

线程锁通常用来实现线程的同步和通信,在单核机器上仍然存在线程同步的问题。在抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽以后,操作系统会把它挂起,然后运行另一个线程。如果两个线程共享某些数据,但没有线程锁,可能会导致同享数据修改引起冲突。

死锁发生的条件以及如何解决死锁

死锁是指两个或者两个以上进程在执行过程中,因为争夺资源而造成相互等待的现象。

四个发生条件分别为:

  1. 互斥条件。进程分配到的资源不允许其他进程同时访问。若其他进程想要访问该资源,只能等待到占用资源的进程使用完。
  2. 请求和保持条件。进程获得一定资源后,又对其他资源发出请求。如果其他资源被占用,此时请求阻塞,但该进程不会释放以有的资源。
  3. 不可剥夺条件。进程获得的资源在使用完成之前不可被剥夺,只能自己使用完后释放。
  4. 循环等待条件。发生死锁时,一定存在一个 进程——资源 的循环链。

解决死锁的方法:

  1. 破坏请求和保持条件。在进程开始运行之前,必须一次性分配该进程需要的所有资源。
  2. 破坏不可剥夺条件。当一个进程对新资源的请求又不能被满足时,必须释放已经获得的所有资源。
  3. 破坏循环等待条件。系统对所有资源类型进行编号,每个进程必须按序号递增的顺序请求资源。如果想要请求序号较低的资源,必须释放已经获得的高序号的资源。

虚拟内存技术

传统存储器存在问题:当有的作业很大或同一时间有大量作业要求运行时,其需要的内存空间超过了内存总容量,作业无法全部装入,导致作业无法运行。这都是由于传统存储器要求一次性装入作业导致的,所以采用了虚拟内存。

虚拟内存技术使进程在运行过程中,内存中只装入了当前要运行的少数页面,其余部分暂存在外存上。如果程序访问的页尚未调入内存中,便发出缺页中断,

OS

将需要的页调入内存。如果内存满了,无法装入新的页时,便会使用页面置换方式将暂时不用的页调至外存,再将要访问的页调入内存。

虚拟内存的优点:

  1. 可以更加高效的使用物理内存。
  2. 使内存的管理更加便捷。在编译程序的时候使用虚拟地址,就不会因为物理地址有时被占用而需要重新编译了。
  3. 更加安全。每个进程运行在各自的虚拟内存地址空间中,互相不干扰对方。

虚拟内存的缺点:

  1. 虚拟内存需要建立额外的数据结构,需要占用额外的内存。
  2. 虚拟地址到物理地址的转换增大的运行时间。
  3. 页面的换入换出需要磁盘
I/O

,需要耗费很大的时间。

虚拟内存和物理内存怎么对应

请求分页存储管理中一般使用二级页表。

cr3

中取出页表地址。

  1. 根据地址前十位,找到对应的索引项。此时获得页目录的地址,而不是页的地址。
  2. 根据地址中间十位,从页表中获得该页的起始地址。
  3. 将获得的页的起始地址和最后
12

位地址相加,获得想要的物理地址。

操作系统中的缺页中断

通过

malloc

分配内存时,只是分配了虚拟内存而不是实际的物理地址,进程访问时也是访问的虚拟地址而不是物理地址。

在请求分页系统中,可以查询页表的状态来确定要访问的页表是否在内存中。每当要访问的页面不存在内存中时,就会发生一个缺页中断,然后操作系统会将缺失的页调入到内存中。

缺页中断的处理一般分为

4

个步骤:

  1. 保护
CPU

现场

  1. 分析中断原因
  2. 转入缺页中断处理程序进行处理
  3. 恢复
CPU

现场并继续处理

虚拟内存置换的方式

当访问一个不存在内存中的页时,需要从外存调入。如果此时内存已满,就需要调出一个页到外存,在将需要的页调入。这个过程叫做缺页置换。

  1. 最佳置换算法:调出的页面是未来不访问或最久不访问的页面,但由于实际过程中无法预知未来,这是一种理论的算法。
  2. 先进先出
(FIFO)

页面置换算法:置换掉最早调入内存的页面,也就是说在内存中按队列的形式管理页,从队尾插入,从队首删除。

  1. 最近最久未使用
(LRU)

置换算法:置换掉最近一段时间内最久未访问的页面。根据局部性原理,刚刚被访问过的页面可能马上又要被访问,而较长时间未访问的页面可能最近不会访问。

  1. 最少使用
(LFU)

置换算法。置换掉最近一段时间访问次数最少的页面。

CLOCK

置换算法。为每一页设置一个访问位,再将页面设置成循环队列。在选择一个页面时,如果访问位是

0

,就把它置换掉,如果是

1

,就把访问位置为

0

并开始检查下一个页面。

为什么要有

cache
cache

CPU

的高速缓存,可以加快读取数据的速度。

在页式存储结构中,需要先访问内存获得数据物理地址,然后再去物理地址中读出数据。但访问内存的速度比较慢,所有引入了

cache

,访问一个地址时,先去

cache

中查找,如果命中了就可以直接获得其物理地址,然后访问数据,这样可以大大增加访问速度。

5

IO

模型

  1. 阻塞
IO

。一直检查

IO

事件是否就绪,没有就继续等待,期间什么事也不做。

  1. 非阻塞
IO

。每隔一段时间检查一下

IO

事件是否就绪,没有就绪就做其他事。

  1. 信号驱动
IO

。安装一个信号处理函数,进程继续运行。当

IO

事件就绪时,进程会收到

SIGIO

信号,然后处理

IO

事件。

IO

多路复用。

IO

多路复用在阻塞

IO

上多了个

select

函数,

select

函数可以看后面。

  1. 异步
IO

。应用程序把

IO

请求给内核后,由内核去完成相关操作。当内核完成相关操作后,会发信号告诉应用进程本次

IO

已经完成。

水平触发和边缘触发

  1. 水平触发(状态达到):当被监控的文件描述符上有可读写事件发生时,会通知用户程序去读写。如果用户一次读写没取完数据,他会一直通知用户。如果这个描述符是用户不关心的,它每次都返回通知用户,则会导致用户对于关心的描述符的处理效率降低。
  2. 边缘触发(状态变化):当被监控的文件描述符上有可读写事件发生时,会通知用户程序去读写,它只会通知用户进程一次,这需要用户一次把内容读取完,相对于水平触发,效率更高。如果用户一次没有读完数据,再次请求时,不会立即返回,需要等待下一次的新的数据到来时才会返回,这次返回的内容包括上次未取完的数据。
select

poll

是水平触发的。

epoll

支持水平触发也支持边缘触发,但默认是水平触发的。

windows

消息机制

当用户使用键盘或者鼠标时,系统会把这些操作转化成消息。系统会将这些消息放入消息队列中,然后对应的进程会循环从消息队列中取出消息,完成对应的操作。

僵尸进程

  1. 正常进程
    • 正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。但子进程的结束和父进程的运行是一个异步过程,也就是说父进程无法预知子进程什么时候结束。一个进程完成他的工作后,它的父进程要调用
    wait/waitpid

    函数来收集子进程的终止状态,并把他彻底销毁后返回,如果没有等到这样的一个子进程,就会阻塞在这里等待。

  2. 孤儿进程
    • 如果一个父进程推出,而他的子进程还在运行,那么这些子进程就变成了孤儿进程。孤儿进程会由
    init

    进程收养。

  3. 僵尸进程
    • 如果子进程退出,而父进程没有使用
    wait/waitpid

    函数,那么这些进程的进程描述符仍然保存在系统中,这些进程被称为僵尸进程。

    • 僵尸进程是一个必经的阶段,如果子进程退出,父进程还没有使用
    wait/waitpid

    函数,此时就是僵尸进程,等到父进程处理以后才消失。如果父进程在子进程结束之前退出,子进程就会由

    init

    进程接管,

    init

    进程会以父进程的身份处理僵尸进程。

僵尸进程的危害:

  • 僵尸进程如果不被释放,就会一直占用系统的进程号。而系统的进程号是有限的,如果有大量的僵尸进程,可用的进程就会减少。

如何避免僵尸进程

  • 外部解决
    • 通过
    kill

    消灭产生僵尸进程的进程,那么僵尸进程就变成了孤儿进程,由

    init

    进程处理。

  • 内部解决
    • 子进程退出时向父进程发送信号,父进程接收到信号时,在信号处理中调用
    wait

    处理僵尸进程

    • 两次
    fork

    :父进程

    fork

    后马上

    wait/waitpid

    ,子进程在

    fork

    一次后马上

    exit

    ,孙进程完成父进程中本来要完成的事情,由于是孙进程的父进程已经退出了,它变成了孤儿进程,由

    init

    进程处理。

线程池

线程池就是首先创建一些线程,它们的集合称为线程池。线程池在系统启动时即创建大量空闲的线程,程序将一个任务传给线程池,线程池就会启动一条线程来执行这个任务,执行结束以后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个任务。线程池可以很好的提高性能。

线程池工作流程

  1. 初始化线程池、任务队列和工作线程
  2. 向任务队列中添加任务
  3. 将等候在条件变量(任务队列上有任务)上的一个线程唤醒并从该任务队列中取出第一个任务给该线程执行
  4. 等待任务队列中所有任务执行完毕
  5. 关闭线程池

如果线程的空闲时间过长,就可以考虑缩小线程池。

大端小端以及如何判断大端小端

  • 大端:指低字节存储在高地址
  • 小端:指低字节存储在低地址

可用通过

union

来判断系统是大端还是小端,因为

union

总是从低地址开始存放

int main() {
 // freopen("in", "r", stdin);
 union T {
  int x;
  char y;
 } t;
 t.x = 1;
 if(t.y == 1) printf("xiao duann");
 else printf("da duann");
 return 0;
}

fork、vfork、select、poll、epoll

函数

fork

函数通过系统调用创建一个和原来进程几乎一模一样的进程,两个进程可以做同一件事,如果传的参数不同也可以做不同的事。在子进程中,成功的

fork()

会返回

0

,在父进程中

fork()

会返回子进程的

pid

,失败会返回负数。

vfork

的调用和作用和

fork

是一致的。但存在一些区别:

fork

的子进程拷贝父进程的地址空间,

vfork

的子进程和父进程共享地址空间。

fork

的子进程和父进程执行顺序不定,

vfork

保证子进程先执行,父进程在执行。

select

函数是实现

IO

多路复用的一种方式。

select

函数监听程序的文件描述符集,由数组来描述哪个文件描述符被置位了。当某个文件描述符就绪时,就会返回所有的描述符集,然后应用程序去检查哪个文件描述符上有事件发生。

select

函数还存在一些缺点:

  • 内置数组的形式使最大文件数受限
  • 每次调用前,都要把文件描述符集从用户态拷贝到内核态,每次调用后,都要从内核态拷贝到用户态
  • 轮询排查的方式在文件描述符多时效率很低
poll

函数通过一个可变长度的数组解决了

select

函数中文件描述符受限的问题。

epoll

函数把要监听的描述符添加进去,这些描述符会组成一颗红黑树。当某个描述符上有事件发生时,会把对应的文件描述符添加到链表中,然后返回链表。

epoll

相较与

select

的优点在于:

  • 支持监听大数目的文件描述符。
select

最大为

1024

epoll

可以远远大于这个值。

  • 效率上提高。
select

返回时不可以把有事件的描述符筛选出来,需要在遍历一遍,而

epoll

返回时会加到一个链表中,然后直接对链表操作。

fork

后父子进程的内存关系

  1. 首先可以确定的是,代码是相同的,所以父子进程会共用代码段
  2. 对于数据部分,一开始时,子进程的页表项指向和父进程相同的物理内存页。而当父进程或子进程想要对这些页面做修改之前,操作系统会拷贝要修改的页面,并对父子进程的页表项做出相应的调整。

Linux

进程内存空间

从高地址的到低地址分别为:

  • 内核空间
1G
  • 用户空间
3G

多核

CPU

进程调度算法

  • 全局队列调度
    • 操作系统维护一个全局的任务等待队列。
    • 当系统中有一个
    CPU

    核心空闲时,操作系统就从全局任务等待队列中选取就绪任务开始在此核心上执行。

    • 这种方法的优点是
    CPU

    核心利用率较高。

  • 局部队列调度。
    • 操作系统为每个
    CPU

    内核维护一个局部的任务等待队列。

    • 当系统中有一个
    CPU

    内核空闲时,便从该核心的任务等待队列中选取恰当的任务执行。

    • 这种方法的优点是任务基本上无需在多个
    CPU

    核心间切换,有利于提高

    CPU

    核心局部

    cache

    命中率。

  • 目前多数多核
CPU

操作系统采用的是基于全局队列的任务调度算法。

磁盘的物理结构

通过 (柱面号、盘面号、扇区号) 的三元组来定位到要读数据的位置。

  1. 通过 柱面号 移动磁臂,让磁头指向指定的柱面
  2. 激活指定盘面的磁头
  3. 磁盘旋转过程中,从指定扇区划过,完成了读/写

一次读取数据需要的时间:

  1. 寻找时间:启动磁头臂和移动磁头花费的时间
  2. 延迟时间:旋转磁盘,使磁头定位到指定扇区的时间
  3. 传输时间:从磁盘中读出数据或向磁盘写入数据所经历的时间