首先来看看两种I/O的定义:同步I/O和异步I/O
“执行I/O操作”是否被阻塞?如果被阻塞,就是同步I/O,否则就是异步I/O。
同步IO指的是用户进程触发I/O操作并等待或者轮询地查看I/O操作是否就绪。
同步IO的执行者是IO操作的发起者。
同步IO需要发起者请求之后内核才进行内核态到用户态的数据拷贝过程,所以这里必须有个阻塞。
异步IO是指用户进程触发I/O操作以后就立即返回,继续开始做自己的事情,而当I/O操作已经完成的时候会得到I/O完成的通知。
异步IO的执行者是内核线程,内核线程将数据从内核态拷贝到用户态,所以这里没有阻塞
阻塞I/O:调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作
用个例子说明一下:比如A同学用杯子装水,打开水龙头装满水然后离开。这一过程就可以看成是使用了阻塞IO模型,因为如果水龙头没有水,他也要等到有水并装满杯子才能离开去做别的事情。很显然,这种IO模型是同步的。
非阻塞I/O:非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。非阻塞I/O执行系统调用总是立即返回,不管时间是否已经发生,若时间没有发生,则返回-1,此时可以根据errno区分这两种情况,对于accept,recv和send,事件未发生时,errno通常被设置成eagain
B同学也用杯子装水,打开水龙头后发现没有水,它离开了,过一会他又拿着杯子来看看……在中间离开的这些时间里,B同学离开了装水现场(回到用户进程空间),可以做他自己的事情。这就是非阻塞IO模型。但是它只有是检查无数据的时候是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(等着水将水杯装满),因此它还是同步IO。
信号驱动I/O:linux用套接口进行信号驱动IO,通过调用sigaction注册信号函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号。然后处理IO事件。
D同学让舍管阿姨等有水的时候通知他(注册信号函数),没多久D同学得知有水了,跑去装水。是不是很像异步IO?很遗憾,它还是同步IO(省不了装水的时间啊)。
I/O复用:发起IO操作前先调用Select或者Poll。这两个函数都会在内核态准备好数据后告诉用户进程,相对于非阻塞IO模型来说,不需要轮询,用户进程可以做其他事情。但是本质上还是同步IO。但是它的优点在于可以同时触发多个IO任务并在每个IO完成后依次处理。
比如这个时候C同学来装水,发现有一排水龙头,舍管阿姨告诉他这些水龙头都还没有水,等有水了告诉他。于是等啊等(select调用中),过了一会阿姨告诉他有水了,但不知道是哪个水龙头有水,自己看吧。于是C同学一个个打开,往杯子里装水(recv)。这里再顺便说说鼎鼎大名的epoll(高性能的代名词啊),epoll也属于IO复用模型,主要区别在于舍管阿姨会告诉C同学哪几个水龙头有水了,不需要一个个打开看(当然还有其它区别)。
异步I/O:调用aio_read,让内核等数据准备好,并且复制到用户进程空间后执行事先指定好的函数。
E同学让舍管阿姨将杯子装满水后通知他。整个过程E同学都可以做别的事情(没有recv),这才是真正的异步IO。
总结:
IO分两个阶段:1. 数据准备阶段;2. 内核空间复制回用户进程缓冲区阶段
一般来讲:阻塞I/O、非阻塞I/O、I/O复用模型、信号驱动I/O都属于同步I/O,因为阶段2是阻塞的(尽管时间很短)。
服务器程序通常需要处理三类事件:I/O事件,信号事件、定时事件。有两种事件处理模式:
Reactor模式: 同步IO模型通常用于实现Reactor模式
Proactor模式: 异步IO模型用于实现Proactor模式
主线程(IO处理单元)只负责监听文件描述符上是否有事件发生,有的话立刻将该事件通知工作线程(逻辑单元)。
除此之外主线程不做任何其他工作,读写数据、接收新的连接及处理客户请求均在工作线程中完成。
使用同步IO模型epoll_wait实现的Reactor模式的工作流程如下:
流程图如下:
工作线程从队列中取出事件后,将根据事件的类型来决定如何处理该事件,上图1所示的Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。当然也可以分别使用读写工作线程,对应大型应用,有时候需求区分不同的线程处理不同的业务。
与Reactor模式不同,Proactor模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
流程图如下:
在上图2中,连接socket上的读写事件是通过aio_read/aio_write向内核注册的,因此内核将通过信号向应用程序报告连接socket上的读写事件。所以主线程的epoll_wait仅能检测监听socket上的连接请求事件,不能用来检测连接socket上的读写事件。
我们可以使用同步IO模拟出Proactor模式:主线程直接执行数据的读写操作,读写完成之后,主线程向工作队列通知这一“完成事件”。工作线程直接获取读写的结果,之后只是对读写的结果进行逻辑处理。
两种高效的事件处理模式
两者对比
Reactor
实现相对简单,对于耗时短的处理场景处理高效;
操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性;
事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
事务分离:将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来,
Proactor
性能更高,能够处理耗时长的并发场景;
Reactor
处理耗时长的操作会造成事件分发的阻塞,影响到后续事件的处理;
Proactor
实现逻辑复杂;依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少,实现优秀的如windows IOCP,但由于其windows系统用于服务器的局限性,目前应用范围较小;而Unix/Linux系统对纯异步的支持有限,应用事件驱动的主流还是通过select/epoll来实现;
Reactor
:同时接收多个服务请求,并且依次同步的处理它们的事件驱动程序;
Proactor
:异步接收和同时处理多个服务请求的事件驱动程序;
综上我们可以发现Reactor模式和Proactor模式的主要区别:
1. Reactor实现同步I/O多路分发,Proactor实现异步I/O分发。
如果只是处理网络I/O单线程的Reactor尚可处理,但如果涉及到文件I/O,单线程的Reactor可能被文件I/O阻塞而导致其他事件无法被分发。所以涉及到文件I/O最好还是使用Proactor模式,或者用多线程模拟实现异步I/O的方式。
2. Reactor模式注册的是文件描述符的就绪事件,而Proactor模式注册的是完成事件。
即Reactor模式有事件发生的时候要判断是读事件还是写事件,然后用再调用系统调用(read/write等)将数据从内核中拷贝到用户数据区继续其他业务处理。
而Proactor模式一般使用的是操作系统的异步I/O接口,发起异步调用(用户提供数据缓冲区)之后操作系统将在内核态完成I/O并拷贝数据到用户提供的缓冲区中,完成事件到达之后,用户只需要实现自己后续的业务处理即可。
3. 主动和被动
Reactor模式是一种被动的处理,即有事件发生时被动处理。而Proator模式则是主动发起异步调用,然后循环检测完成事件。
select就是用户区用一个bitmap的监听集合rset来存放各个连接过来的文件描述符,在进入select函数后,内核会将该监听集合拷贝一份放入内核区fdset,然后由内核区来轮询遍历该集合,从而找到有读事件发生的文件描述符,接着将rset中该位置位,然后返回。如果没有事件满足读事件,那么select会一直轮询检查,直到有读事件满足,所以select是阻塞的。返回后,程序需要遍历文件描述符,找到对应的读事件,并做处理。当所有的事件处理完之后,将rset清空重新进行初始化。接着进行select循环。
所以:select有如下缺点:
poll相对于select几乎一样,主要区别在于,poll使用一个结构体来表示文件描述符,而不是一个bitmap位图,结构体有三个成员,分别是fd,events,revents。使用结构体数组来存放事件,这样就解决了select的1024的大小限制,另外,poll结构体里的revents成员是表示有无事件发生,置位也只是改变这一位,那么在处理完事件后只需要改变revents就行,这样就避免了不能重用的问题。因而poll解决了select的前两个问题。另外,poll也是阻塞的。
因为select和poll都是通过遍历整个文件描述符表来查找是哪个或哪几个文件描述符有事件发生,所以当并发连接数量很大,而只有少量活跃时,是很浪费CPU资源的。
当内核初始化epoll的时候(当调用epoll_create的时候内核也是个epoll描述符创建了一个文件,毕竟在Linux
中一切都是文件,而epoll面对的是一个特殊的文件,和普通文件不同),会开辟出一块内核高速cache区,这块区
用来存储我们要监管的所有的socket描述符,当然在这里面存储一定有一个数据结构,这就是红黑树,由于红黑树的
接近平衡的查找,插入,删除能力,在这里显著的提高了对描述符的管理。
epoll是这么做的,epoll是由红黑树实现的,一个epollfd充当树根,其他的文件描述符都是树上的节点,通过epoll_ctl来添加、删除、改变监听节点,当epoll_wait监听到有事件发生时,他会将就绪链表中有事件发生文件描述符换到前面,并返回有事件发生的文件描述符的个数,这样,只需要遍历前面几个文件描述符就行了,无需遍历整个文件描述符表。
当内核创建了红黑树之后,同时也会建立一个双向链表rdlist,用于存储准备就绪的描述符,当调用epoll_wait
的时候在timeout时间内,只是简单的去管理这个rdlist中是否有数据,如果没有则睡眠至超时,如果有数据则立即
返回并将链表中的数据赋值到events数组中。这样就能够高效的管理就绪的描述符,而不用去轮询所有的描述符。
所以当管理的描述符很多但是就绪的描述符数量很少的情况下如果用select来实现的话效率可想而知,很低,但是
epoll的话确实是非常适合这个时候使用。对与rdlist的维护:当执行epoll_ctl时除了把socket描述符放入到
红黑树中之外,还会给内核中断处理程序注册一个回调函数,告诉内核,当这个描述符上有事件到达(或者说中断了)
的时候就调用这个回调函数。这个回调函数的作用就是将描述符放入到rdlist中,所以当一个socket上的数据到达
的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入就绪链表rdlist中。
注意,很多博客说epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。epoll_wait的实现~有关从内核态拷贝到用户态代码.可以看到__put_user这个函数就是内核拷贝到用户空间.分析完整个linux ②.⑥版本的epoll实现没有发现使用了mmap系统调用,根本不存在共享内存在epoll的实现。
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模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
在一个非阻塞的socket上调用read/write函数, 返回EAGAIN或者EWOULDBLOCK(注: EAGAIN就是EWOULDBLOCK)
从字面上看, 意思是:EAGAIN: 再试一次,EWOULDBLOCK: 如果这是一个阻塞socket, 操作将被block,perror输出: Resource temporarily unavailable
总结:
这个错误表示资源暂时不够,能read时,读缓冲区没有数据,或者write时,写缓冲区满了。遇到这种情况,如果是阻塞socket,read/write就要阻塞掉。而如果是非阻塞socket,read/write立即返回-1, 同时errno设置为EAGAIN。
所以,对于阻塞socket,read/write返回-1代表网络出错了。但对于非阻塞socket,read/write返回-1不一定网络真的出错了。可能是Resource temporarily unavailable。这时你应该再试,直到Resource available。
综上,对于non-blocking的socket,正确的读写操作为:
读:忽略掉errno = EAGAIN的错误,下次继续读
写:忽略掉errno = EAGAIN的错误,下次继续写
所以,在epoll的ET模式下,正确的读写方式为:
读:只要可读,就一直读,直到返回0,或者 errno = EAGAIN
写:只要可写,就一直写,直到数据发送完,或者 errno = EAGAIN
在使用ET模式时,必须要保证该文件描述符是非阻塞的(确保在没有数据可读时,该文件描述符不会一直阻塞);并且每次调用read
和write
的时候都必须等到它们返回EWOULDBLOCK
(确保所有数据都已读完或写完)。
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法
HTTP请求报文由请求行(request line)、请求头部(header)、空行和请求数据四个部分组成。
其中,请求分为两种,GET和POST,具体的:
GET /562f25980001b1b106000338.jpg HTTP/1.1
Host:img.mukewang.com
User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
Accept:image/webp,image/*,*/*;q=0.8
Referer:http://www.imooc.com/
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8
空行
请求数据为空
POST / HTTP1.1
Host:www.wrox.com
User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
Content-Type:application/x-www-form-urlencoded
Content-Length:40
Connection: Keep-Alive
空行
name=Professional%20Ajax&publisher=Wiley
请求行,用来说明请求类型,要访问的资源以及所使用的HTTP版本。
GET说明请求类型为GET,/562f25980001b1b106000338.jpg(URL)为要访问的资源,该行的最后一部分说明使用的是HTTP1.1版本。
请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息。
空行,请求头部后面的空行是必须的即使第四部分的请求数据为空,也必须有空行。
请求数据也叫主体,可以添加任意的其他数据。
HTTP响应也由四个部分组成,分别是:状态行、消息报头、空行和响应正文。
HTTP/1.1 200 OK
Date: Fri, 22 May 2009 06:07:21 GMT
Content-Type: text/html; charset=UTF-8
空行
<html>
<head>head>
<body>
body>
html>
HTTP有5种类型的状态码,具体的:
1xx:指示信息–表示请求已接收,继续处理。
2xx:成功–表示请求正常处理完毕。
3xx:重定向–要完成请求必须进行更进一步的操作。
4xx:客户端错误–请求有语法错误,服务器无法处理请求。
5xx:服务器端错误–服务器处理请求出错。
非活跃
,是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。
定时事件
,是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源。
定时器
,是指利用结构体或其他形式,将多种定时事件进行封装起来。具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器。
定时器容器
,是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来。
Linux
下提供了三种定时的方法:
Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行。
为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕。
一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久。
这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码。
每个进程之中,都有存着一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位来标识对应的信号类型。
信号的接收
信号的检测
信号的处理
至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
日志,由服务器自动创建,并记录运行状态,错误信息,访问数据的文件。
同步日志,日志写入函数与工作线程串行执行,由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程,服务器所能处理的并发能力将有所下降,尤其是在峰值的时候,写日志可能成为系统的瓶颈。
生产者-消费者模型,并发编程中的经典模型。以多线程为例,为了实现线程间数据同步,生产者线程与消费者线程共享一个缓冲区,其中生产者线程往缓冲区中push消息,消费者线程从缓冲区中pop消息。
阻塞队列,将生产者-消费者模型进行封装,使用循环数组实现队列,作为两者共享的缓冲区。
异步日志,将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志。
单例模式,最简单也是被问到最多的设计模式之一,保证一个类只创建一个实例,同时提供全局访问的方法。
单例模式作为最常用的设计模式之一,保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
实现思路:私有化它的构造函数,以防止外界创建单例类的对象;使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。
单例模式有两种实现方法,分别是懒汉和饿汉模式。顾名思义,懒汉模式,即非常懒,不用的时候不去初始化,所以在第一次被使用时才进行初始化;饿汉模式,即迫不及待,在程序运行时立即初始化。
// 双检测锁模式
class single {
private:
// 私有化静态指针变量指向唯一实例
static single *p;
// 静态锁,是由于静态函数只能访问静态成员
static pthread_mutex_t lock;
// 静态构造函数
single() {
pthread_mutex_init(&lock, NULL);
}
~single() {}
public:
// 公有化静态方法获取实例
static single* getinstance();
};
pthread_mutex_t single::lock;
single *single::p = NULL;
single* single::getinstance() {
if(p == NULL) {
pthread_mutex_lock(&lock);
if(p == NULL) {
p = new single;
}
pthread_mutex_unlock(&lock);
}
return p;
}
为什么要用双检测,只检测一次不行吗?
如果只检测一次,在每次调用获取实例的方法时,都需要加锁,这将严重影响程序性能。双层检测可以有效避免这种情况,仅在第一次创建单例的时候加锁,其他时候都不再符合NULL == p的情况,直接返回已创建好的实例。
// 单检测模式(不加锁模式)
class single{
private:
single() {}
~single() {}
public:
static single* getinstance();
};
single* single::getinstance() {
static single obj;
return &obj;
}
这时候有人说了,这种方法不加锁会不会造成线程安全问题?
其实,C++0X以后,要求编译器保证内部静态变量的线程安全性,故C++0x之后该实现是线程安全的,C++0x之前仍需加锁,其中C++0x是C++11标准成为正式标准之前的草案临时名字。
所以,如果使用C++11之前的标准,还是需要加锁,这里同样给出加锁的版本。
// 单检测模式(加锁)
class single{
private:
static pthread_mutex_t lock;
single() {
pthread_mutex_init(&lock, NULL);
}
public:
static single* getinstance();
};
pthread_mutex_t single::lock;
single* single::getinstance() {
pthread_mutex_lock(&lock);
static single obj;
pthread_mutex_unlock(&lock);
return &obj;
}
饿汉模式不需要用锁,就可以实现线程安全。原因在于,在程序运行时就定义了对象,并对其初始化。之后,不管哪个线程调用成员函数getinstance(),都只不过是返回一个对象的指针而已。所以是线程安全的,不需要在获取实例的成员函数中加锁。
/* 饿汉模式 */
class single{
private:
static single* p;
single() {}
~single() {}
public:
static single* getinstance();
};
single* single::p = new single();
single* single::getinstance() {
return p;
}
饿汉模式虽好,但其存在隐藏的问题,在于非静态对象(函数外的static对象)在不同编译单元中的初始化顺序是未定义的。如果在初始化完成之前调用 getInstance() 方法会返回一个未定义的实例。
在处理用户注册,登录请求的时候,我们需要将这些用户的用户名和密码保存下来用于新用户的注册及老用户的登录校验,相信每个人都体验过,当你在一个网站上注册一个用户时,应该经常会遇到“您的用户名已被使用”,或者在登录的时候输错密码了网页会提示你“您输入的用户名或密码有误”等等类似情况,这种功能是服务器端通过用户键入的用户名密码和数据库中已记录下来的用户名密码数据进行校验实现的。若每次用户请求我们都需要新建一个数据库连接,请求结束后我们释放该数据库连接,当用户请求连接过多时,这种做法过于低效,所以类似线程池的做法,我们构建一个数据库连接池,预先生成一些数据库连接放在那里供用户请求使用。
我们首先看单个数据库连接是如何生成的:
使用mysql_init()
初始化连接
使用mysql_real_connect()
建立一个到mysql数据库的连接
MYSQL *mysql_real_connect(MYSQL *mysql, const char *host, const char *user, const char *passwd,const char *db, unsigned int port,const char *unix_socket, unsigned int client_flag)
// unix_socket为null时,表明不使用socket或管道机制
使用mysql_query()
执行查询语句
使用result = mysql_store_result(mysql)
获取结果集
使用mysql_num_fields(result)
获取查询的列数,mysql_num_rows(result)
获取结果集的行数
通过mysql_fetch_row(result)
不断获取下一行,然后循环输出
使用mysql_free_result(result)
释放结果集所占内存
使用mysql_close(conn)
关闭连接
对于一个数据库连接池来讲,就是预先生成多个这样的数据库连接,然后放在一个链表中,同时维护最大连接数MAX_CONN
,当前可用连接数FREE_CONN
和当前已用连接数CUR_CONN
这三个变量。同样注意在对连接池操作时(获取,释放),要用到锁机制,因为它被所有线程共享。
, const char *passwd,const char *db, unsigned int port,const char *unix_socket, unsigned int client_flag)
// unix_socket为null时,表明不使用socket或管道机制
```
使用mysql_query()
执行查询语句
使用result = mysql_store_result(mysql)
获取结果集
使用mysql_num_fields(result)
获取查询的列数,mysql_num_rows(result)
获取结果集的行数
通过mysql_fetch_row(result)
不断获取下一行,然后循环输出
使用mysql_free_result(result)
释放结果集所占内存
使用mysql_close(conn)
关闭连接
对于一个数据库连接池来讲,就是预先生成多个这样的数据库连接,然后放在一个链表中,同时维护最大连接数MAX_CONN
,当前可用连接数FREE_CONN
和当前已用连接数CUR_CONN
这三个变量。同样注意在对连接池操作时(获取,释放),要用到锁机制,因为它被所有线程共享。