面经汇总 操作系统

面经汇总 操作系统

Posted by zhaostu4 on November 28, 2019

T:2019/11/28 W:四 17:0:11

1. 进程与线程的概念,以及为什么要有进程线程,其中有什么区别,他们各自又是怎么同步的

  • 基本概念:
    • 进程
      • 是对运行时程序的封装,
      • 是系统进行资源调度和分配的的基本单位,
      • 实现了操作系统的并发
    • 线程
      • 是进程的子任务,是CPU调度分派的基本单位,用于保证程序的实时性,实现进程内部的并发
      • 线程是操作系统可识别的最小执行和调度单位
      • 每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态(栈也是独立的)。
      • 每个线程完成不同的任务,但是共享
        • 同一地址空间(也就是同样的动态内存(堆区),映射文件(映射区),目标代码等等)
        • 打开的文件队列和其他内核资源。(例如 Socket, 文件句柄什么的)
  • 有了进程又增加线程的原因:
    • 原来存在的问题:
      • 进程在同一时间只能干一件事, 进程在执行的过程中如果阻塞,整个进程就会挂起,即使进程中有些工作不依赖于等待的资源,仍然不会执行。
    • 引入线程有如下优势:
      • 线程所需的资源相对较小
      • 线程间切换的代价较小
      • 线程间通信方便
      • 可以改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序才会利于理解和修改。
      • 可以有效利用多核CPU
  • 区别: 1) 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。 2) 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。 - 资源分配给进程,同一进程的所有线程共享该进程的所有资源。 - 同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。 - 但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。 3) 进程资源分配的最小单位,线程CPU调度的最小单位; 4) 系统开销:进程切换的开销也远大于线程切换的开销。 - 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。 - 在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。 - 而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。 5) 通信: - 同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。 - 而进程间通信IPC主要包括: 管道, 系统IPC(包括消息队列、信号量、信号、共享内存等) 以及套接字socket
    • 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
    • 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉
    • 进程适应于多核、多机分布;线程适用于多核

2. 进程间通信的方式:

  • 进程间通信主要包括管道、系统IPC(包括消息队列信号量信号共享内存等)、以及套接字socket
    • 管道:管道主要包括无名管道命名管道, 管道可用于具有亲缘关系的父子进程间的通信,有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信
      • 普通管道PIPE: 1) 它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端 2) 它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间) 3) 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的readwrite等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
      • 命名管道FIFO: 1) FIFO可以在无关的进程之间交换数据 2) FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
    • 系统IPC
      • 消息队列
        • 消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标记。 (消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点)具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;
      • 特点: 1) 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。 2) 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。 3) 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
    • 信号量semaphore
      • 信号量(semaphore)与已经介绍过的IPC结构不同,它是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
      • 特点: 1) 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。 2) 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。 3) 每次对信号量的 PV 操作不仅限于对信号量值加 1减 1,而且可以加减任意正整数。 4) 支持信号量组。
    • 信号signal: 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
    • 共享内存(Shared Memory):它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等
      • 特点: 1) 共享内存是最快的一种IPC,因为进程是直接对内存进行存取 2) 因为多个进程可以同时操作,所以需要进行同步 3) 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问
    • 套接字SOCKET
      • socket也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机之间的进程通信。

3. 线程间通信的方式:

  • 线程间通信的方式: 临界区, 互斥量, 信号量, 信号
    • 临界区:通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
    • 互斥量Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
    • 信号量Semphare:实际上是一个计数器,为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。
    • 事件(信号) Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作

4. Linux虚拟地址空间

  • 虚拟内存技术
    • 目的是为了解决进程地址空间隔离的问题, 采用了虚拟内存。
    • 虚拟内存技术为每个进程提供一个大的, 一致的和私有的地址空间, 就好像独占整个内存空间一样.
    • 所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。
    • 事实上,在每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。
    • 还有进程运行过程中,要动态分配内存,比如malloc时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。
    • 请求分页系统、请求分段系统和请求段页式系统都是针对虚拟内存的,通过请求实现内存与外存的信息置换。
    • 扩大地址空间; (最简单的例子不就是64位的系统么, 逻辑地址空间大的逆天 !!)

  • 虚拟内存的好处:
    • 扩大地址空间; (最简单的例子不就是64位的系统么, 逻辑地址空间大的逆天, 物理地址远小于逻辑地址 !!)
    • 扩大了可寻址空间 (例如32位逻辑地址寻址36位的物理地址)
    • 内存保护:每个进程运行在各自的虚拟内存地址空间,互相不能干扰对方。虚存还对特定的内存地址提供写保护,可以防止代码或数据被恶意篡改。
    • 公平内存分配。采用了虚存之后,每个进程都相当于有同样大小的虚存空间。
    • (内存共享)当进程通信时,可采用虚存共享的方式实现。
      • 当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存
    • (虚拟储存技术)虚拟内存很适合在多道程序设计系统中使用,许多程序的片段同时保存在内存中。当一个程序等待它的一部分读入内存时,可以把CPU交给另一个进程使用。在内存中可以保留多个进程,系统并发度提高
      • 在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用内存碎片


  • 虚拟内存的代价:
    • 虚存的管理需要建立很多数据结构,这些数据结构要占用额外的内存
    • 虚拟地址到物理地址的转换,增加了指令的执行时间。
    • 页面的换入换出需要磁盘I/O,这是很耗时的
    • 如果一页中只有一部分数据,会浪费内存。

5. 操作系统中的程序的内存结构

操作系统中的程序的内存结构

  • 任何程序程序本质上都是由BSS段data段text段三个组成的。
    • BSS段(未初始化数据区):通常用来存放程序中未初始化的全局变量和静态变量的一块内存区域。BSS段属于静态分配,程序结束后静态变量资源由系统自动释放。
    • 数据(data)段:存放程序中已初始化的全局变量, 静态变量以及常量数据的一块内存区域。数据段也属于静态内存分配。
    • 代码(text)段:存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。在代码段中,也有可能包含一些只读的常数变量(例如字节型字面值常量)
  • 可执行程序在存储(没有调入内存)时分为代码段数据区未初始化数据区三部分。
    • text段data段在编译时已经分配了空间,而BSS段并不占用可执行文件的大小(因为没有初值),它是由链接器来获取内存的。bss段(未进行初始化的数据)的内容并不存放在磁盘上的程序文件中。其原因是内核在程序开始运行前将它们设置为0。需要存放在程序文件中的只有正文段和初始化数据段。
    • data段(已经初始化的数据)则为数据分配空间,数据保存到目标文件中。数据段包含经过初始化的全局变量以及它们的值。
    • BSS段的大小从可执行文件中得到,然后链接器得到这个大小的内存块,紧跟在数据段的后面。当这个内存进入程序的地址空间后全部清零。
    • 包含数据段BSS段的整个区段此时通常称为数据区(静态区)
  • 可执行程序在运行时又多出两个区域:栈区堆区
    • 栈区:
      • 由编译器自动释放,存放函数的参数值、局部变量等。
      • 每当一个函数被调用时,该函数的返回类型和一些调用的信息被存放到栈中。
      • 然后这个被调用的函数再为他的自动变量和临时变量在栈上分配空间。
      • 每调用一个函数一个新的栈就会被使用。
      • 栈区是从高地址位向低地址位增长的,是一块连续的内存区域,最大容量(1M)是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。
    • 堆区:
      • 用于动态分配内存,位于BSS中间的地址区域。由程序员申请分配和释放。
      • 堆是从低地址位向高地址位增长,采用链式存储结构。
      • 频繁的malloc/free造成内存空间的不连续,产生碎片。
      • 申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

6. 操作系统中的缺页中断

  • malloc()mmap()等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存.
  • 当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。
  • 缺页中断:在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存是,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
  • 缺页本身是一种中断,与一般的中断一样,需要经过4个处理步骤:
    • 保护 CPU 现场
    • 分析中断原因
    • 转入缺页中断处理程序进行处理
    • 恢复 CPU 现场,继续执行

    • 中断的处理右两个处理步骤: 中断响应和中断处理
    • 中断响应由硬件实现主要高阔:
      • 保护断点
      • 识别中断源,
      • 引入中断服务程序入口地址(检查中断向量表)
    • 中断处理:
      • 保护寄存器状态
      • 执行相应逻辑
      • 恢复现场

    • 但是缺页中断是由于所要访问的页面不存在于内存时,由硬件所产生的一种特殊的中断,因此,与一般的中断存在区别: 1) 在指令执行期间产生和处理缺页中断信号 2) 一条指令在执行期间,可能产生多次缺页中断 3) 缺页中断返回是,执行产生中断的一条指令,而一般的中断返回是,执行下一条指令。

7. forkvfork的区别

  • fork:创建一个和当前进程映像一样的进程可以通过fork( )系统调用:
      #include <sys/types.h>
      #include <unistd.h>
      pid_t fork(void);
    
    • 成功调用fork( )会创建一个新的进程,它几乎与调用fork( )的进程一模一样,这两个进程都会继续运行。
      • 在子进程中,成功的fork( )调用会返回0
      • 在父进程中fork( )返回子进程的pid
      • 如果出现错误,fork( )返回一个负值。
    • 最常见的fork( )用法是创建一个新的进程,然后使用exec( )载入二进制映像,替换当前进程的映像。
      • 这种情况下,fork派生了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。

  • 早期的Unix系统中,创建进程比较原始。当调用fork时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中。但从内核角度来说,逐页的复制方式是十分耗时的。
  • 现代的Unix系统采取了更多的优化,例如Linux,采用了写时复制的方法,而不是对父进程空间进程整体复制。

  • vfork:在实现写时复制之前,Unix的设计者们就一直很关注在fork后立刻执行exec所造成的地址空间的浪费。BSD的开发者们在3.0BSD系统中引入了vfork( )系统调用。
      #include <sys/types.h>
      #include <unistd.h>
      pid_t vfork(void);
    
    • 除了子进程必须要立刻执行一次对exec的系统调用,或者调用_exit( )退出,对vfork( )的成功调用所产生的结果和fork( )是一样的。
    • vfork( )挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。
      • 通过这样的方式,vfork( )避免了地址空间的按页复制。
      • 在这个过程中,父进程和子进程共享相同的地址空间和页表项。
      • 实际上vfork( )只完成了一件事:复制内部的内核数据结构。因此,子进程也就不能修改地址空间中的任何内存。
    • vfork( )是一个历史遗留产物,Linux本不应该实现它。需要注意的是,即使增加了写时复制,vfork( )也要比fork( )快,因为它没有进行页表项的复制。然而,写时复制的出现减少了对于替换fork( )争论。
    • 实际上,直到2.2.0内核,vfork( )只是一个封装过的fork( )。因为对vfork( )的需求要小于fork( ),所以vfork( )的这种实现方式是可行的。

  • 补充知识点:写时复制
    • Linux采用了写时复制的方法,以减少fork时对父进程空间进程整体复制带来的开销。
    • 写时复制是一种采取了惰性优化方法来避免复制时的系统开销。
    • 它的前提很简单:
      • 如果有多个进程要读取它们自己的那部门资源的副本,那么复制是不必要的。
      • 每个进程只要保存一个指向这个资源的指针就可以了。
      • 只要没有进程要去修改自己的“副本”,就存在着这样的幻觉:每个进程好像独占那个资源。
      • 从而就避免了复制带来的负担。如果一个进程要修改自己的那份资源“副本”,那么就会复制那份资源,并把复制的那份提供给进程。
      • 不过其中的复制对进程来说是透明的。这个进程就可以修改复制后的资源了,同时其他的进程仍然共享那份没有修改过的资源。
      • 所以这就是名称的由来:在写入时进行复制。
    • 写时复制的主要好处在于:如果进程从来就不需要修改资源,则不需要进行复制。惰性算法的好处就在于它们尽量推迟代价高昂的操作,直到必要的时刻才会去执行。
    • 在使用虚拟内存的情况下,写时复制(Copy-On-Write)是以页为基础进行的。所以,只要进程不修改它全部的地址空间,那么就不必复制整个地址空间。在fork( )调用结束后,父进程和子进程都相信它们有一个自己的地址空间,但实际上它们共享父进程的原始页,接下来这些页又可以被其他的父进程或子进程共享。
    • 写时复制在内核中的实现非常简单。与内核页相关的数据结构可以被标记为只读和写时复制。如果有进程试图修改一个页,就会产生一个缺页中断。内核处理缺页中断的方式就是对该页进行一次透明复制。这时会清除页面的COW属性,表示着它不再被共享。
    • 现代的计算机系统结构中都在内存管理单元(MMU)提供了硬件级别的写时复制支持,所以实现是很容易的。
    • 在调用fork( )时,写时复制是有很大优势的。因为大量的fork之后都会跟着执行exec,那么复制整个父进程地址空间中的内容到子进程的地址空间完全是在浪费时间:如果子进程立刻执行一个新的二进制可执行文件的映像,它先前的地址空间就会被交换出去。写时复制可以对这种情况进行优化。

  • forkvfork的区别:
    1. fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段
    2. fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用execexit之前与父进程数据是共享的,在它调用execexit之后父进程才可能被调度运行。
    3. vfork( )保证子进程先运行,在它调用execexit之后父进程才可能被调度运行 。 如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会 导致死锁
    4. 当需要改变共享数据段中变量的值,则拷贝父进程。

8. 如何修改文件最大句柄数?

  • linux默认最大文件句柄数是1024个,在linux服务器文件并发量比较大的情况下,系统会报"too many open files"的错误。故在linux服务器高并发调优时,往往需要预先调优Linux参数,修改Linux最大文件句柄数。

  • 有两种方法:

    • ulimit -n <可以同时打开的文件数>,将当前进程的最大句柄数修改为指定的参数
      • 注:该方法只针对当前进程有效,重新打开一个shell或者重新开启一个进程,参数还是之前的值
          首先用 ulimit -a 查询`Linux`相关的参数,如下所示:
          core file size          (blocks, -c) 0
          data seg size           (kbytes, -d) unlimited
          scheduling priority             (-e) 0
          file size               (blocks, -f) unlimited
          pending signals                 (-i) 94739
          max locked memory       (kbytes, -l) 64
          max memory size         (kbytes, -m) unlimited
          open files                      (-n) 1024
          pipe size            (512 bytes, -p) 8
          POSIX message queues     (bytes, -q) 819200
          real-time priority              (-r) 0
          stack size              (kbytes, -s) 8192
          cpu time               (seconds, -t) unlimited
          max user processes              (-u) 94739
          virtual memory          (kbytes, -v) unlimited
          file locks                      (-x) unlimited
          其中,open files就是最大文件句柄数,默认是1024个。
          修改Linux最大文件句柄数:  ulimit -n 2048, 将最大句柄数修改为 2048个。
        
    • 对所有进程都有效的方法,修改Linux系统参数
      • vi /etc/security/limits.conf 添加
          soft  nofile  65536
          hard  nofile  65536
          // 将最大句柄数改为 65536
        
      • 修改以后保存,注销当前用户,重新登录,修改后的参数就生效了

9. 并发(concurrency)和并行(parallelism)

  • 并发(concurrency):指宏观上看起来两个程序在同时运行,比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的,你的指令之间穿插着我的指令,我的指令之间穿插着你的,在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能,只能提高效率。
  • 并行(parallelism):指严格物理意义上的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。

10. MySQL的端口号是多少,如何修改这个端口号

  • 查看端口号:
    • 使用命令show global variables like 'port';查看端口号
    • mysql的默认端口是3306
    • 补充:
      • sqlserver默认端口号为:1433
      • oracle默认端口号为:1521
      • DB2默认端口号为:5000
      • PostgreSQL默认端口号为:5432
  • 修改端口号:
    • 修改端口号:编辑/etc/my.cnf文件,早期版本有可能是my.conf文件名,增加端口参数,并且设定端口,注意该端口未被使用,保存退出。

11. 操作系统中的页表寻址

  • 页式内存管理,内存分成固定长度的一个个页片。
    • 操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构,叫页表,页表的内容就是该进程的虚拟地址到物理地址的一个映射。页表中的每一项都记录了这个页的基地址。
    • 通过页表,由逻辑地址的高位部分先找到逻辑地址对应的页基地址,再由页基地址偏移一定长度就得到最后的物理地址,偏移的长度由逻辑地址的低位部分决定。一般情况下,这个过程都可以由硬件完成,所以效率还是比较高的。
    • 页式内存管理的优点就是比较灵活,内存管理以较小的页为单位(内存碎片的最小单位为页),方便内存换入换出和扩充地址空间。
    • 优点:
      • 页面都用固定的长度, 页表简单,调如方便
    • 缺点:
      • 程序不可能正好是页面的整数倍, 所以最后一页的零头将会浪费
      • 页不是逻辑上的独立实体,所以处理, 保护和共享都不级段式虚拟存储
  • Linux最初的两级页表机制:
    • 两级分页机制将32位的虚拟空间分成三段,分别表示页目录表项, 页表项, 内页偏移, 虚拟地址高10位表示页目录表偏移, 中间10位表示页表偏移, 低12位表示页内偏移
    • 低十二位表示页内偏移,高20分成两段分别表示两级页表的偏移。
      • PGD(Page Global Directory): 最高10位,全局页目录表索引
      • PTE(Page Table Entry):中间10位,页表入口索引
      • 其中的计算关系为:
        • 一页的大小为4K, 页表项索引的大小为4bytes, 所以一页中可以存放1024(2$^{10}$)个页表项
          • 对于页目录表 就是一页存放了1024个页表项索引
          • 对于页表就是 一页可以存放 1024页索引
    • 当在进行地址转换时,
      • 首先CR3寄存器中存放的全局页目录表(page directory, PGD)的这一页的物理地址
      • 再加上从虚拟地址中抽出高10位叫做页目录表项(内核也称这为pgd)的部分作为偏移, 即定位到可以描述该地址的pgd;从该pgd中可以获取可以描述该地址的页表的物理地址,
      • 再加上从虚拟地址中抽取中间10位作为偏移, 即定位到可以描述该地址的pte
      • 在这个pte中即可获取该地址对应的页的物理地址, 加上从虚拟地址中抽取的最后12位,即形成该页的页内偏移, 即可最终完成从虚拟地址到物理地址的转换。
      • 从上述过程中,可以看出,对虚拟地址的分级解析过程,实际上就是不断深入页表层次,逐渐定位到最终地址的过程,所以这一过程被叫做page talbe walk
  • Linux的三级页表机制:
    • X86引入物理地址扩展(Pisycal Addrress Extension, PAE)后,可以支持大于4G的物理内存(36位),但虚拟地址依然是32位,原先的页表项不适用,它实际多4 bytes被扩充到8 bytes,这意味着,每一页现在能存放的pte数目从1024变成512了(4k/8)。相应地,页表层级发生了变化,Linux新增加了一个层级,叫做页中间目录(page middle directory, PMD), 变成:
    字段 描述 位数
    cr3 指向一个PDPT crs寄存器存储
    PGD 指向PDPT中4个项中的一个 位31~30
    PMD 指向页目录中512项中的一个 位29~21
    PTE 指向页表中512项中的一个 位20~12
    page offset 4KB页中的偏移 位11~0
    • 现在就同时存在2级页表和3级页表,在代码管理上肯定不方便。巧妙的是,Linux采取了一种抽象方法:所有架构全部使用3级页表: 即PGD -> PMD -> PTE
    • 那只使用2级页表(如非PAE的X86)怎么办?
      • 办法是针对使用2级页表的架构,把PMD抽象掉,即虚设一个PMD表项。这样在page table walk过程中,PGD本直接指向PTE的,现在不了,指向一个虚拟的PMD,然后再由PMD指向PTE。这种抽象保持了代码结构的统一。
  • Linux的四级页表机制:
    • 硬件在发展,3级页表很快又捉襟见肘了,原因是64位CPU出现了, 比如X86_64, 它的硬件是实实在在支持4级页表的。它支持48位的虚拟地址空间。如下:
    字段 描述 位数
    PML4 指向一个PDPT 位47~39
    PGD 指向PDPT中512个项中的一个 位38~30
    PMD 指向页目录中512项中的一个 位29~21
    PTE 指向页表中512项中的一个 位20~12
    page offset 4KB页中的偏移 位11~0
    • Linux内核针为使用原来的3级列表(PGD->PMD->PTE),做了折衷。即采用一个唯一的,共享的顶级层次,叫PML4。这个PML4没有编码在地址中,这样就能套用原来的3级列表方案了。不过代价就是,由于只有唯一的PML4, 寻址空间被局限在(239=)512G, 而本来PML4段有9位, 可以支持512个PML4表项的。现在为了使用3级列表方案,只能限制使用一个, 512G的空间很快就又不够用了,解决方案呼之欲出。
    • 在2004年10月,当时的X86_64架构代码的维护者Andi Kleen提交了一个叫做4level page tables for LinuxPATCH系列,为Linux内核带来了4级页表的支持。在他的解决方案中,不出意料地,按照X86_64规范,新增了一个PML4的层级, 在这种解决方案中,X86_64拥一个有512条目的PML4, 512条目的PGD, 512条目的PMD, 512条目的PTE。对于仍使用3级目录的架构来说,它们依然拥有一个虚拟的PML4,相关的代码会在编译时被优化掉。 这样,就把Linux内核的3级列表扩充为4级列表。这系列PATCH工作得不错,不久被纳入Andrew Morton的-mm树接受测试。不出意外的话,它将在v2.6.11版本中释出。但是,另一个知名开发者Nick Piggin提出了一些看法,他认为Andi的Patch很不错,不过他认为最好还是把PGD作为第一级目录,把新增加的层次放在中间,并给出了他自己的Patch:alternate 4-level page tables patches。Andi更想保持自己的PATCH, 他认为Nick不过是玩了改名的游戏,而且他的PATCH经过测试很稳定,快被合并到主线了,不宜再折腾。不过Linus却表达了对Nick Piggin的支持,理由是Nick的做法conceptually least intrusive。毕竟作为Linux的扛把子,稳定对于Linus来说意义重大。最终,不意外地,最后Nick Piggin的PATCH在v2.6.11版本中被合并入主线。在这种方案中,4级页表分别是:PGD -> PUD -> PMD -> PTE

12. 有了进程,为什么还要有线程?

  • 线程产生的原因:
    • 进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;
    • 但是其具有一些缺点:
      • 进程在同一时间只能干一件事
      • 进程在执行的过程中如果阻塞,整个进程就会挂起,即使进程中有些工作不依赖于等待的资源,仍然不会执行。
    • 因此,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性。
    • 和进程相比,线程的优势如下:
      • 从资源上来讲,线程是一种非常”节俭”的多任务操作方式。在linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种”昂贵”的多任务工作方式。
      • 从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间。据统计,一个进程的开销大约是一个线程开销的30倍左右。
      • 从通信机制上来讲,线程间的通信机制更方便。
        • 对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过进程间通信的方式进行,这种方式不仅费时,而且很不方便。
        • 线程则不然,由于同一进程的线程之间共享数据空间,所以一个线程的数据可以直接为其他线程所用,这不仅快捷,而且方便。
    • 除以上优点外,多线程程序作为一种多任务、并发的工作方式,还有如下优点: 1) 使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。 2) 改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序才会利于理解和修改。

13. 单核机器上写多线程程序,是否需要考虑加锁,为什么?

  • 在单核机器上写多线程程序,仍然需要线程锁。
    • 因为线程锁通常用来实现线程的同步和通信。
    • 在单核机器上的多线程程序,仍然存在线程同步的问题。
    • 因为在抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽时,操作系统会将其挂起,然后运行另一个线程。
    • 如果这两个线程共享某些数据,不使用线程锁的前提下,可能会导致共享数据修改引起冲突。

14. 线程需要保存哪些上下文,SP、PC、EAX这些寄存器是干嘛用的

  • 线程在切换的过程中需要保存当前线程ID线程状态寄存器状态等信息。其中寄存器主要包括SP PC EAX等寄存器,
  • 其主要功能如下:
    • SP:栈指针,指向当前栈的栈顶地址
    • PC:程序计数器,存储下一条将要执行的指令
    • EAX:累加寄存器,用于加法乘法的缺省寄存器

15. 线程间的同步方式,最好说出具体的系统调用

  • 信号量
    • 信号量是一种特殊的变量,可用于线程同步。它只取自然数值,并且只支持两种操作:
      • P(SV):如果信号量SV大于0,将它减一;如果SV值为0,则挂起该线程。
      • V(SV):如果有其他进程因为等待SV而挂起,则唤醒,然后将SV+1;否则直接将SV+1
    • 其系统调用为:
      • sem_wait(sem_t *sem): 以原子操作的方式将信号量减1,如果信号量值为0,则sem_wait将被阻塞,直到这个信号量具有非0值。
      • sem_post(sem_t *sem): 以原子操作将信号量值+1。当信号量大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒。
  • 互斥量
    • 互斥量又称互斥锁,主要用于线程互斥,不能保证按序访问,可以和条件锁一起实现同步。
      • 当进入临界区时,需要获得互斥锁并且加锁;
      • 当离开临界区时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程。
    • 其主要的系统调用如下:
      • pthread_mutex_init:初始化互斥锁
      • pthread_mutex_destroy:销毁互斥锁
      • pthread_mutex_lock:以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被上锁,pthread_mutex_lock调用将阻塞,直到该互斥锁的占有者将其解锁。
      • pthread_mutex_unlock:以一个原子操作的方式给一个互斥锁解锁。
  • 条件变量
    • 条件变量,又称条件锁,用于在线程之间同步共享数据的值。
    • 条件变量提供一种线程间通信机制:
      • 当某个共享数据达到某个值时,唤醒等待这个共享数据的一个/多个线程。
      • 当某个共享变量等于某个值时,调用 signal/broadcast。此时操作共享变量时需要加锁。
    • 其主要的系统调用如下:
      • pthread_cond_init:初始化条件变量
      • pthread_cond_destroy:销毁条件变量
      • pthread_cond_signal:唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级。
      • pthread_cond_wait:等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁,然后接收到信号后会再加锁,保证该线程对共享资源正确访问。

    • 这玩意儿是个坑, 一个条件变量可以被signal唤醒,也可以被broadcast唤醒, 所以我们使用它的时候要配合while循环弄一个重陷入机制, 在stl中的condition_variable的底层就是这个

16. 多线程和多进程的不同

  • 进程是资源分配的最小单位,而线程时CPU调度的最小单位。
  • 多线程之间共享同一个进程的地址空间,线程间通信简单,同步复杂,线程创建、销毁和切换简单,速度快,占用内存少,适用于多核分布式系统,但是线程间会相互影响,一个线程意外终止会导致同一个进程的其他线程也终止,程序可靠性弱。
  • 而多进程间拥有各自独立的运行地址空间,进程间不会相互影响,程序可靠性强,但是进程创建、销毁和切换复杂,速度慢,占用内存多,进程间通信复杂,但是同步简单,适用于多核、多机分布。

17. 进程和线程的区别

1) 进程是资源分配的最小单位,线程是调度的最小单位。 2) 进程有独立的系统资源,而同一进程内的线程共享进程的大部分系统资源,包括堆、代码段、数据段,每个线程只拥有一些在运行中必不可少的私有属性,比如线程控制块(tcb),线程Id,寄存器。 3) 一个进程崩溃,不会对其他进程产生影响;而一个线程崩溃,会让同一进程内的其他线程也死掉。 4) 进程在创建、切换和销毁时开销比较大,而线程比较小。 - 进程创建的时候需要分配系统资源,而销毁的的时候需要释放系统资源。 - 进程切换需要分两步: - 切换页目录、刷新TLB(Translation Lookaside Buffer, 页表缓存)以使用新的地址空间; - 切换内核栈和硬件上下文(寄存器); - 而同一进程的线程间逻辑地址空间是一样的,不需要切换页目录、刷新TLB。 5) 进程间通信比较复杂,而同一进程的线程由于共享代码段和数据段,所以通信比较容易。

18. 游戏服务器应该为每个用户开辟一个线程还是一个进程,为什么?

  • 游戏服务器应该为每个用户开辟一个进程。
    • 因为同一进程间的线程会相互影响,一个线程死掉会影响其他线程,从而导致进程崩溃。
    • 因此为了保证不同用户之间不会相互影响,应该为每个用户开辟一个进程

19. OS缺页置换算法

  • 当访问一个内存中不存在的页,并且内存已满,则需要从内存中调出一个页或将数据送至磁盘对换区,替换一个页,这种现象叫做缺页置换。
  • 当前操作系统最常采用的缺页置换算法如下:
    • 先进先出(FIFO)算法:置换最先调入内存的页面,即置换在内存中驻留时间最久的页面。按照进入内存的先后次序排列成队列,从队尾进入,从队首删除。
    • 最近最少使用(LRU)算法: 置换最近一段时间以来最长时间未访问过的页面。根据程序局部性原理,刚被访问的页面,可能马上又要被访问;而较长时间内没有被访问的页面,可能最近不会被访问。
    • 当前最常采用的就是LRU算法。

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

  • 多进程模型的优势是CPU, 适用于CPU密集型。同时,多进程模型也适用于多机分布式场景中,易于多机扩展。
  • 多线程模型主要优势为线程间切换代价较小,因此适用于I/O密集型的工作场景,因此I/O密集型的工作场景经常会由于I/O阻塞导致频繁的切换线程。同时,多线程模型也适用于单机多核分布式场景。

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

  • 死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的相互等待的现象。
  • 死锁发生的四个必要条件如下:
    • 互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源;
    • 请求和保持条件:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源
    • 不可剥夺条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放
    • 环路等待条件:进程发生死锁后,必然存在一个进程-资源之间的环形链
  • 解决死锁的方法即破坏上述四个条件之一,主要方法如下:
    • 资源一次性分配,从而剥夺请求和保持条件
    • 可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件
    • 资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件

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

  • 物理地址(physical address):
    • 用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
  • 虚拟地址(virtual memory)
    • 是相对于物理内存来讲的,可以理解为虚拟的地址空间,
    • 之所以是这样,是因为现代操作系统都提供了一种内存管理的抽像,即虚拟内存(virtual memory)。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成真正的物理地址。
    • 有了这样的抽像,
      • 一个程序,就可以使用比真实物理地址大得多的地址空间。
      • 而且可以简单的实现进程间的内存共享, 减少复制操作。
  • 地址转换: 参考 总结一下linux中的分段机制
    • 第一步: CPU段式管理中——逻辑地址转线性地址
      • CPU利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址。
      • 一个逻辑地址由两部份组成: 段标识符, 段内偏移量
        • 段标识符是由16位的字段组成,称为段选择符。其中前13位是一个索引号。后3位包含一些硬件细节
        • 如图: 段标识符
        • 通过段标识符中的索引号从全局段描述符表(GDT)或者LDT找到该段的段描述符,段描述符中的base字段是段的起始地址, 段描述符:Base字段,它描述了一个段的开始位置的线性地址。
        • 一些全局的段描述符,就放在全局段描述符表(GDT)中,一些局部的,例如每个进程自己的,就放在局部段描述符表(LDT)中。
        • GDT在内存中的地址和大小存放在CPUgdtr控制寄存器中,而LDT则在ldtr寄存器中。
      • 段起始地址 + 段内偏移量 = 线性地址 段式内存管理
      • 首先,给定一个完整的逻辑地址[段选择符:段内偏移地址], 1) 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。 2) 拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即段基地址就知道了。 3) 把Base + offset,就是要转换的线性地址了。 段式内存管理
  • 第二步 页式管理——线性地址转物理地址: 再利用其页式内存管理单元,转换为最终物理地址。
    • linux假的段式管理
      • Intel要求两次转换,这样虽说是兼容了,但是却是很冗余,但是这是intel硬件的要求。
      • 其它某些硬件平台,没有二次转换的概念,Linux也需要提供一个高层抽像,来提供一个统一的界面。
      • 所以,Linux的段式管理,事实上只是“哄骗”了一下硬件而已。按照Intel的本意,全局的用GDT,每个进程自己的用LDT——不过Linux则对所有的进程都使用了相同的段来对指令和数据寻址。即用户数据段,用户代码段,对应的,内核中的是内核数据段和内核代码段。(主要原因是, 分段可以把每一个进程分配不同的线性地址空间,而分页则可以把相同的线性地址空间映射到不同的物理空间)
      • Linux下,逻辑地址与线性地址总是一致的,即逻辑地址的偏移量字段的值与线性地址的值总是相同的, 映射到不同的物理空间是由页管理机制实现的。
    • linux页式管理
      • CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。
      • 线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。
      • 另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。 页式管理
    • 每个进程都有自己的页目录,当进程处于运行态的时候,其页目录地址存放在cr3寄存器中。
    • 每一个32位的线性地址被划分为三部份,【页目录索引(10位):页表索引(10位):页内偏移(12位)】
    • 依据以下步骤进行转换:
      • 从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
      • 根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
      • 根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
      • 将页的起始地址与线性地址中最后12位相加。
    • 目的:
      • 内存节约:如果一级页表中的一个页表条目为空,那么那所指的二级页表就根本不会存在。这表现出一种巨大的潜在节约,因为对于一个典型的程序,4GB虚拟地址空间的大部份都会是未分配的; 四级页目录
    • 32位,PGD = 10bit,PUD = PMD = 0,table = 10bit,offset = 12bit
    • 64位,PUD和PMD ≠ 0

  • 总结:
    • 参考:总结一下linux中的分段机制
    • 首先是段式映射: 逻辑地址转线性地址
      • 逻辑地址由两部份组成: 段标识符, 段内偏移量
        • 段标识符(也叫段选择符)为16位, 前13位为段索引号, 后3位为硬件细节,
        • 通过段索引号, 从段描述符表(分为全局(GDT)\局部(LDT))中找到该段描述符, 描述符中的Base子段就是段的起始地址(段开始位置的线性地址). - 段起始地址 + 段内偏移量 = 线性地址
          • 全局段描述符(GDT): 一些全局的段描述符就放在全局段描述符表(GDT)中, GDT在内存中的地址和大小存放在CPUgdtr控制寄存器中
          • 局部段描述符(LDT): 每个进程都有自己的段描述符就存放在局部段描述符中, LDT则在ldtr寄存器中。
        • Linux中,只是引入了段式管理的概念,并没有严格遵守. 它对所有的用户态进程都是用相同的段来进行指令和数据的寻址. 即所有用户态进程是使用相同的用户数据段和用户代码段, 所有的内核态进程使用想偶通的内核数据段和内核代码段.
        • 之所以这样设计, 是因为分段可以把相同的逻辑地址映射到不同的线性地址空间,而Linux逻辑地址等于线性地址, 每个进程维护了自己的页表, 再多级分页机制可以把相同的线性地址空间映射到不同的物理空间.

        • Linux中,只是引入了段式管理的概念,并没有严格遵守. 不仅简化了linux内核的设计,而且为了把linux移植到其他平台创造了条件,因为很多RISC处理器并不支持段机制。
        • 为什么是linux内核设计有限度使用分段机制?
        • 因为linux内核中内存管理中:对整个逻辑地址空间j进行了划分(用户态0-3G, 内核态3-4G), 所有用户态的进程的段基地址均为0,即每个段的逻辑地址与线性地址保持一致, 然后每个进程维护了各自的页表, 从而实现了不同进程中的小童线性地址到不同物理地址的映射.
    • 其次是页式内存管理: 线性地址转物理地址
    • 参考:

23. 操作系统中的结构体对齐,字节对齐

  • 原因:
    • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
    • 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
  • 规则
    • 数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。
    • 结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值结构(或联合)最大数据成员长度中,比较小的那个进行。
    • 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。
    • 定义结构体对齐
      • 可以通过预编译命令#pragma pack(n)n=1,2,4,8,16来改变这一系数,其中的n就是指定的“对齐系数”。
    • 举例
        #pragma pack(2)
        struct AA {
            int a;       //长度4 > 2 按2对齐;偏移量为0;存放位置区间[0,3]
            char b;  //长度1 < 2 按1对齐;偏移量为4;存放位置区间[4]
            short c;     //长度2 = 2 按2对齐;偏移量要提升到2的倍数6;存放位置区间[6,7]
            char d;  //长度1 < 2 按1对齐;偏移量为7;存放位置区间[8];共九个字节
        };
        #pragma pack()
      

  • 总结:
    • 数据成员对齐规则: 数据成员第一个放在offset0的地址处,以后每个数据成员要对齐至 min(pragma pack指定的数值, 自身)
    • 整体对齐规则: min(pragma pack, max(数据成员));
    • 结构体作为成员: 此成员要从max(结构体内部成员)的整数倍开始存储

24. 进程间怎么通信

  • 进程间通信主要包括管道、系统IPC(包括消息队列信号量信号共享内存等)、以及套接字socket
  • 管道:
    • 管道主要包括无名管道和命名管道:
      • 管道可用于具有亲缘关系的父子进程间的通信,
      • 有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信
    • 普通管道PIPE: 1) 它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端 2) 它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间) 3) 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

    • 命名管道FIFO: 1) FIFO可以在无关的进程之间交换数据 2) FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
  • 系统IPC
    • 消息队列
      • 消息队列,是消息的链接表,存放在内核中。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等特点。 一个消息队列由一个标识符(即队列ID)来标记。 具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息;
    • 特点: 1) 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。 2) 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。 3) 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
  • 信号量semaphore
    • 信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
    • 特点: 1) 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。 2) 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。 3) 每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。 4) 支持信号量组。
  • 信号(signal)
    • 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 共享内存(Shared Memory)
    • 它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等
    • 特点: 1) 共享内存是最快的一种IPC,因为进程是直接对内存进行存取 2) 因为多个进程可以同时操作,所以需要进行同步 3) 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问
  • 套接字SOCKET
    • socket也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机之间的进程通信。

25. 虚拟内存置换的方式

  • 比较常见的内存替换算法有:FIFOLRULFULRU-K2Q
  • FIFO(先进先出淘汰算法)
    • 思想:最近刚访问的,将来访问的可能性比较大。
    • 实现:使用一个队列,新加入的页面放入队尾,每次淘汰队首的页面,即最先进入的数据,最先被淘汰。
    • 弊端:无法体现页面冷热信息
  • LFU(最不经常访问淘汰算法)
    • 思想:如果数据过去被访问多次,那么将来被访问的频率也更高。
    • 实现:每个数据块一个引用计数,所有数据块按照引用计数排序,具有相同引用计数的数据块则按照时间排序。每次淘汰队尾数据块。
    • 开销:排序开销。
    • 弊端:缓存颠簸。 LFU
  • LRU(最近最少使用替换算法)
    • 思想:如果数据最近被访问过,那么将来被访问的几率也更高。
    • 实现:使用一个栈,新页面或者命中的页面则将该页面移动到栈底,每次替换栈顶的缓存页面。
    • 优点:LRU算法对热点数据命中率是很高的。
    • 缺陷: 1) 缓存颠簸,当缓存(1,2,3)满了,之后数据访问(0,3,2,1,0,3,2,1。。。)。 2) 缓存污染,突然大量偶发性的数据访问,会让内存中存放大量冷数据。
  • LRU-K(LRU-2、LRU-3)
    • 思想:最久未使用K次淘汰算法。
    • LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。 相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
    • 实现: 1) 数据第一次被访问,加入到访问历史列表; 2) 如果数据在访问历史列表里后没有达到K次访问,则按照一定规则(FIFO,LRU)淘汰; 3) 当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列删除,将数据移到缓存队列中,并缓存此数据,缓存队列重新按照时间排序; 4) 缓存数据队列中被再次访问后,重新排序; 5) 需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。
    • 针对问题:
      • LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
  • 2Q
    • 类似LRU-2。使用一个FIFO队列和一个LRU队列。
    • 实现: 1) 新访问的数据插入到FIFO队列; 2) 如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰; 3) 如果数据在FIFO队列中被再次访问,则将数据移到LRU队列头部; 4) 如果数据在LRU队列再次被访问,则将数据移到LRU队列头部; 5) LRU队列淘汰末尾的数据。
    • 针对问题:LRU的缓存污染
    • 弊端:
      • 当FIFO容量为2时,访问负载是:ABCABCABC会退化为FIFO,用不到LRU。

26. 多线程,线程同步的几种方式

  • 概念:
    • 进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
    • 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
    • 线程是操作系统可识别的最小执行和调度单位。
      • 每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。
      • 每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。
  • 线程间通信的方式:
    • 临界区:
      • 通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
    • 互斥量 Synchronized/Lock
      • 采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
    • 信号量 Semphare
      • 为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。
    • 事件(信号) Wait/Notify
      • 通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作

  • 进程同步方式: 信号量, 管程, 消息传递
  • 信号量
    • 用于进程间传递信号的一个整数值。在信号量上只有三种操作可以进行:初始化P操作V操作,这三种操作都是原子操作。
    • P操作(递减操作)可以用于阻塞一个进程,V操作(增加操作)可以用于解除阻塞一个进程。
    • 基本原理是两个或多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一位置停止,直到它接收到一个特定的信号。该信号即为信号量s。
    • 为通过信号量s传送信号,进程可执行原语semSignal(s);为通过信号量s接收信号,进程可执行原语semWait(s);如果相应的信号仍然没有发送,则进程被阻塞,直到发送完为止。
    • 可把信号量视为一个具有整数值的变量,在它之上定义三个操作:
    • 一个信号量可以初始化为非负数
    • semWait操作使信号量s减1.若值为负数,则执行semWait的进程被阻塞。否则进程继续执行。
    • semSignal操作使信号量加1,若值大于或等于零,则被semWait操作阻塞的进程被解除阻塞。
  • 管程
    • 管程是由一个或多个过程、一个初始化序列和局部数据组成的软件模块,其主要特点如下:
    • 局部数据变量只能被管程的过程访问,任何外部过程都不能访问。
    • 一个进程通过调用管程的一个过程进入管程。
    • 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。
    • 管程通过使用条件变量提供对同步的支持,这些条件变量包含在管程中,并且只有在管程中才能被访问。有两个函数可以操作条件变量:
    • cwait(c):调用进程的执行在条件c上阻塞,管程现在可被另一个进程使用。
    • csignal(c):恢复执行在cwait之后因为某些条件而阻塞的进程。如果有多个这样的进程,选择其中一个;如果没有这样的进程,什么以不做。
  • 消息传递
    • 消息传递的实际功能以一对原语的形式提供:
    • send(destination,message)
    • receive(source,message)
    • 这是进程间进程消息传递所需要的最小操作集。
    • 一个进程以消息的形式给另一个指定的目标进程发送消息;
    • 进程通过执行receive原语接收消息,receive原语中指明发送消息的源进程和消息。
  • 参考:

27. 互斥锁(mutex)机制,以及互斥锁和读写锁的区别

  • 基本概念:
    • 互斥锁:mutex
      • 用于保证在任何时刻,都只能有一个线程访问该对象。
      • 当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。
    • 读写锁:rwlock,分为读锁和写锁。
      • 处于读操作时,可以允许多个线程同时获得读操作。
      • 同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。
      • 注意:写锁会阻塞其它读写锁。
        • 当有一个线程获得写锁在写时,读锁也不能被其它线程获取;
        • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。
      • 适用于读取数据的频率远远大于写数据的频率的场合。
  • 互斥锁和读写锁的区别: 1) 读写锁区分读者和写者,而互斥锁不区分 2) 互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。

28. Linux的4种锁机制:

  • 互斥锁:mutex
    • 用于保证在任何时刻,都只能有一个线程访问该对象。
    • 当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒
  • 读写锁:rwlock,分为读锁和写锁。
    • 处于读操作时,可以允许多个线程同时获得读操作。
      • 同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。
      • 注意:写锁会阻塞其它读写锁。
        • 当有一个线程获得写锁在写时,读锁也不能被其它线程获取;
        • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。
      • 适用于读取数据的频率远远大于写数据的频率的场合
  • 自旋锁:spinlock
    • 在任何时刻同样只能有一个线程访问对象。
    • 但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。
    • 这样节省了线程从睡眠状态到被唤醒期间的消耗。
    • 在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源
    • 死锁问题参考:42. 两个进程访问临界区资源,会不会出现都获得自旋锁的情况
  • RCU:即read-copy-update.
    • 在修改数据时,首先需要读取数据,然后生成一个副本,对副本进行修改。修改完成后,再将老数据update成新的数据。
    • 使用RCU时,读者几乎不需要同步开销,既不需要获得锁,也不使用原子指令,不会导致锁竞争,因此就不用考虑死锁问题了。
    • 而对于写者的同步开销较大,它需要复制被修改的数据,还必须使用锁机制同步并行其它写者的修改操作。
    • 在有大量读操作,少量写操作的情况下效率非常高

29. 进程状态转换图,动态就绪,静态就绪,动态阻塞,静态阻塞

  • 进程的五种基本状态: 进程状态转换图
    • 创建状态:进程正在被创建
    • 就绪状态:进程被加入到就绪队列中等待CPU调度运行
    • 执行状态:进程正在被运行
    • 等待阻塞状态:进程因为某种原因,比如等待I/O,等待设备,而暂时不能运行。
    • 终止状态:进程运行完毕

  • 交换技术
    • 当多个进程竞争内存资源时,会造成内存资源紧张,并且,如果此时没有就绪进程,处理机会空闲,I/0速度比处理机速度慢得多,可能出现全部进程阻塞等待I/O。
  • 针对以上问题,提出了两种解决方法: 1) 交换技术:换出一部分进程到外存,腾出内存空间。 2) 虚拟存储技术:每个进程只能装入一部分程序和数据。
  • 在交换技术上
    • 将内存暂时不能运行的进程,或者暂时不用的数据和程序,换出到外存,来腾出足够的内存空间,把已经具备运行条件的进程,或进程所需的数据和程序换入到内存。从而出现了进程的挂起状态:进程被交换到外存,进程状态就成为了挂起状态

  • 活动阻塞,静止阻塞,活动就绪,静止就绪 1) 活动阻塞:进程在内存,但是由于某种原因被阻塞了。 2) 静止阻塞:进程在外存,同时被某种原因阻塞了。 3) 活动就绪:进程在内存,处于就绪状态,只要给CPU和调度就可以直接运行。 4) 静止就绪:进程在外存,处于就绪状态,只要调度到内存,给CPU和调度就可以运行。
  • 从而出现了(七状态转换):
    • 1, 2 活动就绪 <——> 静止就绪 (内存不够,调到外存)
    • 3, 4 活动阻塞 <——> 静止阻塞 (内存不够,调到外存)
    • 5, 6 活动就绪 <——> 执行
    • 7 执行 ——> 活动阻塞 (被某种原因阻塞)
    • 8 执行 ——> 终止

30. A* a = new A; a->i = 10;在内核中的内存分配上发生了什么?

  • 程序内存管理:
    • 一个程序本质上都是由BSS段data段text段三个组成的。
    • 可以看到一个可执行程序在存储(没有调入内存)时分为代码段、数据区和未初始化数据区三部分。
    • 参考这里: 操作系统中的程序的内存结构 内存分段
  • A* a = new A; a->i = 10: 1) A *aa是一个局部变量,类型为指针,故而操作系统在程序栈区开辟4/8字节的空间(0x000m),分配给指针a。 2) new A:通过new动态的在堆区申请类A大小的空间(0x000n)。 3) a = new A:将指针a的内存区域填入栈中类A申请到的地址的地址。即*(0x000m)=0x000n。 4) a->i:先找到指针a的地址0x000m,通过a的值0x000ni类a偏移offset,得到a->i的地址0x000n + offset,进行*(0x000n + offset) = 10的赋值操作,即内存0x000n + offset的值是10

31. 给你一个类,里面有static,virtual,之类的,说一说这个类的内存分布

  • static修饰符: 参考说一下static关键字的作用 1) static修饰成员变量 - 对于非静态数据成员,每个类对象都有自己的拷贝。 - 而静态数据成员被当做是类的成员,无论这个类被定义了多少个,静态数据成员都只有一份拷贝,为该类型的所有对象所共享(包括其派生类)。所以,静态数据成员的值对每个对象都是一样的,它的值可以更新。 - 因为静态数据成员在全局数据区分配内存,属于本类的所有对象共享,所以它不属于特定的类对象,在没有产生类对象前就可以使用。

    2) static修饰成员函数 - 与普通的成员函数相比,静态成员函数由于不是与任何的对象相联系,因此它不具有this指针。从这个意义上来说,它无法访问属于类对象的非静态数据成员,也无法访问非静态成员函数,只能调用其他的静态成员函数。 - Static修饰的成员函数,在代码区分配内存。 - (所有的函数不都是再代码区么?? ) | 参考 C++成员函数在内存中的存储方式

  • C++继承和虚函数
    • C++多态分为静态多态动态多态
      • 静态多态是通过重载和模板技术实现,在编译的时候确定。
      • 动态多态通过虚函数和继承关系来实现,执行动态绑定,在运行的时候确定。
  • 动态多态实现有几个条件: 1) 虚函数; 2) 一个基类的指针或引用指向派生类的对象;
  • 实现过程
    • C++内部为每一个存在虚函数的类都维持一个虚函数表,该类的所有对象都保存着一个指向这张虚函数表的指针。
    • 再继承体系下子类会复制基类的虚函数表, 如果覆盖了其中的某个虚函数, 那么虚函数表中的指针会被替换。从而实现动态多态。
    • 基类指针在调用成员函数(虚函数)时,会去查找该对象的虚函数表。虚函数表的地址在每个对象的首地址。
    • 查找该虚函数表中该函数的指针进行调用。
  • virtual修饰符
    • 包括两种用法:
      • 定义虚函数
        • C++内部为每一个存在虚函数的类都维持一个虚函数表,虚函数表中每一项指向该虚函数的函数入口地址,该类的所有对象都保存着一个指向这张虚函数表的指针。虚函数表的地址在每个对象的首地址。
        • 在继承体系下子类会复制基类的虚函数表, 如果覆盖了其中的某个虚函数, 那么虚函数表中的指针会被替换。从而实现动态多态。
        • 基类指针在调用成员函数(虚函数)时,会去查找该对象的虚函数表, 从而得到函数指针
      • 虚继承
        • 参考:
        • 虚继承主要解决菱形继承问题, 防止一个子类中存在多个基类实体.
        • 如果类B虚拟继承自类A, 则类B中存在一个虚基类表指针,指向一个虚基类表(不占用对象内存), 此虚基类表中存储中虚基类相对于当前类对象的偏移量.
        • 对于虚基类表指针是否占用对象内存这个问题, 需要分类来看, 对于VS编译器它会占用对象内存, 而sun公司的编译器就不会, 它存储与虚函数表上.

虚函数

32. 软链接和硬链接区别


  • 附加
    • 什么是inode?
      • 文件数据都储存在磁盘”块”中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。
      • 这种储存文件元信息的区域就叫做inode,中文译名为”索引节点”。
    • 每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。
  • 参考: Inode 节点

  • 为了解决文件共享问题,Linux引入了软链接硬链接
  • 除了为Linux解决文件共享使用,还带来了隐藏文件路径、增加权限安全及节省存储等好处。
  • 硬链接:
    • 若1个inode号对应多个文件名,则为硬链接,即硬链接就是同一个文件使用了不同的别名,使用ln创建。
  • 软链接:
    • 若文件用户数据块中存放的内容是另一个文件的路径名指向,则该文件是软连接。
    • 软连接是一个普通文件,有自己独立的inode,但是其数据块内容比较特殊。

33. 什么是大端小端以及如何判断大端小端

  • 什么是大端小端:
    • 大端是指低字节存储在高地址;
    • 小端存储是指低字节存储在低地址。
    • 主机大多数是小端数据
    • 网络字节序为大端数据
  • 怎么判断:
    • 我们可以根据联合体来判断该系统是大端还是小端。因为联合体变量总是从低地址存储。
      // 判断系统时大端还是小段, 通过联合体, 因为联合体的所有成员都是从低地址开始存放的(也就是小端)
      int fun1(){
        union test{
        int i;
        char c;
        }
        test t;
        t.i=1;
        // 如果为大端, 则t.c为0x00 返回0; 如果时小端, 则t.c为0x01, 返回1
        return t.c==1;
      }
      

34. 静态变量什么时候初始化

  • 静态变量存储在虚拟地址空间的数据段bss段
  • C语言中其在代码执行之前初始化,属于编译期初始化, 所以不能使用变量对他们进行初始化。
  • C++中由于引入对象,对象生成必须调用构造函数,因此C++规定全局或局部静态对象当且仅当对象首次调用时进行构造, 所以可以使用变量对他们进行初始化.

35. 用户态和内核态区别

  • 用户态和内核态是操作系统的两种运行级别,两者最大的区别就是特权级不同(inter x86体系中内核态位于R0, 用户态位于R3)。
  • 用户态拥有最低的特权级,内核态拥有较高的特权级。
  • 运行在用户态的程序不能直接访问操作系统内核数据结构和程序。
  • 内核态和用户态之间的转换方式主要包括:系统调用,异常和中断。

36. 如何设计server,使得能够接收多个客户端的请求

  • 多线程,线程池,io复用

37. 死循环+来连接时新建线程的方法效率有点低,怎么改进?

  • 提前创建好一个线程池,用生产者消费者模型,创建一个任务队列,队列作为临界资源,有了新连接,就挂在到任务队列上,队列为空所有线程睡眠。
  • 改进死循环:使用select, epoll这样的技术

38. 怎么唤醒被阻塞的socket线程?

  • 给阻塞时候缺少的资源

39. 怎样确定当前线程是繁忙还是阻塞?

  • 使用ps命令查看

40. 请问就绪状态的进程在等待什么?

  • 起始就绪状态也可以划分为: 活动就绪和静止就绪
    • 活动就绪:进程在内存,处于就绪状态,只要给CPU和调度就可以直接运行。
    • 静止就绪:进程在外存,处于就绪状态,只要调度到内存,给CPU和调度就可以运行。

41. 多线程的同步,锁的机制

  • 同步的时候用一个互斥量,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。
  • 对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。
  • 如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。
  • 在这种方式下,每次只有一个线程可以向前执行

42. 两个进程访问临界区资源,会不会出现都获得自旋锁的情况?

  • 单核cpu,并且开了抢占可以造成这种情况。(?????)
  • 使得, 因为禁止内核抢占只是关闭“可抢占标志”,而不是禁止进程切换。显式使用schedule或进程阻塞(此也会导致调用schedule)时,还是会发生进程调度的。而单核情况下自旋锁其实是空的,啥都没干 !

  • 参考:
  • linux上的自旋锁有三种实现:
    1. 在单cpu,不可抢占内核中,自旋锁为空操作(就是什么也不做)。
    2. 在单cpu,可抢占内核中,自旋锁实现为“禁止内核抢占”,并不实现“自旋”。
    3. 在多cpu,可抢占内核中,自旋锁实现为“禁止内核抢占” + “自旋”。
  • 关于抢占式内核非抢占式内核
    • 非抢占式内核中,如果一个进程在内核态运行,其只有在以下两种情况会被切换: 1. 其运行完成(返回用户空间) 2. 主动让出cpu(即主动调用schedule或内核中的任务阻塞——这同样也会导致调用schedule)
    • 抢占式内核中,如果一个进程在内核态运行,其只有在以下四种情况会被切换: 1. 其运行完成(返回用户空间) 2. 主动让出cpu(即主动调用schedule或内核中的任务阻塞——这同样也会导致调用schedule) 3. 当中断处理程序正在执行,且返回内核空间之前(此时可抢占标志premptcount须为0) 。 4. 当内核代码再一次具有可抢占性的时候,如解锁及使能软中断等。
  • 禁止内核抢占只是关闭“可抢占标志”,而不是禁止进程切换。显式使用schedule或进程阻塞(此也会导致调用schedule)时,还是会发生进程调度的。, 这就是同时又两个进程获得了资源所的原因 !!!
  • 死锁
    • 第一种死锁情况: 死锁发生在多核的情况
      • 下面来分析一下:
      • 首先,对于多核抢占与多核非抢占的情况,在使用自旋锁时,其情况基本是一致的。因为在多核抢占的情况下,使用自旋锁会禁止内核抢占,这样多核抢占就相当于多核非抢占的情况。那下面就只分析多核非抢占的情况。
      • 假设系统有AB两个CPU
        • A上正在运行的a进程已获得自旋锁,并在临界区运行。
        • B上正在运行的b进程企图获得自旋锁,但由于自旋锁已被占用,于是b进程B CPU上“自旋”空转。
      • 这时,如果在A上的a进程因程序阻塞,而被休眠。接着A会切换运行另一进程c
      • 若这个进程c也企图获取自旋锁,c进程同样会因为锁已被占用,而在A上“自旋”空转。
      • 这时候,A上的a进程c进程就形成了死锁。a进程需要被c进程占用的CPU,c进程需要被a进程占用的锁
      • 至于在单cpu内核上不会出现上述情况,因为单cpu上的自旋锁实际没有“自旋功能”。
    • 第二种死锁情况
      • 想象你的内核代码请求到一个自旋锁并且在它的临界区里做它的事情,在中间某处,你的代码失去了处理器。
        • 或许它已调用了一个函数(copy_from_user,假设)使进程进入睡眠。
        • 也或许,内核抢占发威,一个更高优先级的进程将你的代码推到了一边(注意,这都是假设,自旋锁其实不允许这些操作)。
      • 此时,正好某个别的线程想获取同一个锁,
        • 如果这个线程运行在和你的内核代码不同的处理器上(幸运的情况),那么它可能要自旋等待一段时间(可能很长),当你的代码从休眠中唤醒或者重新得到处理器并释放锁,它就能得到锁。
        • 而最坏的情况是,那个想获取锁得线程刚好和你的代码运行在同一个处理器上,这时它将一直持有CPU进行自旋操作,而你的代码是永远不可能有任何机会来获得CPU释放这个锁了,这就是悲催的死锁。
    • 第三种死锁情况
      • spin_lockspin_lock_irq速度快,但是它并不是任何情况下都是安全的。
      • 举个例子:
        • 进程A中调用了spin_lock(&lock)然后进入临界区,此时来了一个中断(interrupt),该中断也运行在和进程A相同的CPU上,并且在该中断处理程序中恰巧也会spin_lock(&lock)试图获取同一个锁。由于是在同一个CPU上被中断,进程A会被设置为TASK_INTERRUPT状态,中断处理程序无法获得锁,会不停的忙等,由于进程A被设置为中断状态,schedule()进程调度就无法再调度进程A运行,这样就导致了死锁!
        • 但是如果该中断处理程序运行在不同的CPU上就不会触发死锁。 因为在不同的CPU上出现中断不会导致进程A的状态被设为TASK_INTERRUPT,只是换出。当中断处理程序忙等被换出后,进程A还是有机会获得CPU,执行并退出临界区。所以在使用spin_lock时要明确知道该锁不会在中断处理程序中使用。

  • 自旋锁有几个重要的特性: 1) 被自旋锁保护的临界区代码执行时不能进入休眠。 2) 被自旋锁保护的临界区代码执行时是不能被被其他中断中断。 3) 被自旋锁保护的临界区代码执行时,内核不能被抢占。
    • 从这几个特性可以归纳出一个共性:被自旋锁保护的临界区代码执行时,它不能因为任何原因放弃处理器。
    • 注:所以现代处理器在处理自旋锁时都会设定自旋上限时间以防死锁. 另自旋锁在单核非抢占式CPU上是无效的.被设为空操作,不做任何事.
    • 可能你会奇怪,持有自旋锁的进程在执行内核代码时是不能被抢占的,那么为什么在可抢占式系统中有用呢.
    • 其实linux在设计可抢占式系统的自旋锁时只是把自旋锁设计为”只是禁止内核抢占”,而没有自旋(所以使用自旋锁的代码一定要可以很快执行完,否则进程就一直持着锁不释放,也不可被抢占).
    • 可理解为只是实现了一个不被打扰的原子操作,操作完后释放锁.所以它不能休眠.

43. windows消息机制知道吗,请说一说


  • Windows 是事件驱动的。事件驱动围绕着消息的产生与处理展开,事件驱动是靠消息循环机制来实现的。
  • 也可以理解为消息是一种报告有关事件发生的通知。
  • 消息的概念和表示
    • 消息(Message)指的就是Windows 操作系统发给应用程序的一个通告,它告诉应用程序某个特定的事件发生了。
      • 比如:
        • 用户单击鼠标或按键都会引发Windows 系统发送相应的消息。
        • 最终处理消息的是应用程序的窗口函数,如果程序不负责处理的话系统将会作出默认处理。
    • 从数据结构的角度来说,消息是一个结构体,它包含了消息的类型标识符以及其他的一些附加信息。
    • 系统定义的结构体MSG[1]用于表示消息,MSG 具有如下定义形式:
        typedef struct tagMSG	
        {
        HWND hwnd;
        UINT message;
        WPARAM wParam;
        LPARAM lParam;
        DWORD time;
        POINT pt;
        }MSG;
      
    • 其中hwnd是窗口的句柄,这个参数将决定由哪个窗口过程函数对消息进行处理;
    • message是一个消息常量,用来表示消息的类型;
    • wParamlParam都是32 位的附加信息,具体表示什么内容,要视消息的类型而定;
    • time是消息发送的时间;
    • pt是消息发送时鼠标所在的位置。
  • Windows编程原理
    • Windows是一消息(Message)驱动式系统,Windows消息提供了应用程序与应用程序之间、应用程序与Windows系统之间进行通讯的手段。
    • 应用程序要实现的功能由消息来触发,并靠对消息的响应和处理来完成。
    • Windows系统中有两种消息队列,一种是系统消息队列,另一种是应用程序消息队列
    • 计算机的所有输入设备由 Windows 监控,当一个事件发生时,Windows 先将输入的消息放入系统消息队列中,然后再将输入的消息拷贝到相应的应用程序队列中,应用程序中的消息循环从它的消息队列中检索每一个消息并发送给相应的窗口函数中。
    • 一个事件的发生,到达处理它的窗口函数必须经历上述过程。
    • 所谓消息就是描述事件发生的信息,Windows 程序是事件驱动的,用这一方法编写程序避免了死板的操作模式,因为Windows 程序的执行顺序将取决于事件的发生顺序,具有不可预知性。
    • Windows 操作系统,计算机硬件,应用程序之间具有如图1 所示的关系: win消息
    • 箭头1 说明操作系统能够操纵输入输出设备,例如让打印机打印;
    • 箭头2 说明操作系统能够感知输入输出设备的状态变化,如鼠标单击,按键按下等,这就是操作系统和计算机硬件之间的交互关系,应用程序开发者并不需要知道他们之间是如何做到的,我们需要了解的操作系统与应用程序之间如何交互。
    • 箭头3 是应用程序通知操作系统执行某个具体的操作,这是通过调用操作系统的API 来实现的;操作系统能够感知硬件的状态变化,但是并不决定如何处理,而是把这种变化转交给应用程序,由应用程序决定如何处理,
    • 箭头4 说明了这种转交情况,操作系统通过把每个事件都包装成一个称为消息结构体MSG 来实现这个过程,也就是消息响应,要理解消息响应,首先需要了解消息的概念和表示。
  • Windows 消息循环
    • 消息循环Windows 应用程序存在的根本,应用程序通过消息循环获取各种消息,并通过相应的窗口过程函数,对消息加以处理;正是这个消息循环使得一个应用程序能够响应外部的各种事件,所以消息循环往往是一个Windows 应用程序核心部分

  • 当用户有操作(鼠标,键盘等)时,系统会将这些时间转化为消息。
  • 每个打开的进程系统都为其维护了一个消息队列,系统会将这些消息放到进程的消息队列中,而应用程序会循环从消息队列中取出来消息,完成对应的操作。

  • 整理了一下方便记忆
    • windows消息是windows系统发送给windows应用程序的一个通告, 告知特定的事件发生了, 例如鼠标单击, windows应用程序再调用相应的窗口函数完成对消息的处理
    • windows系统是事件驱动的, 他的事件驱动围绕着消息的产生和处理展开的, 应用程序功能由windows消息来触发,并靠的消息的响应和处理来实现. windows消息提供了应用程序和应用程序之间, 操作系统和应用程序之间的通信.
    • windows系统维护了两个消息队列, 系统消息队列和应用程序消息队列
    • 同时windows系统负责监控所有的输入设备,当输入设备输入消息时, 系统负责将消息放入系统消息队列,然后复制到应用程序消息队列中, 应用程序中的消息循环负责遍历所有的应用程序消息队列中的所有消息,并发送给对应的窗口函数完成处理.
    • 消息循环是windows应用程序的根本, 应用程序通过消息循环获取消息,并调用相应的窗口函数实现消息的处理, 正是消息循环使得应用程序能够及时的响应各种操作, 所以一个应用程序的核心往往是消息循环.

44. C++的锁你知道几种?

  • 参考:
  • 锁包括: C++线程中的几种锁
    • 互锁斥:
      • 用于保证在任何时刻,都只能有一个线程访问该对象。
      • 当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。
    • 条件变量:
      • 条件锁就是所谓的条件变量,某一个线程因为某个条件未满足时可以使用条件变量使该线程处于阻塞状态。
      • 一旦条件满足以“信号量”的方式唤醒一个因为该条件而被阻塞的线程。
    • 自旋锁:
      • 任何时刻同样只能有一个线程访问对象。
      • 但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。
      • 这样节省了线程从睡眠状态到被唤醒期间的消耗。
      • 在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
    • 读写锁:
      • 处于读操作时,可以允许多个线程同时获得读操作。
      • 同一时刻只能有一个线程可以获得写锁。
      • 其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。

45. 说一说你用到的锁

  • 生产者消费者问题利用互斥锁和条件变量可以很容易解决,条件变量这里起到了替代信号量的作用

46. 请你说一说死锁产生的必要条件?

  1. 互斥条件:一个资源每次只能被一个进程使用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

47. 内存溢出和内存泄漏

  • 内存溢出
    • 概念:
      • 指程序申请内存时,没有足够的内存供申请者使用。
      • 内存溢出就是你要的内存空间超过了系统实际分配给你的空间,此时系统相当于没法满足你的需求,就会报内存溢出的错误
    • 内存溢出原因:
      • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据
      • 集合类中有对对象的引用,使用完后未清空,使得不能回收
      • 代码中存在死循环或循环产生过多重复的对象实体
      • 使用的第三方软件中的BUG
      • 启动参数内存值设定的过小
    • 内存泄漏 ->什么是memory leak,也就是内存泄漏

48.

  • Future模型
    • 核心思想是异步调用
    • 传统的同步方法,当客户端发出call请求,需要很长的一段时间才会返回,客户端一直在等待直到数据返回随后再进行其他任务的处理。
    • Future模型虽然call本身任然需要一段很长时间处理程序。但是服务程序并不等数据处理完成便立即返回客户端一个伪造的数据(如:商品的订单,而不是商品本身),实现了Future模式的客户端在得到这个返回结果后并不急于对其进行处理而是调用其他业务逻辑,充分利用等待时间,这就是Future模式核心所在。在完成其他业务处理后,最后再使用返回比较慢的Future数据这样在整个调用中就不存在无谓的等待,充分利用所有的时间片,从而提高了系统响应速度。

    • 该模型通常在使用的时候需要结合Callable接口配合使用。
    • Future是把结果放在将来获取,当前主线程并不急于获取处理结果。
    • 允许子线程先处理一段时间,处理结束之后就把结果保存下来,当主线程需要使用的时候再向子线程索取。
    • Callable是类似于Runnable的接口,其中call方法类似于run方法,所不同的是run方法不能抛出受检异常没有返回值,而call方法则可以抛出受检异常并可设置返回值。两者的方法体都是线程执行体。
  • fork & join模型
    • 该模型包含递归思想和回溯思想,递归用来拆分任务,回溯用合并结果。可以用来处理一些可以进行拆分的大任务。
    • 其主要是把一个大任务逐级拆分为多个子任务,然后分别在子线程中执行,当每个子线程执行结束之后逐级回溯,返回结果进行汇总合并,最终得出想要的结果。
    • 这里模拟一个摘苹果的场景:
      • 有100棵苹果树,每棵苹果树有10个苹果,现在要把他们摘下来。
      • 为了节约时间,规定每个线程最多只能摘10棵苹树以便于节约时间。
      • 各个线程摘完之后汇总计算总苹果树。
  • actor模型 每个人都有明确分工,这就是Actor模式。每个线程都是一个Actor,这些Actor不共享任何内存,所有的数据都是通过消息传递的方式进行的。
    • actor模型属于一种基于消息传递机制并行任务处理思想,actor之间不共享任何内存它, 以消息的形式来进行线程间数据传输,进而避免了数据同步错误的隐患。
    • actor在接受到消息之后可以自己进行处理,也可以继续传递(分发)给其它actor进行处理。
    • 在使用actor模型的时候需要使用第三方Akka提供的框架。
  • 生产者消费者模型
    • 生产者消费者模型都比较熟悉,其核心是使用一个缓存来保存任务。
    • 开启一个/多个线程来生产任务,然后再开启一个/多个线程来从缓存中取出任务进行处理。
    • 这样的好处是任务的生成和处理分隔开
      • 生产者不需要处理任务,只负责向生成任务然后保存到缓存。
      • 而消费者只需要从缓存中取出任务进行处理。
    • 使用的时候可以根据任务的生成情况和处理情况开启不同的线程来处理。比如,生成的任务速度较快,那么就可以灵活的多开启几个消费者线程进行处理,这样就可以避免任务的处理响应缓慢的问题。
  • master-worker模型
    • master-worker模型类似于任务分发策略,开启一个master线程接收任务,然后在master中根据任务的具体情况进行分发给其它worker子线程,然后由子线程处理任务。
    • 如需返回结果,则worker处理结束之后把处理结果返回给master

49. 请你来说一说协程

  • 概念:
    • 协程,又称微线程,纤程,英文名Coroutine
    • 协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
    • 例如:
        def A() :
        print '1'
        print '2'
        print '3'
        def B() :
        print 'x'
        print 'y'
        print 'z'
      
    • 由协程运行结果可能是12x3yz。在执行A的过程中,可以随时中断,去执行BB也可能在执行过程中中断再去执行A
    • 但协程的特点在于是一个线程执行。
  • 协程和线程区别: ->进程、线程和协程的理解
    • 那和多线程比,协程最大的优势就是协程极高的执行效率。
    • 因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
    • 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
  • 其他
    • 在协程上利用多核CPU呢——多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
    • Python对协程的支持还非常有限,用在generator中的yield可以一定程度上实现协程。虽然支持不完全,但已经可以发挥相当大的威力了。

50. 系统调用是什么,你用过哪些系统调用

  • 概念:
    • 应用程序有时会需要一些危险的、权限很高的指令,如果把这些权限放心地交给用户程序是很危险的(比如一个进程可能修改另一个进程的内存区,导致其不能运行),但是又不能完全不给这些权限。 - 另外,计算机硬件的资源是有限的,为了更好的管理这些资源,所有的资源都由操作系统控制,进程只能向操作系统请求这些资源。操作系统是这些资源的唯一入口,这个入口就是系统调用。
    • 于是有了系统调用,危险的指令被包装安全的系统调用,系统调用就是操作系统为用户程序提供的接口(即系统调用是用户程序和内核交互的接口)用户程序只能调用而无权自己运行那些危险的指令。
    • 操作系统中的状态分为管态(核心态)和目态(用户态)。
      • 大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。
      • 特权指令:一类只能在核心态下运行而不能在用户态下运行的特殊指令。
      • 不同的操作系统特权指令会有所差异,但是一般来说主要是和硬件相关的一些指令。
    • 用户程序只在用户态下运行,有时需要访问系统核心功能,这时通过系统调用接口使用系统调用。
  • 系统调用举例:
    • 对文件进行写操作,程序向打开的文件写入字符串“hello world”,openwrite都是系统调用。如下:
        #include<stdio.h>
        #include<stdlib.h>
        #include<string.h>
        #include<errno.h>
        #include<unistd.h>
        #include<sys/types.h>
        #include<sys/stat.h>
        #include<fcntl.h>
        int main(int argc, char *argv[]){
        if (argc<2) return 0;
        //用读写追加方式打开一个已经存在的文件
        int fd = open(argv[1], O_RDWR | O_APPEND);
        if (fd == -1) {
            printf("error is %s\n", strerror(errno));
        } else {
            //打印文件描述符号
            printf("success fd = %d\n", fd);
            char buf[100];
            memset(buf, 0, sizeof(buf));
            strcpy(buf, "hello world\n");
            write(fd, buf, strlen(buf));
            close(fd);
        }
        return 0;
        }
      
    • 还有写数据write,创建进程forkvfork等都是系统调用。

51. 请你来手写一下fork调用示例

  • 概念参考: fork和vfork的区别
  • fork实例
      int main(void){
          pid_t pid;
          signal(SIGCHLD, SIG_IGN);
          printf("before fork pid:%d\n", getpid());
          int abc = 10;
          pid = fork();
          if (pid == -1) {           //错误返回
              perror("tile");
              return -1;
          }
          if (pid > 0) {              //父进程空间
              abc++;
              printf("parent:pid:%d \n", getpid());
              printf("abc:%d \n", abc);
              sleep(20);
          } else if (pid == 0) {       //子进程空间
              abc++;
              printf("child:%d,parent: %d\n", getpid(), getppid());
              printf("abc:%d", abc);
          }
          printf("fork after...\n");
       }
    

52. 用户态到内核态的转化原理

  • 用户态切换到内核态的3种方式
    • 系统调用
      • 这是用户进程主动要求切换到内核态的一种方式,用户进程通过系统调用申请操作系统提供的服务程序完成工作。
      • 而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linuxine 80h中断。
    • 异常
      • CPU在执行运行在用户态的程序时,发现了某些事件不可知的异常,这是会触发由当前运行进程切换到处理此异常的内核相关程序中,也就到了内核态,比如缺页异常
    • 外围设备的中断
      • 当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令,转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有用户态到内核态的切换。
      • 比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
  • 切换操作
    • 从触发方式看,可以在认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一样的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断处理机制基本上是一样的
    • 用户态切换到内核态的步骤主要包括: 1) 从当前进程的描述符中提取其内核栈的ss0esp0信息。 2) 使用ss0esp0指向的内核栈将当前进程的cs,eipeflagsss, esp信息保存起来,这个过程也完成了由用户栈找到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。 3) 将先前由中断向量检索得到的中断处理程序的cseip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

53. 源码到可执行文件的过程

  • 预编译: 主要处理源代码文件中的以#开头的预编译指令。
    • 处理规则见下 1) 删除所有的#define,展开所有的宏定义。 2) 处理所有的条件预编译指令,如#if#endif#ifdef#elif#else。 3) 处理#include预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。 4) 删除所有的注释,///**/。 5) 保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重复引用。 6) 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是能够显示行号。
  • 编译: 把预编译之后生成的xxx.ixxx.ii文件,进行一系列词法分析语法分析语义分析优化后,生成相应的汇编代码文件。 1) 词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。 2) 语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。 3) 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分析的语义,相对应的动态语义是在运行期才能确定的语义。 4) 优化:源代码级别的一个优化过程。 5) 目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。 6) 目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。

  • 汇编
    • 将汇编代码转变成机器可以执行的指令(机器码文件)。
    • 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。
    • 经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Windows下)、xxx.obj(Linux下)。
  • 链接: 将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接动态链接
    • 静态链接:
      • 函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
      • 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件在多个程序内都存在一个副本;
      • 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
      • 运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
    • 动态链接:
      • 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
      • 共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多个副本,而是这多个程序在执行时共享同一份副本;
      • 更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
      • 性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。

54. 微内核与宏内核

  • 宏内核:
    • 除了最基本的进程、线程管理、内存管理外,将文件系统,驱动,网络协议等等都集成在内核里面,例如linux内核。
    • 优点:效率高。
    • 缺点:稳定性差,开发过程中的bug经常会导致整个系统挂掉。
  • 微内核:
    • 内核中只有最基本的调度、内存管理。驱动、文件系统等都是用户态守护进程去实现的。
    • 优点:稳定,驱动等的错误只会导致相应进程死掉,不会导致整个系统都崩溃。
    • 缺点:效率低。典型代表QNXQNX的文件系统是跑在用户态的进程,称为resmgr的东西,是订阅发布机制,文件系统的错误只会导致这个守护进程挂掉。不过数据吞吐量就比较不乐观了。

55. 僵尸进程

  • 正常进程
    • 正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。
    • 子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。
    • 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
    • unix机制
      • unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到:
        • 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。
        • 但是仍然为其保留一定的信息,直到父进程通过wait / waitpid来取时才释放。
        • 保存信息包括:
          • 进程号the process ID
          • 退出状态the termination status of the process
          • 运行时间the amount of CPU time taken by the process
  • 孤儿进程
    • 一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。
    • 孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
  • 僵尸进程
    • 一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用waitwaitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
    • 僵尸进程是一个进程必然会经过的过程:这是每个子进程在结束时都要经过的阶段
    • 如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是Z
    • 如果父进程能及时处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。
    • 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
    • 危害:
      • 如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程。
    • 外部消灭:
      • 通过kill发送SIGTERM或者SIGKILL信号消灭产生僵尸进程的进程,它产生的僵死进程就变成了孤儿进程,这些孤儿进程会被init进程接管,init进程wait()这些孤儿进程,释放它们占用的系统进程表中的资源
    • 内部解决:
      • 子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
      • fork两次,原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程可以处理僵尸进程。
        • 这种方法其实有问题, 它只能保证孙子进程可以被init进程收养, 但是子进程尼? 子进程依旧可能成为僵尸!, 除非父进程创建子进程之后,立即使用wait !, 这样就不用设置信号处理函数了
        • 参考: fork两次解决僵尸进程 - skylark的总结 - CSDN博客

56. 请问GDB调试用过吗,什么是条件断点

  • GDB调试
    • GDB 是自由软件基金会(Free Software Foundation)的软件工具之一。
    • 它的作用是协助程序员找到代码中的错误。
      • 如果没有GDB的帮助,程序员要想跟踪代码的执行流程,唯一的办法就是添加大量的语句来产生特定的输出。
      • 但这一手段本身就可能会引入新的错误,从而也就无法对那些导致程序崩溃的错误代码进行分析。
    • GDB的出现减轻了开发人员的负担,
      • 他们可以在程序运行的时候单步跟踪自己的代码,或者通过断点暂时中止程序的执行。
      • 此外,他们还能够随时察看变量和内存的当前状态,并监视关键的数据结构是如何影响代码运行的。
  • 条件断点
    • 条件断点是当满足条件就中断程序运行,命令:break line-or-function if expr
    • 例如:(gdb)break 666 if testsize==100

57. 介绍一下5种IO模型

  • 参考:
  • 阻塞IO:
    • 调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作
  • 非阻塞IO:
    • 非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。
  • 信号驱动IO:
    • 用户进程使用sigaction 函数对信号驱动IO安装一个信号处理函数, 继承继续执行, 当IO事件就绪,进程收到SIGIO信号。
    • linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号。
    • 然后处理IO事件。(数据从内核空间拷贝到用户空间是线程干的, 此期间线程阻塞)
  • 异步IO:
    • linux中可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回
    • 当内核将数据拷贝到缓冲区后,再通知应用程序。(数据从内核空间拷贝至用户空间,是内核干的, 此期间线程不阻塞, 通知的方式也是调用的时候指定的)
  • IO复用/多路转接IO:
    • linuxselect/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作
    • 而且可以同时对多个读操作、写操作的IO函数进行检测。
    • 知道有数据可读或可写时,才真正调用IO操作函数

58. 异步编程的事件循环

  • 参考: 文章最后 异步编程和事件驱动 - 简书
  • 事件驱动的程序一般都有一个主循环(main loop)或称事件循环(event loop)
  • 事件循环:
    • 事件循环(event loop)不停地做两件事: 事件监测事件处理
    • 首先要监测是否发生了事件, 如果有事件发生则调用相应的事件处理程序, 处理完毕再继续监测新事件。
  • 特点:
    • 事件循环只是在一个进程中运行的单个线程。
    • 只有当前事件绑定的方法执行完毕之后才会去检测新的事件.
    • 如果当前事件绑定了多个方法, 会允许执行这些方法, 所有的方法执行完毕之哦吼才回去检测新事件

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

  • 参考: 系统调用是什么,你用过哪些系统调用
  • 为了安全性。
    • 在cpu的一些指令中,有的指令如果用错,将会导致整个系统崩溃。
    • 分了内核态和用户态后,当用户需要操作这些指令时候,内核为其提供了API,可以通过系统调用陷入内核,让内核去执行这些操作。

  • 内核态和用户态是操作系统的两种运行级别,内核态权限高,用户态权限低。
  • 内核态:CPU可以访问内存的所有数据,包括外围设备 ,CPU也可以将自己从一个程序切换到另一个程序。
  • 用户态:只能受限的访问内存,且不允许访问外围设备,占用CPU的能力被剥夺,CPU资源可以被其他程序获取。

60. 为什么要有page cache,操作系统怎么设计的page cache



  • 加快从磁盘读取文件的速率。
    • page cache中有一部分磁盘文件的缓存,因为从磁盘中读取文件比较慢,
    • 所以读取文件先去page cache中去查找,
      • 如果命中,则不需要去磁盘中读取,大大加快读取速度。
  • Linux 内核中,文件的每个数据块最多只能对应一个Page Cache项,它通过两个数据结构来管理这些 Cache项,
    • 一个是radix tree: Radix tree是一种搜索树,Linux内核利用这个数据结构,快速查找脏的(dirty)和回写的(writeback)页面,得到其文件内偏移,从而对page cache进行快速定位。
    • 另一个数据结构是双向链表,Linux内核为每一片物理内存区域(zone)维护active_list inactive_list两个双向链表,这两个list主要用来实现物理内存的回收。这两个链表上除了文件Cache之外,还包括其它匿名 (Anonymous)内存,如进程堆栈等。

61. server端监听端口,但还没有客户端连接进来,此时进程处于什么状态?

  • 如果是 阻塞io则处于阻塞状态
  • 如果是select, poll, epoll方式的io复用则是循环等待状态

62. 怎么实现线程池

    1. 设置一个生产者消费者队列,作为临界资源
    1. 初始化n个线程,并让其运行起来,加锁去队列取任务运行
    1. 当任务队列为空的时候,所有线程阻塞
    1. 当生产者队列来了一个任务后,先对队列加锁,把任务挂在到队列上,然后使用条件变量去通知阻塞中的一个线程

63. Linux下怎么得到一个文件的100200

64. 请你来说一下awk的使用

  • 还是比较简单的参考如下链接即可: Linux awk命令详解
  • 作用:样式扫描和处理语言。它允许创建简短的程序,这些程序读取输入文件、为数据排序、处理数据、对输入执行计算以及生成报表,还有无数其他的功能。
  • 使用方法: awk -F 'field-separator' '{pattern + action }' filename
    • -F:后面跟着分隔符, 可以使用'[xxx]'可以设置多种分割符
    • pattern: 支持正则表达式
    • action:BEGIN, END, print, printf 以及类似c语言的逻辑操作
  • 内置变量
    • ARGC:命令行参数个数
    • ARGV:命令行参数排列
    • ENVIRON:支持队列中系统环境变量的使用
    • FILENAME:awk浏览的文件名
    • FNR:浏览文件的记录数
    • FS:设置输入域分隔符,等价于命令行 -F选项
    • NF:浏览记录的域的个数
    • NR:已读的记录数
    • OFS:输出域分隔符
    • ORS:输出记录分隔符
    • RS:控制记录分隔符
  • 实例:
    • 找到当前文件夹下所有的文件和子文件夹,并显示文件大小
      ls -l awk ‘{print $5 “\t” $9}’
    • 读入有’\n’换行符分割的一条记录,然后将记录按指定的域分隔符划分域,填充域。$0则表示所有域,$1表示第一个域,$n表示第n个域。默认域分隔符是”空白键” 或 “[tab]键”。
  • 找到当前文件夹下所有的文件和子文件夹,并显示文件大小,并显示排序

    ls -l | awk ‘BEGIN {COUNT = -1; print “BEGIN COUNT”} {COUNT = COUNT + 1; print COUNT”\t”$5”\t”$9} END {print “END, COUNT = “COUNT}’

    • 先处理BEGIN, 然后进行文本分析,进行第二个{}的操作,分析完进行END操作。
  • 找到当前文件夹下所有的子文件夹,并显示排序

    ls -l | awk ‘BEGIN {print “BEGIN COUNT”} /4096/{print NR”\t”$5”\t”$9} END {print “END”}’

    • /4096/ 正则匹配式子
    • 使用print $NF可以打印出一行中的最后一个字段,使用$(NF-1)则是打印倒数第二个字段,其他以此类推。

65. linux内核中的Timer定时器机制

  • 低精度时钟
    • Linux 2.6.16之前,内核只支持低精度时钟,
    • 内核定时器的工作方式:
      • 系统启动后,会读取时钟源设备(RTC, HPET,PIT…)初始化当前系统时间
      • 内核会根据HZ(系统定时器频率,节拍率)参数值,设置时钟事件设备,启动tick(节拍)中断。
      • HZ表示1秒内产生时钟硬件中断的个数,tick就表示连续两个中断的间隔时间。
      • 设置时钟事件设备后,时钟事件设备会定时产生一个 tick中断,触发时钟 中断处理函数,更新系统时钟,并检测timer wheel,进行超时事件的处理
    • 在上面工作方式下,Linux 2.6.16之前,内核软件定时器采用timer wheel多级时间轮的实现机制,维护操作系统的所有定时事件。timer wheel的触发是基于系统tick周期性中断。
    • 所以说这之前,linux只能支持ms级别的时钟,随着时钟源硬件设备的精度提高和软件高精度计时的需求,有了高精度时钟的内核设计。
  • 高精度时钟
    • Linux 2.6.16 ,内核支持了高精度的时钟,内核采用新的定时器hrtimer
    • 其实现逻辑和Linux 2.6.16之前定时器逻辑区别:
      • hrtimer采用红黑树进行高精度定时器的管理,而不是时间轮;
      • 高精度时钟定时器不在依赖系统的tick中断,而是基于事件触发
      • 旧内核的定时器实现依赖于系统定时器硬件定期的tick,基于该tick,内核会扫描timer wheel处理超时事件,会更新jiffieswall time(墙上时间,现实时间),process的使用时间等等工作。
      • 新的内核不再会直接支持周期性的tick,新内核定时器框架采用了基于事件触发,而不是以前的周期性触发。新内核实现了hrtimer(high resolution timer):于事件触发。
    • hrtimer的工作原理:
      • 通过 将高精度时钟硬件的下次中断触发时间设置为红黑树中最早到期的Timer的时间时钟到期后从红黑树中得到下一个 Timer的到期时间,并设置硬件,如此循环反复。
      • 在高精度时钟模式下,操作系统内核仍然需要周期性的tick中断,以便刷新内核的一些任务。hrtimer是基于事件的,不会周期性出发tick中断,所以为了实现周期性的tick中断(dynamic tick)
        • 系统创建了一个模拟tick时钟的特殊hrtimer
          • 将其超时时间设置为一个tick时长,在超时回来后,完成对应的工作,
          • 然后再次设置下一个tick的超时时间,以此达到周期性tick中断的需求。
        • 引入了dynamic tick,是为了能够在使用高精度时钟的同时节约能源,这样会产生tickless情况下,会跳过一些tick
      • 新内核对相关的时间硬件设备进行了统一的封装,定义了主要有下面两个结构:
        • 时钟源设备(closk source device):抽象那些能够提供计时功能的系统硬件,比如 RTC(Real Time Clock)TSC(Time Stamp Counter)HPETACPI PM-TimerPIT等。不同时钟源提供的精度不一样,现在PC大都是支持高精度模式(high-resolution mode)也支持低精度模式(low-resolution mode)
        • 时钟事件设备(clock event device):系统中可以触发 one-shot(单次)或者周期性中断的设备都可以作为时钟事件设备。
    • 当前内核同时存在新旧timer wheelhrtimer两套timer的实现,内核启动后会进行从低精度模式到高精度时钟模式的切换,hrtimer模拟的tick中断将驱动传统的低精度定时器系统(基于时间轮)和内核进程调度。

Zookeeper