操作系统篇-2021秋招准备

1、CPU上下文

CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。

  • CPU 寄存器是 CPU 内置的容量小、但速度极快的内存。
  • 程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。

2、什么是上下文切换

即使是单核CPU也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程时同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态,从任务保存到再加载的过程就是一次上下文切换

进程切换分两步
  • 切换页目录以使用新的地址空间
  • 切换内核栈和硬件上下文
线程切换
  • 切换虚拟内存空间依然是相同的

  • 切换内核栈和硬件上下文

  • 线程的调度只有拥有最高权限的内核空间才可以完成,所以线程的切换涉及到一次用户态到内核态的切换,以及一次内核态到用户态的切换

区别与联系

  • 内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出
  • 上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了
  • 当改变虚拟内存空间的时候,处理的页表缓冲被全部刷新,这将导致内存的访问在一段时间内相当的低效,在线程的切换中,不会出现这个问题。
协程切换
  • 在用户态做
  • 协程切换相比线程切换做的事情更少,协程切换只涉及基本的CPU上下文切换,所谓的 CPU 上下文,就是一堆寄存器,里面保存了 CPU运行任务所需要的信息。当前协程的 CPU 寄存器状态保存起来,然后将需要切换进来的协程的 CPU 寄存器状态加载的 CPU 寄存器上就 ok 了

3、阻塞、非阻塞、多路复用、信号驱动、异步IO

阻塞IO
  • 描述: 用户请求数据,系统内核(kernel)准备数据,用户进程被阻塞,数据被准备好后,kernel会将数据拷贝到用户内存,拷贝的过程中用户进程也被阻塞,直到kernel返回结果后,用户进程才解除阻塞。
  • 解决: 在服务器端使用多进程或多线程,让每个连接都拥有独立的线程或进程
  • 暴露的问题:连接请求多的时候,占用系统资源,降低系统对外界响应速度
  • 改进方案:
    • 线程池:减少创建和销毁线程的频率
    • 连接池:维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率
    • 问题:池有上限

title

非阻塞IO
  • 描述:
    • 当用户进程发出read操作时,进程并没有被阻塞,内核马上返回给进程响应,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvfrom系统调用。
    • 重复上面的过程,循环往复的进行recvfrom系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

title

多路复用IO

title

title

  • 描述:
    • select/epoll这个函数会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程 ,select/epoll的好处就在于单个线程/进程,就可以同时处理多个网络连接的IO
    • 当用户进程调用了select,那么整个进程会被阻塞,系统内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel系统内核拷贝到用户进程
  • 与阻塞IO区别:
    • 多路复用需要使用两个系统调用:select和recvfrom,而阻塞IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection
    • 如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接
    • 在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,整个用户的process其实是一直被阻塞的。只不过process是被select这个函数阻塞的,而不是被socket IO给阻塞

title

在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点

  • 表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调

  • poll没有最大文件描述符数量的限制

  • select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

信号驱动IO
  • 描述:允许套接口进行信号驱动I/O, 并安装信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了select的阻塞与轮询,当有活跃套接字时,由注册的handler处理。

title

异步IO
  • 描述:
    • 异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要
    • 用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从系统内核的角度,当它收到一个asynchronous(异步) read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。
    • 系统内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel系统内核会给用户进程发送一个signal,告诉它read操作完成了

title

总结

title

阻塞式I/O、非阻塞式I/O、I/O复用、信号驱动式I/O他们的第二阶段都相同,也就是都会阻塞到recvfrom调用上面就是图中“发起”的动作

IO分两阶段:

  • 数据准备阶段

  • 内核空间复制回用户进程缓冲区阶段

举例:
  • 阻塞IO: 用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩了再拉杆;
  • 非阻塞IO: 鱼竿有个功能,能够显示是否有鱼上钩,所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;
  • 多路复用IO: 用的鱼竿和B差不多,鱼竿有显示是否上钩的功能,但他想了一个好办法,就是同时放好几根鱼竿(select/poll/epoll),然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;
  • 异步IO: 是个有钱人,干脆雇了一个人(kernel)帮他钓鱼,一旦那个人把鱼钓上来了,就给D发个短信,并送鱼上门;
  • 信号驱动IO: 鱼竿比较高级,鱼竿自带一个信号器,E不需要守着鱼竿,鱼上钩后,信号器自动给E发送短信通知,E过来取鱼。

4、内存溢出、内存泄漏

是什么
  • 内存溢出: 指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出
  • 内存泄漏: 指程序在申请内存new/malloc后,无法释放delete/free已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。memory leak会最终会导致out of memory!
原因
  • 内存泄漏

    • 程序循环new创建出来的对象没有及时的delete掉,导致了内存的泄露
    • delete掉一个void*类型的指针,导致没有调用到对象的析构函数,析构的所有清理工作都没有去执行从而导致内存的泄露
    • new创建了一组对象数组,内存回收的时候却只调用了delete而非delete []来处理,导致只有对象数组的第一个对象的析构函数得到执行并回收了内存占用,数组的其他对象所占内存得不到回收,导致内存泄露;
  • 内存溢出

    • 内存分配未成功,却使用了它
    • 内存分配尽管成功,可是尚未初始化就引用它
    • 内存分配成功而且已经初始化,但操作越过了内存的边界
    • 使用free 或delete 释放了内存后,没有将指针设置为NULL。导致产生“野指针”
    • 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象到底是否已经释放了内存,此时应该又一次设计数据结构,从根本上解决对象管理的混乱局面
    • 忘记为数组和动态内存赋初值,导致未被初始化的内存被作为右值使用
解决
  • 内存溢出:

    • 避免上面所说的导致内存溢出原因

    • 在使用内存之前检查指针是否为NULL。假设指针p 是函数的參数,那么在函数的入口处用assert(p!=NULL)进行检查。假设是用malloc 或new 来申请内存,应该用if(p==NULL)或if(p!=NULL)进行防错处理

  • 内存泄露

    • 良好的编码习惯,尽量在涉及内存的程序段,检測出内存泄露
    • 重载 new 和 delete。将分配的内存以链表的形式自行管理,使用完成之后从链表中删除,程序结束时可检查改链表,当中记录了内存泄露的文件
    • 使用智能指针
    • 一些常见的工具软件BoundsChecker,它主要定位程序运行时期发生的各种错误
    • 调试运行DEBUG版程序,运用以下技术:CRT(C run-time libraries)、运行时函数调用堆栈、内存泄漏时提示的内存分配序号(集成开发环境OUTPUT窗口),综合分析内存泄漏的原因,排除内存泄漏

5、进程

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

  • 高级管道通信(popen):将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式

  • 有名管道通信(named pipe): 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

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

  • 信号量通信(semophore): 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段

  • 信号(sinal): 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生

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

  • 套接字通信(socket): 套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信

僵尸进程

僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被init接管,子进程退出后init会回收其占用的相关资源

由于子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程 到底什么时候结束. 那么会不会因为父进程太忙来不及wait子进程,或者说不知道 子进程什么时候结束,而丢失子进程结束时的状态信息呢? 不会。因为UNⅨ提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放. 但这样就导致了问题,如果进程不调用wait / waitpid的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

僵尸进程的避免

⒈父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起。

⒉ 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。

⒊ 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIG_IGN) 通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。


操作系统篇-2021秋招准备
https://zhangfuli.github.io/2021/08/02/2021秋招准备-操作系统篇/
作者
张富利
发布于
2021年8月2日
许可协议