目录
内存管理
CPU三级缓存
缓存又叫高速缓冲存储器,其作用在于缓解主存速度慢、跟不上CPU读写速度要求的矛盾。
缓存的实现原理,是把CPU最近最可能用到的少量信息(数据或指令)从主存复制到cache中,当CPU下次再用这些信息时,它就不必访问慢速的主存,而直接从快速的cache中得到,从而提高了得到这些信息的速度,使CPU有更高的运行效率。
随着多核的发展, CPU cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,所以速度也更快,同时也代表着容量越小。 L1和L2缓存是每个cpu核独享的,L3缓存则是多个cpu核共享。
物理内存和虚拟内存
引入虚拟内存的原因一:
内存的读写性能要比硬盘快的多,因此,在设计上会充分利用内存进行数据的读取和写入,提高性能,但是内存是有限的,这就引入了物理内存和虚拟内存的概念。物理内存指通过物理内存条而获得的内存空间,称为RAM。而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。当物理内存不足时,可以用虚拟内存代替。虚拟内存是利用磁盘空间虚拟出的一块逻辑内存,用作虚拟内存的磁盘空间被称为交换空间(swap space)
原因二
在操作系统中,让进程直接接触物理内存是十分危险的,因为如果两个进程同时对一块物理内存进行读写操作的话,会造成进程的崩溃。
所以引入了虚拟内存的概念,每一个进程都有一套虚拟地址,进程通过虚拟地址间接的访问物理内存。各个进程之间的地址不会干扰。
-
在进程里所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
-
实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来
Linux的内存管理采取的是分页存取机制,为了保证物理内存能得到充分的利用,内核会在适当的时候将物理内存中不经常使用的数据块自动交换到虚拟内存中,而将经常使用的信息保留到物理内存。
虚拟内存的最大容量与实际容量
虚拟内存的最大容量是 计算机地址位数能容纳的最大容量。
虚拟内存的实际容量是 min ( 内存与外存之和,CPU寻址范围 )
例如:
某计算机的地址结构是64位,按字节编址,内存大小512MB,外存大小4GB。
虚拟内存的最大容量= 2的64次方 = 8GB
虚拟内存的实际容量是 = min(512MB+4GB, 8GB)= 512MB + 4GB
进程的虚拟内存空间
在linux的32位系统中,通过虚拟内存技术,每个进程都有4个G的虚拟内存空间,如下:
- 一个新进程建立的时候,内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体,task_struct中有一个struct mm_struct指针,mm_struct结构体抽象了进程自己的虚拟地址空间。
- 在每个mm_struct又都有一个pgd_t 指针pgd指向页表,然后通过页表实现从虚拟地址到物理地址的转换。
关于虚拟内存有三点需要注意:
- 4G的进程地址空间被人为的分为两个部分–用户空间与内核空间。用户空间从0到3G(0xc0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间的虚拟地址。例外情况只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。
- 用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程变化,是固定的。内核空间地址有自己对应的页表,用户进程各自有不同的页表。
- 每个进程的用户空间都是完全独立、互不相干的。
为什么需要区分内核空间与用户空间
在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。
所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。 Linux 系统将空间的使用权限分为了用户态和内核态。当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。
从用户态切换到内核态
其实所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。
比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的某某文件”。其实就是通过一个特殊的指令让进程从用户态进入到内核态(到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据。
再比如,库接口malloc申请动态内存,malloc的实现内部最终还是会调用brk()或者mmap()系统调用来分配内存。也是先切换到内核态,分配成功后在切换会用户态。
从用户态到内核态切换可以通过三种方式:
- 系统调用,其实系统调用本身就是中断,其是软件中断,跟硬中断不同。
- 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
- 外设中断:当外设完成用户的请求时,会向CPU发送中断信号。
使用内存映射(mmap)操作文件
mmap内存映射的实现过程,总的来说可以分为三个阶段:
(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
1、进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
2、在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
3、为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
4、将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中
(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
5、为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
6、通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
7、内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
8、通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。
(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
9、进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
10、缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
11、调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
12、之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。
相较于IO寻址需要拷贝两次数据,mmap只需要拷贝一次数据。分配好的虚拟内存段通过页表与磁盘中的文件数据一一对应,但是虚拟内存段内没有数据,当需要该页时,会产生缺页中断,此时系统会从磁盘将对应的页拷贝到内存中来,此页在用户空间和内核空间中均可用。
mmap优点总结
- 对文件的读取操作只需一次拷贝,用内存读写取代I/O读写,提高了文件读取效率。
- 实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉(因为不同的虚拟地址可以映射到同一个物理地址)。
- 提供进程间共享内存及相互通信的方式。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域。从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。同时,如果进程A和进程B都映射了区域C,当A第一次读取C时通过缺页从磁盘复制文件页到内存中;但当B再读C的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。
- 可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件I/O操作,极大影响效率。这个问题可以通过mmap映射很好的解决。换句话说,但凡是需要用磁盘空间代替内存的时候,mmap都可以发挥其功效。
malloc的分配内存过程
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。
分页存储管理
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为4KB。
虚拟地址与物理地址之间通过页表来映射,如下图:
内部碎片与外部碎片
内部碎片(产生于分区内)
已经被分配出去(能明确指出属于哪个进程)的内存空间大于请求所需的内存空间,不能被利用的内存空间就是内部碎片。
因为所有的内存分配必须起始于可被 4、8 或 16 整除(视处理器体系结构而定)的地址或者因为MMU的分页机制的限制,决定内存分配算法仅能把预定大小的内存块分配给客户。假设当某个客户请求一个43字节的内存块时,因为没有适合大小的内存,所以它可能会获得 44字节、48字节等稍大一点的字节,因此由所需大小四舍五入而产生的多余空间就叫内部碎片。
外部碎片(产生于分区外)
频繁的分配与回收物理页面会导致大量的、连续且小的页面块夹杂在已分配的页面中间,外部碎片是指还没有分配出去(不属于任何进程),但是由于大小而无法分配给申请内存空间的新进程的内存空闲块。
分页是怎么解决分段的内存碎片、内存交换效率低的问题?
由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制下,虚拟内存与物理内存之间的映射
首先要明确:在进行映射时,需要用到虚拟地址和页表这两个数据结构。
虚拟地址的结构:
这个就是在进程中所用到的地址,0~ 11位为页内地址,即每页大小4KB;12~ 31位为页号,地址空间最多允许有1M()页。
页表的结构:
页表的大小4MB。页表含有一条一条的页表项,页表项的大小为4byte(32位)。一个页表中有条页表项。每一个页表项都对应着一个物理内存的页。
注意:上图只是示意图,实际上页表的页表项中只有20位的物理内存块号,并没有存储虚拟内存页号,剩下的12位存储的是一些控制位。
建立页表时,要求页表连续并且足够长以映射全部物理地址空间,在通过虚拟地址定位页表中页表项的位置时,用下面公式计算:
页表项地址=页表起始地址+页号×页表项长度
映射的具体过程如下:
-
把虚拟内存地址,切分成页号和偏移量;
-
根据页号,从页表里面,查询对应的物理页号;
-
直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
页表的缺陷
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,这就意味着页表所占用的空间会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB
的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么,100
个进程的话,就需要 400MB
的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
多级页表
要解决上面的问题,就需要采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。
对于单页表的实现方式,在 32 位和页大小 4KB
的环境下,一个进程的页表需要装下 个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
我们把这个「页表项」的单级页表再分页,先把个页表项凑成一页大小(4KB),形成一级页表。然后一级页表里的每个页表项再对应一个页,每个页也有 个页表项。这些页是二级页表。如下图所示:
多级页表如何节省内存空间
经过上面的页表分级,页表所需要的总空间是4KB+4MB,看起来反而还增大了,实则不然
每个进程都有 4GB 的虚拟地址空间,但对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
如果使用了二级分页,一级页表的4kb大小就可以覆盖整个 4GB 虚拟地址空间,如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
而对于单级页表来说,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
快表(TLB)
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了地址转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
于是利用此特性,将最常访问的几个页表项存储到访问速度更快的硬件,于是在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
虚拟内存的实现
虚拟内存需要的支持技术:
- 一定容量的内存和外存
- 页表机制
- 中断机制
- 地址变换机构
请求分页管理方式是虚拟内存实现的一种方式,请求分页系统建立在基本分页系统上,为了支持虚拟存储器功能而加入了请求调页功能和页面置换功能
请求分页 存储管理与基本分页存储管理的主要区别:
- 在程序执行过程中,当所访问的信息不在内存时,由操作系统负责将所需信息从外存调入内存【操作系统要提供请求调页功能,将缺失页面从外存调入内存】,然后继续执行程序。
- 若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存【操作系统要提供页面置换的功能,将暂时用不到的页面换出外存】。
请求分页系统的页表项也不同于基本分页系统的页表项,增加了四个字段,如下:
新增字段的作用如下:
- 状态位P:用于指示该页是否已调入内存,供程序访问时参考。
- 访问字段A:用于记录本页在一段时间内被访问的次数,或记录本页最近已有多长时间未被访问,供置换算法换出页面时参考。
- 外存地址:用于指出该页在外存上的地址,通常是物理块号,供调入该页时参考。
- 修改位M:标识该页在调入内存后是否被修改过。
虚拟内存的管理流程
进程与线程
老生常谈:两者联系与区别
线程是调度的基本单位,进程则是资源分配的基本单位。
- 从资源上来讲,开辟一个线程所需要的资源要远小于一个进程。
- 从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间(这种时间的差异主要由于缓存的大量未命中导致)。
- 从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的地址空间,要进行数据的传递只能通过进程间通信的方式进行。线程则不然,属于同一个进程的不同线程之间共享同一地址空间,所以一个线程的数据可以被其它线程感知,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步措施)
- 进程有独立的地址空间和代码段,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮
什么时候用多线程,什么时候用多进程
多进程和多线程的优缺点分析
多进程优点:
- 每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系;
- 通过增加CPU,就可以容易扩充性能;
- 可以尽量减少线程加锁/解锁的影响,极大提高性能
- 每个子进程都有4GB地址空间和相关资源,总体能够达到的性能上限非常大。
多进程缺点:
- 逻辑控制复杂,需要和主程序交互;
- 需要跨进程边界,如果有大数据量传送效率较低,适合小数据量传送、密集运算。多进程调度开销比较大;
多线程的优点:
- 创建速度快,上下文切换的开销较小
- 程序逻辑和控制方式简单;
- 所有线程可以直接共享内存和变量等;
多线程缺点:
- 每个线程与主程序共用地址空间,受限于4GB地址空间;
- 线程之间的同步和加锁控制比较麻烦;
- 一个线程的崩溃可能影响到整个程序的稳定性;
- 到达一定的线程数程度后,即使再增加CPU也无法提高性能
- 线程能够提高的总性能有限,线程多了之后,线程本身的调度也需要消耗较多的CPU。
应用场景
- 多进程模型的优势是CPU,多线程模型的优势是线程间切换代价较小
- 多线程模型适用于I/O密集型,同时,多线程模型也适用于单机多核分布式场景。
- 多进程模型适用于CPU密集型,同时,多进程模型也适用于多机分布式场景中,易于多机扩展。
nginx主流的工作模式是多进程模式(也支持多线程模型)。几乎所有的web server服务器服务都有多进程的,至少有一个守护进程配合一个worker进程,例如apached,httpd等等以d结尾的进程包括init.d本身就是0级总进程,所有你认知的进程都是它的子进程;
chrome浏览器也是多进程方式。 (原因:①可能存在一些网页不符合编程规范,容易崩溃,采用多进程一个网页崩溃不会影响其他网页;而采用多线程会。②网页之间互相隔离,保证安全,不必担心某个网页中的恶意代码会取得存放在其他网页中的敏感信息。)
需要频繁创建销毁以及大量计算频繁切换CPU的,优先采用多线程
线程共享和独占的内容
线程共享的内容 | 线程独占的内容 |
|
|
一个进程内的多个线程共享其所属进程的地址空间,如下:
首先讨论线程有哪些资源是不共享的:每个线程都有自己独立的运行栈,程序计数器,栈指针以及函数运行使用的寄存器,这些资源统称为线程上下文,它们是不共享的。
进程控制块(PCB)
进程由程序、数据和进程控制块3个基本部分组成。程序是进程执行的可执行代码,数据是进程所处理的对象,进程控制块用于记录有关进程的各种信息。
- PCB是进程存在的唯一标志
- 系统能且只能通过PCB对进程进行控制和调度
- PCB记录了操作系统所需的、用于描述进程的当前情况以及控制进程运行的全部信息
PCB的组成:
进程描述信息
进程描述信息用于记录一个进程的标识信息和身份特征,如家族关系和归属关系等。通过这些信息可以识别该进程,了解进程的权限,以及确定这个进程与其他进程之间的关系。系统为每个进程分配了一个唯一的整数作为进程标识号PID,这是最重要的标识信息。系统通过PID来标识各个进程。
进程控制与调度信息
进程的运行需要由系统进行控制和调度。进程控制块记录了进程的当前状态、调度策略、优先级、时间片等信息。系统根据这些信息实施进程的控制与调度。
资源信息
进程的运行需要占用一些系统资源,必要的资源包括进程的地址空间、要访问的文件和设备,以及要处理的信号等。进程是系统分配资源的基本单位。系统将分配给进程的资源信息记录在进程的PCB中。通过这些信息,进程就可以访问分配的各种资源。
现场信息
进程现场也称为进程上下文(process context),包括CPU的各个寄存器的值。这些值刻画了进程的运行状态和环境。退出CPU的进程必须保存好这些现场信息,以便在下次被调度时继续运行。当进程被重新调度运行时,系统用它的PCB中的现场信息恢复CPU现场。现场一旦恢复,下一个指令周期CPU将精确地接着进程上次运行的断点处继续执行下去。
进程/线程的阻塞,休眠,挂起
阻塞是被动的,线程阻塞是指线程由于特定原因暂停执行。阻塞的线程会立刻交出处理器时间片,然后从这时开始不再消耗处理器时间,直至阻塞条件结束。
休眠和挂起,一般是主动(或由父进程发起挂起),休眠在休眠时,就知道了计划休眠时长sleep(10),挂起suspend需要等待resume,时间片到了,也会挂起线程。
举个例子
对线程的控制就好比你控制了一个雇工为你干活。你对雇工的控制是通过编程来实现的。
挂起线程的意思就是你对主动对雇工说:“你睡觉去吧,用着你的时候我主动去叫你,然后接着干活”。
使线程睡眠的意思就是你主动对雇工说:“你睡觉去吧,某时某刻过来报到,然后接着干活”。
线程阻塞的意思就是,你突然发现,你的雇工不知道在什么时候没经过你允许,自己睡觉呢,
但是你不能怪雇工,肯定你 这个雇主没注意,本来你让雇工扫地,结果扫帚被偷了或被邻居家借去了,
你又没让雇工继续干别的活,他就只好睡觉了。至于扫帚回来后,雇工会不会知道,会不会继续干活,你不用担心,
雇工一旦发现扫帚回来了,他就会自己去干活的。因为雇工受过良好的培训。这个培训机构就是操作系统。
进程的fork()函数
fork()函数如果成功返回,其返回值有两个,而是分别返回给父子进程:父进程收到的返回值 > 0(子进程PID)、子进程收到的返回值 = 0; 如下:
#include <stdio.h>
#include <unistd.h>
int main(){
pid_t pid = fork();
if(-1 == pid) {
//创建子进程失败
return -1;
}
if(0 == pid) {
//子进程
printf("I am child, my pid:%d, ppid:%d\n",getpid(),getppid());
sleep(5);
}
else {
//父进程
printf("I am father, my pid:%d, ppid:%d\n",getpid(),getppid());
sleep(5);
}
return 0;
}
父子进程对同一个文件读写
分两种情况
- 如果父进程用open函数打开文件并获得了文件描述符,之后执行的fork()函数,那么此时父子进程中的文件描述符的文件指针是彼此关联的,两者对于同一个文件保存的文件指针的偏移量是同步的,也就是会接续写,每次都写在文件的末尾。
- 如果父进程在fork()出子进程,之后父子进程分别用open函数对同一个文件取得文件描述符,那么后续两者在对文件进行写操作时,可能会发生写覆盖的情况,因为两个进程的PCB不一样,文件指针各自独立。
僵尸进程
僵尸进程是所有进程退出后都会出现的一个状态,如果父进程能够及时处理,那么这个状态极其短暂,对程序没有任何影响,但是如果父进程没有及时的处理,就会带来一些问题。
- 一个进程在调用 exit 命令结束自己的生命的时候,其实它并没有真正的被销毁,而是留下一个称为僵尸进程(zombie)的数据结构
- 在 linux 进程的状态中,僵尸进程是非常特殊的一种,它已经放弃了几乎所有的内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置(PCB),记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。
- 进程在退出时向父进程发送 SIGCHLD 信号,父进程此时应该调用 wait() 系 统调用来获取子进程的退出状态以及其它的信息。在 wait() 调用之后,僵尸进程就完全从内存中移除。因此一个僵尸进程存在于其终止到父进程调用 wait() 等函数这个时间的间隙,一般很快就消失,但如果编程不合理,父进程从不调用 wait() 等系统调用来收集僵尸进程(如父进程处于一个死循环中),那么这些进程会一直存在内存中。如果有大量的僵尸进程驻在系统之中,必然消耗大量的系统资源。
僵尸进程处理方式
- 一种比较暴力的做法是将其父进程杀死,那么它的子进程,即僵尸进程会变成孤儿进程,由系统来回收。但是这种做法在大多数情况下都是不可取的,如父进程是一个服务器程序,如果为了回收其子进程的资源,而杀死服务器程序,那么将导致整个服务器崩溃,得不偿失。显然这种回收进程的方式是不可取的,但其也有一定的存在意义。
- 父进程的wait()函数是用来处理僵尸进程的,但是父进程执行wait()函数时会一直阻塞在wait函数处直到僵尸进程出现,此处需要配合信号使用。当子进程终止时,内核就会向它的父进程发送一个SIGCHLD信号,对于这种信号的系统默认动作是忽略它。在程序中选择不忽视该信号,当父进程接收到SIGCHLD信号后就应该调用 wait ()或 waitpid() 函数对子进程进行善后处理,释放子进程占用的资源。
#include <sys/wait.h>
pid_t wait(int * status);
pid_t waitpid(pid_t pid, int *status, int options);
// 成功返回进程ID,出错返回0或者-1。
waith()函数的参数status是输出型参数,获取子进程退出状态,不关心则设置成NULL
waitpid()参数:
pid:
pid=-1: 等待任意一个子进程,和wait等效
pid>0:只等待进程id等于pid的进程
pid=0:等待同一进程组的任意一个进程,如果子进程加入其他进程组不加理会
pid<-1:等待一个指定进程组中的任意一个进程,这个进程组的id就是pid的绝对值
status:
WIFEXITED: 若为正常终止子进程返回的状态,则为真,(查看进程是否正常退出)
WEXITSTATUS: 若WIFEXITED 非零,提取子进程退出码(查看进程退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回孩子进程的进程id
孤儿进程
子进程死亡需要父进程来处理,那么意味着正常的进程应该是子进程先于父进程死亡。当父进程先于子进程死亡时,子进程死亡时没父进程处理,这个死亡的子进程就是孤儿进程。孤儿进程将被init进程( 进程号为1 )所收养,并由init进程对它们完成状态收集工作。
但孤儿进程与僵尸进程不同的是,由于父进程已经死亡,系统会帮助父进程回收处理孤儿进程。所以孤儿进程实际上是不占用资源的,因为它终究是被系统回收了。不会像僵尸进程那样占用ID,损害运行系统。
进程的状态
首先明确,进程状态之所以要划分,是因为不同进程之间要协同工作,CPU 需要从一个进程切换到另外一个进程,在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。进程不同的状态存储了进程不同的信息。
上图中各个状态的意义:
-
运行状态(Runing):该时刻进程占用 CPU;
-
就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
-
阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
-
创建状态(new):进程正在被创建时的状态;
-
结束状态(Exit):进程正在从系统中消失时的状态;
不同状态之间的切换:
-
NULL -> 创建状态:一个新进程被创建时的第一个状态;
-
创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
-
就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
-
运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
-
运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
-
运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
-
阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;
Linux中最多可以有多少个进程?
Linux中有一个命令可以帮助我们查看系统中的进程上限
$ ulimit -u
4096
这属于软限制,是可以改变的。也就是说在我的机器上最多可以有4096个进程,但是我可以通过改变这个参数的值来修改对于进程数量的软限制,比如说用下面的命令将软限制改到5120。
ulimit -u 5120
二.我们用pid_t来表示一个进程的pid,因此能表示的进程的范围一定不会超过pid_t类型的大小
$ cat /proc/sys/kernel/pid_max
32768
pid_t实际上就是一个short类型变量,当然这里能表示的范围只是进程id最多表示到这么多,这只是一个理论值,实际上,由于内存等系统资源的限制,根本不会同时有这么多的进程存在。
一个进程中最多可以有多少个线程?
创建一个线程会占用多少内存,这取决于分配给线程的调用栈大小,可以用ulimit -s命令来查看大小(一般常见的有10M或者是8M)。我们还知道,一个进程的虚拟内存是4G,在Linux32位平台下,内核分走了1G,留给用户用的只有3G,于是我们可以想到,创建一个线程占有了10M内存,总共有3G内存可以使用。于是可想而知,最多可以创建差不多300个左右的线程。
因此,进程最多可以创建的线程数是根据分配给调用栈的大小,以及操作系统(32位和64位不同)共同决定的。
进程的并发
在单核 CPU 系统里,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程执行每次执行一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是就造成了「并发」的现象。并发也经常出现在线程的工作模式里。
进程同步与线程同步有什么区别
线程的同步与互斥
所谓互斥,就是对于一片临界区资源,一次只允许一个进程/线程对进行操作,两个进程/线程不能同时对一片资源进行操作。
所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,几个进程/线程之间的执行有先后关系。
进程间通讯的方式
由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。
匿名管道:
- 没有名字标识,匿名管道是特殊文件,只存在于内存,不存在于文件系统中。
- shell 命令中的|竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向半双工的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道。
- 匿名管道是只能用于存在父子关系的进程间通信,(因为子进程会继承父进程中管道的文件描述符),shell 命令中的 a | b,a与b都是shell进程的子进程,共享shell的管道文件描述符,所以它们之间也能通过匿名管道进行通讯。
- 匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
- 数据一旦被读取,便不在管道中存在,即不能重复读取
命名管道:
- 突破了匿名管道只能在父子进程间的通信限制,使用命名管道的前提,是要在文件系统创建一个类型为 p 的设备文件,不同的进程就可以通过这个设备文件进行通信。
- 此外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
消息队列:
- 克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。
- 消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
共享内存:
- 可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,多个进程都有一片虚拟空间,这些虚拟空间指向物理内存中的同一块内存上。也就是多个进程拥有对同一片物理空间的读写权。
- 不同进程在交换数据时,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。
- 但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
信号量:
- 此时需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步
- 信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
信号:
- 它和信号量名字虽然相似,但功能完全不同。信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件
- 信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)
- 一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即
SIGKILL
和SEGSTOP
,这是为了方便我们能在任何时候结束或停止某个进程。
套接字:计网部分详细介绍
补充:线程之间通讯
同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,即同步和互斥问题。
用户态与内核态
基本概念
用户态和内核态是CPU的两种不同工作方式
内核态:也叫内核空间,是内核进程/线程所在的区域。主要负责运行系统、硬件交互。
用户态:也叫用户空间,是用户进程/线程所在的区域。主要用于执行用户程序。
内核态:运行的代码不受任何限制,CPU可以执行任何指令。
用户态:运行的代码需要受到CPU的很多检查,不能直接访问内核数据和程序,也就是说不可以像内核态线程一样访问任何有效地址。
操作系统在执行用户程序时,主要工作在用户态,只有在其执行没有权限完成的任务时才会切换到内核态。
切换方式
从用户态到内核态切换可以通过三种方式,或者说会导致从用户态切换到内核态的操作:
- 系统调用,系统调用本身就是中断,但是它是软件中断,跟硬中断不同。系统调用机制是使用了操作系统为用户特别开放的一个中断来实现,如 Linux 的 int 80h 中断。
- 异常:如果当前进程运行在用户态,这个时候发生了异常事件,会触发由当前运行进程切换到处理此异常的内核相关进程中
- 外围设备中断:外围设备完成用户请求的操作之后,会向CPU发出中断信号,这时CPU会转去处理对应的中断处理程序。
代价何在
当发生用户态到内核态的切换时,会发生如下过程(本质上是从“用户程序”切换到“内核程序”)
- 设置处理器至内核态。
- 保存当前寄存器(栈指针、程序计数器、通用寄存器)。
- 将栈指针设置指向内核栈地址。
- 将程序计数器设置为一个事先约定的地址上,该地址上存放的是系统调用处理程序的起始地址。
而之后从内核态返回用户态时,又会进行类似的工作。
如何避免频繁切换
用户态和内核态之间的切换有一定的开销,如果频繁发生切换势必会带来很大的开销,所以要想尽一切办法来减少切换。这也是面试常考的问题。
减少线程切换
因为线程的切换会导致用户态和内核态之间的切换,所以减少线程切换也会减少用户态和内核态之间的切换。那么如何减少线程切换呢?
- 无锁并发编程。多线程竞争锁时,加锁、释放锁会导致比较多的上下文切换。(因为互斥锁的使用需要在用户态和核心态之间切换)
- CAS算法。使用CAS避免加锁,避免阻塞线程
- 使用最少的线程。避免创建不需要的线程
- 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
I/O 频繁发生内核态和用户态切换,怎么解决。
首先要同意这个说法,即I/O会导致系统调用,从而导致内核态和用户态之间的切换。因为对I/O设备的操作是发生在内核态。那如何减少因为I/O导致的系统调用呢?答案是:使用进程缓冲区。
用户进程缓区
一些程序在读取文件时,会先申请一块内存数组,称为buffer,然后每次调用read,读取设定字节长度的数据,写入buffer。之后的程序都是从buffer中获取数据,当buffer使用完后,在进行下一次调用,填充buffer。所以说:用户缓冲区的目的就是是为了减少系统调用次数,从而降低操作系统在用户态与核心态切换所耗费的时间。除了在进程中设计缓冲区,内核也有自己的缓冲区。
内核缓存区
当一个用户进程要从磁盘读取数据时,内核一般不直接读磁盘,而是将内核缓冲区中的数据复制到进程缓冲区中。但若是内核缓冲区中没有数据,内核会把对数据块的请求,加入到请求队列,然后把进程挂起,为其它进程提供服务。等到数据已经读取到内核缓冲区时,把内核缓冲区中的数据读取到用户进程中,才会通知进程,当然不同的IO模型,在调度和使用内核缓冲区的方式上有所不同。
小结
图中的read,write,sync都是系统调用。read是把数据从内核缓冲区复制到进程缓冲区。write是把进程缓冲区复制到内核缓冲区。当然,write并不一定导致内核的缓存同步动作sync,比如OS可能会把内核缓冲区的数据积累到一定量后,再一次性同步到磁盘中。这也就是为什么断电有时会导致数据丢失。所以说内核缓冲区,可以在OS级别,提高磁盘IO效率,优化磁盘写操作。
文件管理
文件系统的组成
Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。
索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。
目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。
由于索引节点唯一标识一个文件,而目录项记录着文件的名,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个目录项。比如,硬链接的实现就是多个目录项中的索引节点指针指向同一个文件。
注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。
软链接与硬链接
有时候我们希望给某个文件取个别名,那么在 Linux 中可以通过硬链接(Hard Link) 和软链接(Symbolic Link) 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。
硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。
对于一个文件的多个硬链接,如果通过其中一个硬链接对文件进行了写操作,那么其他的硬链接也会同步更新。
由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。
创建一个硬链接,链接名叫做file.h
ln file file.h
软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
创建一个软链接,链接名叫file.s
ln -s file file.s
硬链接与软链接的区别:
- 硬链接本质上是同一个文件,不同名称;软件链接,不是同一个文件,只是一个路径的引用。
- 硬链接不可以跨文件系统,软链接可以。
- 硬链接 inode number 相同,软链接 inode number 不同。
- 创建新的链接,硬链接的链接数会增,软链接不会。
- 硬链接不支持文件夹,软链接支持。
- 删除目标文件时,硬链接文件依然可以访问,软链接将无法访问。
- 硬链接的文件类型和目标文件一致,软链接则和目标文件没有关系
互斥锁、自旋锁、读写锁、悲观锁、乐观锁
互斥锁与临界区的区别
1、临界区只能用于对象在同一进程里线程间的互斥访问;互斥体可以用于对象进程间或线程间的互斥访问。
2、临界区是非内核对象,只在用户态进行锁操作,速度快;互斥体是内核对象,在核心态进行锁操作,需要在用户态和内核态之间切换,速度慢。
3、临界区和互斥体在Windows平台都下可用;Linux下只有互斥体可用。
互斥锁与自旋锁
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
-
互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
-
自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在两次线程上下文切换的成本:
当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
如果能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁。
自旋锁是通过 CPU 提供的CAS函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。
读写锁
读写锁的工作原理是:
当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
所以说,写锁是独占锁,读锁是共享锁。
根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。
读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取读锁。
写优先锁是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取读锁。
乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
其实乐观锁全程并没有加锁,所以它也叫无锁编程。
实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
互斥锁和信号量的区别
1.互斥量用于线程的互斥,信号量用于线程的同步。
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源2.互斥量值只能为0/1,信号量值可以为非负整数。
一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量时,也可以完成一个资源的互斥访问。3.互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。
几个调度算法
进程调度
进程调度分为抢占式和非抢占式调度。前者指当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程,而后者则指进程正在运行时,可以被打断,使其把 CPU 让给其他进程。
注意:调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),而不能影响进程真在使用 CPU 的时间和 I/O 时间。
调度发生的时机
当进程从运行状态转到等待状态;(等待事件完成而被阻塞)
当进程从运行状态转到就绪状态;(时间片用完)
当进程从等待状态转到就绪状态;(事件完成)
当进程从运行状态转到终止状态;(进程运行结束)
先来先服务调度算法(FCFS算法)
每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
最短作业优先调度算法(SJF算法)
优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
高响应比优先调度算法(HRRN算法)
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行
如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这样长作业的进程容易被选中运行,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
时间片轮转调度算法
每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度是一个很关键的点:
-
如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
-
如果设得太长又可能引起对短作业进程的响应时间变长;
通常时间片设为 20ms~50ms
通常是一个比较合理的折中值。
最高优先级调度算法
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级调度算法。
进程的优先级可以分为,静态优先级或动态优先级:
静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈队列调度算法
多级反馈队列调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
工作原理
设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
扩展:多级反馈队列调度算法的缺点
- 首先,会有饥饿问题。如果系统有“太多”交互型工作,就会不断占用CPU,导致长工作永远无法得到CPU。即使在这种情况下,我们也希望这些长工作也能有所进展。
- 其次,某些用户会用一些手段欺骗调度程序,让它给予进程远超公平的资源。例如,上述算法对如下的攻击束手无策:进程在时间片用完之前,调用一个I/O操作(比如访问一个无关的文件),从而主动释放CPU。如此便可以保持在高优先级,占用更多的CPU时间。做得好时(比如,每运行99%的时间片时间就主动放弃一次CPU),工作可以几乎独占CPU。
- 最后,一个程序可能在不同时间表现不同。一个计算密集的进程可能在某段时间表现为一个交互型的进程。用目前的方法,它不会享受系统中其他交互型工作的待遇。
页面置换算法
前置知识:缺页中断
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于:
缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
缺页中断的处理流程如上,其中在第 4 步中,如果在物理内存中存在空闲物理页,则直接将页面填入即可。而如果物理内存中没有空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
更准确的讲,页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
衡量页面置换算法性能的好坏的是: 减少页面的换入换出的次数
最佳页面置换算法
最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。
所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
在实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。
先进先出置换算法
选择在内存驻留时间很长的页面进行中置换
最近最久未使用的置换算法
最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问或操作的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。
困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。
所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
时钟页面置换算法
时钟页面置换算法跟 LRU 近似,又是对先进先出算法的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
最不常用算法
最不常用(LFU)算法指的是,当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。
那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。
IO读取的优化以及零拷贝
前置知识——DMA技术
常规的IO过程是这样的:
整个数据的传输过程,都要需要 CPU 亲自参与将数据从磁盘搬运到内存缓冲区(PageCache)的过程,而且这个过程,CPU 是不能做其他事情的。并且IO读取操作的速度非常慢,读写速度相差内存 10 倍以上。如果要传输的数据量过大,那么会极大的浪费CPU资源。
所以引入了DMA——直接内存访问(Direct Memory Access) 技术。在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,即将IO操作这种耗时较长的任务的部分交给DMA来完成,而 CPU 不再参与IO交换,这样 CPU 就可以去处理别的事务,当内和缓冲区的数据准备好之后,再由CPU将数据拷贝到用户空间,此时的数据搬移操作是内存操作,速度较快。如下:
整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,只有内核才能和DMA设备交互。传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
加入了DMA的IO操作
如果服务端要提供文件传输的功能,需要两步:
- 将磁盘上的文件读取出来
- 将文件通过网络协议发送给客户端。
如下:
可以看到,在从磁盘取出数据并将数据发送,这期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read()
,一次是 write()
,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的。
只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数,也就是要从"四拷贝"变为"零拷贝"
如何优化文件传输的性能
如何减少「用户态与内核态的上下文切换」的次数呢?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。也就是减少数据拷贝的次数
减少数据拷贝的次数以至于达到零拷贝
观察上看数据拷贝的示意图,会发现「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
几种优化方式如下:
mmap + write
(此处的mmap是内核调用函数,与之前的系统调用函数mmap()不同)
buf = mmap(file, len);
write(sockfd, buf, len);
mmap()函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
应用进程调用了mmap()后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
应用进程再调用write()操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
我们可以得知,通过使用mmap()来代替read(), 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
sendfile
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
从 Linux 内核2.4版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile()系统调用的具体过程如下:
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里
这个过程之中,只进行了 2 次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy)技术,因为我们在内存层面拷贝数据的次数为零,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
扩展
零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。
另外,当传输大文件时,不能使用零拷贝,因为可能由于内核缓冲区被大文件占据,而导致「热点」小文件无法利用到缓冲区,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。
上下文切换
此处主要指CPU的上下文切换。CPU上下文切换,指的是先保存上一个任务的 CPU 上下文(CPU寄存器和程序计数器),然后将新任务的上下文加载到这些寄存器和程序计数器中,最后跳转到程序计数器
CPU的上下文切换分为三个: 进程上下文切换;线程上下文切换;中断上下文切换
进程上下文的切换
进程上下文切换VS系统调用
- 在系统调用的过程中,进程先从用户态转变到内核态,再从内核态转到用户态,这其中发生了两次CPU的上下文切换,但是调用前后的进程还是那同一个。
- 进程的上下文切换,则是指CPU要从当前运行的进程转到其他进程运行,进程的切换需要在内核态完成。进程上下文不仅包括虚拟内存、栈和全局变量等用户空间资源,还包括内核栈和寄存器等内核空间的状态。所以进程上下文切换比系统调用要多出一步:在保存当前进程的内核状态和 CPU 寄存器之前,需要保存进程的虚拟内存、栈等;并加载下一个进程的内核状态。
进程会被调度/切换到在 CPU 上运行的场景
当一个进程的 CPU 时间片用完时,它会被系统挂起,并切换到其他等待 CPU 运行的进程。
当系统资源不足(如内存不足)时,直到资源充足之前,进程无法运行。此时进程也会被挂起,系统会调度其他进程运行。
当一个进程通过
sleep
函数自动挂起自己时,自然会被重新调度。当优先级较高的进程运行时,为了保证高优先级进程的运行,当前进程会被高优先级进程挂起运行。
当发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。
线程上下文切换
因为线程是任务调度的最小单位,所以,对于线程和进程,我们可以这样理解:
当一个进程只有一个线程时,可以认为一个进程等于一个线程
当一个进程有多个线程时,这些线程共享相同的资源,例如虚拟内存和全局变量。
此外,线程也有自己的私有数据,比如栈和寄存器,在上下文切换时也需要保存。
这样,线程的上下文切换其实可以分为两种情况:
首先,前后两个线程属于不同的进程。此时,由于资源不共享,切换过程与进程上下文切换相同。
其次,前后两个线程属于同一个进程。此时,由于虚拟内存是共享的,所以切换时虚拟内存的资源保持不变,只需要切换线程的私有数据、寄存器等未共享的数据。
中断上下文切换
(此部分未能理解)与进程上下文不同,中断上下文切换前后,进程不会有变化,所以不涉及进程的用户态。因此,即使中断进程中断了处于用户态的进程,也不需要保存和恢复进程的虚拟内存、全局变量等用户态资源。
另外,和进程上下文切换一样,中断上下文切换也会消耗 CPU。过多的切换次数会消耗大量的 CPU 资源,甚至严重降低系统的整体性能。因此,当发现中断过多时,需要注意排查它是否会对您的系统造成严重的性能问题。
死锁
产生条件
- 互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直到占有该资源的进程使用完成后释放该资源。
- 占有并等待条件:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源。
- 非抢占条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放。
- 循环等待条件:进程发生死锁后,必然存在一个进程-资源之间的环形链。
如何解决死锁问题
- 资源一次性分配,这样就不会再有请求了(破坏请求条件)。
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏占有并等待条件)。
- 可抢占资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可抢占的条件。
- 资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反, 从而破坏环路等待的条件
IO多路复用的意义
首先IO有阻塞式IO和非阻塞式IO,它们各有缺点,
阻塞式IO在没有读写事件到来时,就会将进程挂起,对于并发较高的多个IO事件,当有多个IO事件到来时,阻塞式IO无法同时读取,唯一解决办法就是对于每一个IO事件建立一个线程。
非阻塞IO可以通过在一个while循环语句里加入多条语句来监听多个IO事件,但是非阻塞IO的这种轮询方式会使得CPU的使用率极高,无法进行其他的工作。
因此需要一个CPU占有率低,并且能够适应高并发场景的IO模型——IO多路复用模型。
中断
中断的分类
中断类型
中断包括软件中断(不可屏蔽)和硬件中断。
软中断为内核触发机制引起,模拟硬件中断。(可以理解为系统调用)
硬件中断又分为外部中断(可屏蔽)和内部中断(不可屏蔽)。外部中断为一般外设请求;内部中断包括硬件出错(掉电,校验,传输)和运算出错(非法数据,地址,越界,溢出)
中断定义:指当出现需要时,CPU暂时停止当前程序的执行转而执行处理新情况的程序和执行过程。
硬件中断是由与系统相连的外设(比如网卡 硬盘 键盘等)自动产生的。每个设备或设备集都有他自己的IRQ(中断请求),基于IRQ, CPU可以将相应的请求分发到相应的硬件驱动上。
软中断不会直接中断CPU, 也只有当前正在运行的代码(或进程)才会产生软中断. 软中断是一种需要内核为正在运行的进程去做一些事情(通常为I/O)的请求。
中断下半部
为了让中断处理运行的快,同时要完成相应的全部工作。根据具体的内容将中断分为两个部分:上半部分(中断处理程序)和下半部分(推后处理程序)。
上半部分需要立即执行,并且有严格的时间限制,这些工作是在所有中断被禁止的情况下完成的,剩余部分工作推迟到下半部分。
下半部分的任务就是执行与中断处理密切相关但中断处理程序本身不执行的工作。
在Linux2.6的内核中存在三种不同形式的下半部实现机制:软中断,tasklet和工作队列。
硬件中断和软中断的区别
- 硬件中断是由外设引发的, 软中断是执行中断指令产生的.
- 硬件中断的中断号是由中断控制器提供的, 软中断的中断号由指令直接指出, 无需使用中断控制器.
- 硬件中断是可屏蔽的, 软中断不可屏蔽.
- 硬件中断处理程序要确保它能快速地完成任务, 这样程序执行时才不会等待较长时间, 称为上半部.
- 软中断处理硬中断未完成的工作, 是一种推后执行的机制, 属于下半部
中断的处理
中断的产生
- 主动触发(程序中通过函数接口,通知CPU进行中断处理)
- 内部中断(数据溢出、非法地址访问、未识别的操作码...)
- 外部中断(输入/输出设备、硬件设备故障...)
不同中断源如何区分
每一个能够发出中断请求的硬件设备控制器都有一条IRQ的输出线。IRQ输出线与一个名为可编程中断控制器的硬件电路输入引脚相连。中断控制器会监视IRQ线上的信号。如果IRQ线上出现信号,中断控制器会将其转化成对应的中断号,通知CPU处理。CPU根据中断号,在中断向量表中找到对应的中断处理程序
中断向量表
为了处理方便,会为每种设备配以相应的中断处理程序,并且将对应的中断处理程序的地址放入中断向量表中的一个表项中,当中断信号发来时,由中断控制器来确定中断号,再在中断向量表中找到相应的处理程序的地址,执行中断处理程序。
中断优先级
实际处理的过程中,经常会有很多中断的信号源所以系统会规定相应的中断优先级,对于不同优先级的中断信号同时发来时主要有两种处理方式:
屏蔽中断,当处理器在处理一个中断的时候,会屏蔽掉其他的所有中断。直到处理器完成当前中断。
嵌套中断,当处理器在处理中断的时候遇到优先级更高的中断,高优先级可以抢占低优先级的资源。优先处理高优先的中断。
中断处理程序
- 测定是否有未响应的中断信号,若有未处理的中断信号则先停止当前程序去转而执行中断处理程序。
- 保护被中断进程的CPU环境,再把处理器的控制权交给中断处理程序的时候,需要先保存被中断的CPU环境,以便以后能够恢复运行。首先保存的是,从中断现场恢复到当前进程所需要的信息,通常由硬件自动将处理器状态字和保存在程序指令计数器中下一条指令的地址保存在中断保留区中,然后把被中断的CPU的现场信息的内容都压入中断栈中。
- 转入相应的设备处理程序。由处理器对各个中断源进行测试,确定引起本次中断的I/O程序,并且向中断信号的设备发送确认信息,再收到确认信号以后就立刻取消中断信号,再将相应设备的中断处理程序装入到程序计数器中。
- 中断处理
- 恢复CPU的现场,是否返回中断现场取决于两个因素
- 是否采用了屏蔽中断,如果是屏蔽中断则直接返回CPU中断现场。
- 如果是嵌套式如果没有优先级更高的中断则返回CPU中断现场,如果有则执行优先级更高的中断处理。
用户在设计中断服务程序时要预先确定一个中断类型号,不论是硬件中断还是软件中断,都只能在系统预留给用户的类型号中选择
确定中断类型号之后还要把中断服务程序入口地址置入中断向量表,以确保在中断响应时CPU能自动转入该类型号相对应的中断服务程序。
保护现场和恢复现场
保护现场简单来说就是,寄存器、堆栈、压栈。
保护现场就是当出现中断时,把CPU的状态,也就是当前程序地址保存在寄存器中,其实就是保存中断前一时刻的状态不被破坏。
CPU保护现场做如下动作:
- 将标志寄存器内容压入堆栈,以保护中断时的状态;
- 将IF和TF标志清0,目的是防止在中断响应的同时又来别的中断,而将TF清0是为了防止CPU以单步方式执行中断处理子程序。特别提醒,因为CPU在中断响应时自动关闭了IF标志,因此用户如要进行中断嵌套时,必须在自己的中断处理子程序中用开中断指令来重新设置IF;
- 保护断点,断点指的是在响应中断时,主程序当前指令下面的一条指令的地址。因此保护断点的动作就是将当前的IP和CS的内容入栈,保护断点是为了以后正确地返回主程序;
保护现场应该包括保护程序断点和保护CPU内部各寄存器内容的现场两个方面
主程序和中断服务子程序都要使用CPU内部寄存器等资源,为使中断处理程序不破坏主程序中寄存器的内容,应先将断点处各寄存器的内容压入堆栈保护起来,再进入中断。
恢复现场简单来说就是,寄存器、堆栈、出栈。
恢复现场就是指将各寄存器和指针恢复到中断前的状态。
CPU恢复现场做如下动作:
- 恢复断点,断点指的是在响应中断时,原程序当前指令下面的一条指令的地址。因此恢复断点的动作就是将先前的指针和寄存器的内容出栈,即恢复原程序断点处寄存器的原值;
- 将IF和TF标志置1,允许接收新的中断;
中断处理程序的注意事项
不可以执行耗时的任务
中断会关闭调度,中断的优先级高于任何任务的优先级,长时间的中断处理会影响到系统的响应速度,使整个系统的任务无法政策运行,造成很多的任务超时。对于一些必须的操作可以放在中断中处理,其他的以异步执行的方式,放到中断底半部中解决。
不可以睡眠或者放弃CPU
中断处理程序中不可以使用可能引起睡眠的函数和锁。
不可以使用metux等引起休眠的锁。可以使用自旋锁,但必须保证自旋等待时间足够短。
不可以使用会引起休眠的函数。比如ssleep(), msleep(), kmalloc, copy_to_user(), opy_from_user() 等。中断是不可重入函数
同一时刻同一中断只能在一个CPU上被触发,中断被响应后会被关闭该中断,相同的中断处理函数不能同时在多个处理器上运行。
中断嵌套
当CPU响应某一中断时,若有优先权高的中断源发出中断请求,则CPU能中断正在进行的中断服务程序,并保留这个程序的断点(类似于子程序嵌套),响应高级中断,高级中断处理结束以后,再继续进行被中断的中断服务程序,这个过程称为中断嵌套。如果发出新的中断请求的中断源的优先权级别与正在处理的中断源同级或更低时,CPU不会响应这个中断请求,直至正在处理的中断服务程序执行完以后才能去处理新的中断请求。
中断与轮询的区别
中断(Interrupt)
中断就是由硬件或者软件发出的一种IRQ(中断信号),一旦CPU接收到中断信号,CPU就会暂停当前执行的任务,并且保留现场,去响应外设的中断请求。
中断通知机制通过硬件信号异步唤起处理器的注意,解决了外部设备与处理器之间速度不匹配导致的资源浪费问题。
轮询(Polling)
很多I/O设备都有一个状态寄存器,用于描述设备当前的工作状态,每当设备状态发生改变时,设备将修改相应状态寄存器位。通过不断查询设备的状态寄存器,CPU就可以了解设备的状态,从而进行必要的I/O操作。为了节约CPU资源,查询工作往往不是连续的,而是定时进行。
轮询方式具有简单、易实现、易控制等优势,在很多小型系统中有大量的应用。对那些敏感度不高、具有大量CPU资源的系统来说,轮询方式有很广泛的应用。
缺点:
- 增加系统开销。无论是任务轮询还是定时器轮询都需要消耗对应的系统资源。
- 无法及时感知设备状态变化。在轮询间隔内的设备状态变化只有在下次轮询时才能被发现,这将无法满足对实时性敏感的应用市场。
- 浪费CPU资源。无论是设备是否发生状态改变,轮询总在进行,在实际情况中,大多数设备的状态改变通常不会那么频繁,轮询将白白浪费CPU时间片。
中断与轮询的区别
轮询方式存在空转损耗,它是可控并且实时的。消耗大量cpu的处理时间,周期连续检测外部事件的发生。
中断的高优先级和快速响应要求在极端条件下将造成“活锁”效应。各种各样的输入输出设备通过中断处理方式进行并行操作,使中断次数增加,会造成CPU无法响应中断;如果在缓冲区装满数据之后发生中断。那么在数据传送过程中,发生中断的机会较多,将耗去大量的CPU处理时间。
中断不是协议,而是一种硬件机制;轮询反之。
处理器在每个指令周期都会去查看中断寄存器,如果中断寄存器有效,也就是发生了中断,那么cpu会执行一系列与中断相关的操作。也就是说中断也是需要CPU check。中断和轮询并不是完全相反。
Linux常用命令
chmod
用来改变用户对文件的权限,函数原型如下:
chmod mode file
Linux的文件调用权限分为三级 : 文件所有者(Owner)、用户组(Group)、其它用户(Other Users)。
mode可以设定为字符串 [ugoa] [+ - =] [r w x]
其中[ugoa]代表的是
u(owner):表示文件所有者,即创建文件的人
g(group):表示和文件所有者相同组的用户
o(other):表示非文件所有者和相同group的用户
a(all):表示所有用户
[+ - =]表示
+:表示给指定的用户授权指定的权限
- : 表示撤销指定用户的某个权限
=: 将指定用户的指定权限重新设置
[r w x]表示
r:可读权限
w:可写权限
x:可执行权限
此外,chmod可以用三个八进制数来表示三类用户对文件的操作权限,八进制数可以转变为一个三位二进制数,从左到右每一位分别代表了 r w x 权限。如6(4+2)代表有读写权,7(4+2+1)有读、写和执行的权限.具体使用如下:
$ chmod u+x file //给file的属主增加执行权限
$ chmod 751 file //给file的属主分配读写执行(7)的权限,给file的所在组分配读执行(5)的权限,给其他用户分配执行(1)的权限
$ chmod u=rwx,g=rx,o=x file //上例的另一种形式
$ chmod =r file //为所有用户分配读权限
$ chmod 444 file //同上例
$ chmod a-wx,a+r file //同上例
$ chmod -R u+r directory //递归地给directory目录下所有文件和子目录的属主分配读的权限
fcntl
用于改变已经打开的文件的属性
int fcntl(int fd, int cmd, … /* arg */ );
若cmd为F_DUPFD, 复制文件描述符, 与dup相同
若cmd为F_GETFL, 获取文件描述符的flag属性值
若cmd为 F_SETFL, 设置文件描述符的flag属性
函数返回值:返回值取决于cmd
成功:
若cmd为F_DUPFD, 返回一个新的文件描述符
若cmd为F_GETFL, 返回文件描述符的flags值
若cmd为 F_SETFL, 返回0失败: 返回-1, 并设置errno值.
int newfd = fcntl(fd, F_DUPFD, 0); //复制一个新的文件描述符
int flag = fcntl(fd, F_GETFL, 0); //获取文件的属性标志
flag = flag | O_APPEND; //设置文件状态标志
fcntl(fd, F_SETFL, flag);
netstat
netstat是一个控制台命令,可用于监控本机的TCP/IP网络,获得路由表、网络连接以及所有网络接口设备的状态信息。
netstat [-参数] [-A<网络类型>] [--ip]
一些关键的参数
-a, –all:显示所有 socket 连接,默认显示已连接的。
-A<网络类型>, 列出该网络类型连线中的相关地址。
-t, –tcp:仅显示 TCP 相关。
-u, –udp:仅显示 UDP 相关。
-p, –programs:显示建立 socket 连接的进程 ID 和程序名。
-n, –numeric:不解析别名,能显示数字的全部转为数字,例如 IP 和 Port。
-l, –listening:仅显示在监听(Listening)的 socket 服务。
-r, –toute:显示路由表。
-e, –extend:显示更多扩展信息。
-s, –statistics:按各个协议展示网络统计信息。
-c, –continuous:继续监听,即每隔一段时间执行一次 netstat 命令。
常用命令
查看所有监听状态的 TCP 相关,并打印相关的程序名
[root@chenpihost ~]# netstat -nltp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:6379 0.0.0.0:* LISTEN 1449/redis-server *
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1070/sshd
tcp 0 0 127.0.0.1:25 0.0.0.0:* LISTEN 1171/master
tcp6 0 0 :::6379 :::* LISTEN 1449/redis-server *
tcp6 0 0 :::22 :::* LISTEN 1070/sshd
tcp6 0 0 ::1:25 :::* LISTEN 1171/master
ps
进程查看命令(显示瞬间进程,并不动态连续),如果要实现实时监控应该使用下面的top命令。
ps [options]
-A 所有的进程均显示出来
-a 显示现行终端机下的所有进程
a 包括其他用户的进程(不论进程所属人员)
-N 反向选择
r 显示当前终端的进程
T 显示当前终端的所有程序
u 显示用户的所有进程
-au 显示较详细的资讯
-aux 显示所有包含其他使用者的行程
使用实例
# ps -A 显示所有进程信息
# ps -u root 显示指定用户信息
# ps -ef 显示所有进程信息,连同命令行
# ps -ef | grep ssh 查找特定进程
# ps -l 将目前属于您自己这次登入的 PID 与相关信息列示出来
# ps aux 列出目前所有的正在内存当中的程序
# ps -axjf 列出类似程序树的程序显示
# ps aux | egrep '(cron|syslog)' 找出与 cron 与 syslog 这两个服务有关的 PID 号码
# ps -aux | more 可以用 | 管道和 more 连接起来分页查看
# ps -aux > ps001.txt 把所有进程显示出来,并输出到ps001.txt文件
# ps -o pid,ppid,pgrp,session,tpgid,comm 输出指定的字段
top
能够实时显示系统各个进程的资源占用情况,top命令监控的最小单位是进程
top [命令参数]
-c 显示完整的命令路径
-d <时间> 设置间隔时间
-u <用户名> 指定用户名
-p <进程号> 指定进程
-n <次数> 循环显示的次数
# 每隔5秒显式所有进程的资源占用情况
top
# 每隔2秒显式所有进程的资源占用情况
top -d 2
# 显示进程的命令行参数(默认只有进程名)
top -c
# 每隔5秒显示pid是12345和pid是6789的两个进程的资源占用情况
top -p 12345 -p 6789
# 每隔2秒显示pid是12345的进程的资源使用情况,并显式该进程启动的命令行参数
top -d 2 -c -p 123456
# 仅显示用户为root的进程
top -u root
GDB调试
基本操作
#通过-g选项,生成具备调试信息的可执行文件
gcc a.c -o a.out -g
#启动和退出
gdb xxx.out(可执行文件名)
quit
#给程序设置参数、获取参数
set args 10 20
show args
#GDB使用帮助
help
#查看当前文件代码
list/l (从默认位置显示)
list/l 行号 (从指定行显示)
list/l 函数名 (从指定函数显示)
#查看非当前文件代码
list/l 文件名:行号
list/l 文件名:函数名
#设置显示的行数
show list/listsize
set list/listsize 行数
断点操作
#设置断点
b/break 行号
b/break 函数
b/break 文件名:行号
b/break 文件名:函数
#查看断点
info break
i b
#删除断点
d/del/delete
#失能/使能断点
dis/disable 断点编号
ena/enable 断点编号
#设置条件断点(一般用于循环)
b/break 10 if i==5
调试操作
#运行GDB程序
start(程序停在入口函数第一行,等待后续的操作)
run(遇到断点才停)
#继续运行,到下一个断点停
c/continue
#向下执行一行代码(不会进入函数体)
n/next
#变量操作
p/print 变量名(打印变量值)
ptype 变量名(打印变量类型)
#向下单步调试(遇到函数进入函数体)
s/step
finish(跳出函数体)
#自动变量操作
display num(自动打印指定变量的值)
i/info display
undisplay 编号
#其他操作
set var 变量名=变量值
until (跳出循环)