操作系统(未完)

进程、线程、协程的区别

进程与线程

进程是资源分配的最小单位,线程是CPU调度的最小单位

做个简单的比喻:进程=火车,线程=车厢线程在进程下行进(单纯的车厢无法运行)

  • 一个进程可以包含多个线程(一辆火车可以有多个车厢)
  • 不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)同一进程下不同线程间数据很易共享(A车厢换到B车厢很容易)
  • 进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源)
  • 进程间不会相互影响,一个线程挂掉可能将导致整个进程挂掉(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,将影响到所有车厢。而JAVA中不是,线程挂掉不会让进程挂掉)
  • 进程可以拓展到多机,进程最多适合多核(不同火车可以开在多个轨道上,同一火车的车厢不能在行进的不同的轨道上)
  • 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-“互斥锁”
  • 进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量”

线程与协程

为什么需要协程?

我们都知道多线程,当需要同时执行多项任务的时候,就会采用多线程并发执行。拿手机支付举例子,当收到付款信息的时候,需要查询数据库来判断余额是否充足,然后再进行付款。

假设最开始我们只有可怜的10个用户,收到10条付款消息之后,我们开启启动10个线程去查询数据库,由于用户量很少,结果马上就返回了。第2天用户增加到了100人,你选择增加100个线程去查询数据库,等到第三天,你们加大了优惠力度,这时候有1000人同时在线付款,你按照之前的方法,继续采用1000个线程去查询数据库,并且隐隐觉察到有什么不对。

不断增长的线程
几天之后,见势头大好,运营部门开始不停的补贴消费券,展开了史无前例的大促销,你们的用户开始爆炸增长,这时候有10000人同时在线付款,你打算启动10000个线程来处理任务。等等,问题来了,因为每个线程至少会占用4M的内存空间,10000个线程会消耗39G的内存,而服务器的内存配置只有区区8G,这时候你有2种选择,一是选择增加服务器,二是选择提高代码效率。那么是否有方法能够提高效率呢?

我们知道操作系统在线程等待IO的时候,会阻塞当前线程,切换到其它线程,这样在当前线程等待IO的过程中,其它线程可以继续执行。当系统线程较少的时候没有什么问题,但是当线程数量非常多的时候,却产生了问题。一是系统线程会占用非常多的内存空间,二是过多的线程切换会占用大量的系统时间。

线程切换

协程刚好可以解决上述2个问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。

协程切换
回到上面的问题,我们只需要启动100个线程,每个线程上运行100个协程,这样不仅减少了线程切换开销,而且还能够同时处理10000个读取数据库的任务,很好的解决了上述任务。

协程的注意事项

假设协程运行在线程之上,并且协程调用了一个阻塞IO操作,这时候会发生什么?实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度,这往往是不能接受的。

因此在协程中不能调用导致线程阻塞的操作。

线程有最小的内存单位,而且线程的切换由系统进行,由于系统切换调度需要传入上下文,当线程一多的时候,切换来切换去,所以开销大(因为切换和保存恢复上下文)。但是很多场景,上下文可以我们自己搞,系统只会机械的调换,所以能避免一些消耗。而且线程需要系统态,而协程是用户态的,占用小。

区别和实现

进程、线程,都是有内核进行调度,有 CPU 时间片的概念,进行 抢占式调度。

协程(用户级线程)完全由用户自己的程序进行调度(协作式调度),需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。

goroutine 和协程区别

本质上,goroutine 就是协程。 不同的是,Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU § 转让出去,让其他 goroutine 能被调度并执行,也就是 Golang 从语言层面支持了协程。Golang 的一大特色就是从语言层面原生支持协程,在函数或者方法前面加 go关键字就可创建一个协程。

线程是操作系统的内核对象,多线程编程时,如果线程数过多,就会导致频繁的上下文切换,这些 cpu时间是一个额外的耗费。

协程,是在应用层模拟的线程,他避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。

协程如何实现的?

协程是基于线程的。内部实现上,维护了一组数据结构和 n 个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这 n 个线程从队列中拉出来执行。这就解决了协程的执行问题。那么协程是怎么切换的呢?答案是:golang 对各种 io函数 进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步 io函数,当这些异步函数返回 busy 或 bloking 时,golang 利用这个时机将现有的执行序列压栈,让线程去拉另外一个协程的代码来执行,基本原理就是这样,利用并封装了操作系统的异步函数。包括 linux 的 epoll、select 和 windows 的 iocp、event 等。

在任务调度上,协程是弱于线程的。但是在资源消耗上,协程则是极低的。一个线程的内存在 MB 级别,而协程只需要 KB 级别。而且线程的调度需要内核态与用户的频繁切入切出,资源消耗也不小

 

基本概念
进程(Process)

进程是应用程序的启动实例,进程拥有代码和打开的文件资源、数据资源、独立的内存空间。

线程(Lightweight Process,LWP)

线程从属于进程,是程序的实际执行者,一个进程至少包含一个主线程,也可以有更多的子线程,线程拥有自己的栈空间。
对操作系统而言,线程是最小的执行单元,进程是最小的资源管理单元。无论是进程还是线程,都是由操作系统所管理的。

协程(Coroutines)

协程是一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。

进程、线程、协程的对比:

(1)协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程它进程和进程不是一个维度的。

(2)一个进程可以包含多个线程,一个线程可以包含多个协程。

(3)一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。

(4)协程与进程、线程一样,切换是存在上下文切换问题的。

上下文切换对比:

(1)进程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户是无感知的。进程的切换内容包括页全局目录、内核栈、硬件上下文,切换内容保存在内存中。进程切换过程是由“用户态到内核态到用户态”的方式,切换效率低。

(2)线程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户是无感知的。线程的切换内容包括内核栈和硬件上下文,线程切换内容保存在内核栈中.线程切换过程是由“用户态到内核态到用户态”,切换效率中等。因为线程的调度是在内核态运行的,而线程中的代码是在用户态运行,因此线程切换会导致用户态与内核态的切换
(3)协程的切换者是用户(编程者或应用程序),切换时机是用户自己的程序所决定的。协程的切换内容是硬件上下文,切换内存保存在用户自己的变量(用户栈或堆)中。协程的切换过程只有用户态,即没有陷入内核态,因此切换效率高。

进程调度
MAX_RT_PRIO = 100

普通进程,SCHED_NORMAL调度策略 0~MAX_RT_PRIO-1, 即0~99

实时进程,SCHED_FIFO,或SCHED_PR调度策略 MAX_RT_PRIO~MAX_RT_PRIO+40, 即 100~140

linux根据进程优先级来进行调度,任何时候,实时进程的优先级都高于普通进程,实时进程只会被更高级的实时进程抢占,同级实时进程之间是按照FIFO(一次机会做完)或者RR(多次轮转)规则调度的。

实时进程

不同与普通进程,系统调度时,实时优先级高的进程总是先于优先级低的进程执行。直到实时优先级高的实时进程无法执行。实时进程总是被认为处于活动状态。如果有数个 优先级相同的实时进程,那么系统就会按照进程出现在队列上的顺序选择进程。不同调度策略的实时进程只有在相同优先级时才有可比性:

1、对于FIFO的进程,意味着只有当前进程执行完毕才会轮到其他进程执行。由此可见相当霸道。

2、对于RR的进程。一旦时间片消耗完毕,则会将该进程置于队列的末尾,然后运行其他相同优先级的进程,如果没有其他相同优先级的进程,则该进程会继续执行。

  总而言之,对于实时进程,高优先级的进程就是大爷。它执行到没法执行了,才轮到低优先级的进程执行。等级制度相当森严啊。

普通进程

Linux对普通的进程,根据动态优先级进行调度。而动态优先级是由静态优先级(static_prio)调整而来。Linux下,静态优先级是用户不可见的,隐藏在内核中。而内核提供给用户一个可以影响静态优先级的接口,那就是nice值,两者关系如下:

static_prio=MAX_RT_PRIO +nice+ 20

nice值的范围是-20至19,因而静态优先级范围在100至139之间。nice数值越大就使得static_prio越大,最终进程优先级就越低。
动态优先级:
dynamic_prio = max (100, min(static_prio - bonus + 5, 139)),
奖励(bonus)根据进程的平均睡眠时间计算所得,取值范围为0至10。
而进程的时间片就是完全依赖static_prio 定制的

操作系统在选取运行进程的时候按照最小的vruntime来的,虚拟时间的计算公式为虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重,其中权重就是优先级。这就是说,同样的实际运行时间,给高优先级的算少了,低优先级的算多了,但是当选取下一个运行进程的时候,还是按照最小的 vruntime 来的,这样高优先级的获得的实际运行时间自然就多了。

孤儿进程和僵尸进程
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

问题及危害:unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。
进程间的通信方式
管道
管道分为匿名管道和命名管道,匿名管道由pipe系统调用创建。创建后会有两个文件句柄,一个用于读,一个用于写。匿名管道一般用于父子进程间的通信,管道是单向通信的,要实现进程之间的双向通信需要创建两个管道。命名管道由mkfifo创建,命名管道就是FIFO,管道是先进先出的通讯方式。FIFO是一种先进先出的队列。它类似于一个管道,只允许数据的单向流动。其与匿名管道的一个重要区别是它提供了一个文件路径名与之关联,任何进程只要能访问该文件就能实现进程间的相互通信。容量有限,速度慢。
消息队列
消息队列是消息的连接表,存放在内核中并由消息队列标识符标识这种通信机制传递的数据具有某种结构,而不是简单的字节流。消息队列是用于两个进程之间的通讯,首先在一个进程中创建一个消息队列,然后再往消息队列中写数据,而另一个进程则从那个消息队列中取数据。需要注意的是,消息队列是用创建文件的方式建立的,如果一个进程向某个消息队列中写入了数据之后,另一个进程并没有取出数据,即使向消息队列中写数据的进程已经结束,保存在消息队列中的数据并没有消失。
信号量
信号量不能传递复杂消息,只能用来同步
共享内存
只要首先创建一个共享内存区,两个进程只要按照进程A用户空间-共享内存-进程B用户空间的步骤就可以对共享内存区中的数据进行读写。
其中共享内存的效率最高,原因是共享内存的数据拷贝只有两次,即进程A的内存空间到共享内存,共享内存到进程B的内存空间。管道/消息队列的效率较低,原因是数据拷贝有四次。以消息队列为例,首先是进程A用户内存空间数据拷贝到A进程内核缓冲区,内核缓冲区数据拷贝到消息队列,进程B需要将消息队列数据拷贝到B进程内核缓冲区,最后将B进程内核缓冲区的数据拷贝到进程B的用户内存空间。
socket套接字
套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。

一、进程
进程是程序一次动态执行的过程,是程序运行的基本单位。
每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。
进程占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、页表、文件句柄等)比较大,但相对比较稳定安全。协程切换和协程切换
总结:保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己独立的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是资源分配的最小单位。

二、线程
线程又叫做轻量级进程,是CPU调度的最小单位。
线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。
多个线程共享所属进程的资源,同时线程也拥有自己的专属资源。
程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
总结:线程从属于进程,是程序的实际执行者。一个进程可以有多个线程,最少有一个线程,但一个线程只能有一个进程。

三、协程
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。
一个线程可以拥有多个协程,协程不是被操作系统内核所管理,而完全是由程序所控制。
与其让操作系统调度,不如我自己来,这就是协程
总结:协程最主要的作用是在单线程的条件下实现并发的效果,但实际上还是串行的(像yield一样)一个线程可以拥有多个协程,协程不是被操作系统内核所管理,而完全是由程序所控制。

四、进程与线程区别
线程是指进程内的一个执行单元,也是进程内的可调度实体。线程与进程的区别:

1、根本区别: 进程是操作系统资源分配和独立运行的最小单位;线程是任务调度和系统执行的最小单位。
2、地址空间区别: 每个进程都有独立的地址空间,一个进程崩溃不影响其它进程;一个进程中的多个线程共享该 进程的地址空间,一个线程的非法操作会使整个进程崩溃。
3、上下文切换开销区别: 每个进程有独立的代码和数据空间,进程之间上下文切换开销较大;线程组共享代码和数据空间,线程之间切换的开销较小。

五、协程与线程进行区别
1) 一个线程可以多个协程,一个进程也可以单独拥有多个协程。
2) 线程进程都是同步机制,而协程则是异步。
3) 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。
4)线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
5)协程并不是取代线程, 而且抽象于线程之上, 线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行, 线程是协程的资源, 但协程不会直接使用线程, 协程直接利用的是执行器(Interceptor), 执行器可以关联任意线程或线程池, 可以使当前线程, UI线程, 或新建新程.。
6)线程是协程的资源。协程通过Interceptor来间接使用线程这个资源。

注:协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

协程,在执行过程中可中断去执行其他任务,执行完毕后再回来继续原先的操作——可以理解为两个或多个程序协同工作。

六、进程、线程、协程的对比
协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程与进程和线程不是一个维度的。

一个进程可以包含多个线程,一个线程可以包含多个协程。

一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。

协程与进程一样,切换是存在上下文切换问题的

八、上下文切换
进程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户是无感知的。进程的切换内容包括页全局目录、内核栈、硬件上下文,切换内容保存在内存中。进程切换过程是由“用户态到内核态到用户态”的方式,切换效率低。

线程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户无感知。线程的切换内容包括内核栈和硬件上下文。线程切换内容保存在内核栈中。线程切换过程是由“用户态到内核态到用户态”, 切换效率中等。协程的切换者是用户(编程者或应用程序),切换时机是用户自己的程序所决定的。

协程的切换内容是硬件上下文,切换内存保存在用户自己的变量(用户栈或堆)中。协程的切换过程只有用户态,即没有陷入内核态,因此切换效率高。

九、CPU 时间分片
时间片即CPU分配给各个程序的时间,每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。

如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。而不会造成CPU资源浪费。

在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。

但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
线程是CPU调度的基本单位
进程是CPU分配资源的基本单位
CPU时间片是直接分配给线程的,线程拿到CPU时间片就能执行了
CPU时间片不是先分给进程然后再由进程分给进程下的线程的。
所有的进程并行,线程并行都是看起来是并行,其实都是CPU片轮换使用。
线程分到了CPU时间片,就可以认为这个线程所属的进程在运行,这样就看起来是进程并行。
线程也一样。

进程间常用的通信方式

进程间通信的方式有很多,这里主要讲到进程间通信的六种方式,分别为:管道、FIFO、消息队列、共享内存、信号、信号量。

一、管道

管道的特点:

  1. 是一种半双工的通信方式;
  2. 只能在具有亲缘关系的进程间使用.进程的亲缘关系一般指的是父子关系;
  3. 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

二、FIFO

FIFO,也叫做命名管道,它是一种文件类型。

FIFO的特点:

  1. FIFO可以在无关的进程之间交换数据,与无名管道不同;
  2. FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

三、消息队列 
消息队列,是消息的链接表,存放在内核之中。一个消息队列由一个标识符(即队列ID)来标识。

用户进程可以向消息队列添加消息,也可以向消息队列读取消息。

消息队列的特点:

消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级;
消息队列是独立于发送和接收进程的,进程终止时,消息队列及其内容并不会被删除;
消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

四、共享内存
共享内存,指两个或多个进程共享一个给定的存储区。

ipcs -m 查看系统下已有的共享内存;ipcrm -m shmid可以用来删除共享内存。

共享内存的特点:

共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。
因为多个进程可以同时操作,所以需要进行同步。
信号量 + 共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。
 

五、信号 
 对于 Linux来说,实际信号是软中断,许多重要的程序都需要处理信号。终端用户输入了 ctrl+c 来中断程序,会通过信号机制停止一个程序。

信号的相关概述:

1、信号的名字和编号:

每个信号都有一个名字和编号,这些名字都以“SIG”开头。我们可以通过kill -l来查看信号的名字以及序号。

不存在0信号,kill对于0信号有特殊的应用。

2、信号的处理:

信号的处理有三种方法,分别是:忽略、捕捉和默认动作。

忽略信号,大多数信号可以使用这个方式来处理,但是有两种信号不能被忽略(分别是 SIGKILL和SIGSTOP);
捕捉信号,需要告诉内核,用户希望如何处理某一种信号,说白了就是写一个信号处理函数,然后将这个函数告诉内核。当该信号产生时,由内核来调用用户自定义的函数,以此来实现某种信号的处理。
系统默认动作,对于每个信号来说,系统都对应由默认的处理动作,当发生了该信号,系统会自动执行。具体的信号默认动作可以使用man 7 signal来查看系统的具体定义。

六、信号量
信号量与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。

信号量的特点:

信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
支持信号量组

七、进程间通信方式总结:
管道:速度慢,容量有限,只有父子进程能通讯;
FIFO:任何进程间都能通讯,但速度慢;
消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题;
共享内存:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题;
信号:有入门版和高级版两种,区别在于入门版注重动作,高级版可以传递消息。只有在父子进程或者是共享内存中,才可以发送字符串消息;
信号量:不能传递复杂消息,只能用来同步。用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。

channel 是 goroutine 之间通信的一种方式,可以类比成 Unix 中进程管道通信方式,channel 是支撑 Go 语言高性能并发编程模型的重要结构。

通道像一个传送带或者队列,总是遵循先进先出(First In First Out)的规则,保证收发数据的顺序。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。

5种网络IO模型

网络IO,会涉及到两个系统对象,一个是用户空间调用IO的进程或线程,另一个是内核空间的内核系统,比如发生IO操作read时,它会经历两个阶段。

  1. 等待数据准备就绪
  2. 将数据从内核拷贝到进程或者线程

  因为在以上两个阶段上各有不同的情况,所以出现了多种网络 IO 模型。

阻塞 IO(blocking IO)

  在 linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程

当用户进程调用了 read 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于 network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的数据包)这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。
  所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被block 了。

  第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器/客户机的模型。下面是一个简单地“一问一答”的服务器。

大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。
  实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send()的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。
  一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create ()创建新线程,fork()创建新进程。
  我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。

在上述模型中,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。

  很多人可能不明白为何一个 socket 可以 accept 多次。实际上 socket 的设计者可能特意为多客户机的情况留下了伏笔,让 accept()能够返回一个新的 socket。

,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请求,则将该连接请求加入请求队列。调用 accept()接口正是从 socket s 的请求队列抽取第一个连接信息,创建一个与 s 同类的新的 socket 返回句柄。新的 socket 句柄即是后续 read()和 recv()的输入参数。如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进入队列。

  上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。

  很多人可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

  对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

优点:开发简单,容易入门。在阻塞等待期间,用户线程挂起,在挂起期间不会占用 CPU 资源

缺点:一个线程维护一个 IO ,不适合大并发,在并发量大的时候需要创建大量的线程来维护网络连接,内存、线程开销非常大

非阻塞 IO(non-blocking IO)
  Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

非阻塞 IO(non-blocking IO)

  Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回,所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel数据准备好了没有。

  在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中

recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;
recv() 返回 0,表示连接已经正常断开;
recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;
recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno。
  非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。

可以看到服务器线程可以通过循环调用 recv()接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv()将大幅度推高 CPU占用率;此外,在这个方案中 recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select()多路复用模式,可以一次检测多个连接是否活跃。

非阻塞 IO优缺点:

同步非阻塞 IO 优点:每次发起 IO 调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性好

同步非阻塞 IO 缺点:多个线程不断轮询内核是否有数据,占用大量 CPU 资源,效率不高。一般 Web 服务器不会采用此模式

多路复用 IO(IO multiplexing)
  IO multiplexing 这个词可能有点陌生,但是提到 select/epoll,大概就都能明白了。有些地方也称这种 IO 方式为事件驱动 IO(event driven IO)。我们都知道,select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select/epoll 这个 function会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:

 

 

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

  这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select 和 read),而 blocking IO 只调用了一个系统调用(read)。但是使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。(多说一句:所以,如果处理的连接数不是很高的话,使用select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 webserver 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

  在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。因此 select()与非阻塞 IO 类似。

大部分 Unix/Linux 都支持 select 函数,该函数用于探测多个文件句柄的状态变化。

上述模型只是描述了使用 select()接口同时从多个客户端接收数据的过程;由于 select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。

上述模型主要模拟的是“一问一答”的服务流程,所以如果 select()发现某句柄捕捉到了“可读事件”,服务器程序应及时做recv()操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入 writefds,准备下一次的“可写事件”的 select()探测。同样,如果 select()发现某句柄捕捉到“可写事件”,则程序应及时做 send()操作,并准备好下一次的“可读事件”探测准备。

  这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。

但这个模型依旧有着很多问题。首先 select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。如果需要实现更高效的服务器程序,类似 epoll 这样的接口更被推荐。遗憾的是不同的操作系统特供的 epoll 接口有很大差异,所以使用类似于 epoll 的接口实现具有较好跨平台能力的服务器会比较困难。

  其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent 库,还有作为 libevent 替代者的 libev 库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。使用 libev 库替换 select 或 epoll接口,实现高效稳定的服务器模型。

多路复用 IO优缺点:

优点:系统不必创建维护大量线程,只使用一个线程、一个选择器就可同时处理成千上万连接,大大减少了系统开销

缺点:本质上,select 和 epoll 的系统调用是阻塞式的,属于同步 IO,需要在读写时间就绪后,由系统调用进行阻塞的读写

实际上,Linux 内核从 2.6 开始,也引入了支持异步响应的 IO 操作,如 aio_read,aio_write,这就是异步 IO。

异步 IO(Asynchronous I/O)

Linux 下的 asynchronous IO 用在磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引入。先看一下它的流程

用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel的角度,当它收到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。
异步 IO 是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。

到目前为止,已经将四个 IO 模型都介绍完了。现在有几个问题:blocking 和 non-blocking 的区别在哪,synchronous IO 和 asynchronous IO 的区别在哪。

  先回答最简单的这个:blocking 与 non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用 blocking IO 会一直 block 住对应的进程直到操作完成,而non-blocking IO 在 kernel 还在准备数据的情况下会立刻返回。

  synchronous IO 和 asynchronous IO 的区别就在于 synchronous IO 做”IO operation”的时候会将 process 阻塞。按照这个定义,之前所述的 blocking IO,non-blocking IO,IO multiplexing 都属于synchronous IO。有人可能会说,non-blocking IO 并没有被 block 啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的 IO 操作,就是例子中的 read 这个系统调用。non-blocking IO 在执行 read 这个系统调用的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。但是当 kernel 中数据准备好的时候,read 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内进程是被 block的。而 asynchronous IO 则不一样,当进程发起 IO 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说 IO 完成。在这整个过程中,进程完全没有被 block。

异步 IO 才是真正的非阻塞(两个阶段全是非阻塞)


信号驱动 IO(signal driven I/O, SIGIO)


  首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 read 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当有活跃套接字时,由注册的 handler 处理。

经过上面的介绍,会发现 non-blocking IO 和 asynchronous IO 的区别还是很明显的。在non-blocking IO 中,虽然进程大部分时间都不会被 block,但是它仍然要求进程去主动的 check,并且当数据准备完成以后,也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存。而 asynchronous IO 则完全不同。它就像是用户进程将整个 IO 操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查 IO 操作的状态,也不需要主动的去拷贝数据。

五种网络 IO 模型对比


  阻塞IO,非阻塞IO,多路复用IO,信号驱动IO这四种的主要区别在第一阶段,他们在第二阶段是一样的:数据从内核缓冲区复制到调用者缓冲区期间都被阻塞住。他们都是同步IO,只有同步 IO 模型才考虑阻塞和非阻塞。异步 IO 肯定是非阻塞。

并发与并行的区别 

 

 最开始的计算机只有一个处理器,也就是单核,最开始处理任务的模式也是只能处理一个任务,但是这种就比较傻瓜式,比如你在看视频时,就不能同时登微信聊天,简直离谱。

           后来就想着我们是否可以给任务分配一个时间片,时间到了就切换另外一个任务,由于时间片很短,用户的眼睛几乎感觉不到停顿,可以给用户一种“任务并行起来了”的假象。这种交替执行不同任务的方式就是并发。

      后来计算机随着发展,就有了多核处理器,每个核都可以处理一个任务,并且这两个任务之间不需要抢夺时间片,这种方式就是并行。

1、并行

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行,所以无论从微观还是从宏观来看,二者都是一起执行的。

 

2、并发

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。

 

3、异同点
相同点:
并发和并行的目标都是最大化CPU的使用率,将cpu的性能充分压榨出来。

不同点:
        (1)并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在

        (2)并行要求程序能够同时执行多个操作,而并发只是要求程序“看着像是”同时执行多个操作,其实是交替执行。

同步与异步的区别

同步是阻塞模式,异步是非阻塞模式

同步的理解:
同步就是指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行下去;

同步就相当于是 当客户端发送请求给服务端,在等待服务端响应的请求时,客户端不做其他的事情。当服务端做完了才返回到客户端。这样的话客户端需要一直等待。用户使用起来会有不友好。

异步的理解:

异步是指进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程进行处理,这样可以提高执行的效率。
异步就相当于当客户端发送给服务端请求时,在等待服务端响应的时候,客户端可以做其他的事情,这样节约了时间,提高了效率。

 异步虽然好 但是有些问题是要用同步用来解决,比如有些东西我们需要的是拿到返回的数据在进行操作的。这些是异步所无法解决的。

  • ajax是前端常用的和后台进行异步交互数据的请求方式,其实它本身也包括同步和异步,同步的就是由代码从上到下顺序执行,而异步的ajax是我们发送请求到服务器之后,只需要在监听服务器的响应即可,不用等到请求结束在执行其他的代码,这就是异步ajax

 阻塞与非阻塞的区别

阻塞

为了完成一个功能,发起一个调用,如果不具备条件的话则一直等待,直到具备条件则完成

非阻塞

为了完成一个功能,发起一个调用,具备条件直接输出,不具备条件直接报错返回
对于非阻塞的使用必须使用循环进行调用

区别

其实就相当于在捕捉一个子进程退出的时候,阻塞则会一直等待,直到这个子进程退出,返回对应的值,而非阻塞,如果刚好捕捉到子进程的退出则直接输出,

如果没有捕捉到,也不进行等待,直接输出报错!

常见缓存淘汰算法

缓存是指可以进行高速数据交换的存储器,它先于内存与CPU交换数据,因此速率很快。计算机中,所有的运算操作都是由CPU的寄存器来完成的,CPU指令的执行过程需要涉及数据的读取和写入操作,CPU所能访问的所有数据只能是计算机的主存(通常RAM)。CPU和主存两边的速度严重的不对等,所以才有中间增加缓存的设计,其中L3、L2、L1分别为三级缓存、二级缓存、一级缓存,速度依次递增。

做涉及数据库开发时,使用缓存提高性能是一个比较常用的方法。除了可以设置过期时间以外,当缓存满时,我们需要释放一定的资源来插入新的缓存,那么缓存淘汰算法是我们需要考虑到的。如下图(缓存调度流程),下面就介绍几种常见的缓存淘汰算法。

这里顺带提一下为什么要设置过期时间?

Redis数据存储是基于内存的,如果不设置过期时间,所有存储的数据都会积压在内存,直到内存满,触发类似LRU这样的缓存淘汰策略,会让Redis处理效率变慢。自己设置一个合理的过期时间则会在自己所设置的特定时间内存在于内存,超过过期时间则释放内存。

LRU(Least Recently Used)最近最少使用算法
淘汰最近不使用的页面。

实现LRU的关键点:

模式识别:键(key)和值(value)——哈希表(O(1)时间内通过key找到value)
改变数据访问时间:get和put操作后的数据需要设置为最新访问数据——能随机访问,且把该数据插入到头部或者尾部

LFU(Least Frequently Used)最近频次最少算法
淘汰使用次数最少的页面。

实现LFU的关键点:

模式识别:键(key)和值(value)——利用散列表实现O(1)的快速索引
排序要求:使用次数最少的排在前面,使用次数相同的情况下,最早使用的排在前面。(维护最小值)
解题方法:1)散列表+平衡二叉树 ;2)双散列表

FIFO(First In First Out)先入先出算法
淘汰最先进来的页面。

ARC(Adjustable Replacement Cache)自适应缓存替换算法
同时跟踪记录LFU和LRU,以及驱逐缓存条目,来获得可用缓存的最佳使用。

MRU(Most Recently Used)最近最常使用算法
最先移除最近最常使用的条目。一个MRU算法擅长处理一个条目越久,越容易被访问的情况。
 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我爱golang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值