几年前的一个下午,公司里的码农们正在静静地敲代码,突然很多人的手机同时“滴”的一声。我以为我得到了报酬,所以我很高兴!打开一看,竟然是一条警告短信。图片来自Pexels断层评测。告警提示“线程数超过阈值”、“CPU空闲率过低”。打开监控系统,看到订单服务的20个服务节点全部不工作,服务无响应。查看监控(全链路性能监控工具),每个SpringBoot节点的线程数已经达到最大值。但是JVM堆内存和GC没有明显异常。CPU空闲率基本为0%,但是CPU使用率不高,但是IO等待很高。下面是执行top命令查看CPU状态的截图:从上图我们可以看到CPU空闲率为0%(上图中红框id)。CPU使用率为22%(us13%加上上图红框内sy9%,us可以理解为用户进程占用的CPU,sy可以理解为系统进程占用的CPU).CPU花费了76.6%的时间等待磁盘IO操作(上图中红框wa)。至此,可以确定问题一定出在IO等待上。通过监控系统和jstack命令,最终定位问题出在文件写入上。大量的磁盘读写导致系统线程资源耗尽,最终订单服务无法响应上游服务的请求。IO,那些你不知道的东西既然IO对系统性能和稳定性的影响这么大,那我们就来深入探讨一下。所谓I/O(Input/Output)操作,其实就是输入输出的数据传输行为。程序员最关心的是磁盘IO和网络IO,因为这两个IO操作与应用程序的关系最直接、最密切。DiskIO:磁盘输入输出,如磁盘与内存之间的数据传输;networkIO:不同系统之间跨网络的数据传输,比如两个系统之间的远程接口调用。下图是应用中IO的具体场景:通过上图,我们可以了解IO操作的具体场景。一个请求过程可能有很多IO操作:当向服务器请求页面时会发生网络IO。服务之间的远程调用会引起网络IO。当应用程序访问数据库时会发生网络IO。数据库查询或数据写入时发生磁盘IO。IO和CPU的关系很多攻城狮都懂。如果CPU空闲率为0%,说明CPU已经满负荷工作,没有精力处理其他任务。真的是这样吗?我们先来看看计算机是如何管理磁盘IO操作的。在计算机发展初期,磁盘和内存之间的数据传输是由CPU控制的,也就是说从磁盘读取数据到内存需要CPU进行存储和转发,期间CPU会一直被占用。我们知道,磁盘的读写速度远不及CPU的运行速度。这样在传输数据的时候会占用大量的CPU资源,造成CPU资源的严重浪费。后来有人设计了IO控制器专门控制磁盘IO。在磁盘和内存之间的数据传输发生之前,CPU会向IO控制器发送指令,让IO控制器负责数据传输操作,IO控制器会在数据传输完成后通知CPU。因此,从磁盘读取数据到内存的过程不再需要CPU参与,可以腾出CPU来处理其他事情,大大提高了CPU利用率。这个IO控制器就是“DMA”,即直接内存访问,DirectMemoryAccess。现在的计算机基本都是采用这种DMA方式进行数据传输。通过上面的内容我们知道,IO数据在传输的时候,是不占用CPU的。当一个应用进程或线程在等待IO时,CPU会及时释放相应的时间片资源,并将该时间片分配给其他进程或线程,使CPU资源得到充分利用。因此,如果大部分CPU消耗在IO等待(wa)上,即使CPU空闲率(id)为0%,也不代表CPU资源完全耗尽。如果有新的任务来了,CPU还有Energy来执行任务。如下图所示:在DMA模式下进行IO操作并不占用CPU,所以CPUIO等待(上图中的wa)其实是CPU空闲率的一部分。因此,我们在执行top命令时,除了要关注CPU空闲率和CPU占用率(us,sy)外,还要关注IOWait(wa)。注意wa只代表diskIOWait,不包括networkIOWait。Java中线程状态与IO的关系当我们使用jstack查看Java线程状态时,会看到各种线程状态。当发生IO等待时(比如远程调用),线程的状态是Blocked还是Waiting?答案是Runnable状态,是不是有点出乎意料!其实在操作系统层面,Java的Runnable状态包括Running状态,还包括Ready(就绪状态,等待CPU调度)和IOWait状态。如上图所示,Runnable状态的注解清楚地表明,在JVM层面执行的线程在操作系统层面可能正在等待其他资源。如果等待资源是CPU,在操作系统层面线程处于Ready状态等待CPU调度;如果等待资源是磁盘网卡等IO资源,在操作系统层面线程处于IOWait状态等待IO操作完成。有人可能会问,为什么Java线程没有专门的Running状态呢?目前主流的操作系统大多采用时间分片的方式来轮询和调度任务。时间片通常很短,大约几十毫秒。也就是说,一个线程每次只能在CPU上执行几十毫秒,然后会被CPU调度到Ready状态,等待CPU再次执行,线程在Ready和Ready之间快速切换运行状态。通常JVM线程状态主要是做监控用的,给人看的。当看到线程状态为Running时,线程状态已经切换了N次了。所以给线程加上Running状态是没有意义的。深入理解网络IO模型Linux的五种网络IO模型包括:同步阻塞IO同步非阻塞IO多路复用IO信号驱动IO异步IO为了更好的理解网络IO模型,我们先了解几个基本概念:①Socket(套接字):当两个应用程序通过网络进行通信时,Socket可以理解为两个应用程序中的通信端点。通信时,一个应用程序向Socket写入数据,然后通过网卡将数据发送给另一个应用程序的Socket。我们通常所说的HTTP和TCP协议的远程通信都是基于Socket实现的。五种网络IO模型也必须基于Socket实现网络通信。②阻塞和非阻塞:所谓阻塞就是一个请求不能立即返回,需要等所有逻辑处理完才能返回响应。相反,发送请求并立即返回响应,而无需等待所有逻辑处理完毕。③内核空间和用户空间:在Linux中,应用程序的稳定性远不如操作系统程序。为了保证操作系统的稳定性,Linux区分了内核空间和用户空间。可以理解为内核空间运行操作系统程序和驱动程序,用户空间运行应用程序。通过这种方式,Linux将操作系统程序和应用程序隔离开来,防止应用程序影响操作系统本身的稳定性。这也是Linux系统超级稳定的主要原因。所有的系统资源操作都在内核空间进行,如磁盘文件的读写、内存的分配与回收、网络接口的调用等,因此在一次网络IO读取过程中,数据并不是直接从网卡读取到应用缓冲区在用户空间,但是是先从网卡复制到内核空间缓冲区,再从内核复制到用户空间缓冲区。应用缓冲区。对于网络IO写入过程,流程相反,先将数据从用户空间的applicationbuffer复制到kernelbuffer,然后通过网卡将kernelbuffer中的数据发送出去。同步阻塞IO我们先来看看传统的阻塞IO。在Linux中,所有的Sockets默认都是阻塞模式。当用户线程调用系统函数read()时,内核开始准备数据(从网络接收数据)。内核准备数据后,将数据从内核复制到用户空间的应用程序缓冲区中。数据拷贝完成后,请求返回。从发起Read请求到最后完成从内核到应用程序的拷贝,整个过程都是阻塞的。为了提高性能,可以为每个连接分配一个线程。因此,在连接数较多的情况下,需要大量的线程,会造成巨大的性能损失,这也是传统阻塞IO最大的缺陷。同步非阻塞IO用户线程发起Read请求后立即返回,无需等待内核准备数据。如果Read请求没有读取到数据,用户线程会继续轮询并发起Read请求,直到数据到达(内核准备好数据)才会停止轮询。非阻塞IO模型虽然避免了线程阻塞问题导致的大量线程消耗,但是频繁的重复轮询大大增加了请求数,对CPU的消耗也很明显。这种模型在实际应用中很少使用。多路复用IO模型多路复用IO模型是基于多路复用事件分离函数Select、Poll和Epoll。在发起Read请求前,先更新SelectSocket监控列表,然后等待Select函数返回(这个过程是阻塞的,所以multiplexedIO也是阻塞IO模型)。当Socket有数据到达时,Select函数返回。这时,用户线程正式发起Read请求,对数据进行读取和处理。这种模式使用一个专门的监控线程来检查多个Socket,如果一个Socket有数据到达,则交给工作线程处理。由于等待Socket数据到达的过程是非常耗时的,这种方式解决了阻塞IO模型中1个Socket连接需要1个线程的问题,并且不会出现非阻塞IO模式下忙轮询导致的CPU性能损失-阻塞IO模型。多路复用IO模型有很多实际应用场景,比如大家熟悉的JavaNIO、Redis、Netty,Dubbo采用的通信框架,都采用了这种模型。下图是基于Select函数的Socket编程的详细过程:signal-drivenIOmodelsignal-drivenIO模型,应用进程使用Sigaction函数,内核会立即返回,也就是说应用进程是非-在内核准备阶段阻塞。内核准备好数据后,向应用进程发送SIGIO信号,应用进程收到信号后将数据复制到应用进程。这样CPU利用率就高了。但是,在这种模式下,在大量IO操作的情况下,信号队列可能会溢出,导致信号丢失,造成灾难性的后果。异步IO模型异步IO模型的基本机制是应用进程告诉??内核开始一个操作,内核操作完成后通知应用进程。在多路复用IO模型中,Socket状态事件到来,应用进程接到通知后开始自行读取和处理数据。在异步IO模型中,当通知应用进程时,内核已经读取数据并将数据放入应用进程的缓冲区中,此时应用进程可以直接使用数据。显然,异步IO模型是高性能的。但到目前为止,异步IO和信号驱动IO模型的应用还很少见,传统的阻塞IO和多路复用IO模型仍然是当前应用的主流。异步IO模型是在Linux2.6版本之后引入的。目前很多系统都不支持异步IO模型。许多应用场景使用多路复用IO而不是异步IO模型。如何避免IO问题引起的系统故障对于磁盘文件访问的操作,可以使用线程池的方式,设置线程上线,避免污染整个JVM线程池,导致线程和CPU资源耗尽。用于网络之间的远程调用。为了避免服务之间整个链路失效,需要设置合理的TImeout值,高并发场景可以使用熔断机制。同一个JVM内部采用了线程隔离机制,将线程分成若干组,不同的线程组分别为不同的类和方法服务,避免因为一个小的功能点失效而影响到JVM内部的所有线程。另外,全面的运维监控(磁盘IO、网络IO)和APM(全链路性能监控)也很重要,可以及时预警,防患于未然,在故障发生时帮助我们快速定位问题。作者:尔玛读书简介:曾就职于阿里巴巴、每日优鲜等互联网公司,担任技术总监,拥有15年电子商务和互联网经验。编辑:陶佳龙来源:架构师进阶之路(ID:ermadushu)
