一、同步IO、异步IO、阻塞IO、非阻塞IO
首先来看看两种I/O的定义:同步I/O和异步I/O
- 同步(阻塞)I/O:在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。
- 异步(非阻塞)I/O:当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。
1、完成I/O操作一般分成两步:
- 发起I/O请求
- 执行I/O操作
2、如何区分同步I/O和异步I/O呢?
“执行I/O操作”是否被阻塞?如果被阻塞,就是同步I/O,否则就是异步I/O。
3、同步I/O的特点
同步IO指的是用户进程触发I/O操作并等待或者轮询地查看I/O操作是否就绪。
同步IO的执行者是IO操作的发起者。
同步IO需要发起者请求之后内核才进行内核态到用户态的数据拷贝过程,所以这里必须有个阻塞。
4、异步I/O的特点
异步IO是指用户进程触发I/O操作以后就立即返回,继续开始做自己的事情,而当I/O操作已经完成的时候会得到I/O完成的通知。
异步IO的执行者是内核线程,内核线程将数据从内核态拷贝到用户态,所以这里没有阻塞
5、5种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模式
1、Reactor模式:
主线程(IO处理单元)只负责监听文件描述符上是否有事件发生,有的话立刻将该事件通知工作线程(逻辑单元)。
除此之外主线程不做任何其他工作,读写数据、接收新的连接及处理客户请求均在工作线程中完成。
使用同步IO模型epoll_wait实现的Reactor模式的工作流程如下:
- 主线程往epoll内核事件表中注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket上有数据可读。
- 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。
- 睡眠在请求队列上的工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
- 主线程调用epoll_wait等待socket可写。
- 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。
流程图如下:
工作线程从队列中取出事件后,将根据事件的类型来决定如何处理该事件,上图1所示的Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。当然也可以分别使用读写工作线程,对应大型应用,有时候需求区分不同的线程处理不同的业务。
2、Proactor模式:
与Reactor模式不同,Proactor模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
- 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情sigevent的man手册)
- 主线程继续处理其他逻辑。
- 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
- 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区位置,以及写操作完成时如何通知应用程序(仍以信号为例)
- 主线程继续处理其他逻辑。
- 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
- 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket.
流程图如下:
在上图2中,连接socket上的读写事件是通过aio_read/aio_write向内核注册的,因此内核将通过信号向应用程序报告连接socket上的读写事件。所以主线程的epoll_wait仅能检测监听socket上的连接请求事件,不能用来检测连接socket上的读写事件。
3、同步IO模拟Proactor模式
我们可以使用同步IO模拟出Proactor模式:主线程直接执行数据的读写操作,读写完成之后,主线程向工作队列通知这一“完成事件”。工作线程直接获取读写的结果,之后只是对读写的结果进行逻辑处理。
- 使用同步IO模型(epoll_wait)模拟出的Proactor模式的工作流程如下:
- 主线程往epoll内核事件表中注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket上有数据可读。
- 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入到请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。
- 主线程调用epoll_wait等待socket可写。
- 当socket可写时,epoll_wait通知主线程。主线程网socket上写入服务器处理客户端请求的结果。
两种高效的事件处理模式
两者对比
-
优点
Reactor
实现相对简单,对于耗时短的处理场景处理高效;
操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性;
事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
事务分离:将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来,Proactor
性能更高,能够处理耗时长的并发场景;缺点
Reactor
处理耗时长的操作会造成事件分发的阻塞,影响到后续事件的处理;Proactor
实现逻辑复杂;依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少,实现优秀的如windows IOCP,但由于其windows系统用于服务器的局限性,目前应用范围较小;而Unix/Linux系统对纯异步的支持有限,应用事件驱动的主流还是通过select/epoll来实现;适用场景
Reactor
:同时接收多个服务请求,并且依次同步的处理它们的事件驱动程序;
Proactor
:异步接收和同时处理多个服务请求的事件驱动程序;
4、两者区别
综上我们可以发现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/poll/epoll原理
1、select
select就是用户区用一个bitmap的监听集合rset来存放各个连接过来的文件描述符,在进入select函数后,内核会将该监听集合拷贝一份放入内核区fdset,然后由内核区来轮询遍历该集合,从而找到有读事件发生的文件描述符,接着将rset中该位置位,然后返回。如果没有事件满足读事件,那么select会一直轮询检查,直到有读事件满足,所以select是阻塞的。返回后,程序需要遍历文件描述符,找到对应的读事件,并做处理。当所有的事件处理完之后,将rset清空重新进行初始化。接着进行select循环。
所以:select有如下缺点:
- bitmap最大为1024位
- 内核空间的fdset不能重用
- 需要从用户态拷贝rset到内核态fdset
- 返回后还需要遍历rset才能找到对应的读事件
2、poll
poll相对于select几乎一样,主要区别在于,poll使用一个结构体来表示文件描述符,而不是一个bitmap位图,结构体有三个成员,分别是fd,events,revents。使用结构体数组来存放事件,这样就解决了select的1024的大小限制,另外,poll结构体里的revents成员是表示有无事件发生,置位也只是改变这一位,那么在处理完事件后只需要改变revents就行,这样就避免了不能重用的问题。因而poll解决了select的前两个问题。另外,poll也是阻塞的。
3、epoll
因为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的实现。
4、为什么用epoll?
- 对于select和poll来说,所有文件描述符都是在用户态被加入其文件描述符集合的,每次调用都需要将整个集合拷贝到内核态;epoll则将整个文件描述符集合维护在内核态,每次添加文件描述符的时候都需要执行一个系统调用。系统调用的开销是很大的,而且在有很多短期活跃连接的情况下,epoll可能会慢于select和poll由于这些大量的系统调用开销。
- select使用线性表描述文件描述符集合,文件描述符有上限;poll使用链表来描述;epoll底层通过红黑树来描述,并且维护一个ready list,将事件表中已经就绪的事件添加到这里,在使用epoll_wait调用时,仅观察这个list中有没有数据即可。
- select和poll的最大开销来自内核判断是否有文件描述符就绪这一过程:每次执行select或poll调用时,它们会采用遍历的方式,遍历整个文件描述符集合去判断各个文件描述符是否有活动;epoll则不需要去以这种方式检查,当有活动产生时,会自动触发epoll回调函数通知epoll文件描述符,然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理。
- select和poll都只能工作在相对低效的LT模式下,而epoll同时支持LT和ET模式。
- 综上,当监测的fd数量较小,且各个fd都很活跃的情况下,建议使用select和poll;当监听的fd数量较多,且单位时间仅部分fd活跃的情况下,使用epoll会明显提升性能。
四、LT(电平触发)和ET(水平触发)
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
1、LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
2、ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
3、epoll在LT和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
(确保所有数据都已读完或写完)。
4、EPOLLONESHOT
- 一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket
- 我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件
五、两种并发编程模式(半同步/半异步模式、领导者/追随者模式)
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法
1、并发模式中的同步和异步
- 同步指的是程序完全按照代码序列的顺序执行
- 异步指的是程序的执行需要由系统事件驱动
2、半同步/半异步模式工作流程
- 同步线程用于处理客户逻辑
- 异步线程用于处理I/O事件
- 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中
- 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象
3、半同步/半反应堆工作流程(以Proactor模式为例)
- 主线程充当异步线程,负责监听所有socket上的事件
- 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
- 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
- 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权
六、HTTP
1、请求报文
HTTP请求报文由请求行(request line)、请求头部(header)、空行和请求数据四个部分组成。
其中,请求分为两种,GET和POST,具体的:
- GET
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
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版本。 -
请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息。
-
- HOST,给出请求资源所在服务器的域名。
- User-Agent,HTTP客户端程序的信息,该信息由你发出请求使用的浏览器来定义,并且在每个请求中自动发送等。
- Accept,说明用户代理可处理的媒体类型。
- Accept-Encoding,说明用户代理支持的内容编码。
- Accept-Language,说明用户代理能够处理的自然语言集。
- Content-Type,说明实现主体的媒体类型。
- Content-Length,说明实现主体的大小。
- Connection,连接管理,可以是Keep-Alive或close。
- HOST,给出请求资源所在服务器的域名。
-
空行,请求头部后面的空行是必须的即使第四部分的请求数据为空,也必须有空行。
-
请求数据也叫主体,可以添加任意的其他数据。
2、响应报文
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 goes here-->
</body>
</html>
- 状态行,由HTTP协议版本号, 状态码, 状态消息 三部分组成。
第一行为状态行,(HTTP/1.1)表明HTTP版本为1.1版本,状态码为200,状态消息为OK。 - 消息报头,用来说明客户端要使用的一些附加信息。
第二行和第三行为消息报头,Date:生成响应的日期和时间;Content-Type:指定了MIME类型的HTML(text/html),编码类型是UTF-8。 - 空行,消息报头后面的空行是必须的。
- 响应正文,服务器返回给客户端的文本信息。空行后面的html部分为响应正文。
3、HTTP状态码
HTTP有5种类型的状态码,具体的:
-
1xx:指示信息–表示请求已接收,继续处理。
-
2xx:成功–表示请求正常处理完毕。
-
- 200 OK:客户端请求被正常处理。
- 206 Partial content:客户端进行了范围请求。
- 200 OK:客户端请求被正常处理。
-
3xx:重定向–要完成请求必须进行更进一步的操作。
-
- 301 Moved Permanently:永久重定向,该资源已被永久移动到新位置,将来任何对该资源的访问都要使用本响应返回的若干个URI之一。
- 302 Found:临时重定向,请求的资源现在临时从不同的URI中获得。
- 301 Moved Permanently:永久重定向,该资源已被永久移动到新位置,将来任何对该资源的访问都要使用本响应返回的若干个URI之一。
-
4xx:客户端错误–请求有语法错误,服务器无法处理请求。
-
- 400 Bad Request:请求报文存在语法错误。
- 403 Forbidden:请求被服务器拒绝。
- 404 Not Found:请求不存在,服务器上找不到请求的资源。
- 400 Bad Request:请求报文存在语法错误。
-
5xx:服务器端错误–服务器处理请求出错。
-
- 500 Internal Server Error:服务器在执行请求时出现错误。
4、GET和POST的区别
- 最直观的区别就是GET把参数包含在URL中,POST通过request body传递参数。
- GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留。
- GET请求在URL中传送的参数是有长度限制。(大多数)浏览器通常都会限制url长度在2K个字节,而(大多数)服务器最多处理64K大小的url。
- GET产生一个TCP数据包;POST产生两个TCP数据包。对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);而对于POST,浏览器先发送header,服务器响应100(指示信息—表示请求已接收,继续处理)continue,浏览器再发送data,服务器响应200 ok(返回数据)。
七、定时器
非活跃
,是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。
定时事件
,是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源。
定时器
,是指利用结构体或其他形式,将多种定时事件进行封装起来。具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器。
定时器容器
,是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来。
Linux
下提供了三种定时的方法:
- socket选项SO_RECVTIMEO和SO_SNDTIMEO
- SIGALRM信号
- I/O复用系统调用的超时参数
1、信号通知流程
-
Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行。
-
为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕。
-
一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久。
-
这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码。
2、信号处理机制
每个进程之中,都有存着一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位来标识对应的信号类型。
-
信号的接收
-
- 接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。
-
信号的检测
-
- 进程从内核态返回到用户态前进行信号检测
- 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
- 进程陷入内核态后,有两种场景会对信号进行检测:
- 当发现有新信号时,便会进入下一步,信号的处理。
- 进程从内核态返回到用户态前进行信号检测
-
信号的处理
-
- ( 内核 )信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
- ( 用户 )接下来进程返回到用户态中,执行相应的信号处理函数。
- ( 内核 )信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。
- ( 用户 )如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。
- ( 内核 )信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
八、日志
日志,由服务器自动创建,并记录运行状态,错误信息,访问数据的文件。
同步日志,日志写入函数与工作线程串行执行,由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程,服务器所能处理的并发能力将有所下降,尤其是在峰值的时候,写日志可能成为系统的瓶颈。
生产者-消费者模型,并发编程中的经典模型。以多线程为例,为了实现线程间数据同步,生产者线程与消费者线程共享一个缓冲区,其中生产者线程往缓冲区中push消息,消费者线程从缓冲区中pop消息。
阻塞队列,将生产者-消费者模型进行封装,使用循环数组实现队列,作为两者共享的缓冲区。
异步日志,将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志。
单例模式,最简单也是被问到最多的设计模式之一,保证一个类只创建一个实例,同时提供全局访问的方法。
1、单例模式
单例模式作为最常用的设计模式之一,保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
实现思路:私有化它的构造函数,以防止外界创建单例类的对象;使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。
单例模式有两种实现方法,分别是懒汉和饿汉模式。顾名思义,懒汉模式,即非常懒,不用的时候不去初始化,所以在第一次被使用时才进行初始化;饿汉模式,即迫不及待,在程序运行时立即初始化。
经典的线程安全懒汉模式
// 双检测锁模式
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() 方法会返回一个未定义的实例。
九、数据库连接
1、数据库连接池是如何运行的
在处理用户注册,登录请求的时候,我们需要将这些用户的用户名和密码保存下来用于新用户的注册及老用户的登录校验,相信每个人都体验过,当你在一个网站上注册一个用户时,应该经常会遇到“您的用户名已被使用”,或者在登录的时候输错密码了网页会提示你“您输入的用户名或密码有误”等等类似情况,这种功能是服务器端通过用户键入的用户名密码和数据库中已记录下来的用户名密码数据进行校验实现的。若每次用户请求我们都需要新建一个数据库连接,请求结束后我们释放该数据库连接,当用户请求连接过多时,这种做法过于低效,所以类似线程池的做法,我们构建一个数据库连接池,预先生成一些数据库连接放在那里供用户请求使用。
我们首先看单个数据库连接是如何生成的:
-
使用
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
这三个变量。同样注意在对连接池操作时(获取,释放),要用到锁机制,因为它被所有线程共享。