http:https://blog.csdn.net/2401_82607598/article/details/158208828?fromshare=blogdetail&sharetype=blogdetail&sharerId=158208828&sharerefer=PC&sharesource=2401_82607598&sharefrom=from_link

linux网络,tcp与udp:https://blog.csdn.net/2401_82607598/article/details/157391364?fromshare=blogdetail&sharetype=blogdetail&sharerId=157391364&sharerefer=PC&sharesource=2401_82607598&sharefrom=from_link

目录

mmap系统调用

内存对齐是什么,为什么要内存对齐,好处?

进程之间的通信方式(IPC)

说到进程之间通信,进程和线程之间的关系和区别,这是很经典的问题;

关于并行与并发

线程私有资源

进程之间的通信方式(IPC)

线程之间的通信方式

多线程会发生什么问题,线程同步有哪些手段

进程、线程、协程

进程的五种状态

进程和线程的切换需要哪些开销

程序运行类型----IO密集型/CPU密集型

缓冲区溢出

操作系统内存管理—缺页中断、内存页

虚拟内存

虚拟内存空间划分​编辑

同步和互斥

信号量(semaphore)和互斥量(mutex)区别

线程池(thread pool)

分段和分页的区别

动态分区分配算法

页面置换算法

malloc,free,new,delete区别

OOM(内存溢出)

死锁

零拷贝、DMA(direct memory access)技术

外中断和异常的区别

回收线程

守护进程、孤儿进程、僵尸进程

进程、进程组、会话

编码方式

常用高级一点的命令linux

socket的多路复用


mmap系统调用

mmap 即 memory map,也就是内存映射。
mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。
相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

mmap的作用,在应用这一层,是让你把文件的某一段,当作内存一样来访问。将文件映射到物理内存,将进程虚拟空间映射到那块内存。
这样,进程不仅能像访问内存一样读写文件,多个进程映射同一文件,还能保证虚拟空间映射到同一块物理内存,达到内存共享的作用。

mmap 具有如下的特点:

  • mmap 向应用程序提供的内存访问接口是内存地址连续的,但是对应的磁盘文件的 block 可以不是地址连续的;
  • mmap 提供的内存空间是虚拟空间(虚拟内存),而不是物理空间(物理内存),因此完全可以分配远远大于物理内存大小的虚拟空间(例如 16G 内存主机分配 1000G 的 mmap 内存空间);
  • mmap 负责映射文件逻辑上一段连续的数据(物理上可以不连续存储)映射为连续内存,而这里的文件可以是磁盘文件、驱动假造出的文件(例如 DMA 技术)以及设备;
  • mmap 由操作系统负责管理,对同一个文件地址的映射将被所有线程共享,操作系统确保线程安全以及线程可见性;
  • mmap 的设计很有启发性。基于磁盘的读写单位是 block(一般大小为 4KB),而基于内存的读写单位是地址(虽然内存的管理与分配单位是 4KB)。

换言之,CPU 进行一次磁盘读写操作涉及的数据量至少是 4KB,但是进行一次内存操作涉及的数据量是基于地址的,也就是通常的 64bit(64 位操作系统)。mmap 下进程可以采用指针的方式进行读写操作,这是值得注意的。

Linux系统中的系统调用,用于将文件或者其他对象映射到进程的地址空间,从而使得文件的内容可以像内存一样被直接访问。使用 mmap 函数可以实现一些高效的文件读写操作,尤其适用于处理大文件或者需要频繁读写的文件。它也被用于一些 IPC(进程间通信)机制,例如共享内存。需要注意的是,使用 mmap 函数时需要小心处理内存映射区域的权限和生命周期,以避免出现内存泄漏或者非法访问的问题。

为什么不全部使用mmap来分配内存?

因为mmap是系统调用,执行时需要系统进入内核态,然后再回到用户态,状态的切换增加了开销,并且mmap分配的内存释放的时候都会归还给操作系统,于是每次mmap分配的虚拟地址都是缺页状态,然后在第一次访问该虚拟地址的时候就会触发缺页中断。

(了解)这里再补充下三个总线的知识,即:地址总线、数据总线、控制总线

  • 地址总线,用来传输地址
  • 数据总线,用来传输数据
  • 控制总线,用来传输命令

比如CPU通过控制总线发送读取命令,同时用地址总线发送要读取的数据虚地址,经过MMU后到内存

内存通过数据总线将数据传输给CPU。虚拟地址的空间和指令集的地址长度有关,不一定和物理地址长度一致,比如现在的64位处理器,从VA角度看来,可以访问64位的地址,但地址总线长度只有48位,所以你可以访问一个位于2^52这个位置的地址。

虚地址通过转换得到实地址,转换方式课程内也讲得很清楚,虚地址头部包含了页号(段地址和段大小,看存储模式:页存储、段存储,段页式),剩下部分是偏移量,经过MMU转换成实地址。

详细的mmap底层,看:https://storage.blog.csdn.net/article/details/139408868?fromshare=blogdetail&sharetype=blogdetail&sharerId=139408868&sharerefer=PC&sharesource=2401_82607598&sharefrom=from_link

那这里说到mmap系统调用,,就会有brk系统调用

进程分配内存两种方式,分别由两个系统调用完成brk和mmap

  • brk将数据段最高地址指针往高地址推(不考虑共享内存,即往堆推上去
  • mmap是在进程的虚拟地址空间中(堆栈之间,称文件映射区)找一块空闲的虚拟内存

!!两种分配方式分的都是虚拟内存,不是物理内存,是malloc/free的底层实现

windows下用virtualAlloc()
linux下用brk()申请堆内存和mmap()申请映射区内存

尽管可见度不高,brk也许是最常用的系统调用了,用户进程通过它向内核申请空间。

brk 调用通过改变数据段的末端(通常称为程序的“堆”)来分配内存。brk 将程序的数据段(从静态数据和 BSS 段之后的部分)向上扩展,增加可用的内存。brk 基本上是通过连续增加进程的“堆”来分配内存。

  • 优点: 使用 brk 分配的内存是连续的,因此管理和释放内存的成本较低,系统开销较小,非常适合小块或中等大小的内存分配。
  • 缺点: 由于 brk 只能向堆的末端扩展,因此它受到堆顶物理地址的限制,无法轻易进行大块不连续的内存分配。若要处理非常大的内存块(例如超过堆区域可扩展的空间),brk 并不适用。

mmap 是一种内存映射调用,它可以将文件或匿名内存映射到虚拟地址空间。mmap 通过映射虚拟内存页来分配内存,可以将文件、设备或者匿名内存映射到程序的地址空间,允许灵活的内存管理。

  • 优点: mmap 不依赖堆的增长,它可以分配不连续的内存块,适合处理非常大的内存分配(例如,分配超大块内存时)。此外,由于内存映射与文件系统或设备相关联,mmap 还可以高效地实现内存和文件的共享。
  • 缺点: mmap 分配的内存相对来说系统开销较大,尤其是在小块内存的分配和释放时。因为每次调用 mmap 都需要操作系统进行复杂的内存映射操作,所以不适合频繁的小块内存分配。

小块和中等大小内存分配优先brk,,大块内存分配(通常超过 128 KB)用mmap

释放内存时的行为 brk 内存释放:

  • 对于通过 brk 分配的内存,free 操作通常只会将内存标记为可用,而不会立刻将其归还给操作系统。除非程序整体减少了堆的使用(即 brk 指针回缩),否则这些内存仍然保留在进程的堆空间中,可以被后续的 malloc 请求重用。
  • mmap 内存释放:对于通过 mmap 分配的内存,free 通常会立即调用 munmap,将内存归还给操作系统。这是因为 mmap 分配的大块内存并不依赖堆的连续性,释放时可以立刻将虚拟内存映射解除。

内存对齐是什么,为什么要内存对齐,好处?

一般来说调用sizeof 直接返回变量类型占用的字节数,例如:int占用4个字节,float占用8个字节,sizeof指针,在32位系统中,一个地址需要4个字节,在64位系统中,一个地址需要8个字节。 但是,如果sizeof一个结构体,结构体包含占用一个字节的char,和占用四个字节的int,则返回值为8。这就是因为内存对齐机制,按4个字节对齐,当然也可以强制修改为按1个字节对齐

在内存对齐中,数据被存储在内存中的地址必须是某个特定值的倍数。这个特定值通常称为对齐边界或对齐要求。例如,如果一个数据的对齐要求是4字节,那么这个数据的起始地址必须是4的倍数。 内存对齐是处理器为了提高处理性能而对存取数据的起始地址所提出的一种要求。 有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定的地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时将进行对齐,这就具有平台的移植性。CPU每次寻址有时需要消耗时间的,并且CPU访问内存的时候并不是逐个字节访问,而是以字长为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐内存,处理器需要做多次内存访问,而对齐的内存访问可以减少访问次数,提升性能

即,提高程序的运行效率,增强程序的可移植性。


进程之间的通信方式(IPC)

说到进程之间通信,进程和线程之间的关系和区别,这是很经典的问题;

什么是进程:

进程是一个具有一定独立功能的程序在一个数据集合上依次动态执行的过程。
进程是一个正在执行的程序的实例,包括程序计数器、寄存器和程序变量的当前值

  • 进程依赖程序运行而存在,进程是动态的,程序是静态的;
  • 进程是操作系统进行资源分配和调度的一个独立单位(CPU除外,线程是处理器任务调度和执行的基本单位)
  • 每个进程拥有独立的地址空间,地址空间包括代码区、数据区和堆栈区,进程之间的地址空间是隔离的,互不影响

什么是线程:

进程的创建、销毁与切换存在着较大的时空开销,因此人们急需一种轻型的进程技术来减少开销。在80年代,线程的概念开始出现,线程被设计成进程的一个执行路线,同一个进程中的线程共享进程的资源,因此系统对线程的调度所需的成本远远小于进程。

进程和线程的区别总结:

本质区别:进程操作系统资源分配的基本单位,而线程处理器任务调度和执行的基本单位。

包含关系:一个进程至少有一个线程,线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

资源开销: 每个进程都有独立的地址空间,进程之间的切换会有较大的开销;线程可以看做轻量级的进程,同一个进程内的线程共享进程的地址空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。

影响关系: 一个进程崩溃后,在保护模式下其他进程不会被影响,但是一个线程崩溃可能导致整个进程被操作系统杀掉,所以多进程要比多线程健壮。


关于并行与并发

一个基本的事实前提:一个CPU在一个瞬间只能处理一个任务。 但为什么在我们人类视角,哪怕是单核心计算机也能同时做很多事情,比如同时听音乐和浏览网页,作为整个系统唯一可以完成计算任务的 CPU 是如何保证两个进程“同时进行”的呢?时间片轮转调度!

每个进程会被操作系统分配一个时间片,即每次被 CPU 选中来执行当前进程所用的时间。时间一到,无论进程是否运行结束,操作系统都会强制将 CPU 这个资源转到另一个进程去执行。为什么要这样做呢?

因为只有一个单核 CPU,假如没有这种轮转调度机制,那它该去处理写文档的进程还是该去处理听音乐的进程?无论执行哪个进程,另一个进程肯定是不被执行,程序自然就是无运行的状态。如果 CPU 一会儿处理 word 进程一会儿处理听音乐的进程,起初看起来好像会觉得两个进程都很卡,但是 CPU 的执行速度已经快到让人们感觉不到这种切换的顿挫感,就真的好像两个进程在“并行运行”。

随着多核心CPU的出现,真正的并行得以实现,于是并行与并发的区别也成了面试常见题:

所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈上的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。


“进程是操作系统分配资源的单位,线程是调度的基本单位,线程之间共享进程资源”。

可是你真的理解了上面的那句话吗?到底线程之间共享了哪些进程资源,共享资源意味着什么?共享资源这种机制是如何实现的?

如果你没有答案的话,这篇文章就是为你准备的。

线程私有资源

函数运行时的信息保存在栈帧中,栈帧中保存了函数的返回值、调用其它函数的参数、该函数使用的局部变量以及该函数使用的寄存器信息,如图所示,假设函数A调用函数B:

此外,CPU 执行指令的信息保存在一个叫做程序计数器的寄存器中,通过这个寄存器我们就知道接下来要执行哪一条指令。由于操作系统随时可以暂停线程的运行,因此我们保存以及恢复程序计数器中的值就能知道线程是从哪里暂停的以及该从哪里继续运行了。

由于线程运行的本质就是函数运行,函数运行时信息是保存在栈帧中的,因此每个线程都有自己独立的、私有的栈区。

同时函数运行时需要额外的寄存器来保存一些信息,像部分局部变量之类。这些寄存器也是线程私有的,一个线程不可能访问到另一个线程的这类寄存器信息。 从上面的讨论中我们知道,到目前为止,所属线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。 以上这些信息有一个统一的名字,就是线程上下文,thread context。

我们也说过操作系统调度线程需要随时中断 线程的运行并且需要线程被暂停后可以继续运行,操作系统之所以能实现这一点,依靠的就是线程上下文信息。 现在你应该知道哪些是线程私有的了吧。除此之外,剩下的都是线程间共享资源。

那么剩下的还有什么呢?还有图中的这些。代码区、数据区、栈区。

代码区

存的就是我们写的代码,更准确的是编译后的可执行机器指令。
从可执行文件中加载到内存的,可执行程序中的代码区就是用来初始化进程地址空间中的代码区的。

线程之间共享代码区,这就意味着程序中的任何一个函数都可以放到线程中去执行,不存在某个函数只能被特定线程执行的情况。

数据区

进程地址空间中的数据区,这里存放的就是所谓的全局变量 。 什么是全局变量?所谓全局变量就是那些你定义在函数之外的变量

堆区

堆区是程序员比较熟悉的,我们在 C/C++中用 malloc 或者 new 出来的数据就存放在这个区域,很显然,只要知道变量的地址,也就是指针,任何一个线程都可以访问指针指向的数据,因此堆区也是线程共享的属于进程的资源。

总结:进程就像一个程序,线程就是一个个函数。。


进程之间的通信方式(IPC)

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

管道:管道分为匿名管道和命名管道,管道本质上是一个内核中的一个缓存,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端。缺点:半双工通信,一个管道只能一个进程写,一个进程读。不适合进程间频繁的交换数据。

两个描述符均在一个进程里面,并没有起到进程间通信的作用,通过fork创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个fd[0] 和fd[1]。

为了避免混乱,通过
父进程关闭读取的fd[0],只保留写入的fd[1]
子进程关闭写入的fd[1],只保留读取的fd[0]

管道/匿名管道(pipe)

管道是一种半双工的通信机制,只允许数据在一个方向上流动。当需要双向通信时,需要建立两个独立的管道。它 ,例如父子进程或者兄弟进程。 管道在操作系统中单独构成一种独立的文件系统,但与普通文件不同,它只存在于内存中,不属于任何文件系统。管道两端的进程将其视为一个文件,通过写入和读取数据来进行通信。 数据在管道中的读取和写入是按照先进先出的原则进行的。一个进程向管道中写入的内容被另一个进程从管道的另一端读取。写入的内容被添加在管道缓冲区的末尾,并且从缓冲区的头部逐个读取。

消息队列:可以边发边收,但是每个消息体都有最大长度限制,每个进程消息队列所包含的消息体的总数量也有上限并且在通信过程中存在用户态和内核态之间的数据拷贝问题(因为需要进程A通过消息体,使用系统调用把数据传送给内核地址空间,然后进程B通过系统调用,从内核的地址空间读取消息体,从而获取数据)。

共享内存:由操作系统在内存中分配一段共享地址空间,供两个进程读写,操作系统只需要分配地址空间这一个内核态,分配完成就由两个进程自己访问,解决了消息队列存在的内核态和用户态之间的数据拷贝问题。

信号量机制:本质上是一个计数器,当使用共享内存的通信方式时,如果有多个进程同时往共享内存中写入数据,有可能先写的进程的内容被其他进程覆盖了,信号量就用于实现进程间的互斥和同步PV操作不限于信号量±1,而且可以任意加减正整数。

信号:上述的进程间通信都是常规状态下的工作模式,对于异常情况下的工作模式,就需要用信号的方式来通知进程。
在linux操作系统中,为了响应各种各样的事件,提供了几十种信号,分别代表不同的含义,用 kill -l命令可以查看所有的信号。

信号是Linux系统提供的让用户(进程)给其他进程发送异步信息的一种方式。

异步:信号的产生,是由别人(用户,进程)产生的,我收到之前,我一直在忙我的事情,并发在跑的

  • kill命令
  • 键盘产生信号
  • 系统调用
  • 软件条件
  • 异常

无论信号的产生有多少种,最终都是操作系统向进程中写入信号

像键盘上的组合键,在操作系统上也会被解释成为信号,向目标发送进程,进程收到信号并进行响应。

套接字:Socket套接字,用于不同进程之间的通信,可通过网络,得知道对方的IP和端口号。


线程之间的通信方式

信号量:对多个资源的访问,控制数量,阻塞或唤醒线程。

信号量是一种同步机制,用于控制对共享资源的访问,本质是对资源的预定机制。

可以理解为一个计数器,一个描述临界资源数量的计数器

多线程同步用,一个线程完成了某一个动作就通过信号告诉别的线程,别的线程再进行某些

动作

信号量有两个主要操作:P(等待)和 V(释放)

  • P(等待)操作:当一个进程或线程需要访问共享资源时,它会尝试执行 P 操作。如果信号量的值大于 0,则进程可以继续访问资源,并将信号量的值减 1;如果信号量的值等于 0,则进程会被阻塞,直到信号量的值变为正数。
  • V(释放)操作:当一个进程或线程完成对共享资源的访问时,它会执行 V 操作,将信号量的值加 1。如果有其他等待进程被阻塞,它们中的一个将被唤醒并获得对资源的访问权限。

信号量的申请(–)释放(++)的操作都必须是原子的!!

互斥 和 同步

互斥:在访问一部分共享资源的时候,任何时刻只有我一个人访问,就叫做互斥

同步:访问资源在安全的前提下,具有一定的顺序性

条件变量:在等待某个条件时进入阻塞状态,直到该条件被满足并通知线程唤醒,条件变量通常与互斥量一起使用,为防止竞争条件和确保同步。signal(),wait()

条件变量是线程可用的一种同步机制。条件变量和互斥量一起使用,允许线程以无竞争的方式等待特定的条件发生。

条件变量用于等待而不是用来上锁,通常和互斥量一起使用。

  • 条件不满,阻塞线程
  • 条件满足,通知阻塞的线程开始工作

互斥量: mutex,对临界资源的互斥访问。也就是互斥锁

多线程互斥用,一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程离开,其他线程才开始可以利用这个资源。

。。。后面再专门写一篇进程间通信的文章


多线程会发生什么问题,线程同步有哪些手段

会引发资源竞争问题,频繁上锁会导致程序运行效率低下,甚至会导致死锁
线程同步手段:使用atomic原子变量,使用互斥量也就是上锁,使用条件变量或信号量制约对共享资源的并发访问。

原子操作,保证在多线程环境中执行不会被中断,避免数据竞争。即原子操作要么完全执行,要么完全不执行,不会在执行期间被其他线程操作干扰

#include <atomic>
std::atomic<int> counter(0);

void increment() {
    counter.fetch_add(1, std::memory_order_relaxed); // 原子递增
}

锁的底层依赖原子操作atomic;原子操作底层原理,通常由硬件指令CAS,LL/SC实现。。。。

CAS(Compare-And-Swap)通过比较内存中的值与预期值,若匹配则更新为新值

bool compare_exchange_strong(T& expected, T desired) {
    if (current == expected) {
        current = desired;
        return true;
    } else {
        expected = current;
        return false;
    }
}

LL/SC(Load-Linked/Store-Conditional)在特定地址上标记“监视”,若未被其他线程修改则写入

为了实现线程安全,除了加锁还有其他方式吗?
除了锁之外还可以使用

  • 互斥量(防止多个线程来同时访问共享资源,从而避免数据竞争的问题)
  • 原子操作(原子操作不可分割,使用原子操作可以确保在多线程环境中操作是安全的,原子性是通过开/关中断的特权指令实现的)
  • 条件变量(协调线程之间的协作,用来在线程之间传递信号,从而控制线程的执行流程)等方式

进程、线程、协程

进程是系统进行资源分配和调度的基本单位,进程本质上是运行中的程序是动态的,需要将进程运行的当前状态,所需资源等信息保存到进程控制块(PCB)中,操作系统为了管理进程设计的数据结构叫进程控制块,里面存的字段可以分为进程标识符、处理机状态、进程调度信息、进程控制信息。

线程是任务调度和执行的基本单位CPU上真正运行的是线程。同一个进程的多个线程共享同一个进程资源。进程和线程切换者都是操作系统,都涉及到从用户态到内核态再到用户态的转变过程。

协程:是一种比线程更轻量级的并发机制,它们运行在单一线程内,由程序自身调度,而非由操作系统内核调度。允许你以看似同步的方式编写异步代码,同时提供更高效、更可靠的并发处理能力。
特点:调度由程序员控制,所以开销非常小,
非抢占式:在运行时不会被其他协程打断,除非协程主动放弃控制权。共享上下文,减少了线程切换中上下文切换的开销,
资源效率高:由于协程切换开销小,可以在一个线程中创建管理大量协程,比线程和进程更高效。

协程分为对称协程(协程之间的切换是对等的,任何一个协程都可以将控制权转移给另一个协程,更灵活,但是管理和调度也更复杂)和非对称协程(协程的控制权转移是单向的,即从调用者协程到被调用者协程,然后再返回到调用者协程,这种模型类似线程切换)。
有栈协程 Stackful Coroutine:每一个协程都会有自己的调用栈,有点儿类似于线程的调用栈,这种情况下的协程实现其实很大程度上接近线程,主要不同体现在调度上。有栈协程(Stackful Coroutines)每个协程都有自己独立的栈。这意味着每个协程在执行时会维护自己的调用栈,从而可以支持更复杂的调用和嵌套。
无栈协程 Stackless Coroutine:协程没有自己的调用栈,挂起点的状态通过状态机或者闭包等语法来实现。它们不维护独立的栈,而是通过保存必要的上下文信息来进行切换。通常通过状态机或生成器的方式实现。
 

轻量级线程:

  • 协程不是操作系统线程,而是用户态"虚拟线程"
  • 一个线程可以运行数千个协程
  • 协程切换开销远小于线程切换

挂起与恢复:

  • 协程可以在不阻塞线程的情况下挂起(suspend)执行
  • 当资源就绪时再恢复(resume)执行
  • 挂起时释放底层线程供其他协程使用

结构化并发:

  • 协程之间存在父子关系
  • 父协程取消时会自动取消所有子协程
  • 提供更好的资源管理和错误传播机制
     

详情可以看https://blog.csdn.net/shinecjj/article/details/149205151?fromshare=blogdetail&sharetype=blogdetail&sharerId=149205151&sharerefer=PC&sharesource=2401_82607598&sharefrom=from_link


进程的五种状态

创建态、就绪态、运行态、阻塞态、终止态。

进程调度算法

分为抢占式(操作系统可以在进程执行时剥夺CPU使用权,分配给其他进程使用)和非抢占式(进程获得CPU资源后,一直运行到进程结束或者阻塞才释放CPU的使用权)

进程调度算法:

  • 短作业优先:进程所需时间较短的优先进入cpu,会导致饥饿
  • 优先级调度:进程在创建时被赋予优先级,优先级高的优先调度,会导致饥饿
  • 时间片轮转:为每个进程执行设置时间片,时间片用完就交出cpu,不会导致饥饿。
  • 先进先出算法,FIFO:容易造成短进程等待时间较长
  • 最高响应比优先:对短进程有利,长进程由于等待时间变成会导致响应比增加,不至于让长进场饥饿,响应比(等待时间+所需运行时间/所需运行时间)
  • 多级反馈队列轮转算法:将进程就绪队列分为多个优先级不同的队列,刚创建的进程和因IO未用完时间片的进程排在最高优先级队列,2-3哥时间片还未执行完的进程放入,较低优先级的队列,只有优先级更高的队列为空时才会调度当前队列,会导致饥饿。
     

僵尸进程、孤儿进程、守护进程

僵尸进程:子进程被杀死,父进程没有回收子进程的资源,子进程虽然退出了,但是仍保留原有PCB,当大量僵尸进程在内存中时,会消耗内存资源;

孤儿进程:父进程被杀死,子进程被PID为1的initi进程收养。

守护进程:常驻后台运行的进程


进程和线程的切换需要哪些开销

进程切换的开销:

1、切换页表,
2、切换内核态堆栈,
3、将寄存器中的上下文保存到PCB中
4、刷新快表,快表是在高速缓存中为了更快的进行地址翻译而将部分的页表项保存在高速缓存中。

线程切换的开销:

1、切换内核栈
2、切换硬件上下文。

两者开销差距较大的在于页表的切换上,因为进程是资源分配的基本单位,而线程切换,资源不需要切换。进程切换后,页表刷新,一段时间内内存命中率都会较低。


系统调用

应用程序如果想要访问硬件资源,需要使用操作系统为其提供的接口,使CPU从用户态->内核态->用户态,主要出于资源访问的安全考虑,让操作系统管理资源,而应用程序需要使用资源的时候,通过操作系统提供的系统调用接口来向操作系统申请分配资源。

从用户态进入内核态的方式

  • 系统调用
  • 异常,如果进行运行在用户态的时候发生了异常事件,就会触发切换,例如:缺页异常。
  • 外部中断:当外设完成了用户的请求后,会向cpu发送中断信息,cpu会暂停当前执行,转而执行中断信号对应的处理程序。

局部性原理

空间局部性:已经访问过的页面相邻的页面也有很大概率在接下来被访问。
时间局部性:最近访问过的页面,很可能在接下来也会访问。


程序运行类型----IO密集型/CPU密集型

CPU密集型:也叫计算密集型,指的是IO可以在很短时间内完成,而CPU的运算占据主要的工作,CPU负载高
IO密集型:指的是系统CPU性能相对IO设备好很多,需要频繁的调用IO读写操作,CPU负载不高。


缓冲区溢出

指计算机缓冲区填充数据时超过了缓冲区最大容量,导致数据被写到缓冲区以外。

危害:程序崩溃,跳转到意料之外的代码
原因:程序中没有仔细检查用户输入。


操作系统内存管理—缺页中断、内存页

一个进程不是完完整整的放入到连续的内存区域,而是把进程分段放入内存,因为把进程连续的放入内存带来的问题就是碎片问题很严重,所以分段放入内存就可以有效的缓解碎片问题;

将进程分段放入内存,也会带来一个新的问题,这些分段的数据被放到哪里了?
此时就需要操作系统维护一张表来记录了,这时候操作系统又会增加了新的负担,把进程分片,放入内存,计算内存哪儿有空位,计算表的数据等等等;
但是它解决了碎片问题带来的收益是大于连续内存分配的策略方案的; 对于操作系统这种哲学问题设计方案,我们认为是可行的;

分页:将内存从物理上划分为大小相同的页框(LInux一般为4K),进程所需的数据分别存放在多个页中,这些页离散地分布在内存中,因此需要一张记录内存逻辑地址到物理地址映射关系的表也就是页表。分页系统重,访问数据需要两次访问内存,第一次访问的是内存中的页表,根据页号和页内偏移量计算查找出实际的物理地址,第二次根据物理地址访问内存读取数据

分段:将进程的逻辑空间划分为若干不同大小的段(长度由连续逻辑的长度决定),段地址是二维的,一维是段号、二维是段内偏移。因为多个段之间也是离散分布在内存中的,所以需要段表来记录逻辑地址到物理地址的映射关系。
分段动机就是为了解决内存管理带来的碎片问题的一种机制,分段允许逻辑地址空间的进程放入内存是不连续的

分页和分段的区别:

  • 页是物理单位,方便管理内存空间,用户不可见;段是逻辑单位,根据用户需要自行划分,对用户可见
  • 段的大小不固定,页的大小固定,一般为4K
  • 段向用户提供二维地址空间,页向用户提供一维地址空间
  • 分页的主要目的是为了实现虚拟内存,提高内存利用率,而分段是为了程序和数据能够根据逻辑进行划分,从而更好地进行管理

段页式存储管理
先把逻辑空间按段式管理分成若干段,在将段空间按页式管理分成若干页,段页地址就可以通过段号、段内页号、页内地址找到属于那一段那一页,综合两者优点。

虚拟内存到物理内存核心分页;分段是程序逻辑分代码段、数据段、栈段、全局段。


虚拟内存

虚拟内存(Virtual Memory)是一种内存管理技术,它允许操作系统为一个进程提供比实际物理内存更大的地址空间。虚拟内存通过将内存地址空间与物理存储(如RAM和硬盘)分离,使得每个进程都认为自己独占整个计算机的内存资源。
虚拟内存概述:作为操作系统的内存管理技术,把程序使用的内存划分,(根据局部性原理)将部分暂时不用的内存放置在辅存。替换策略发生在缓存-主存层次/主存-辅存层次。解决速度/容量问题。如果访问页不在内存,则发出缺页中断,发起页面置换。

逻辑地址空间:指进程可以使用的内存空间,大小仅受CPU地址长度限制(如32位的最大逻辑空间为4G–2^32字节,64位的最大逻辑地址空间为2的64次方字节)进程运行时可以使用的相对地址空间。
物理地址空间:物理内存的存储空间,进程运行时在物理内存分配和使用的地质空间。
缺页中断:当进程读取的页号在页表中不存在时,会触发缺页中断,处理缺页中断的方法是从外存中找出目标页,然后装入内存中

具体做法:

  • 从外存中找到目标页
  • 检查当前内存中的空闲是否足够目标页装入
  • 若空间足够,则直接装入目标页,更新页表,中断处理结束
  • 若空间不够,则执行页面置换算法,淘汰页面直到空间足够为止,然后装入目标页,中断处理结束。
     

为什么虚拟地址切换会比较耗时

进行虚拟地址到物理地址的转换需要去查页表,而查找页表是一个很慢的操作,所以为了加快该过程就会在高速缓存中用快表去缓存最近查找过的虚拟地址,但每个进程都有自己的页表,一旦发生进程切换,页表就需要切换,快表也会被清空,会导致进程切换后快表的命中率非常低,此时大量时间花在查页表上,表现出来的就是程序运行速度变慢。

线程切换不会导致快表失效,因为线程切换无需切换地址空间,所以线程切换比进程切换快。


虚拟内存空间划分

划分为内核空间用户空间。

其中用户空间可进一步划分为:
代码段:用来存储程序运行所需的机器指令
数据段:存储初始化的全局变量和静态变量
BSS段:存储未初始化的全部变量和静态变量,默认初始值为0
:由程序有自由分配的区域,用于程序运行时动态申请内存的堆。
待分配的区域:
文件映射与匿名营社区:mmap系统调用,将其他文件映射到该进程的用户空间的存储位置,动态链接库中的代码段,数据段,bss段加载在这。
:存储局部变量、函数和临时变量,利用栈的先进先出实现作用域的功能
 

内核空间:存储内核运行环境

在64位系统中,可以表示2^64个虚拟地址空间,但是实际上只使用了48位来描述虚拟内存空间,所能表达的虚拟空间为258TB,其中高128TB为内核空间,低128TB为用户空间。

就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。


同步和互斥

同步:两个进程在完成某项任务的时候,需要有个执行上的先后关系,某一个步骤得在另一个步骤之前。

互斥:两个进程彼此互斥,则表示同时只能有一个进程进行,对临界资源的访问必须互斥地访问。


信号量(semaphore)和互斥量(mutex)区别

互斥量:一种用于确保在任意时刻只有一个线程可以访问某一共享资源的同步机制,二元状态,互斥量只有两种状态:锁定(locked)和未锁定(unlocked),所有权:互斥量是有所有权的,锁定互斥量的线程必须在完成资源访问后解锁互斥量。阻塞行为:如果一个线程试图锁定已经被另一个线程锁定的互斥量,它将被阻塞,直到互斥量被解锁。用于保护临界资源,确保同一时间只有一个线程可以进入。

信号量:计数器信号量包含一个计数器,表示当前可用资源的数量,计数器可以是正数、零或负数,无所有权,任何线程都可以增加或减少信号量的计数。阻塞和唤醒:如果信号量的计数为零,试图减少信号量的线程将被阻塞,直到其他线程增加信号量。用于控制对有限数量资源的访问,如数据库连接池、资源池等。适用于需要限制同时访问共享资源的线程数量的场景。


线程池(thread pool)

线程池通过预创建一定数量的线程来处理任务,从而避免频繁创建和销毁线程所带来的开销,线程池中的线程在完成任务后不会终止,而是等待分配新的任务,这样可以更高效地管理和使用系统资源。


分段和分页的区别

分段:将内存划分为若干大小不同的内存段,每一段表示一个逻辑单元,如代码段、数据段、堆段、栈段等。每个段都有一个段基地址和段界限,描述段的起始地址和长度。

  • 按逻辑划分为大小不等的段
  • 段表:使用段表来管理各个段的信息
  • 段间隔离:不同段之间相互独立,可以提高安全性和稳定性。
  • 会产生外部碎片、内存交换效率低。

解决外部碎片的方法就是swap内存交换,先将一部分段换出内存到外存的交换空间,然后再换回内存,但是换回的时候,地址要与前一段紧凑。因此,当频繁的内存交换会导致效率低下。

分页存储 是一种将内存划分为若干固定大小页面的内存管理方式,内存被划分为固定大小的页帧,每个页面映射到一个页帧。

  • 固定大小,linux下,每一页为4Kb
  • 页表 来记录每个页面映射到的页帧地址
  • 虚拟地址,通过页表转换成物理地址访问内存
  • 页内保护,每个页面可以独立设置访问权限。

当进程访问的虚拟地址在页表中查不到时,系统就会产生一个缺页异常,进入系统内存态,从新分配物理内存、更新进程页表,恢复进程的运行。
段页式管理方式:
段页式内存管理实现的方式:
先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
这样,地址结构就由段号、段内页号和页内位移三部分组成。

linux系统下的虚拟空间分段。

内存分段


动态分区分配算法

  • 首次适应算法
    算法思想:每次从低地址开始查找,找到第一个能满足大小的空闲分区。
    实现:空闲分区以地址递增的次序排序,每次分配内存时顺序查找空闲分区链/表,找到能满足大小要求的第一个空闲分区
  • 最佳适应算法
    算法思想:由于动态分区分配是一种连续的分配方式,为各进程分配的空间必须是连续的一整片区域,因此为了保证当“大进程”到来时有连续的大片空间,应该尽可能多得保留下大片的空闲分区,即:优先使用更小的空闲区。
    实现:空闲分区按容量递增的次序链接,每次分配内存时顺序查找空闲分区链表,找到大小能满足要求的第一个空闲分区。
  • 最坏适应算法
    又称最大适应算法,为了避免最佳适应算法产生的大量难以利用的小外部碎片,在每次分配时优先使用最大的连续空闲区,这样分配后剩余的空闲区就不会太小,更方便使用。
    实现:空闲分区按容量递减链接,每次分配内存时,顺序查找空闲分区链,找到大小能满足的第一个空闲分区。
  • 邻近适应算法
    思想:首次适应算法每次都从链表头部开始查找,可能会导致低地址部分出现很多小的空闲分区,而每次分配查找时,都需要经过这些分区,增加了查找的开销。因此,选择每次从上次查找结束的位置开始检索。
    实现:使用以地址递增的循环链表,每次分配内存时,从上次查找结束的位置开始查找第一个满足要求的空闲分区。

页面置换算法

1、最佳置换算法:每次选择淘汰的页面将是以后永不使用或者最长时间不再被访问的页面,这样可以保证最低的缺页率。但是进程执行的过程中才能知道接下来会访问的是哪个页面,操作系统无法提前知道页面访问的顺序,因此,最佳置换算法是无法实现的。

2、先进先出置换:每次选择淘汰的都是最早进入内存的页面,实现方法:把调入内存的页面根据调入的先后顺序排成一个队列,需要换出页面时,选择从队头开始。不符合局部性原理,性能低下,有时会出现分配的页面数增多,但是缺页率反而提高的异常现象。

3、最近最久未使用算法:每次淘汰的页面是最近最久未使用的页面。实现方法:赋予每个页面对应的页表项中,用访问字段记录该页面自上次被访问依赖经历的时间(该算法实现需要专门的硬件支持,虽然算法性能好,但是实现困难,开销大),当需要淘汰一个页面时,选择现有页面中经历时间最长的页面。

4、时钟置换算法:简单的时钟算法实现:为每个页面设置一个访问位,再将内存中的页面通过链接指针链接成一个循环队列。当某页被访问时,访问位置为1。当需要淘汰一个页面时,只需要检查页的访问位,如果是0,就将该页换出,如果是1,则将其置为0,暂不换出,继续检查下一个页面。简单的CLOCK算法选择一个淘汰页面最多会经历两轮扫描。

5、改进的时钟置换算法:简单的时钟置换算法仅考虑一个页面最近是否被访问过,事实上,如果被淘汰的页面没有被修改过,就不需要执行I/O操作写回外存。只有被淘汰的页面被修改过,才需要写回外存。因此,除了考虑一个页面最近有没有被访问过之外,操作系统还要考虑页面有没有被修改过,在其他条件都相同时,应优先淘汰没有修改过的页面,避免I/O操作,这就是改进的时钟置换算法的思路。即添加一个修改位,用(访问位,修改位来标识)。


malloc,free,new,delete区别

malloc:是C语言提供的函数。从堆上分配内存。需要显示地指出所需内存的尺寸,没有调用对象的构造函数。返回的是指向那块内存的(void*)类型的指针,一般需要进行类型转换。free只是释放内存空间。malloc不允许重载。 malloc申请内存的时候,会有两种方式向操作系统申请堆内存 方式一:通过brk()系统调用从堆分配内存

方式二:通过mmap()系统调用在文件映射区分配内存,通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:

malloc() 源码里默认定义了一个阈值:
如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;

malloc(1)会分配多大的虚拟内存?
malloc()在分配内存的时候,并不是按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。具体预分配大多空间跟malloc使用的内存管理器有关。
对于 「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结了:
malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

malloc申请的地址会多16个字节,这16个字节保存了该内存块的描述信息,如该内存块的大小,free()的时候,只需要传入一个内存地址,对该内存地址向左偏移16个字节,就可以分析出当前需要释放的内存块的大小。
new:是CPP中关键字(操作符),不是函数,不依赖头文件。从自由存储区分配内存。申请内存是按照对象申请,会调用对象的构造函数。返回的是申请对象类型的指针。delete会调用对象的析构函数。可以重载new/delete操作符。
new先调用operator new标准库函数开辟内存空间,然后再调用指定对象的构造函数。
delete调用指定对象的析构函数,再调用operator delete释放空间,因此new/delete是安全的。
 


OOM(内存溢出)

内存溢出(out of memory),是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行时要用到的内存大于能提供的最大内存,此时程序就运行不了了,系统会提示内存溢出。 linux有OOM机制,操作系统会kill 内存溢出的进程。

LRU(least recently Used,最近最少使用)

是一种常见的缓存替换策略,用于管理计算机内存或缓存中的数据,当缓存(或内存)已满且需要腾出空间给新的数据时,LRU策略会选择最近最少使用的那块数据进行替换。这种策略基于局部性原理。
LRU算法的实现可以采用以下几种方式:
LRU实现方式
使用链表(双向链表)和哈希表:
链表:双向链表用于保存数据项,链表中的数据按访问顺序排列。最近访问的数据放在链表头部,最久未使用的数据放在链表尾部。
哈希表:哈希表用于存储键值对,以快速定位数据项并在链表中移动它们。
当访问某个数据时,将该数据移动到链表头部。当需要替换数据时,从链表尾部移除数据。
计数器:
为每个数据项维护一个计数器,记录上一次访问的时间戳。
每次访问数据时,更新该数据项的时间戳。
需要替换数据时,选择时间戳最早的那个数据项进行替换。

LRU算法对应的两个问题
1、预读失效:导致缓存命中率下降。预读机制,基于空间局部性原理,操作系统会预读邻近的几个页的数据。预读失效即多读的页没有被访问。
2、缓存污染:导致缓存命中率下降。在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到活跃LRU链表里,然后之前缓存在活跃LRU链表里的热点数据全部被淘汰了,如果这些大量的数据在很长一段时间内都不会被访问的话,那么整个活跃LRU链表(或者young区域)就被污染了。

linux和MySQL的innodb引擎通过改进传统的LRU链表来避免预读失效带来的影响,具体的改进如下

  • linux操作系统实现了两个LRU链表:活跃LRU链表和非活跃LRU链表
  • MySQL的Innodb存储引擎是在一个LRU链表上划分来2个区域:young区域和old区域

两个改进方式设计思想都是类似的,都是将数据分为冷数据和热数据,然后分别进行LRU算法。不像传统的LRU算法那样,所有数据都只用一个LRU算法管理。

解决缓存污染的影响:
传统LRU算法只要数据被访问一次,就将数据加入活跃LRU链表(或者young区域),这种LRU算法进入活跃LRU链表的门槛太低,正因为门槛太低,才导致在发生缓存污染的时候,很容易将原本活跃在LRU链表里的热点数据淘汰。
linux操作系统:在内存页被访问第二次的时候,才将页从inactive list 升级到active list里。
MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从old区域升级到young区域,因为还要进行停留在old区域的时间判断
如果第二次的访问时间与第一次访问的时间在1秒内(默认值),那么该页就不会从old区域升级到young区域
如果两次时间超过1秒,那么该页就会从old区域升级到young区域。


加锁的目的是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
互斥锁和自旋锁是最底层的锁机制,
当已经有一个线程加锁后,其他线程加锁则会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的
互斥锁:互斥锁加锁失败后,线程会释放CPU,给其他线程。互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
自旋锁加锁失败后,线程会忙等待,直到它拿到锁。也就是说自旋锁是在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
==互斥锁会阻塞线程,从而导致上下文的切换。而线程的上下文切换需要几十纳秒到几微妙之间,如果锁住的代码执行时间比较短,可能导致上下文切换的时间比锁住的代码执行时间还要长,因此此时需要使用自旋锁。==自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

读写锁由读锁和写锁两部分组成,如果只读取共享资源用【读锁】加锁,如果要修改共享资源用【写锁】加锁
读写锁适用于能明确区分读操作和写操作的场景。
读写锁的工作原理是:
当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。

乐观锁与悲观锁:
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。


死锁

产生的原因

  • 互斥条件:对临时资源的互斥访问
  • 不剥夺条件:如果进程a持有了资源,不可剥夺其他进程所持有的资源
  • 请求和保持条件:当前进程持有了一部分资源,并且需要等待其他进程释放资源的时候,所持有的资源不会主动释放。
  • 循环等待条件:产生一个循环等待链,链中每个进程已获得资源的同时等待下一个进程释放资源。

死锁的处理方法

  • 鸵鸟策略
    把头埋在沙子里,假装根本没发生问题。
    因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率较低,可以采用鸵鸟策略。大多数操作系统,包括unix,linux和Windows处理死锁问题的办法仅仅是忽略它。
  • 死锁检测与死锁恢复
    不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。
    1、每种类型一个资源的死锁检测。
  • 死锁预防
    在程序运行之前预防发生死锁,破坏死锁发生的条件之一。
    破坏互斥条件:假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机的守护进程。
    破坏请求和保持条件:一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。
    破坏不可剥夺条件:允许抢占资源
    破坏循环请求等待条件:给资源统一编号,进程只能按编号顺序来请求资源。
  • 死锁避免
    在程序运行时避免发生死锁。

零拷贝、DMA(direct memory access)技术

DMA(直接内存访问)技术:允许硬件子系统在不经过CPU干预的情况下,直接与系统内存进行数据传输。这种技术可以显著提高数据传输效率,减轻CPU的负担,使其可以处理其他任务。使用DMA控制器进行数据传输的过程。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

零拷贝:是一种计算机数据传输技术,意在在两个位置之间移动数据时尽量减少消除数据在CPU内存之间的拷贝。传统的数据传输方式往往需要多个拷贝步骤,而零拷贝技术可以直接将数据从一个位置传输到另一个为止,从而提高效率,减少CPU负载,并提升整体系统性能。

如何实现零拷贝?
通常有两种:
1、mmap+write
使用mmap替换read()系统调用函数
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。可以减少一次数据拷贝的过程,但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次
2、sendfile
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
传输大文件的时候,使用「异步 I/O + 直接 I/O」;
传输小文件的时候,则使用「零拷贝技术」;


外中断和异常的区别

  • 外中断是指由CPU执行指令以外的事件引起的,如I/O完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个I/O请求,此外还有时钟中断、控制台中断等。
  • 异常是由CPU执行指令的内部事件引起的,如非法操作码、地址越界、算术溢出等。

回收线程

  • 等待线程结束:主线程调用,int pthread_join,等待子线程退出并回收其资源
  • 结束线程:void pthread_exit ,子线程执行,用以结束当前线程
  • 分离线程:int pthread_detach(pthread_t tid); 主线程、子线程均可调用,主线程中pthread_detach(tdi),子线程中pthread_detach(pthread_self()),调用后和主线程分离,子线程结束时自己立即回收资源。

守护进程、孤儿进程、僵尸进程

守护进程
指在后台运行的,没有控制终端与之相连的进程,它独立于控制终端,周期性地执行某种任务。

创建守护进程的步骤

  • 让程序在后台执行,方法是调用fork()产生一个子进程,然后使父进程退出
  • 调用setsid()创建一个新的对话组。(控制终端、登录会话和进程通常是从父进程继承下来的,守护进程要拜托他们,不受他们的影响,方法就是调用setsid()使进程成为一个会话组组长)。setsid()调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组、控制终端脱离。
  • 紧张进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端,为了避免这种情况发生,可以通过使进程不再是会话组长来实现,再一次通过fork()创建新的子进程,使调用fork的进程退出
  • 关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符,如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符。
  • 将当前目录更改为根目录。
  • 子进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权,为防止这一点,使用unmask(0)将屏蔽字清零。
  • 处理SIGCHLD信号,对于服务器进程,在请求到来时往往生成子进程处理请求,如果子进程等待父进程捕获状态,则子进程会成为僵尸进程,从而占用系统资源,如果父进程等待子进程的结束,将增加父进程的负担,影响服务器进程的并发性能,在linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN,这样,子进程结束时不会产生僵尸进程。

孤儿进程:
如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程。
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程,孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成回收工作。

僵尸进程:
如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程了。(或者说,子进程退出了,但是父进程没有回收子进程的资源
设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取,这些信息至少包含进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或者waitpid时就可以得到这些信息,如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说inti进程将wait它们,从而去除它们的僵尸状态。)

如何避免僵尸进程

  • 通过signal(SIGCHLD,SIG_IGN)通知内核对子进程的结束不关心,由内核回收,如果不想让父进程挂起,可以在父进程中加入一条语句:signal(SIGCHLD,SIG_IGN)表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的
  • 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
  • 如果父进程很忙可以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。
  • 通过两次调用fork,父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个子进程后退出,这样子进程退出后会被父进程等待回收,而对于孙子进程,其父进程已经退出所以孙子进程成为一个孤儿进程,孤儿进程由init进程接管,孙子进程结束后,init会等待回收。

第一种方法忽略SIGCHLD信号,这常用语并发服务区的性能的一个技巧,因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源,如果将次信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。


进程、进程组、会话

进程:运行着的程序

进程组:多个进程的集合,其中有一个组长,其进程PID等于进程组的PGID,只要在某个进程组中有一个进程存在,该进程组就存在,这与其组长进程是否终止无关。

作业:
shell分前后台来控制的不是进程而是作业或者进程组
一个前台作业可以由多个进程组成,一个后台也可以由多个进程组成,shell可以运行一个前台作业和任意多个后台作业,这称为作业控制。

会话:
会话是一个或多个进程组的集合,一个会话可以有一个控制终端,在xshell或者winscp中打开一个窗口就是新建一个会话。


编码方式

  • ASCII编码:只有127个字符,表示英文字母的大小写、数字和一些符号。其他语言使用ASCII编码字节不够,中文需要两个字节,中国定制了GB2312编码格式。
  • Unicode编码:通常两个字节表示一个字符,但是,如果文本是纯英文的,那么编码会多一倍的存储空间。
  • UTF-8:将Unicode编码转化为可变长编码,每个字符按数字大小编码为1-6个字节,英文字母被编码成一个字节,常用汉字被编码成三个字节,ASCII码也是UTF-8的一部分。
     

常用高级一点的命令linux

ps aux 查看进程状态
top 查看系统资源使用情况

补充。。


socket的多路复用

select、poll、epoll都是IO多路复用的一种机制,可以监视多个文件描述符,一旦某个文件描述符进入读或写就绪状态,就能够通知系统进行相应的读写操作。

select
优点:

可移植性好,因为在某些Unix系统中不支持poll和epoll
对于超时时间提供了更好的精度:微妙,而poll和epoll都是毫秒级
缺点:
支持监听的文件描述符fd的数量有限,最大数量默认是1024个
select需要维护一个用来存放文件描述符的数据结构,每次调用select都需要把fd集合从用户区拷贝到内核区,而select系统调用后有需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多的时候会很大。
poll
优点(相对于select)
没有最大文件描述符数量的限制,poll基于链表存储主要解决了这个最大文件描述符数量的限制(上限为操作系统能支持的能开启的最大文件描述符数量),优化了编程接口,减少了函数调用参数,并且,每次调用select函数时,都必须重置该函数的三个fd_set类型的参数值,而poll不需要重置。
缺点
poll和select一样同样都需要维护一个用来存放文件描述符的数据结构,当注册的文件描述符无限多时,会使得用户态和内核区之间传递该数据结构的复制开销很大。每次poll系统调用时,需要把文件描述符fd从用户态拷贝到内核区,然后poll系统调用返回前,又需要把文件描述符fd集合从内核区拷贝到用户区,这个内存拷贝的系统开销在fd数量很多的时候会很大。

epoll
优点:和poll一样没有最大文件描述符数量的限制,epoll虽然也需要维护用来存放文件描述符的数据结构(epoll_event),但是它只需要将该数据结构拷贝一次,不需要重复拷贝,并且它只在调用epoll_ctl系统调用时拷贝一次要监听的文件描述符数据结构到内核区,在调用epoll_wait的时候不需要再把所有的要监听的文件描述符重复拷贝进内核区,这就解决了select和poll种内存复制开销的问题。
缺点:目前只有Linux操作系统支持epoll,不支持跨平台使用,而Unix操作系统上是使用kqueue。
Epoll水平触发(LT):对于读操作,只要缓冲区内容不为空,LT模式返回读就绪。 对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
Epoll边缘触发(ET):对于读操作,当缓冲区由不可读变为可读的时候,有新数据到达时,进程修改了EPOLL_CTL_MOD修EPOLLIN事件时。
在ET模式下,缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。通常配合将文件描述符设置为非阻塞状态一起使用,这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
 

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐