一、基础概念说明:
0)CPU的时间片轮转机制
每个进程(线程)被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
如果在时间片结束时进程还在运行,则CPU使用权将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。
程序阻塞了它的进程之后,CPU 会立马跑别的进程。
但是你想知道CPU 还会不会回来尝试跑这个进程,你需要知道工作队列和等待队列。
0.1)工作队列与等待队列
Linux 内核空间里会维持一个工作队列,因为时间片轮转机制,系统会在进程A、B、C等多个进程间切换着跑。
假如现在进程 A 里跑的程序有一个对象执行了某个方法将当前进程阻塞了,内核会立刻将进程A从工作队列中移除,同时在该对象里创建等待队列,并新建一个引用指向进程A。如下图:
从图中可以看到,进程A被排在了工作队列之外,不受系统调度了,这就是我们常说的被操作系统“挂起”。
这也提现了阻塞和挂起的关系。阻塞是人为安排的,让你程序走到这里阻塞。而阻塞的实现方式是系统将进程挂起。当这个对象受到某种“刺激”(某事件触发)之后, 操作系统将该对象等待队列上的进程重新放回到工作队列上就绪,等待时间片轮转到该进程。
1)socket套接字
数据在client与server端的通信中的收发过程
客户端:char buf[] 应用程序缓冲区 — send/wirte(socket API)—> 内核缓冲区 —>网卡 ----> 网络 ---> 网卡 —>内核缓冲区 —> recv / read —>应用程序缓冲区:服务器端
客户端与服务器端都会通过socket生产一个套接字描述符fd;
套接字描述符是用来标定系统为当前的进程划分的一块缓冲空间的
2)进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
进程切换要历经以下变化:
保存处理机上下文,包括程序计数器和其他寄存器。
更新PCB信息。
把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
选择另一个进程执行,并更新其PCB。
更新内存管理的数据结构。
恢复处理机上下文。
总之,进程切换很耗费CPU资源;
3)同步与异步
同步和异步关注的是消息通知机制
当一个同步调用发出后,
调用者要一直等待返回消息(结果)通知后
,才能进行后续的执行; —— 主动等待当一个异步过程调用发出后,调用者不能立刻得到返回消息(结果)。
实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者
。—— 被动通知
比喻:
去银行办理业务,可能会有两种方式: 1、选择排队等候; 2、另种选择取一个小纸条上面有我的号码,等到排到我这一号时由柜台的人通知我轮到我去办理业务了 第一种:前者(排队等候)就是同步等待消息通知,也就是我要一直在等待银行办理业务情况; 第二种:后者(等待别人通知)就是异步等待消息通知。在异步消息处理中,等待消息通知者(在这个例子中就是等待办理业务的人)往往注册一个回调机制,在所等待的事件被触发时由触发机制(在这里是柜台的人)通过某种机制(在这里是写在小纸条上的号码,喊号)找到等待该事件的人。
4)阻塞与非阻塞
阻塞和非阻塞描述的是程序在等待调用结果(消息,返回值)时的状态.
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程
5)文件描述符
文件描述符的本质是一个索引值,指向一个进程打开的文件的记录表。
linux中每个进程默认最多可以打开1024个文件,最多有1024个文件描述符
文件描述符的特点:
1、非负整数(默认 0~1023)
2、从最小可用的数字来分配
3、每个进程启动时默认打开0,1,2三个文件描述符
需要被监控的文件描述符会被放到结构体fd_set集合中,本质是一个long数组,但以一位表示一个文件描述符。比如0~1023位,数组大小为1024/8个字节。
6)标准IO
标准IO又称缓存IO,大多文件系统的默认IO操作都是标准IO,在Linux的标准IO机制中,操作系统会将IO的数据缓冲在文件系统的页缓存中,即:
数据先会被复制到内核的缓冲区中,然后才会从内核缓冲区复制到应用程序的地址空间。
二、IO模型
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从内核的缓冲区拷贝到应用程序的地址空间
。
所以说,当一个read操作发生时,它会经历两个阶段:
第一阶段:等待数据准备 (Waiting for the data to be ready)。
第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。
对于socket流而言:
第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
第二步:把数据从内核缓冲区复制到应用进程缓冲区。
1、在UNIX/Linux下主要有以下网络I/O模型:
-
阻塞IO(bloking IO) —— 最常用
-
非阻塞IO(non-blocking IO) —— 可防止进程阻塞在IO操作上,需要轮询
-
多路复用IO(multiplexing IO)—— 允许对多个IO进行控制
-
信号驱动式IO(signal-driven IO)(不常用)—— 一种异步通信模型
-
异步IO(asynchronous IO)
前四种都属于同步IO模型。
2、基本 Linux IO 模型的简单矩阵。如下图所示:
常见的IO模型有阻塞、非阻塞、IO多路复用,异步
- 阻塞I/O
最为普遍的IO模式·,缺省情况下,套接字建立后所处的模式就是阻塞IO;之前很多读写函数在调用过程中会发生阻塞。
---读操作read、recv、recvfrom
---写操作write、send
---其他操作accept、connect
eg:
读阻塞:进程调用read函数从套接字上读取数据,当套接字的接收缓冲区没有数据可读,read函数会一直阻塞,直到有数据可读。缓冲区收到数据,内核就会唤醒该进程,通过read访问数据。
写阻塞:当写入数据量大于要写入的缓冲区大小时,写操作将不会进行任何拷贝工作,发生阻塞。一旦发送缓冲区内有足够空间,内核将唤醒进程,将数据从用户缓冲区拷贝到发送数据缓冲区。 /**UDP不用等待确认,没有实际发送缓冲区,所以UDP协议中不存在发送缓冲区满的情况。在UDP套接字上执行的写操作永远不会阻塞。***/
对于阻塞IO,以read为例,若内核缓冲区未完全接收到数据,以及内核缓冲区数据未将数据拷贝到用户缓冲区,这两个过程都会被阻塞。
- 非阻塞I/O
当把套接字设为非阻塞模式O_NONBLOCK,相当于进程请求的IO操作无法完成时,内核会返回一个错误而不是让进程休眠等待。
当应用程序使用了非阻塞的套接字,需使用轮询的方式不断循环测试一个文件描述符是否有数据可读(polling)。
应用程序不断轮询内核来检测IO操作是否就绪,是极其浪费CPU资源的。故此模式不普遍,而是在其他IO模型中使用非阻塞IO这一特性。
流程描述:
当用户线程发起IO请求,通过系统调用,进入内核空间,
若数据准备好了,就会执行IO操作;若数据未准备好,线程会立即返回一个错误,回到用户空间,过一段时间(线程可以去干其他的事),
然后又进行IO操作,发起系统调用,以此循环往复检查内核数据,直到内核准备好数据,并拷贝到用户线程缓存,再进行数据处理。
与阻塞IO不一样,"非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会 '被' CPU光顾",而且用户线程是不断的主动询问kernel数据。
实现非阻塞模式 —— fcntl()函数
建立套接字后,内核默认将其设置为阻塞IO,可以使用fcntl()设置套接字标志为O_NONBLOCK实现非阻塞。
- IO多路复用
引入:
当多个客户端与服务器通信,若服务器read,并阻塞于其中一个客户端socketfd1,当另一个客户的数据到达套接字socketfd2时,服务器不能去处理,仍然阻塞在read(socketfd1),怎么办?
如何应用程序中同时处理多路输入输出流?
若采用阻塞模式,将达不到预期目的;
若采用非阻塞模式,对多个输入轮询,很浪费CPU时间
若设置多进程并发处理,每个进程分别处理一条数据通路,将产生新的进程通信与同步问题,更加复杂。
较好的办法就是IO多路复用
基本思想为:
使用单个线程,通过记录来跟踪每个IO流的状态,来同时管理多个IO流。
—先构造一张有关描述符的表,然后调用一个函数循环查询多任务的完成状态。当这些文件描述符中的一个或多个已经准备好进行IO时,该函数才返回。
—函数返回时,告诉进程那个描述符已经就绪,可以进行IO操作。
该函数就是系统调用函数select、poll、epoll,
流程描述:
select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block
,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回
。
这个时候用户线程再调用read操作,将数据从kernel拷贝到用户线程。
对于非阻塞IO,在轮询时,会在用户空间与内核空间来回切换,而select函数是在内核中进行轮询,一旦有socket准备好,就会去用户空间通知用户线程
对于阻塞IO,会执行一次系统调用read,但对于IO多路复用,会执行两次,read与select,IO多路复用的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
IO多路复用使用场景:
服务器需要同时处理多个处于监听状态或者多个连接状态的套接字。
服务器需要同时处理多种网络协议的套接字。
多路复用模型
一)实现步骤
- 把关心的文件描述符加入到fd_set集合中
- 调用select / poll函数去监控集合fd_set中的文件描述符(阻塞等待集合中一个或多个文件描述符有数据)
- 当有数据时,退出select()阻塞
- 依次判断哪个文件描述符有数据
- 依次处理有数据的文件描述符的数据
二)相关结构体与函数
1、相关结构体
fd_set
1 fd_set rset; 2 3 void FD_ZERO(fd_set *fdset); //集合清零 4 void FD_SET(int fd, fd_set *fdset); //把fd加到fdset集合里 5 void FD_CLR(int fd, fd_set *fdset); //将fd从fdset里面清除 6 void FD_ISSET(int fd, fd_set *fdset); //检查fdset中的fd是否可读写,>0即可读写
2、相关函数
2.1、select
基本原理:
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
1 #include <sys/types.h> 2 #include <sys/times.h> 3 #include <sys/select.h> 4 5 int select(int nfds, fd_set *readfds, fd_set *writefds, 6 fd_set *exceptfds, struct timeval *timeout); 7 -----------------------------------参数------------------------------------ 8 //nfds--select监视的fd数,视进程中打开的文件数而定,一般设为你要监视各文件中的最大文件号加一(maxfd+1) 9 //readfds--select监视的可读fd集合 10 //writefds--select监视的可写fd集合 11 //exceptfds--select监视的异常fd集合 12 //timeout--本次select的超时结束时间
返回值:准备就绪的描述符数,若超时则返回0,若出错则返回-1。 13 -----------------------------------备注------------------------------------- 14 一般:填读集合,对于写集合填NULL,异常集合(带外数据)填null 15 struct timeval { 16 long tv_sec; /* seconds */ 17 long tv_usec; /* microseconds,微秒us-10^6 */ 18 };
注:select退出后,集合表示由数据的集合
//注:select退出后,集合表示有数据的集合 if(FD_SET(fd, &rset)) {....} //1、若是监听套接字上有数据,则有新客户端连接,则accept //2、若是已建立连接的套接字上有数据,则去读数据
select模型----fd_set在select前后的变化
注:select函数里的文件描述符fd_set集合的参数在select前后发送了变化
前:表示关心的文件描述符集合‘(在监控后不一定都有数据)
后:有数据的集合(例如,未超时返回的情况下)’
select缺点:
- select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。
- 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。
需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
2.2、poll
基本原理:
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间
,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
优点:
基于链表存储,没有最大连接数限制
缺点:
1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
2、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd
注意:
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。
事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,
因此随着监视的描述符数量的增长,其效率也会线性下降。
2.3、epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。
epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
基本原理:
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。
还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,
一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,
因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll操作fd的两种模式:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,
应用程序可以不立即处理该事件
。下次调用epoll_wait时,会再次响应应用程序并通知此事件。ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,
应用程序必须立即处理该事件
。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
select/poll 与 epoll的区别:
—在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,
—而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,
内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。
(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)
注意:
如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
epoll的使用:
2.
1.执行epoll_create时,创建了红黑树和就绪list链表。
1 #include <sys/epoll.h> 2 int epoll_create ( int size ); 3 //在epoll早期的实现中,对于监控文件描述符的组织并不是使用红黑树,而是hash表。这里的size实际上已经没有意义。
2.执行epoll_ctl时,如果增加fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,
然后向内核注册回调函数,用于当中断事件来临时向准备就绪list链表中插入数据。
1 #include <sys/epoll.h> 2 int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event ); 3 4 /* 5 函数说明: 6 fd:要操作的文件描述符 7 op:指定操作类型 8 操作类型: 9 EPOLL_CTL_ADD:往事件表中注册fd上的事件 10 EPOLL_CTL_MOD:修改fd上的注册事件 11 EPOLL_CTL_DEL:删除fd上的注册事件 12 event:指定事件,它是epoll_event结构指针类型 13 */ 14 15 //epoll_event定义: 16 struct epoll_event 17 { 18 __unit32_t events; // epoll事件 19 epoll_data_t data; // 用户数据 20 }; 21 //结构体说明: 22 events:描述事件类型,和poll支持的事件类型基本相同(两个额外的事件:EPOLLET和EPOLLONESHOT,高效运作的关键) 23 data成员:存储用户数据 24 typedef union epoll_data 25 { 26 void* ptr; //指定与fd相关的用户数据 27 int fd; //指定事件所从属的目标文件描述符 28 uint32_t u32; 29 uint64_t u64; 30 } epoll_data_t;
3.执行epoll_wait时立刻返回准备就绪链表里的数据即可。
1 #include <sys/epoll.h> 2 int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout ); 3 4 //函数说明: 5 返回:成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno 6 timeout:指定epoll的超时时间,单位是毫秒。当timeout为-1是,epoll_wait调用将永远阻塞,直到某个时间发生。当timeout为0时,epoll_wait调用将立即返回。 7 maxevents:指定最多监听多少个事件 8 events:检测到事件,将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中。
epoll详解文章:
Linux下的I/O复用与epoll详解
2.4 select、poll、epoll区别
1)支持一个进程所能打开的最大连接数
2)FD剧增后带来的IO效率问题
3)信息传递方式
4、异步非阻塞IO
相对于同步IO,异步IO不是顺序执行。
用户线程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户线程,然后用户态线程可以去做别的事情。
等到socket数据准备好了,内核直接复制数据给线程,然后从内核向线程发送通知。
IO两个阶段,线程都是非阻塞的。
(Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv)
模型:
参考:
聊聊IO多路复用之select、poll、epoll详解