- 存储器:内存
- 控制器:南桥北桥
- 运算器:CPU
- 输入设备:键盘
- 输出设备:显示器、网卡
直接映射、全相连、组相连、
内存访问的时候根据目录表找对应cache映射区域,并有效位判断对比,如果cache 中对应位置是要的数据且有效,就不去内存找了;
- 优点:命中率比较高,Cache存储空间利用率高。
- 缺点:访问相关存储器时,每次都要与全部内容比较,速度低,成本高,因而应用少。
内存访问的时候根据区号对比,如果cache 中对应区号位置是要的数据,就不去内存找了;
- 优点:地址映象方式简单,数据访问时,只需检查区号是否相等即可,因而可以得到比较快的访问速度,硬件设备简单。
- 缺点:替换操作频繁,命中率比较低。(如果程序同时用到了对应于同一个cache行中的两个主存地址,那么就会发生冲突,结果就是导致这个cache行不停的进行替换操作。引入组相连)
例如:
主存中的第0块、第8块……均映射于Cache的第0组,但可映射到Cache第0组中的第0块或第1块;主存中的第1块、第9块……均映射于Cache的第1组,但可映射到Cache第1组中的第2块或第3块。
例如:(内循环大,外循环小;二维数组顺序访问,按照行访问而不是按照列来)这样会更快;
- 写直达:数据同时写入内存和cache;(简单、费时)
- 写回:新数据写进cache block;当修改过的cache block 要被替换的时候,才写回内存;(缓存命中率高的情况下,性能提升较多)
两个核心的两级缓存操作一个变量的时候,造成不一致,导致执行结果的错误;
- 写传播:写了数据,通知其他核心;
- 事务串行化:对数据操作,同步给其他核心;锁机制;
- 包括:网络收发、定时、调度、RCU锁等;
会打断CPU运行过程,来立即响应中断处理程序;
包括:网络接收中断、网络发送中断、定时中断、内核调度中断等;
软中断不会打断CPU,是以内核线程的方式运行;
内核通常提供了4个基本功能:
- 管理进程、线程,负责调度;
- 管理内存;
- 管理硬件设备,为进程和硬件设备间提供通信能力;
- 提供系统调用,使得引用程序运行更高权限的服务;(是应用程序与操作系统之间的接口)
- 宏内核意味着:内核是一个完整的可执行程序,且拥有最高权限;
- 宏内核特征是:系统内核所有模块都运行在内核态;(进程调度、内存管理、文件系统、设备驱动等)
- 与之相对的是微内核:内核中保留基本能力(进程调度、虚拟内存、中断等),其他的放在用户空间(驱动程序、文件系统等),这样使得服务间隔离,单个服务故障不会引起系统挂掉;提高了稳定性与可靠性;
- 还有混合内核(windows),是宏内核与微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有一个小型内核,其他模块在此基础上搭建,整个内核是个完整的程序;
- 单片机不跑系统使得CPU 直接操作内存物理地址;
- 操作系统提供虚拟内存机制,使得不同进程虚拟地址映射到不同的物理地址上;
- cpu中有MMU(内存管理单元)来将虚拟内存映射为物理地址;
- 程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(*Segmentation*)的形式把这些段分离出来。
- 虚拟地址是通过段表与物理地址进行映射的;
- 分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址
- 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
- 第一个就是内存碎片的问题。(外碎片)
- 第二个就是内存交换的效率低的问题。
这里的内存碎片的问题共有两处地方:
- 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
- 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
- 解决外部内存碎片的问题就是内存交换。(将内存写入硬盘,再读回来,读回来紧贴之前的,使得外部碎片集中变成能用的内存空间)
- 分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为
4KB
。- 虚拟地址与物理地址之间通过页表来映射
- 页表是存储在内存中的,MMU 作为将虚拟内存转换成为物理地址的工具;
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页是怎么解决分段的内存碎片、内存交换效率低的问题?
- 由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。。
- 而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
- 如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
- 在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,
- 32位环境,4GB 空间,页表4KB,需要1M个表项,一个表4B,就需要4MB空间存表;
- 采用分级页表例如二级页表,1M个表,用1K空间映射;(平时不需要那么多空间都被用到,所以只有部分二级页表被创建)
- 多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
- 程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
- 利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
段页式内存管理实现的方式:(先分段再分页;将逻辑空间按照段式管理,段内页式管理)
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
- 这样,地址结构就由段号、段内页号和页内位移三部分组成。
- 用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号
- 段页式地址变换中要得到物理地址须经过三次内存访问:
- 第一次访问段表,得到页表起始地址;
- 第二次访问页表,得到物理页号;
- 第三次将物理页号与页内位移组合,得到物理地址。
- 可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
进程 | 线程 | 协程 | |
---|---|---|---|
定义 | 资源分配和拥有的基本单位 | 程序执行的基本单位 | 用户态的轻量级线程,线程内部调度的基本单位 |
切换情况 | 进程CPU环境(栈、寄存器、页表和文件句柄等)的保存以及新调度的进程CPU环境的设置 | 保存和设置程序计数器、少量寄存器和栈的内容 | 先将寄存器上下文和栈保存,等切换回来的时候再进行恢复 |
切换者 | 操作系统 | 操作系统 | 用户 |
切换过程 | 用户态->内核态->用户态 | 用户态->内核态->用户态 | 用户态(没有陷入内核) |
调用栈 | 内核栈 | 内核栈 | 用户栈 |
拥有资源 | CPU资源、内存资源、文件资源和句柄等 | 程序计数器、寄存器、栈和状态字 | 拥有自己的寄存器上下文和栈 |
并发性 | 不同进程之间切换实现并发,各自占有CPU实现并行 | 一个进程内部的多个线程并发执行 | 同一时间只能执行一个协程,而其他协程处于休眠状态,适合对任务进行分时处理 |
系统开销 | 切换虚拟地址空间,切换内核栈和硬件上下文,CPU高速缓存失效、页表切换,开销很大 | 切换时只需保存和设置少量寄存器内容,因此开销很小 | 直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快 |
通信方面 | 进程间通信需要借助操作系统 | 线程间可以直接读写进程数据段(如全局变量)来进行通信 | 共享内存、消息队列 |
在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
另外,还有一个状态叫挂起状态,它表示进程没有占有物理内存空间。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。(挂起包括了阻塞挂起、就绪挂起)
由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起,或者用户要其挂起;
- 进程描述信息:
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
- 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;
- 进程控制和管理信息:
- 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
- 进程优先级:进程抢占 CPU 时的优先级;
- 资源分配清单:
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
- CPU 相关信息:
- CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。
- 01 创建进程
- 操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程时同时也会终止其所有的子进程。
- 创建进程的过程如下:
- 为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB,PCB 是有限的,若申请失败则创建失败;
- 为进程分配资源,此处如果资源不足,进程就会进入等待状态,以等待资源;
- 初始化 PCB;
- 如果进程的调度队列能够接纳新进程,那就将进程插入到就绪队列,等待被调度运行;
- 02 终止进程
- 进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号
kill
掉)。
- 终止进程的过程如下:
- 查找需要终止的进程的 PCB;
- 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
- 如果其还有子进程,则应将其所有子进程终止;
- 将该进程所拥有的全部资源都归还给父进程或操作系统;
- 将其从 PCB 所在队列中删除;
- 03 阻塞进程
- 当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
- 阻塞进程的过程如下:
- 找到将要被阻塞进程标识号对应的 PCB;
- 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
- 将该 PCB 插入的阻塞队列中去;
04 唤醒进程- 进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
- 如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
- 唤醒进程的过程如下:
- 在该事件的阻塞队列中找到相应进程的 PCB;
- 将其从阻塞队列中移出,并置其状态为就绪状态;
- 把该 PCB 插入到就绪队列中,等待调度程序调度;
- 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
fork
后子进程返回pid 0;整个进程空间复制自父进程;但是有写时复制(COW,读时共享,写时复制),也就是不适用的时候其实没有拷贝;基本上fork 结束都会调用exec
,去执行其他的代码;exec
子进程将会跳转;代码段、堆栈、都会被重新覆盖;pid不会变;exec
可能引起进程状态的变化,因为覆盖需要时间,可能阻塞了vfork
也是一个创建进程的系统调用,不需要创建同样的内存映像;调用完毕几乎立即调用exec
跳转;但是用到了 COW (copy on write)以后很少用了;
wait
系统调用,用于父进程等待子进程的结束;睡眠父进程,用于回收子进程所有资源;
子进程已经exit ,但是父进程还没有wait 来回收,子进程就是僵尸进程状态;(该死了,没死,啥事都没干的状态)
- CPU 寄存器和程序计数器是CPU在运行程序前所必须依赖的环境,这些环境就是CPU 上下文;
- CPU上下文切换就是把一个任务的 cpu上的下文保存起来,然后加载新的任务的上下文>* 到这些寄存器和程序计数器中,然后跳转pc 的位置执行新任务;
- 任务包括了:进程、线程、中断;
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行;
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
fork
创建的新进程被称为子进程(child process)。- 该函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值则是新进程(子进程)的进程 id。
- 将子进程id返回给父进程的理由是:因为一个进程的子进程可以多于一个,没有一个函数使一个进程可以获得其所有子进程的进程id。
- 对子进程来说,之所以fork返回0给它,是因为它随时可以调用getpid()来获取自己的pid;也可以调用getppid()来获取父进程的id。(进程id 0总是由交换进程使用,所以一个子进程的进程id不可能为0 )。
- fork之后,操作系统会复制一个与父进程完全相同的子进程,虽说是父子关系,但是在操作系统看来,他们更像兄弟关系,这2个进程共享代码空间,但是数据空间是互相独立的,子进程数据空间中的内容是父进程的完整拷贝,指令指针也完全相同,子进程拥有父进程当前运行到的位置(两进程的程序计数器pc值相同,也就是说,子进程是从fork返回处开始执行的);
- 但有一点不同,如果fork成功,子进程中fork的返回值是0,父进程中fork的返回值是子进程的进程号,如果fork不成功,父进程会返回错误。
- 进程的资格(真实(real)/有效(effective)/已保存(saved)用户号(UIDs)和组号(GIDs))
- 环境(environment)
- 堆栈
- 内存
- 进程组号
- 进程号;
- 不同的父进程号(译者注:即子进程的父进程号与父进程的父进程号不同, 父进程号可由getppid函数得到);
- 资源使用(resource utilizations)设定为0
- 进程组就是多个进程的集合,其中肯定有一个组长,其进程PID等于进程组的PGID。只要在某个进程组中一个进程存在,该进程组就存在,这与其组长进程是否终止无关。
- shell分前后台来控制的不是进程而是作业(job)或者进程组(Process Group)。
- 一个前台作业可以由多个进程组成,一个后台也可以由多个进程组成,shell可以运行一个前台作业和任意多个后台作业,这称为作业控制
- 答:当我们在前台新起了一个作业,shell就被提到了后台,因此shell就没有办法再继续接受我们的指令并且解析运行了。 但是如果前台进程退出了,shell就会有被提到前台来,就可以继续接受我们的命令并且解析运行。
- 作业与进程组的区别:如果作业中的某个进程有创建了子进程,则该子进程是不属于该作业的。
一旦作业运行结束,shell就把自己提到前台(子进程还存在,但是子进程不属于作业),如果原来的前台进程还存在(这个子进程还没有终止),他将自动变为后台进程组
- 会话(Session)是一个或多个进程组的集合。一个会话可以有一个控制终端。在xshell或者WinSCP中打开一个窗口就是新建一个会话。
- 就绪状态(ready):等待被调度
- 运行状态(running)
- 阻塞状态(waiting):等待资源
应该注意以下内容:
- 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
- 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。
- 如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程。(注:任何一个进程都必须有父进程)。
- 一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
- 如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。
- 设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。
- 如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将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进程去处理,省去了大量僵尸进程占用系统资源。
线程标识符
一组寄存器
通用寄存器
程序计数器PC
状态寄存器
线程运行状态
优先级
线程专有存储区
信号屏蔽
堆栈指针
进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。
进程的状态,有就绪、运行、挂起、停止等状态。
进程切换时需要保存和恢复的一些CPU寄存器。
描述虚拟地址空间的信息。
描述控制终端的信息。
当前工作目录(Current Working Directory)。
umask掩码。
文件描述符表,包含很多指向file结构体的指针。
和信号相关的信息。
用户id和组id。
会话(Session)和进程组。
进程可以使用的资源上限(Resource Limit)。
- 单进程会有使用效率问题;多进程会由通信问题、切换开销大的问题;
- 多线程:线程之间可以并发运行、线程之间共享相同地址空间;
- 同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源;
- 但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。
- 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 同一个进程内的线程切换比进程切换快,
因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
- 线程启动速度快,轻量级
- 线程的系统开销小
- 线程使用有一定难度,需要处理数据一致性问题
- 同一线程共享的有堆、全局变量、静态变量、指针,引用、文件等;
线程独自占有栈、标识线程的tid;线程彼此之间是无法访问其他线程栈上内容的。
- 作为处理机调度的最小单位,线程调度只需要保存线程栈、寄存器数据和PC即可,相比进程切换开销要小很多。
- 每一个进程是资源分配的基本单位。
- 进程结构:代码段、数据段、堆栈段。代码段是静态的二进制代码,多个程序可以共享。
- 实际上在父进程创建子进程之后,父、子进程除了pid外,几乎所有的部分几乎一样。(拷贝)
- 父、子进程共享全部数据,但并不是说他们就是对同一块数据进行操作,子进程在读写数据时会通过写时复制机制将公共的数据重新拷贝一份,之后在拷贝出的数据上进行操作。
- 如果子进程想要运行自己的代码段,还可以通过调用execv()函数重新加载新的代码段,之后就和父进程独立开了。
- 我们在
shell
中执行程序就是通过shell进程先fork()一个子进程再通过execv()重新加载新的代码段的过程。
- 进程有两种创建方式,一种是操作系统创建的一种是父进程创建的。
- 从计算机启动到终端执行程序的过程为:0号进程 -> 1号内核进程 -> 1号用户进程(init进程) -> getty进程 -> shell进程 -> 命令行执行进程。
- 所以我们在命令行中通过 ./program执行可执行文件时,所有创建的进程都是shell进程的子进程,这也就是为什么shell一关闭,在shell中执行的进程都自动被关闭的原因。
- 从shell进程到创建其他子进程需要通过以下接口。
- 创建进程:
pid_t fork(void);
- 结束进程:
void exit(int status);
- 获得PID、父进程PID :
pid_t getpid(void);
、pid_t getppid(void);
- 正常退出方式:exit()、_exit()、return ;(exit()是对_exit()的封装,exit()会在调用_exit()函数前刷新数据流)
- 异常退出方式:abort()、终止信号。
- 频繁修改:需要频繁创建和销毁的优先使用多线程
- 计算量:需要大量计算的优先使用多线程 因为需要消耗大量CPU资源且切换频繁,所以>多线程好一点
- 相关性:任务间相关性比较强的用多线程,相关性比较弱的用多进程。因为线程之间的数据共享和同步比较简单。
- 多分布:可能要扩展到多机分布的用多进程,多核分布的用多线程。
但是实际中更常见的是进程加线程的结合方式,并不是非此即彼的。
- 进程中,用户空间独立、内核空间每个进程共享;所以通信需要通过内核;
- 所有进程的内核栈都在内核空间,每个进程都有自己单独的内核栈,它们共享的是内核地址空间(在内核态时可以使用内核接口)。由于进程数量很多,所以这也是内核栈比较小的原因。
- 就进程而言,自己独享整个虚拟空间;只是用户态不能进入内核态空间操作;
- 消息传递可以是阻塞或者非阻塞;还可分成直接通信与间接通信;
- 阻塞认为是同步的;
- 非阻塞认为是异步的;
通信方式 | 简介 | 特点 |
---|---|---|
信号 | 异常工作模式可以使用;包括硬、软件来源;异步通信机制,是一种软件中断;软件中断通知事件处理 | 只能传送少量信号用来通知,并不适用于数据交换;(异步打断) |
管道 | 简单、半双工、效率低;可以得知有没有被别的进程读取;内核中的fifo缓存实现;生命周期随进程; | 一般限制于父子进程 |
消息队列 | 通信不及时、大小受限、用户态与内核态切换开销;内核中消息链表实现;生命周期随内核; | 相对于管道,能传输有结构的数据流; |
共享内存 | 通过虚拟内存映射到同一块物理内存中;使用信号量等配合同步和通信 | 大量数据交互;需要保证数据同步; |
套接字 | 可跨主机通信;域套接字用本地socket文件; | 可跨主机进程通信 |
- 信号是一种比较复杂的通信方式,用于通知接受进程进程某个事件已经发生;
- 包括了中断与中断处理函数(handle);
- (注册中断函数、产生中断(进入内核态)(产生中断后,修改用户进程的堆栈,使其进入handle 后返回原断点),进入中断处理函数)
例如:
ls | more
:就是shell进程
创建一个管道、创建两个子进程ls 进程
和more 进程
;- 用连接
ls 进程
和more 进程
;- shell进程将 ls 的输出重定向到 内核中的管道buffer(内存)中;
- shell进程将 more 进程的输入重定向到内核中的管道buffer(内存)中;
- 所访问的管道也是一种共享的资源,需要加锁之类的;(管道是同步、互斥的吧)
- 管道传输的buffer 是有限的,满了就会阻塞;另一个进程会sleep;
- 管道效率低、不适合进程间频繁交换数据;但是简单,同时能知道数据被另一个进程接受了;(因为写管道会阻塞)
- 管道只是内核中的一串缓存,读写管道就是对内核中一段数据的读写;
- 无名管道的API :
int pipe(int fd[2]);
两个fd (读、写);- 有名管道的API:
int mkfifo(const char *pathname, mode_t mode);
创建管道文件,来通信;
- 消息队列是基于消息的,而管道是基于字节流的;且消息队列不需要顺序收取,只要不删除,就一直在;
- 消息队列中的message 作为一个字节序列存储;是一种结构化数据;
- 消息队列由IPC 标识符所唯一标识,不受进程生命周期而存在;
- 消息队列传输的buffer 是有限的,满了就会阻塞;另一个进程会sleep;
- 消息队列是保存在内核中的消息链表。按照消息体(数据块)链接而成;
- 消息队列通信时,存在用户态-内核态之间的数据拷贝;(用户数据拷贝内核消息队列中,另一边反向拷贝回去)
- 异步 : 将串行处理的事情并行化。自己的处理完了,消息分发出去后就不用管了;
- 解耦 : 将消息队列分发,使得多个系统并发。就是将多个业务处理的解耦;(就像下单后需要增减优惠券、增减积分、短信… ,只需要消息发送,多个系统并发执行即可)
- 削峰 : 高峰时,将请求放入队列中,慢慢处理,比直接硬交互好很多;
- 系统复杂性提高;(重复消费、消息丢失、消息顺序消费等)
- 数据一致性问题;(不能保证其他消息的成功与否)(解耦后的缺陷)
- 可用性;
- 相比于 FIFO,消息队列具有以下优点:(解耦、提速(异步)、广播、削峰)
- 消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;(相当于对处理与通信的解耦)
- 避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;
- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。
- 也有自己的缺点:
- 通信不及时、消息大小受限;
- 其他的都是间接通信方式;共享内存是一种直接通信方式;
- 两个进程有一个特殊的共享内存空间;明确共享内存段;
- 共享内存,实现采用的是引用计数的原理;进程脱离存储区,即计数值–;计数值无了,就销毁;
- 创建共享内存:
int shmget(key_t key, int size, int flag);
- 成功时返回一个和key相关的共享内存标识符,失败范湖范围-1。
- key:为共享内存段命名,多个共享同一片内存的进程使用同一个key。
- size:共享内存容量。
- flag:权限标志位,和open的mode参数一样。
- 连接到共享内存地址空间:
void *shmat(int shmid, void *addr, int flag);
- 返回值即共享内存实际地址。
- shmid:shmget()返回的标识。
- addr:决定以什么方式连接地址。
- flag:访问模式。
- 从共享内存分离:int shmdt(const void *shmaddr);
- 调用成功返回0,失败返回-1。
- shmaddr:是shmat()返回的地址指针。
注意:
- 共享内存的方式像极了多线程中线程对全局变量的访问,大家都对等地有权去修改这块内存的值,这就导致在多进程并发下,最终结果是不可预期的。所以对这块临界区的访问需要通过信号量来进行进程同步。
- 但共享内存的优势也很明显,首先可以通过共享内存进行通信的进程不需要像无名管道一样需要通信的进程间有亲缘关系。其次内存共享的速度也比较快,不存在读取文件、消息传递等过程,只需要到相应映射到的内存地址直接读写数据即可。
socket
- tcp、ip协议栈都实现在内核态中;
线程同步手段:
- 信号量:用于线程、进程的同步和互斥;(可以互斥-1、可以同步-0)
- 锁:互斥锁、读写锁、自旋锁;(只能互斥)
- 信号:类似进程间信号处理;
- 条件变量:使用通知的方式解锁;与互斥锁配合使用。(wait 在没获得的时候,会解锁然后睡眠)
- windows 下有类似时间cEvent:用于线程同步
- 互斥:保证一个线程在临界区执行时,其他线程应该被阻止进入临界区;(多线程、多进程都会用到;
- 同步:并发进程、线程可能需要互相等待、互通信息,存在访问顺序问题;
- 根据锁的实现不同,分为:忙等待锁(自旋锁)、无忙等待锁;
key_t ftok(const char *pathname, int proj_id);
通过文件、编号;确定唯一IPC 标志;- ftok返回的是根据文件(pathname)信息和计划编号(proj_id)合成的IPC key键值,从而避免用户使用key值的冲突。proj_id值的意义让一个文件也能生成多个IPC key键值。
CreateSemaphore()
创建一个信号量OpenSemaphore()
打开一个已经创建的信号量 PReleaseSemaphore()
释放对信号量的所有权WaitForSingleObject();
V
- 多线程同步的信号量是POSIX信号量,而在进程里使用SYSTEM V信号量。
- Posix 信号量与System v信号量的区别
- 从使用上来说,互斥锁的lock和unlock必须在同一个线程;
- 而信号量的wait和signal可以在不同的线程;
#include
#include
class semaphore {
public:
semaphore(long count = 0) :count(count) {
}
void wait() {
std::unique_lock<std::mutex>lock(mx);
cond.wait(lock, [&]() {
return count > 0; });
--count;
}
void signal() {
std::unique_lock<std::mutex>lock(mx);
++count;
cond.notify_one();
}
private:
std::mutex mx;
std::condition_variable cond;
long count;
};
- POSIX信号量:可用于进程同步,也可用于线程同步。
- POSIX互斥锁 + 条件变量:只能用于线程同步。
线程和进程的区别?
- 调度:线程是调度的基本单位**(PC,状态码,通用寄存器,线程栈及栈指针);进程是拥有资源的基本单位(打开文件,堆,静态区,代码段等**)。
- 并发性:一个进程内多个线程可以并发(最好和CPU核数相等);多个进程可以并发。
- 拥有资源:线程不拥有系统资源,但一个进程的多个线程可以共享隶属进程的资源;进程是拥有资源的独立单位。
- 系统开销:线程创建销毁只需要处理PC值,状态码,通用寄存器值,线程栈及栈指针即可;进程创建和销毁需要重新分配及销毁task_struct结构。
资源互斥: 多个线程不能使用同一个资源;
请求保持: 线程A等待另一资源的同时,并不会释放已经持有的资源;
不可剥夺:持有的资源,在使用完之前不能被其他线程获取;
环路等待:两个线程获取资源的顺序形成了环形链;
- 不需要互斥访问、采取缓存给一个进程专门控制资源;
- 申请所有资源才运行进程;
- 得不到资源就释放占用的资源;
- 资源线性获取(顺序加锁);(常用)
//定义两把锁
mutex m_mutex1;
mutex m_mutex2;
int A = 0, B = 0;
//线程1
void threadFunc1()
{
printf("thread 1 running..\n");
m_mutex1.lock();
A = 1;
printf("thread 1 write source A\n");
Sleep(100);
m_mutex2.lock();
B = 1;
printf("thread 1 write source B\n");
//解锁,实际上是跑不到这里的,因为前面已经死锁了
m_mutex2.unlock();
m_mutex1.unlock();
}
//线程2
void threadFunc2()
{
printf("thread 2 running..\n");
m_mutex2.lock();
B = 1;
printf("thread 2 write source B\n");
Sleep(100);
m_mutex1.lock();
A = 1;
printf("thread 2 write source A\n");
m_mutex1.unlock();
m_mutex2.unlock();
}
int main()
{
thread outMsg(threadFunc1);
thread inMsg(threadFunc2);
inMsg.join();
outMsg.join();
return 0;
}
高级的锁基本都是选择互斥锁、自旋锁来实现;(如读写锁)
互斥锁加锁失败后,线程会释放CPU;加锁的代码会被阻塞;(由OS内核实现的;加锁失败后,内核将线程置为睡眠状态)
- 自旋锁基于 CPU提供的CAS 函数(compare and swap)(原子指令,硬件级指令);在用户态实现加锁、解锁;不产生线程上下文切换;
- 忙等待可以使用 CPU 提供的
PAUSE
指令;
读写锁:包括了读优先锁、写优先锁;
读优先锁: 读锁能被更多线程持有;读线程持有读锁,写线程会被阻塞,同时其他读线程还可获取读锁。直到读线程都释放锁以后,才能写;
写优先锁:读线程就算先锁住,写线程获取写锁后,后面的读锁不能再获取读锁,之前的读锁都释放了,写线程就开始写;
公平读写锁:队列将获取锁的线程排队,读写都按先进先出加锁;
- 多个读者可以同时进行读
- 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
- 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
- 一次只能一个线程拥有互斥锁,其他线程只有等待
- 互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。
- 互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁;;
- 互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。
- 而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。
- 当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。
- 总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
- 如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。
- 如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功;(单核需要抢占式os)
- 但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
- 多对一
- 一对一
- 多对多
- 用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程;
- 用户线程是基于用户态的线程管理库来实现的,那么线程控制块(*Thread Control Block, TCB*) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。
- 所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
- 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
- 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度快;
- 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞 ,那进程所包含的用户线程都不能执行了。
- 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
- 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;
- 内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
- 内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程;
- 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
- 分配给线程,多线程的进程获得更多的 CPU 运行时间;
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下问信息,如 PCB 和 TCB;
- 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;
- 虚拟存储器为每个进程提供了独占系统地址空间的假象。
- 尽管每个进程地址空间内容不尽相同,但是他们的都有相似的结构。X86 Linux进程的地址空间底部是保留给用户程序的,包括文本、数据、堆、栈等;
- 其中文本区和数据区是通过存储器映射方式将磁盘中可执行文件的相应段映射至虚拟存储器地址空间中。
- 有一些"敏感"的地址需要注意下,对于32位进程来说,代码段从0x08048000开始。从0xC0000000开始到0xFFFFFFFF是内核地址空间,通常情况下代码运行在用户态(使用0x00000000 ~ 0xC00000000的用户地址空间);
- 当发生系统调用、进程切换等操作时CPU控制寄存器设置模式位,进入内核模式,在该状态(超级用户模式)下进程可以访问全部存储器位置和执行全部指令。
- 也就说32位进程的地址空间都是4G,但用户态下只能访问低3G的地址空间,若要访问3G ~ 4G的地址空间则只有进入内核态才行。
- 进程的调度实际就是内核选择相应的进程控制块,被选择的进程控制块中包含了一个进程基本的信息。
- 内核管理所有进程控制块,而进程控制块记录了进程全部状态信息。
- 每一次进程调度就是一次上下文切换,所谓的上下文本质上就是当前运行状态,主要包括通用寄存器、浮点寄存器、状态寄存器、程序计数器、用户栈和内核数据结构(页表、进程表、文件表) 等。
- 进程执行时刻,内核可以决定抢占当前进程并开始新的进程,这个过程由内核调度器完成,当调度器选择了某个进程时称为该进程被调度,该过程通过上下文切换来改变当前状态。
- 一次完整的上下文切换: 通常是进程原先运行于用户态,之后因系统调用或时间片到,切换到内核态执行内核指令,完成上下文切换后回到用户态,此时已经切换到进程B。
- 内核态,或者说CPU的特权模式,是CPU的一种工作状态,它影响CPU对不同指令的执行结果。
- 操作系统通过跟CPU配合,设置特权模式和用户模式,来防止应用程序进行越权的操作防止应用程序越权访问内存时使用了虚拟地址空间映射的技术,这是操作系统软件配合硬件的MMU共同实现的。
- 在用户模式下,应用程序访问的内存地址是虚拟内存地址,会映射到操作系统指定的物理地址上。这个虚拟内存地址空间就是你说的用户空间。
- 内核态是个操作系统概念,虽然对应到CPU的特权模式,但一般如果没有操作系统,就不说内核态了,直接说运行在CPU的特权模式应该没毛病。
- 应用程序无法自由进入内核态,只能通过操作系统提供的接口调用进入,或者在硬件中断到来时被动进入应用程序通过操作系统功能来使用硬件
- 简单来说就是限制用户程序的权限;
- 物理内存就是整个计算机状态的全部,如果程序有办法读写所有的物理内存和寄存器,那任何保护手段都无济于事。
- 所以要限制应用程序的行为,必须在应用程序和操作系统执行时有不同的状态,核心问题在于保护关键寄存器和重要的物理内存。
- 现代MMU通常使用虚拟地址空间的技术来解决这个问题,也就是“用户空间”。
- 在用户模式下,所有访问内存的地址实际上都是虚拟地址,它与实际的物理地址是对应不上的。
- 这样,即便两个应用程序使用了相同的地址,它们也可以做到互不干扰,只需要通过技术手段让它们实际映射到不同的物理地址就行了。
- MMU和操作系统通过称作页表的数据结构来实现虚拟地址到物理地址的映射
- 主线程调用,等待子线程退出并回收其资源,类似于进程中wait/waitpid回收僵尸进程,调用pthread_join的线程会被阻塞。
- tid:创建线程时通过指针得到tid值。
- retval:指向返回值的指针。
- 子线程执行,用来结束当前线程并通过retval传递返回值,该返回值可通过pthread_join获得。
- retval:同上。
- 主线程、子线程均可调用。主线程中pthread_detach(tid),子线程中pthread_detach(pthread_self()),调用后和主线程分离,子线程结束时自己立即回收资源。
- tid:同上。
- 运行 - > 等待
- 运行 - > 就绪 (抢占)
- 等待 - > 就绪 (抢占)
- 运行 - > 终止
- 抢占式一般:时间片、优先权、短作业等;
- 从就绪态 -> 运行态*:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
- 从运行态 -> 阻塞态*:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行;
- 从运行态 -> 结束态*:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;
调度算法影响的是等待时间,即进程在就绪队列中等待调度的时间总和;
单核 CPU 系统中常见的调度算法。
1、 先来先服务 first-come first-serverd(FCFS)
- 非抢占式的调度算法,按照请求的顺序进行调度。
- 有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。
2、 短作业优先 shortest job first(SJF)
- 非抢占式的调度算法,按估计运行时间最短的顺序进行调度。
- 长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
3、最短剩余时间优先 shortest remaining time next(SRTN)
- 最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。
- 如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
4、时间片轮转
- 将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。
- 当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。
- 时间片轮转算法的效率和时间片的大小有很大关系:
- 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
- 而如果时间片过长,那么实时性就不能得到保证。
5、优先级调度
- 为每个进程分配一个优先级,按优先级进行调度。
- 为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
6、多级反馈队列
- 一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
- 多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列。
- 这种方式下,之前的进程只需要交换 7 次。每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
- 可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
- CPU访问的页面不在物理内存中时,发生缺页中断,请求os将缺页调入物理内存;
- 区别与普通中断在于中断返回后,重新执行这个指令;(而普通中断会执行下一条指令)
即,缺页中断后,需调入新页面,但是内存满了,所以选择被置换的物理页面;
- 最佳页面置换算法 OPT
- 先进先出算法 FIFO ;先进缓存的先被替换
- 时钟也页面置换算法 Lock;
- 最不经常使用算法 LFU ;淘汰最不常使用的字块;额外空间记录字块使用频率;(用set、hash记录node,排序用访问次数和访问时间来定义)
- 最近最少使用算法 LRU ; 优先淘汰一段时间内没使用的字块;一般使用双链表实现;把当前访问的节点放在表头(淘汰链表尾部);
- 先来先服务
- 最短寻道
- 扫描算法
- 循环扫描
- look 、C look
- 文件头需要指定 起始块位置、长度;
- 缺点: 磁盘空间碎片化、文件长度不易扩展;
- 链式(隐式链表)
- 离散放置、消除磁盘碎片,长度可动态扩展;存的是头尾,每个包都有个下一个包位置的指针;
- 缺点: 缺点只能顺序访问、数据块消耗空间存储下一块位置、一个坏了,后面的都找不到了;
- 链式(显式链表)
- 存在一个文件分配表 (FAT) 存储文件块地址;磁盘块的每个指针都在表中,指针相互链接标识文件;
- 缺点:占用空间大,不适合大磁盘;
- 索引式
- 每个文件创建一个索引数据块,里面存放的是指向文件数据块的指针列表;(像目录)
- 另外,需要文件头包含指向索引数据块的指针;
- 优点: 文件的创建、增大、缩小很方便;不存在碎片问题;支持顺序读写、随机读写;
- 缺点:带来存储开销;
- 索引式的优化:
- 链式索引块:索引块分开,用链式来链接索引块;(可能造成一块坏了,其他的找不到了)
- 多级索引块:索引块存储的是索引块的地址;
- 早期unix文件系统,组合了这几种方式;根据文件数据块多少,采用不同方案;
- 解决了大文件的存储,但是大量查询操作,效率较低;
需要存储文件的时候,就需要引入磁盘空闲空间的机制;(总不能整个扫描一遍,随便找地方放吧)
- 针对的是连续空间分配;记录了空间区第一个块号、空闲块个数;
- 只有在少量空闲区的时候有较好效果;大量小空间时候,使得空闲表很大,查询效果下降;
- 空闲块中有块指向下一块空闲块的指针,将空闲块连接起来;
- 只需要存储一个指向第一个空闲块的指针,简单便捷;
- 但是无法随机访问,效率低;
- 使用二进制的每一位表示一个块的使用情况,每个块都一个二进制位与之对应。
- linux文件系统使用的就是位图的方式管理空闲空间,不仅用于数据空闲块的管理,还用于inode 空闲块的管理,inode 页存在磁盘中。
- 多个目录项中的索引节点指向同一个文件,(同一个inode)
- 因为inode不能跨文件系统,所以硬链接不能跨文件系统;
- 只有删除所有硬链接和源文件时,系统才会彻底删除该文件;
- 重新创建一个文件,这个文件存储另一个文件的路径;
- 访问软链接,其实是访问到另一个文件中;
- 所以,软连接可以跨文件系统;源文件被删除了,链接文件还在,只是索引不到了而已;
- 缓冲、非缓冲IO
- 直接、非直接IO
- 阻塞、非阻塞IO
- 同步、异步IO
- 通过缓存加速文件的访问,标准库还是用系统调用访问文件;
- 直接系统调用访问文件,不经过标准库的缓存;
- 缓冲IO 可以减少系统调用次数,减少CPU上下文切换的开销;
- 不发生内核缓存和用户程序间的数据复制,直接经过文件系统访问磁盘;
- 读操作:数据从内核缓存中拷贝给用户程序;写操作:数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入磁盘;
write
到缓存数据太多,会触发IO写入磁盘;- 调用
sync
同步,使得内核将缓存回写至磁盘;- 内存紧张时,缓存将被写入磁盘;
- 缓存时间超限,也会写入磁盘;
- 阻塞IO :
read
时,线程阻塞,等到内核数据准备好,并将数据拷贝到应用程序的缓冲区中,read
才返回;- (等待的是:内核数据拷贝好、数据从内核到用户态)
- 非阻塞IO
read
请求在数据未准备好的时候,立即返回,可以继续执行,此时应用程序不断轮询内核,>>* 直到数据准备好,内核将数据拷贝到应用程序缓冲区,read
调用才可以获取结果;- 最后一次调用,是个同步过程,即等待内核太数据拷贝到用户缓冲区的过程;需要等待;
- 基于非阻塞的 IO多路复用
- IO多路复用,用于解决轮询过程的浪费;
- 通过IO 时间分发,当内核数据准备好时,再以事件通知应用程序进行操作;
read
调用时,内核将数据从内核空间拷贝到应用程序,这个过程都是需要等待的。read
调用就会等待长时间;
- 异步IO
- 真正的异步IO ,是
内核数据准备好 + 数据从内核拷贝到用户态
这两个都不需要等待。- aio_read 调用后,立即返回,内核将自动把数据从内核拷贝到应用程序空间,拷贝过程同样异步进行,应用程序不需要主动发起拷贝过程;
- 数据准备过程
- 数据从内核空间拷贝到用户进程缓冲区的过程
- 阻塞IO 会阻塞 1. 2. 两个过程,非阻塞IP 和IO 多路复用只会阻塞在 2. 过程,所以这三个都是同步IO
- 异步IO 则,两个过程都不会阻塞。
- 输入输出设备可分为两大类:块设备、字符设备;
- 块设备:把数据存储在固定带线啊哦的块中,每个块由自己的地址,硬盘、USB 是常见块设备;
- 字符设备:以字符为单位发送或接受,字符设备不可寻址,没有寻道操作,鼠标是常见字符设备;
- 块设备数据量传输较大,控制器一般设立可读写的数据缓冲区;
- CPU写入数据时,数据缓冲一部分才发给设备;CPU读取数据时,缓存一段数据才拷贝到内存;(为了减少对设备的频繁操作;)
- CPU 与控制寄存器与数据缓存区的通信:
- 端口IO:每个寄存器分配IO端口,使用特殊指令操作这些寄存器(
in/out
指令)- 内存映射IO :酱控制寄存器映射到内存空间中,可以像读写内存一样读写数据缓冲区;
CPU 给设备控制器指令,反过来怎么通知CPU 呢?
- 设备控制器不属于OS, 属于硬件;设备驱动程序属于OS;
- OS 内核代码可以像本地调用代码一样使用设备驱动程序的接口;
- 设备驱动程序会提供统一接口给OS;使得不同设备以相同方式接入操作系统
- 设备完成事件,触发中断通知OS;处理程序就是设备驱动程序里的中断处理程序
- 设备驱动初始化时,需要注册一个该设备中断处理函数;
过程:
- IO时,设备控制器准备好数据,通过中断控制器向CPU 发送中断请求;
- 保存进程上下文
- 处理设备中断处理函数
- 恢复进程上下文
功能:
- 向上提供接口,向下将不同磁盘设备抽象为统一块设备;在内核层面,提供一个框架来管理这些设备的驱动程序;
- 将文件系统、应用程序发来的IO 请求排队,对队列排序、请求合并等(IO调度);为了提高磁盘读写效率;
poll
轮询数据)
- 网络包到达,网卡发起中断,中断处理完,引发软中断,轮询处理数据(软中断处理的时候,关闭中断)
- (这样可以一次中断,处理多个网络包,降低了网卡中断带来的性能开销);
- 针对磁盘的慢速度,优化方法有零拷贝、直接IO、异步IO等,以提高系统吞吐量;
- OS 中的磁盘高速缓存区,可以有效减少磁盘的访问次数;
- 没有DMA ,控制器处理完发送中断,CPU需要将控制器中缓冲区数据,逐字节的读到自己的寄存器(page cache),再将寄存器内容写进内存。
- 加入DMA,IO设备和内存的数据传输,数据搬运工作交给DMA控制器;
- 现在的每个IO 设备都有自己的DMA控制器;
- DMA 将数据从 设备控制器缓冲区搬运到内核缓冲区;CPU再将其搬到用户缓冲区中即可;
用户态、内核态的上下文切换
和 内存拷贝
的次数减少用户态、内核态的切换:
- 因为用户空间无法操作磁盘、网卡等。需要系统调用,陷入内核态来实现;
- 要减少上下文切换,就要减少系统调用的次数;
减少拷贝次数:
- 从内核拷贝到用户,再从用户拷贝到内核socket ;
- 因为文件传输过程,不用对数据再加工,所以这两步完全可以舍弃;
mmap
替换read;减少拷贝次数缓存最近被访问的数据;(局部性原理)
预读功能;(局部性原理)
大文件传输:
- 正常read 后,阻塞进程;
- 此时内核发起IO,等到准备好数据IO中断后,由内核将数据从磁盘控制器缓冲区拷贝到pagecache 中,再由pagecache 拷贝到用户缓冲区;
而使用异步IO ,则会跳过page cache ,等到内核数据被拷贝到进程缓冲区后,然后通知进程即可;
绕过pagecache 的称为直接IO,使用pagecache 的叫缓存IO;通常磁盘异步IO,只能支持直接IO;
大文件拷贝,PageCache并不适用,不仅占用了PageCache,还使得DMA多拷贝一次;
- 最基础TCP 的socket编程是阻塞IO 模型;
- 多进程、多线程模型,当大量客户端情况,对进程、线程的调度、上下文切换、内存占用都将是瓶颈;
- IO多路复用,支持在一个进程中,处理多个文件IO;Linux 下由三种IO多路复用API :
select
poll
epoll
;
select
poll
都是线性结构存储进程关注的socket 集合;- 使用的时候,都需要将集合拷贝到内核态,由内核在网络事件发生时,遍历集合,将对应socket 标记可读写;
- 然后整体再拷贝到用户态,让用户态遍历整个集合找到可读写socket 来处理;
- 当客户端越来越多的时候,大集合使得遍历、拷贝开销巨大;
epoll
通过红黑树关注待检测socket,且不需要每次拷贝socket 集合;- 通过内核内维护就绪事件的链表 ,触发用户态来接收事件;
通常用线程池处理服务器连接问题;
- 使用时,通常使用阻塞或者非阻塞IO处理线程中 【read 、 处理、 send】流程;
- 非阻塞IO 虽然能解决阻塞问题,但是轮询需要消耗CPU 且如果线程对应多个连接,轮询效率就很低;
- 存在的问题: 线程不知道当前连接是否存在数据可读,只能每次
read
去试探
- IO 多路复用可以实现在连接上有数据后,再去发起读请求;
- IO 多路复用技术,用一个系统调用函数来监听我们所有关心的连接,可以在一个监控线程里监控很多连接;
select
poll
epoll
就是内核提供给用户态的多路复用系统调用;使得线程可以通过一个系统容调用函数从内核中获取多个事件;
- 通过
select
poll
epoll
将关心连接交给内核,再由内核检测;- 如果没有事件发生,线程只需要将这个系统调用阻塞,无需轮询;
- 当事件发生,内核返回产生了事件的连接,线程就会从阻塞状态返回,然后用户态再处理;
Reactor 模式
或者 Dispatcher模式
;即IO多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程;
Reactor 模式
主要由Reactor
和处理资源池 这两个核心部分组成
- 单 Reactor 单进程/ 线程
Redis 时C语言实现的 单 Reactor 单进程 方案;其业务处理主要是内存中完成,操作很快,瓶颈不在CPU上,所以应用广泛;
- 单 Reactor 多进程/ 线程
- 多 Reactor 多进程/ 线程
其实时简单:
Netty、 Memcache 都是多 Reactor 多线程;
Nginx 是 多 Reactor 多进程 : 他使用主进程初始化Socket ,由子进程的 Reactor 来 accept 连接。通过锁来保证一次只有一个进程进行accept ;accept 后,就自己进行处理;
O_NONBLOCK
标志,表示用非阻塞IO 方式访问;默认是阻塞IOaio_read
后,立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样异步。应用程序并不需要主动发起拷贝;Proactor采用的是异步IO 技术,所以是异步网络模型;
Reactor 是非阻塞同步网络模型,感知的是就绪可读写事件;感知事件发生后,read 来读取数据,也就是应用程序主动将socket接收缓存中数据读到应用进程内存中, 这个过程是同步的;
Proactor 是异步网络模型,感知的是已完成的读写事件;异步读写时,传入数据缓冲区地址,系统内核自动将数据读写完成;操作系统完成后通知进程直接处理即可;
事件指的是:有新连接、有数据可读/写;
两种模式都是一种 基于事件分发的 网络编程模型;区别在于Reactor 模式基于 待完成 IO事件,Proactor 模式 基于 已完成 IO事件
kmalloc和vmalloc是分配的是内核的内存,malloc分配的是用户的内存;
kmalloc对应于kfree,分配的内存处于3GB~high_memory之间,这段内核空间与物理内存的映射一一对应,可以分配连续的物理内存;
vmalloc对应于vfree,分配的内存在VMALLOC_START~4GB之间,分配连续的虚拟内存,但是物理上不一定连续。
- kmalloc保证分配的内存在物理上是连续的,内存只有在要被DMA访问的时候才需要物理上连续,malloc和vmalloc保证的是在虚拟地址空间上的连续;
- malloc 通过系统调用:
brk,mmap,munmap
来实现内存分配;
Linux的系统调用通过int 80h实现,用系统调用号来区分入口函数。 操作系统实现系统调用的基本过程是:
- 应用程序调用库函数(API);
- API将系统调用号存入EAX,然后通过中断调用使系统进入内核态;
- 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
- 系统调用完成相应功能,将返回值存入EAX,返回到中断处理函数;
- 中断处理函数返回到API中;
- API将EAX返回给应用程序。
- 程序文件段,包括二进制可执行代码;
- 已初始化数据段,包括静态常量;
- 未初始化数据段,包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB
。当然系统也提供了参数,以便我们自定义大小;
- 栈区(stack)— 地址向下增长,由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的队列,先进后出。
- 堆区(heap)— 地址向上增长,一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
- 全局区(静态区)(static)—全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。 - 程序结束后有系统释放
- 文字常量区 —常量字符串就是放在这里的。程序结束后由系统释放
- 程序代码区(text)—存放函数体的二进制代码。
- 虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
- 为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。
- 这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。
- 从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。
- 例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。
- 中断是由硬件设备产生的,而它们从物理上说就是电信号,之后,它们通过中断控制器发送给CPU,接着CPU判断收到的中断来自于哪个硬件设备(这定义在内核中)。最后,由CPU发送给内核,有内核处理中断。
- 当硬盘读写完数据之后也会产生中断、键盘敲击时候也会产生;
- CPU处理程序的时候一旦程序不在内存中,会产生缺页异常;
- 当运行除法程序时,当除数为0时,又会产生除0异常。
- 所以,异常是由CPU产生的,同时,它会发送给内核,要求内核处理这些异常;
- 最后都是由CPU发送给内核,由内核去处理;
- 处理程序的流程设计上是相似的;
- 产生源不相同,异常是由CPU产生的,而中断是由硬件设备产生的;
- 内核需要根据是异常还是中断调用不同的处理程序
- 中断不是时钟同步的,这意味着中断可能随时到来;异常由于是CPU产生的,所以它是时钟同步的;(中断异步,异常同步)
- 当处理中断时,处于中断上下文中;处理异常时,处于进程上下文中
- 分配文件磁盘空间
- 管理文件块(那一块属于哪一个文件)
- 管理空闲空间(那一块是空闲的)
- 分配算法(策略)
- 管理文件集合
- 定位文件及其内容
- 命令:通过名字找到文件的接口
- 最常见:分层文件系统
- 文件系统类型(组织文件的不同方式)
- 提供便利机特征
- 保护:分层来保护数据安全;
- 可靠性/持久性:保持稳健的持久,记识发生崩溃、媒体错误、攻击等;
文件属性:
名称、类型、位置、大小、保护、创建者、创建时间、最近修改时间。。。
文件头:
在存储元数据中保存了每个文件的信息;
保存文件的属性;
跟踪哪一块存储块属于逻辑上文件结构的哪个偏移;
首次适应不仅最简单,通常也是最好最快,不过首次适应算法会使得内存低地址部分出现很多小的空闲分区,而每次查找都要经过这些分区,因此也增加了查找的开销。邻近算法试图解决这个问题,但实际上,它常常会导致在内存的末尾分配空间分裂成小的碎片,它通常比首次适应算法结果要差。
最佳导致大量碎片,最坏导致没有大的空间。
进过实验,首次适应比最佳适应要好,他们都比最坏好。
算法 | 算法思想 | 分区排列顺序 | 优点 | 缺点 |
---|---|---|---|---|
首次适应 | 从头到尾找适合的分区 | 空闲分区以地址递增次序排列 | 综合看性能最好。算法开销小,回收分区后一.般不需要对空闲分区队列重新排序 | |
最佳适应 | 优先使用更小的分区,以保留更多大分区 | 空闲分区以容量递增次序排列 | 会有更多的大分区被保留下来,更能满足大进程需求 | 会产生很多太小的、难以利用的碎片;算法开销大,回收分区后可能需要对空闲分区队列重新排序 |
最坏适应 | 优先使用更大的分区,以防止产生太小的不可用的碎片 | 空闲分区以容量递减次序排列 | 可以减少难以利用的小碎片 | 大分区容易被用完,不利于大进程;算法开销大(原因同上) |
邻近适应 | 由首次适应演变而来,每次从上次查找结束位置开始查找 | 空闲分区以地址递增次序排列(可排列成循环链表) | 不用每次都从低地址的小分区开始检索。算法开销小(原因同首次适应算法) | 会使高地址的大分区也被用完 |
虚拟技术把一个物理实体转换为多个逻辑实体。
主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。
- 多进程与多线程:多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。
- 虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。
- 操作系统负责内存空间的分配与回收。
- 操作系统需要提供某种技术从逻辑上对内存空间进行扩充。
- 操作系统需要提供地址转换功能,负责程序的逻辑地址与物理地址的转换。
- 操作系统需要提供内存保护功能。保证各进程在各自存储空间内运行,互不干扰
- 编译时只需确定变量x存放的相对地址是100 ( 也就是说相对于进程在内存中的起始地址而言的地址)。
- CPU想要找到x在内存中的实际存放位置,只需要用进程的起始地址+100即可。
- 相对地址又称逻辑地址,绝对地址又称物理地址。
- 由于程序运行时并非任何时候都要访问程序及数据的各个部分(尤其是大程序),因此可以把用户空间分成为一个固定区和若干个覆盖区。
- 将经常活跃的部分放在固定区,其余部分按照调用关系分段,首先将那些即将要访问的段放入覆盖区,其他段放在外存中,在需要调用前,系统将其调入覆盖区,替换覆盖区中原有的段。
- 覆盖技术的特点:是打破了必须将一个进程的全部信息装入内存后才能运行的限制,但当同时运行程序的代码量大于主存时仍不能运行,再而,大家要注意到,内存中能够更新的地方只有覆盖区的段,不在覆盖区的段会常驻内存。
- 交换(对换)技术的设计思想:内存空间紧张时,系统将内存中某些进程暂时换出外存,把外存中某些已具备运行条件的进程换入内存(进程在内存与磁盘间动态调度)
- 换入:把准备好竞争CPU运行的程序从辅存移到内存。
- 换出:把处于等待状态(或CPU调度原则下被剥夺运行权力)的程序从内存移到辅存,把内存空间腾出来。
- 内存交换通常在许多进程运行且内存吃紧时进行,而系统负荷降低就暂停。
- 例如:在发现许多进程运行时经常发生缺页,就说明内存紧张,此时可以换出一些进程;如果缺页率明显下降,就可以暂停换出。
四个过程:
(1)预编译
1、删除所有的#define,展开所有的宏定义。
2、处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
3、处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。
4、删除所有的注释,“//”和“/**/”。
5、保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重复引用。(pragma 编译阶段命令)
6、添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是能够显示行号。
(2)编译
1、词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
2、语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
3、语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
4、优化:源代码级别的一个优化过程。
5、目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。
6、目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。
(3)汇编
- 将汇编代码转变成机器可以执行的指令(机器码文件)。
- 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。
- 经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Linux下)、xxx.obj(Windows下)。
(4)链接
1、静态链接:
- 函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
- 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
- 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
- 运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
2、动态链接:
- 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
- 共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;
- 更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
- 性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
线程池采用预创建的技术,在应用程序启动之后,将立即创建一定数量的线程(N1),放入空闲队列中。这些线程都是处于阻塞(Suspended)状态,不消耗CPU,但占用较小的内存空间。当任务到来后,缓冲池选择一个空闲线程,把任务传入此线程中运行。当N1个线程都在处理任务后,缓冲池自动创建一定数量的新线程,用于处理更多的任务。在任务执行完毕后线程也不退出,而是继续保持在池中等待下一次的任务。当系统比较空闲时,大部分线程都一直处于暂停状态,线程池自动销毁一部分线程,回收系统资源。
基于这种预创建技术,线程池将线程创建和销毁本身所带来的开销分摊到了各个具体的任务上,执行次数越多,每个任务所分担到的线程本身开销则越小,不过我们另外可能需要考虑进去线程之间同步所带来的开销
使用线程池的前提是:线程本身开销与线程执行任务相比不可忽略。如果线程本身的开销相对于线程任务执行开销而言是可以忽略不计的,那么此时线程池所带来的好处是不明显的,比如对于FTP服务器以及Telnet服务器,通常传送文件的时间较长,开销较大,那么此时,我们采用线程池未必是理想的方法,我们可以选择“即时创建,即时销毁”的策略。
线程池,最简单的就是生产者消费者模型了。池里的每条线程,都是消费者,他们消费并处理一个个的任务,而任务队列就相当于生产者了。
auto commit(F&& f, Args&&... args) ->future
用的是C++ 的返回类型后置
基于C++11的线程池(threadpool),简洁且可以带任意多的参数
using Task = function
是类型别名,简化了 typedef 的用法。function可以认为是一个函数类型,接受任意原型是 void() 的函数,或是函数对象,或是匿名函数。void() 意思是不带参数,没有返回值。 pool.emplace_back([this]{...})
是构造了一个线程对象,执行函数是lambda匿名函数 ;- lambda匿名函数: [this]{…} 不多说。[] 是捕捉器,this 是引用域外的变量 this指针, 内部使用死循环, 由
cv_task.wait(lock,[this]{...})
来阻塞线程;- make_shared 用来构造 shared_ptr 智能指针。用法大体是
shared_ptr
然后 *p == 4 。智能指针的好处就是, 自动 delete !p = make_shared (4) bind
函数,接受函数 f 和部分参数,返回currying后的匿名函数,譬如 bind(add, 4) 可以实现类似 add4 的函数!forward()
函数,类似于 move() 函数,后者是将参数右值化,前者是… 肿么说呢?大概意思就是:不改变最初传入的类型的引用类型(左值还是左值,右值还是右值);
packaged_task
就是任务函数的封装类,通过 get_future 获取 future , 然后通过 future 可以获取函数的返回值(future.get());packaged_task 本身可以像函数一样调用 () ;condition_variable cv;
条件变量, 需要配合 unique_lock 使用;unique_lock 相比 lock_guard 的好处是:可以随时 unlock() 和 lock()。 cv.wait() 之前需要持有 mutex,wait 本身会 unlock() mutex,如果条件满足则会重新持有 mutex。
//线程池最大容量,应尽量设小一点
#define THREADPOOL_MAX_NUM 16
//#define THREADPOOL_AUTO_GROW
//线程池,可以提交变参函数或拉姆达表达式的匿名函数执行,可以获取执行返回值
//不直接支持类成员函数, 支持类静态成员函数或全局函数,Opteron()函数等
class threadpool
{
using Task = function<void()>; //定义类型
vector<thread> _pool; //线程池
queue<Task> _tasks; //任务队列
mutex _lock; //同步
condition_variable _task_cv; //条件阻塞
atomic<bool> _run{
true }; //线程池是否执行
atomic<int> _idlThrNum{
0 }; //空闲线程数量
public:
inline threadpool(unsigned short size = 4) {
addThread(size); }
inline ~threadpool()
{
_run = false;
_task_cv.notify_all(); // 唤醒所有线程执行
for (thread& thread : _pool) {
//thread.detach(); // 让线程“自生自灭”
if (thread.joinable())
thread.join(); // 等待任务结束, 前提:线程一定会执行完
}
}
public:
// 提交一个任务
// 调用.get()获取返回值会等待任务执行完,获取返回值
// 有两种方法可以实现调用类成员,
// 一种是使用 bind: .commit(std::bind(&Dog::sayHello, &dog));
// 一种是用 mem_fn: .commit(std::mem_fn(&Dog::sayHello), this)
template<class F, class... Args>
auto commit(F&& f, Args&&... args) ->future<decltype(f(args...))>
{
if (!_run) // stoped ??
throw runtime_error("commit on ThreadPool is stopped.");
using RetType = decltype(f(args...)); // typename std::result_of::type, 函数 f 的返回值类型
auto task = make_shared<packaged_task<RetType()>>(
bind(forward<F>(f), forward<Args>(args)...)
); // 把函数入口及参数,打包(绑定)
future<RetType> future = task->get_future();
{
// 添加任务到队列
lock_guard<mutex> lock{
_lock };//对当前块的语句加锁 lock_guard 是 mutex 的 stack 封装类,构造的时候 lock(),析构的时候 unlock()
_tasks.emplace([task]() {
// push(Task{...}) 放到队列后面
(*task)();
});
}
#ifdef THREADPOOL_AUTO_GROW
if (_idlThrNum < 1 && _pool.size() < THREADPOOL_MAX_NUM)
addThread(1);
#endif // !THREADPOOL_AUTO_GROW
_task_cv.notify_one(); // 唤醒一个线程执行
return future;
}
//空闲线程数量
int idlCount() {
return _idlThrNum; }
//线程数量
int thrCount() {
return _pool.size(); }
#ifndef THREADPOOL_AUTO_GROW
private:
#endif // !THREADPOOL_AUTO_GROW
//添加指定数量的线程
void addThread(unsigned short size)
{
for (; _pool.size() < THREADPOOL_MAX_NUM && size > 0; --size)
{
//增加线程数量,但不超过 预定义数量 THREADPOOL_MAX_NUM
_pool.emplace_back([this] {
//工作线程函数
while (_run)
{
Task task; // 获取一个待执行的 task
{
// unique_lock 相比 lock_guard 的好处是:可以随时 unlock() 和 lock()
unique_lock<mutex> lock{
_lock };
_task_cv.wait(lock, [this] {
return !_run || !_tasks.empty();
}); // wait 直到有 task
if (!_run && _tasks.empty())
return;
task = move(_tasks.front()); // 按先进先出从队列取一个 task
_tasks.pop();
}
_idlThrNum--;
task();//执行任务
_idlThrNum++;
}
});
_idlThrNum++;
}
}
};
CreateThreadpool
TrySubmitThreadpoolCallback
;创建线程池API 、提交任务回调函数API;
VOID WINAPI ThreadPoolCallBack(PTP_CALLBACK_INSTANCE instance, PVOID param)
{
cout << "param:" << (int)param << "\tThread id = " << GetCurrentThreadId() << endl;
Sleep(200); // 模拟一个任务时间为100毫秒的执行
return;
}
DWORD GetNumOfProcess()// 获取CPU的核心数
{
SYSTEM_INFO sysinfo;
GetSystemInfo(&sysinfo); // 获取操作系统信息
return sysinfo.dwNumberOfProcessors;
}
int main()
{
PTP_POOL tPool;
tPool = CreateThreadpool(NULL); // 创建一个线程池
DWORD dwMaxThread = 3; // GetNumOfProcess() * 2 + 1;
//设置线程池参数(线程池中的线程数)
SetThreadpoolThreadMaximum(tPool, dwMaxThread); // 线程池中最多线程数
SetThreadpoolThreadMinimum(tPool, 1); // 线程池中最少线程数
TP_CALLBACK_ENVIRON tcEnv;
InitializeThreadpoolEnvironment(&tcEnv); // 初始化线程池的回调环境
SetThreadpoolCallbackPool(&tcEnv, tPool); // 给线程池分配回调环境
cout << "线程池中的线程数为:" << dwMaxThread << endl << endl;
//测试例子
for (int i = 1; i < 20; i++)
{
// 向线程池中投递一个任务
TrySubmitThreadpoolCallback(ThreadPoolCallBack, (PVOID)i, &tcEnv);
}
Sleep(100000);
return 0;
}
段式存储按需分配,产生外碎片;
页式存储按固定分配,产生内碎片;要求相应硬件支持(淘汰页面)
段页式存储:先分段再分页;将逻辑空间按照段式管理,段内页式管理
应用数据与静态资源分离
将静态资源(图片,视频,js,css等)单独保存到专门的静态资源服务器中,在客户端访问的时候从静态资源服务器中返回静态资源,从主服务器中返回应用数据。
客户端缓存
因为效率最高,消耗资源最小的就是纯静态的html页面,所以可以把网站上的页面尽可能用静态的来实现,在页面过期或者有数据更新之后再将页面重新缓存。或者先生成静态页面,然后用ajax异步请求获取动态数据。
集群和分布式
(集群是所有的服务器都有相同的功能,请求哪台都可以,主要起分流作用)
(分布式是将不同的业务放到不同的服务器中,处理一个请求可能需要使用到多台服务器,起到加快请求处理的速度。)
可以使用服务器集群和分布式架构,使得原本属于一个服务器的计算压力分散到多个服务器上。同时加快请求处理的速度。
反向代理
在访问服务器的时候,服务器通过别的服务器获取资源或结果返回给客户端。
小林coding