面试八股之操作系统
面试八股之操作系统
1. Basic Concept
进程、线程、协程概念性区别
- 对于进程、线程,都是==由内核进行调度,有CPU时间片的概念,进行抢占式调度(有多种调度算法)==。 对于协程(用户级线程),这是对内核透明的,也就是系统并不知道有协程的存在,是完全==由用户的程序自己调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的CPU控制权切换到其他进程/线程,通常只能进行协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到==。
- 进程是程序执行的一个实例, 担当分担系统资源的实体.进程是分配资源的基本单位,也是我们说的隔离。进程切换只发生在内核态。
- 线程作为独立运行和独立调度的基本单元。线程(用户级线程/内核级线程),线程是进程的一个执行流,线程是操作系统能够进行运算调度的最小单位, 对于进程和线程,都是由内核进行调度,有 CPU 时间片的概念, 进行抢占式调度,线程可以在启动前设置栈的大小,启动后,线程的栈大小就固定了, 内核由系统内核进行调度, 系统为了实现并发,会不断地切换线程执行, 由此会带来线程的上下文切换.
- 协程(用户态线程)是对内核透明的, 也就是系统完全不知道有协程的存在, 完全由用户自己的程序进行调度,在栈大小分配方便,且每个协程占用的默认占用内存很小,只有
2kb
,而线程需要8mb
,相较于线程,因为协程是对内核透明的,所以栈空间大小可以按需增大减小, 在调度方面, 相较于线程,协程与线程主要区别是它将不再被内核调度,而是交给了程序自己而线程是将自己交给内核调度。python中threading创建的是内核级线程,gevent创建的是用户级线程(即线程)
进程间通信的方式有哪些,以及各自的优劣?
- 最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则。
- 消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。 每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程,主要有以下两个原因:1. 安全性:操作系统为了保障系统的安全性,在进程间进行数据传输时,需要对数据进行检查和验证。这个过程需要从用户态切换到内核态,然后再切换回用户态,因此会导致数据拷贝。2. 数据结构不同:内核态和用户态的地址空间是分离的,它们所使用的数据结构也不同。当一个进程想要向消息队列中写入或读取数据时,需要将数据从当前进程的用户态地址空间复制到内核态的地址空间中;而另一个进程想要读取数据时,需要将数据从内核态的地址空间复制到当前进程的用户态地址空间中。这种数据结构不同也会导致数据拷贝。虽然数据拷贝会带来一定的开销,但是由于操作系统需要对数据进行检查和验证,而且内核态和用户态的数据结构不同,因此无法避免每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。消息队列是一种通过消息传递进行进程间通信的机制,它允许多个进程向一个共享的消息队列发送和接收数据。消息队列可以独立于发送和接收进程存在,因此可以轻松实现一对多或多对一的通信模式,而且可以选择不同的消息优先级。缺点是如果发送方频率过快,则接收方可能无法及时处理所有消息,导致消息队列溢出。
- 共享内存可以==解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,共享内存是一种高效的进程间通信方式,它可以直接将进程地址空间中的某一块区域映射到另一个进程的地址空间中。由于共享内存不需要进行数据的复制和拷贝,所以它可以提供高效的数据传输速度。==但是,由于多个进程访问同一块共享内存区域容易造成数据混乱和死锁问题,因此需要进行同步控制。它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。
- ==信号量是一种进程间同步和互斥机制。通过设置信号量来表示临界资源的使用情况,从而协调多个进程之间的并发访问。它主要用于解决系统中竞争资源的分配问题,如共享内存、文件等。但是,如果信号量的使用不当,可能会导致死锁或者饥饿等问题。==信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。信号量是一种进程间同步和互斥机制,它的主要作用是对临界资源进行保护。为了实现这个目的,信号量提供了两个基本操作:P(wait)和V(signal),它们分别用于对信号量的值进行减一和加一操作。具体来说,P操作和V操作的含义如下:
- P操作(等待操作)P操作用于申请对共享资源的访问权限。如果当前信号量的值大于0,则可以直接访问共享资源,并将信号量的值减一;否则需要阻塞等待,直到其他进程释放资源并通知该进程可以访问共享资源为止。
1
2
3
4
5
6
7
8P(semaphore) {
while (semaphore <= 0)
{
// 阻塞当前进程
sleep();
}
semaphore--;
} - V操作(释放操作)V操作用于释放共享资源。当一个进程使用完共享资源之后,就需要将信号量的值加一,以便其他进程能够继续使用该资源。
1
2
3
4
5V(semaphore)
{ semaphore++;
// 唤醒一个等待该资源的进程
wakeup();
}
- P操作(等待操作)P操作用于申请对共享资源的访问权限。如果当前信号量的值大于0,则可以直接访问共享资源,并将信号量的值减一;否则需要阻塞等待,直到其他进程释放资源并通知该进程可以访问共享资源为止。
- 与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程
- 前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。它具有通用性、可移植性和灵活性, 但是,由于套接字需要进行网络传输,因此会存在数据传输的延迟和不可靠性。
内核态和用户态区别?内核态的底层操作有什么?为什么要分两个不同的态?
内核态和用户态是操作系统中的两种运行模式。它们的主要区别在于权限和可执行的操作:
内核态(Kernel Mode):==在内核态下,CPU 可以执行所有的指令和访问所有的硬件资源。这种模式下的操作具有更高的权限,主要用于操作系统内核的运行。内核态的底层操作主要包括:内存管理、进程管理、设备驱动程序控制、系统调用等。这些操作涉及到操作系统的核心功能,需要较高的权限来执行。==
用户态(User Mode):==在用户态下,CPU 只能执行部分指令集,无法直接访问硬件资源。这种模式下的操作权限较低,主要用于运行用户程序。==
分为内核态和用户态的原因主要有以下几点:
- 安全性:通过对权限的划分,用户程序无法直接访问硬件资源,从而避免了恶意程序对系统资源的破坏。
- 稳定性:用户态程序出现问题时,不会影响到整个系统,避免了程序故障导致系统崩溃的风险。
- 隔离性:内核态和用户态的划分使得操作系统内核与用户程序之间有了明确的边界,有利于系统的模块化和维护。
内核态和用户态的划分有助于保证操作系统的安全性、稳定性和易维护性
IO多路复用 , 理解select和poll、epoll使用, epoll水平、边缘触发的区别,EAGAIN,accept处理新增链接
Linux shell命令、问题排查、管道等
一个进程的多个线程之间,那些资源是共享的,哪些是私有的?
在进程中,线程是执行程序的最小单位。每个线程都有自己的栈空间和寄存器等私有资源,但是它们也可以共享进程的资源。
进程中的多个线程可以共享以下资源:
- 进程地址空间:所有线程都可以访问进程的全局变量、静态变量、常量、堆区和代码段等资源
- 文件描述符:在UNIX系统中,每个进程都有一张打开文件的表格,其中每个打开文件对应一个文件描述符。这些文件描述符是共享的,因此,在不同的线程中打开或关闭文件可能会影响其他线程。
- 信号处理函数:进程中的所有线程都共享同样的信号处理函数。
进程中的多个线程各自拥有以下资源:
- 栈空间:每个线程都有自己的栈空间,用于保存局部变量、函数返回地址和参数等数据。
- 寄存器:每个线程都有自己的寄存器,用于保存临时变量和计算结果等数据。
- 线程ID:每个线程有自己独立的线程ID,用于表示该线程的唯一身份标识符。
- CPU时间:每个线程都有自己的CPU时间,用于记录该线程在CPU上的执行时间。
多个线程之间,线程同步和通讯的原语主要有哪些?
线程同步是指多个线程对共享资源进行访问或修改时,需要保证数据的一致性和正确性。为了实现线程同步,可以采用以下几种方式:
- 互斥锁。互斥锁是一种基本的线程同步机制,它通过给共享资源加锁来确保在任何时刻只有一个线程能够访问共享资源,从而避免数据竞争和冲突。
优点:
简单易用,容易实现。
可以有效地避免数据竞争和冲突。
缺点:
常常会导致死锁问题。
在高并发场景下,由于每个线程都需要抢占锁资源,可能会导致性能瓶颈。
- 条件变量
条件变量是一种高级的线程同步机制,用于在多个线程之间进行通信。它允许线程等待某个条件成立后再继续执行,从而避免了线程忙等的情况。
优点:
可以有效地避免线程忙等的情况。
提供了更精细的线程同步控制。
缺点:
实现相对较为复杂。
可能会导致死锁问题。
- 屏障
屏障是一种同步机制,用于控制多个线程在某一点上等待,直到所有线程都到达这一点后才能继续执行。它通常用于实现模拟多进程程序的并行计算,以及一些需要复杂同步的算法。
优点:
提供了更精细的线程同步控制。
可以有效地避免数据竞争和冲突。
缺点:
实现相对较为复杂。
在高并发场景下,可能会导致性能瓶颈。
- 读写锁
读写锁是一种特殊的互斥锁,它允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。读写锁可以提高程序的并发性能,适用于读多写少的场景。
优点:
可以提高程序的并发性能。
可以有效地避免数据竞争和冲突。
缺点:
实现相对较为复杂。
在高写并发场景下,可能会导致性能瓶颈。
- 原子操作
原子操作是指不可中断的操作,即在执行过程中不能被其他线程打断。在多线程编程中,原子操作可以保证对共享数据的修改是原子的、不可分割的,从而避免了数据竞争和冲突。
优点:
简单易用,容易实现。
可以有效地避免数据竞争和冲突。
可以提高程序的性能。
缺点:
仅适用于对共享资源进行简单修改的场景。
无法提供更精细的线程同步控制。
总之,在多线程编程中,线程同步是确保程序正确性的关键所在。不同的线程同步方式各有优缺点,应该根据具体场景选择合适的方式,以获得最佳的性能和可靠性。
如何优化线程锁带来的效率问题?
互斥锁是一种基本的线程同步机制,它通过给共享资源加锁来确保在任何时刻只有一个线程能够访问共享资源,从而避免数据竞争和冲突。然而,在高并发场景下,由于每个线程都需要抢占锁资源,可能会导致性能瓶颈。为了优化互斥锁带来的线程同步的性能问题,可以采用以下几种策略:
减小锁粒度
如果一个共享资源被多个线程频繁访问,但实际上只有很少的代码段需要对其进行修改,那么可以将锁的粒度减小到这些代码段上,从而降低锁的争用率。例如,可以使用读写锁或者细粒度锁等方式来减小锁粒度。避免长时间持有锁
如果一个线程需要在临界区内执行耗时较长的操作,那么最好在操作之前先释放锁资源,待操作完成后再重新获取锁资源。这样可以减少其他线程的等待时间,提高程序的并发性能。线程局部存储
对于一些只在单个线程中使用的变量,可以将其存储在该线程的局部存储中,避免使用互斥锁来进行同步。这样可以减少线程之间的竞争,提高程序的执行效率。无锁算法
对于一些数据结构和算法,可以使用无锁算法来实现线程间同步。无锁算法通过原子操作和内存屏障等技术来保证数据的一致性和正确性,从而避免了锁带来的性能瓶颈。使用协程或异步编程
协程和异步编程是一种基于事件驱动的程序设计模式,在其内部实现中通常不需要使用互斥锁来进行同步。因此,如果能够采用协程或异步编程的方式来重构代码,也可以有效地提高程序的并发性能。
总之,在优化互斥锁带来的线程同步的性能问题时,需要根据具体情况选择合适的策略,以获得最佳的性能和可靠性。同时,需要注意在优化过程中不要引入新的竞争条件和安全漏洞。
根据不同的场景使用不同的锁-
无锁算法来实现线程间同步
无锁算法是一种多线程编程的技术,它通过使用原子操作和内存屏障等底层机制来实现对共享资源的访问和修改,从而避免了使用锁所带来的性能瓶颈。具体来说,无锁算法通常具有以下几个特点:
- 原子操作
无锁算法通常使用原子操作来保证对共享资源的修改是原子的、不可分割的。原子操作是指一组操作在任何情况下都是不可中断的,在执行过程中不能被其他线程打断。例如,C++11标准中提供的std::atomic类型就可以用来实现原子操作。
- 内存屏障
无锁算法还通常使用内存屏障来保证数据的一致性和正确性。内存屏障是一种CPU指令,可以强制处理器按照程序员指定的顺序执行内存操作,从而确保数据的正确性。例如,在x86架构下,可以使用MFENCE指令来创建内存屏障。
- 自旋
由于无锁算法不使用锁来进行同步,因此在高并发场景下可能会出现多个线程竞争同一个共享资源的情况。为了解决这个问题,无锁算法通常使用自旋来等待共享资源的可用性。自旋是指线程在访问共享资源时不断地重试,直到资源可用为止。
总之,无锁算法通过使用原子操作和内存屏障等底层机制来保证对共享资源的访问和修改是安全的、不会出现数据竞争和冲突的。相比于锁机制,无锁算法可以提高程序的并发能力,减少锁带来的性能瓶颈。但是,无锁算法的实现比较复杂,容易引入新的竞争条件和安全漏洞,需要谨慎使用和调试。
- 程序崩溃了,如何定位崩溃点?
- 程序崩溃时,常见的主要有那些信号?
- 内存泄漏如何调试?如何预防?
- SSD和机械硬盘的主要区别是什么?
- SSD的写放大是什么意思?
不懂Linux的,Linux相关的问题可以不知道。
- 进程&线程 ,常用进程通讯方式、线程间资源竞争问题,线程同步问题
- Proactor、reactor模式区别,知道通过多线程线程提高并发
\6. 数据库的事务的4个特性是什么?并发事务会带来什么问题?
- 原子性: 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
- 一致性: 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
- 隔离性: 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
- 持久性: 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
多线程锁是什么
多线程锁是一种用来保护共享资源的机制。在多线程编程中,如果多个线程同时访问同一个共享资源,可能会发生竞态条件(Race Condition),导致程序的行为出现未定义的情况。为了避免这种情况的发生,可以使用多线程锁来保护共享资源。
多线程锁的基本思想是,在访问共享资源之前先获取锁,访问完成之后再释放锁。这样可以保证同一时刻只有一个线程可以访问共享资源,从而避免竞态条件的发生。
常见的多线程锁包括==互斥锁、读写锁、条件变量==等。其中,互斥锁用于保护共享资源的访问,读写锁用于在读多写少的情况下提高并发性能,条件变量用于线程之间的同步和通信。
select/epoll的区别
select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。
epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。
epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。
IO特别密集时epoll效率还高吗
答:可以考虑select/poll,这种情况轮询也很高效,且结构简单。
补充:
可以先解释io特别密集时为什么 epoll 效率不高。原因是:
连接密集(短连接特别多),使用epoll的话,每一次连接需要发生epoll_wait->accpet->epoll_ctl调用,而使用select只需要select->accpet,减少了一次系统调用。
读写密集的话,如果收到数据,我们需要响应数据的话,使用epoll的情况下, read 完后也需要epoll_ctl 加入写事件,相比select多了一次系统调用
讲一讲ET、LT模式
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read
和 write
)返回错误,错误类型为 EAGAIN
或 EWOULDBLOCK
。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
对于一个后端服务,提升性能可以从以下几个方面入手:
优化算法和数据结构 在编写程序时,应该选择合适的算法和数据结构,以尽量减少程序的计算和存储时间。例如,在进行大规模数据处理时,应该使用散列表或者树型结构,而不是线性查找等慢速算法。
减少I/O操作 I/O操作通常是一个后端服务中最耗时的部分,因此应该尽量减少I/O操作的次数和时间。例如,在读取和写入文件时,可以使用缓冲区来批量读取和写入,从而减少I/O操作的次数。
使用多线程和异步编程 多线程和异步编程可以将一个任务拆分为多个子任务,并行执行,从而提高程序的处理能力和响应速度。例如,可以将一个数据处理任务拆分为多个线程或者进程,并使用消息队列来协调任务之间的数据传输。
应用缓存和内存池 缓存和内存池可以将一些常用的数据和资源预先加载到内存中,并重复利用这些数据和资源,从而减少程序的计算和存储时间。例如,在处理大量图片和视频时,可以使用缓存来缓存已经处理过的数据,避免重复计算。
负载均衡和服务治理 负载均衡和服务治理可以将一个后端服务分发到多个服务器上,从而提高程序的处理能力和稳定性。例如,可以使用Nginx等负载均衡软件来将请求分发到多个服务器上,并使用Zookeeper等服务治理软件来监控和管理服务器的状态和资源。
总之,对于一个后端服务,提升性能可以通过优化算法和数据结构、减少I/O操作、使用多线程和异步编程、应用缓存和内存池、以及负载均衡和服务治理等手段来实现。在实际编程中,应该根据具体情况选择合适的方法,并进行持续优化和监控,以确保程序的高效和稳定运行。
Linux内存管理、分配、回收、OOM问题。
https://mp.weixin.qq.com/s/EsU9FT9D9K5Rt1BM0ySVmw
先来说说第一个问题:虚拟内存有什么作用?(如果你还不知道虚拟内存概念,可以看这篇:真棒!20 张图揭开内存管理的迷雾,瞬间豁然开朗)
第一,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的。这就解决了多进程之间地址冲突的问题。
第二,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
然后今天主要是聊聊第二个问题,「系统内存紧张时,会发生什么?」
发车!
内存分配的过程是怎样的?
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。
后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。
OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。
申请物理内存的过程如下图:
哪些内存可以被回收?
系统内存紧张的时候,就会进行回收内测的工作,那具体哪些内存是可以被回收的呢?
主要有两类内存可以被回收,而且它们的回收方式也不同。
文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
匿名页(Anonymous Page):应用程序通过 mmap 动态分配的堆内存叫作匿名页,这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:
active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。
活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。可以从 /proc/meminfo 中,查询它们的大小,比如:
1 | # grep表示只保留包含active的指标(忽略大小写)# sort表示按照字母顺序排序[root@xiaolin ~]# cat /proc/meminfo | grep -i active | sortActive: 901456 kBActive(anon): 227252 kBActive(file): 674204 kBInactive: 226232 kBInactive(anon): 41948 kBInactive(file): 184284 kB |
回收内存带来的性能影响
在前面我们知道了回收内存有两种方式。
一种是后台内存回收,也就是唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程。
一种是直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高。
可被回收的内存类型有文件页和匿名页:
文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。
匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。
可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。
下面针对回收内存导致的性能影响,说说常见的解决方式。
调整文件页和匿名页的回收倾向
从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。
Linux 提供了一个 /proc/sys/vm/swappiness
选项,用来调整文件页和匿名页的回收倾向。
swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。
1 | [root@xiaolin ~]# cat /proc/sys/vm/swappiness0 |
一般建议 swappiness 设置为 0(默认就是 0),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。
尽早触发 kswapd 内核线程异步回收内存
如何查看系统的直接内存回收和后台内存回收的指标?
我们可以使用 sar -B 1
命令来观察:
图中红色框住的就是后台内存回收和直接内存回收的指标,它们分别表示:
pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。
pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数。
pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand)。
如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。
针对这个问题,解决的办法就是,可以通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。
什么条件下才能触发 kswapd 内核线程回收内存呢?
内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:
页最小阈值(pages_min);
页低阈值(pages_low);
页高阈值(pages_high);
这三个内存阈值会划分为四种内存使用情况,如下图:
kswapd 会定期扫描内存的使用情况,根据剩余内存(pages_free)的情况来进行内存回收的工作。
图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的;
图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,说明内存有一定压力,但还可以满足应用程序申请内存的请求;
图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。
图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。
可以看到,当剩余内存页(pages_free)小于页低阈值(pages_low),就会触发 kswapd 进行后台回收,然后 kswapd 会一直回收到剩余内存页(pages_free)大于页高阈值(pages_high)。
也就是说 kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域,如果剩余内测低于了 pages_min 会触发直接内存回收,高于了 pages_high 又不会唤醒 kswapd。
页低阈值(pages_low)可以通过内核选项 /proc/sys/vm/min_free_kbytes
(该参数代表系统所保留空闲内存的最低限)来间接设置。
min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)都是根据页最小阈值(pages_min)计算生成的,它们之间的计算关系如下:
1 | pages_min = min_free_kbytespages_low = pages_min*5/4pages_high = pages_min*3/2 |
如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。
增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。
所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。
NUMA 架构下的内存回收策略
什么是 NUMA 架构?
再说 NUMA 架构前,先给大家说说 SMP 架构,这两个架构都是针对 CPU 的。
SMP 指的是一种多个 CPU 处理器共享资源的电脑硬件架构,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。
随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。
SMP 与 NUMA 架构
为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)。
NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。
每个 Node 有自己独立的资源,包括内存、IO 等,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。
NUMA 架构跟回收内存有什么关系?
在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。
具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项:
0 (默认值):在回收本地内存之前,在其他 Node 寻找空闲内存;
1:只回收本地内存;
2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。
4:只回收本地内存,在本地回收内存时,可以用 swap 方式回收内存。
在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率是因为 zone_reclaim_mode 没有设置为 0 ,导致当本地内存不足的时候,只选择回收本地内存的方式,而不去使用其他 Node 的空闲内存。
虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为 0。
如何保护一个进程不被 OOM 杀掉呢?
在系统空闲内存不足的情况,进程申请了一个很大的内存,如果直接内存回收都无法回收出足够大的空闲内存,那么就会触发 OOM 机制,内核就会根据算法选择一个进程杀掉。
Linux 到底是根据什么标准来选择被杀的进程呢?这就要提到一个在 Linux 内核里有一个 oom_badness()
函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。
进程得分的结果受下面这两个方面影响:
第一,进程已经使用的物理内存页面数。
第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过
/proc/[pid]/oom_score_adj
来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。
函数 oom_badness() 里的最终计算方法是这样的:
1 | // points 代表打分的结果// process_pages 代表进程已经使用的物理内存页面数// oom_score_adj 代表 OOM 校准值// totalpages 代表系统总的可用页面数points = process_pages + oom_score_adj*totalpages/1000 |
用「系统总的可用页面数」乘以 「OOM 校准值 oom_score_adj」再除以 1000,最后再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大。
每个进程的 oom_score_adj 默认值都为 0,所以最终得分跟进程自身消耗的内存有关,消耗的内存越大越容易被杀掉。我们可以通过调整 oom_score_adj 的数值,来改成进程的得分结果:
如果你不想某个进程被首先杀掉,那你可以调整该进程的 oom_score_adj,从而改变这个进程的得分结果,降低该进程被 OOM 杀死的概率。
如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。
我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。
但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。
参考资料:
总结
内核在给应用程序分配物理内存的时候,如果空闲物理内存不够,那么就会进行内存回收的工作,主要有两种方式:
后台内存回收:在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
直接内存回收:如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
可被回收的内存类型有文件页和匿名页:
文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。
匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。
文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。
针对回收内存导致的性能影响,常见的解决方式。
设置 /proc/sys/vm/swappiness,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页;
设置 /proc/sys/vm/min_free_kbytes,调整 kswapd 内核线程异步回收内存的时机;
设置 /proc/sys/vm/zone_reclaim_mode,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题;
在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,OOM killer 就会根据每个进程的内存占用情况和 oom_score_adj 的值进行打分,得分最高的进程就会被首先杀掉。
我们可以通过调整进程的 /proc/[pid]/oom_score_adj 值,来降低被 OOM killer 杀掉的概率。
面试问题
1)TCP/IP协议
1)三次握手/四次挥手过程
2)TIME_WAIT状态
1)主动关闭/被动关闭
2)需要的原因
3)缓解措施
4)有没有方式不出现TIME_WAIT状态
3)RST出现的场景
4)滑动窗口
2)网络编程
1)EPOLL/SELECT的区别
2)边沿触发/水平触发
3)事件触发的场景
1)读事件 有哪些场景
2)写事件 有哪些场景
4)READ调用返回值场景
1)0
2)-1
3)>0
5)UDP客户端可以CONNECT不,那CONNECT和不CONNECT有啥区别
3)后台编程
1)FORK的用途,FORK区分父子进程方式
2)进程间通信方式
3)僵尸进程
4)内存模型,有哪些段构成
5)进程/线程/协程的区别
4)常见中间件
1)MYSQL
1)为啥不建议SELECT *
2)覆盖索引
3)分页优化
2)ZOOKEEPER
1)有哪些WATCH以及对应唤醒事件
3)KAFKA
1)分区分服
2)生产者
3)消费者
5)语言
1)JAVA语言
1)内存管理
6)设计模式
1)单例模式
7)项目中的难点、挑战点
22989-腾讯云网络后台开发工程师(CSIG全资子公司)(西安)
建议用online ddl(网上可以查)修改,就是逗号后面的参数
另外,添加字段要加上after,根据表结构看看fromWanIp适合在哪个字段后
ALTER TABLE cEip ALTER COLUMN ispId SET DEFAULT -1, ALGORITHM=INPLACE, LOCK=NONE;
FORK的用途,FORK区分父子进程方式
fork()
是Unix/Linux操作系统中一个重要的系统调用,它的主要作用是创建一个新的进程。下面是fork()
函数的一些具体用途:
多进程并发 使用
fork()
函数可以创建一个与当前进程完全相同的子进程,并且这个子进程所拥有的数据、资源和代码段等都复制自父进程。这样,在不同进程之间就可以实现并发执行,提高程序的执行效率。资源隔离和保护 在多进程并发的情况下,需要考虑如何进行资源隔离和保护。通过使用
fork()
函数创建多个进程,可以将不同的任务分配到不同的进程中运行,并且使用信号量、共享内存等机制来进行进程间的通信和同步,从而实现资源的隔离和保护。守护进程 守护进程(daemon)是一种在后台运行的进程,独立于控制终端并且没有用户交互界面。在Unix/Linux操作系统中,守护进程通常是由
fork()
函数创建的子进程,并且使用setsid()
函数将子进程转换为守护进程。
区分父子进程的方式包括以下两种:
fork()
函数的返回值 在父进程中,fork()
函数返回子进程的PID(进程ID),在子进程中则返回0。通过判断fork()
函数的返回值,可以区分父子进程。进程ID(PID) 每个进程都有一个唯一的PID,可以通过系统调用
getpid()
来获取当前进程的PID,也可以通过fork()
函数返回的PID来获取子进程的PID。在父进程和子进程中,它们分别具有不同的PID,从而可以区分父子进程。
总之,fork()
函数是Unix/Linux操作系统中一个重要的系统调用,它可以创建一个新的进程,并且复制父进程的数据、资源和代码段等。通过使用fork()
函数,可以实现多进程并发、资源隔离和保护、守护进程等功能。同时,它还可以通过进程ID和返回值来区分父子进程。