Skip to content

操作系统

线程崩溃,进程也会崩溃吗?

一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃。为什么系统要让进程崩溃呢?

  • 主要是因为进程中,各个线程地址空间是共享的。既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃

线程崩溃后,进程是如何崩溃的呢?

  • 答案是信号
  • 比如:想要干掉一个正在运行的进程经常用 kill -9 pid。kill 其实就是给指定 pid 发送终止信号,其中的 9 就是信号

image-20240403085153541

发个信号进程就崩溃了,这背后的原理是怎样的

  1. CPU 执行正常的进程指令
  2. 调用 kill 系统调用向进程发送信号
  3. 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统
  4. 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)
  5. 操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑会让进程退出

注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行

c
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
int main(void) {
  // 忽略信号
  signal(SIGSEGV, SIG_IGN);
  // 产生一个 SIGSEGV 信号
  raise(SIGSEGV);
  printf("正常结束");
}

如何让正在运行的 Java 工程优雅停机?

  • JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出
  • 这种场景显然不能用 kill -9,不然一下把进程干掉了资源就来不及清除了

为什么线程崩溃不会导致 JVM 进程崩溃?

  • 因为 JVM 自定义了自己的信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃
java
JVM_handle_linux_signal(int sig,
                        siginfo_t* info,
                        void* ucVoid,
                        int abort_if_unrecognized) {

   // Must do this before SignalHandlerMark, if crash protection installed we will longjmp away
  // 这段代码里会调用 siglongjmp,主要做线程恢复之用
  os::ThreadCrashProtection::check_crash_protection(sig, t);

  if (info != NULL && uc != NULL && thread != NULL) {
    pc = (address) os::Linux::ucontext_get_pc(uc);

    // Handle ALL stack overflow variations here
    if (sig == SIGSEGV) {
      // Si_addr may not be valid due to a bug in the linux-ppc64 kernel (see
      // comment below). Use get_stack_bang_address instead of si_addr.
      address addr = ((NativeInstruction*)pc)->get_stack_bang_address(uc);

      // 判断是否栈溢出了
      if (addr < thread->stack_base() &&
          addr >= thread->stack_base() - thread->stack_size()) {
        if (thread->thread_state() == _thread_in_Java) {            // 针对栈溢出 JVM 的内部处理
            stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW);
        }
      }
    }
  }

  if (sig == SIGSEGV &&
               !MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) {
         // 此处会做空指针检查
      stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL);
  }


  // 如果是栈溢出或者空指针最终会返回 true,不会走最后的 report_and_die,所以 JVM 不会退出
  if (stub != NULL) {
    // save all thread context in case we need to restore it
    if (thread != NULL) thread->set_saved_exception_pc(pc);

    uc->uc_mcontext.gregs[REG_PC] = (greg_t)stub;
    // 返回 true 代表 JVM 进程不会退出
    return true;
  }

  VMError err(t, sig, pc, info, ucVoid);
  // 生成 hs_err_pid_xxx.log 文件并退出
  err.report_and_die();

  ShouldNotReachHere();
  return true; // Mute compiler

}

正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)

但如果进程觉得"罪不致死",那么它也可以选择自定义一个信号处理函数,这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事

作系统如何管理各种I/O

CPU和I/O交互

设备控制器与CPU交互

image-20240403110848668

c
// 一直等地啊到设备不忙为止
while (STATUS == BUSY);
// Write data to Data register

// 将设备状态设置为忙碌状态
set STATUS register BUSY;
// Write Command to COMMAND register

// 执行命令,执行命令后,将状态设置为就绪态
set STATUS register READY;

image-20240403111141437

这里会有两个问题:

  1. CPU 是怎么知道将数据发送给哪一个设备控制器的寄存器

    操作系统为命令、数据、状态寄存器这几个寄存器每个都分配一个端口,通过端口来进行标识

    • 端口映射I/O:将每个设备控制器的寄存器标志位不同的端口
    • 内存映射I/O:把I/O设备的各个寄存器都编址,看成"内存地址"
  2. CPU 是怎么知道将数据发送到寄存器

    通过指令来判断

image-20240403141325098

I/O控制方式

每种设备都有一个设备控制器,控制器相当于一个小 CPU,它可以自己处理一些事情,但有个问题是,当 CPU 给设备发送了一个指令,让设备控制器去读设备的数据,它读完的时候,要怎么通知 CPU 呢?

控制器的寄存器一般会有状态标记位,用来标识输入或输出操作是否完成

  • 我们想到第一种轮询等待的方法,让 CPU 一直查寄存器的状态,直到状态标记为完成,很明显,这种方式非常的傻瓜,它会占用 CPU 的全部时间
  • 那我们就想到第二种方法 —— 中断(Interrupt),通知操作系统数据已经准备好了。我们一般会有一个硬件的中断控制器,当设备完成任务后触发中断到中断控制器,中断控制器就通知 CPU,一个中断产生了,CPU 需要停下当前手里的事情来处理中断

image-20240403143222291

image-20240403144843261

但中断的方式对于频繁读写数据的磁盘,并不友好,这样 CPU 容易经常被打断,会占用 CPU 大量的时间。对于这一类设备的问题的解决方法是使用 DMA(Direct Memory Access) 功能,它可以使得设备在 CPU 不参与的情况下,能够自行完成把设备 I/O 数据放入到内存

image-20240403145249334

零拷贝

传统数据拷贝过程:

  • 用户进程向 CPU 发起一个 read() 的系统调用,用 read() 来读取硬盘上的数据,这时候上下文会从用户态切换为内核态。read() 操作是进程阻塞操作,需要等待数据有返回时才能解除阻塞
  • CPU 会对 DMA 控制器发起调度命令,对硬盘发起一个 I/O 请求,把数据读到内核中并缓存起来,之后 DMA 进行拷贝,拷贝到内存缓冲区
  • 当拷贝完毕后会 DMA 会发出数据读完信号之后通知 CPU,收到通知后会把内核缓冲区的数据拷贝到用户缓冲区
  • 当内核态切换为用户态后会唤醒以及阻塞的用户进程,用户进程就会向 CPU 发起 write() 系统调用,通过 CPU 时间片轮询,用户向读数据必须通过 TCP 网卡设备才能读取数据
  • CPU 把用户缓冲区数据拷贝到 Socket 缓冲区,之后由 DMA 发起 I/O 请求,之后由 DMA 拷贝到网卡设备

数据拷贝了 4 次,上下文切换了 4 次

DMA

DMA 就是在主板上面安装了一块独立的芯片,当在内存和 I/O 设备上传输数据的时候,不需要 CPU 来控制数据的传输,而是直接通过 DMA 控制器完成数据传输

image-20240328152737898

MMAP+write 将 PageCahe 数据映射到用户缓存区

  • mmap file 使用虚拟内存
  • MMAP 减少了一次 CPU 拷贝,还是包括 4 次上下文切换

image-20240328153455223

sendfile

sendfile

  • 零拷贝是指在用户态没有发生拷贝,而是在内核态发生了一次 CPU 的拷贝

image-20240328154845228

sendfile + DMA gather copy

  • 数据拷贝的过程中相当于省去了 CPU 数据拷贝过程,不再需要拷贝到 Socket 缓冲区,通过 DMA Gather Copy 拷贝到网卡设备

image-20240328155207669

splice + DMA copy

image-20240328155620625

I/O

阻塞与非阻塞

BIO(Blocking I/O)

  • 你去了书店,告诉老板你想要某本书,然后你就一直在那里等着,直到书店老板找到你想要的书

image-20240403093635238

NIO(Non-Blocking I/O)

  • NIO:你去了书店,问老板有没有一本书,老板告诉你没有,你就离开了。一周后,你又来了这个书店,再问这个老板,老板一查,有了,于是你买了这本书

    问题:每次让应用程序去轮询内核的 I/O 是否准备好,是一个不是很好的做法,因为在轮询的过程中进程啥也不能干(需要占用 CPU)

image-20240403093743441

  • 第二种NIO:你去了书店,告诉老板:老板,书到货了给我打电话吧,我再来付钱买书

image-20240403100617481

I/O多路复用

一旦使用 fgets 方法等待标准输入,就没有办法在套接字有数据的时候读出数据,所以需要去掉 write() -> read() 过程

image-20240403101306588

I/O 多路复用:把标准输入、套接字(把用户空间的应用层和内核空间的传输层进行连接)等都看做 I/O 的一路,多路复用的意思,就是在任何一路 I/O 有事件发生的情况下,通知应用程序去处理相应的 I/O 事件

  • I/O 事件一:fd 对应的内核缓冲区来了数据,可读
  • I/O 事件二:fd 对应的内核缓冲区空闲,可写
  • I/O 事件三:fd 出现异常

多路复用实现技术:select、poll、epoll

image-20240403102459522

epoll

select

将需要进行 I/O 操作的 socket 添加到 select 中进行监听,然后阻塞线程,等待操作完成或超时之后,select 系统被激活调用返回,线程再发起 I/O 操作

具体方式还是通过轮询检查所有的 socket,因为单个进程支持的最大文件描述符是 1024,所以实际并发量低于这个数

image-20240407152537826

优缺点:

  • 优点:同个线程能执行多个 I/O,跨平台支持
  • 缺点:原理上还是属于阻塞,单个 I/O 的处理时间甚至高过阻塞 I/O,需要轮询并发量受限(1024)

poll

同 select 机制类似,但是 poll 基于链表实现,并发量没有限制

优缺点:

  • 优点:同个线程能执行多个 I/O,并发量没有限制
  • 缺点:依然是遍历链表检查,效率低下

select/poll的缺点

  1. 每次调用 select/poll 时都需要在用户态/内核态之间拷贝数据

    将应用进程关心的 fd 维护在内核中(支持高效的增删改) -> 红黑树

  2. 在内核中,select/poll 在检测 IO 事件时,只要有一个 fd 有事情发生,就会线性扫描所有的 fds,事件复杂度 O(n)

epoll

针对前两者的缺点进行改进,通过 callback 回调通知机制。减少内存开销,不因并发量大而降低效率,linux 下最高效率的 I/O 事件机制

优点: 同个线程能执行多个 I/O,并发量远远超过 1024 且不影响性能 缺点: 并发量少的情况下效率可能不如前两者

常备不懈,才能有备无患