用了大概一个半月的时间都在做OS相关的实验感觉操作系统的东西自己还是了解适可而止,当然OS中包含了太多的设计模式以及底层相关的东西都会对自己在server端处理起到指引的作用,但是目前自己还是还是感觉自己还是对server端的处理比较感兴趣,固不再废话,进入正题--server端基本的设计模式。
[注]:所有东西基于Linux环境,并且部分设计模型在Linux下有良好的表现,不一定在Windows下适用。
说起服务端编程,自己算是了解一些基本的概念,当然在这方面就装逼的逼格来说还是不够的,说不了太深层次的东西,只是简单的想提及一些我们会常用到的概念以及模型:阻塞与非阻塞、同步异步、IO多路复用以及多线程、多进程并发、事件轮询驱动等服务端模型。
我们的目的就是将Linux网络编程和多线程、多进程以及Linux底层的设计机制结合起来组建高性能的服务端代码(老装逼了),也算是网络端的业务应用程序。基本的Linux进程线程的概念不是我们讨论的东西,当然Linux网络编程的基本TCP socket流程也不是这里要说的,我假设这些内容已经是基本的概念。关于Linux网络编程我之前的博文连接:http://blog.csdn.net/sim_szm/article/details/9569607 这里不再赘述。
下面开始我们的正文:
[一]、 一些概念(算是赘述)
[阻塞]是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。
[非阻塞]和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
[同步]所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。也就是必须一件一件事做,等前一件做完了才能做下一件事。
[异步]异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
IO多路复用(I/O multiplexing),是一个重要的概念,简单来说就是系统内核缓冲I/O数据,当某个I/O准备好后,系统通知应用程序该I/O可读或可写,这样应用程序可以马上完成相应的I/O操作,而不需要等待系统完成相应I/O操作,从而应用程序不必因等待I/O操作而阻塞,并且系统开销小,系统不必花费多余的进程、线程创建以及维护的开销成本,当然不可避免的会提升系统性能。所有可支持海量链接的系统大多都是基于IO多路复用IPC事件驱动模型的服务端架构(non-blocking IO + IO multiplexing),基本都是一个事件循环(event loop)以及事件驱动(event-driven)和事件回调的方式实现业务逻辑。在Linux中的即是我们常说的select( ) / poll( )调用,但在Linux2.6(实际是2.5.5)中实现的epoll( )(具体下面会阐述)方式才是最为强大的高性能替代产品。
关于I/O模型在《unix网络编程》中提及了以下几种:
- blocking I/O
- nonblocking I/O
- I/O multiplexing (select and poll)
- signal driven I/O (SIGIO)
- asynchronous I/O (the POSIX aio_functions)
I/O多路复用即使上述第三种,其他I/O模型这里不再赘述。
【注】:这里我必须插点东西,上面的I/O模型中我们看到了最后一种,asynchronous I/O,即异步I/O,我们常说的 I/O复用实现的同步和异步其实都是针对事件响应来说的,不论是select、poll、epoll实现的都是事件的异步,而这里的I/O操作其实都只是简单地同步,也就是说,我们借助I/O复用实现了事件响应的异步,但在真实的I/O操作上都是同步的,POSIX和glibc都有对应的异步I/O,实现的都是真实的I/O操作的异步,对于同步、异步,阻塞IO、非阻塞IO最大的区别是:
同步IO和异步IO的区别就在于:数据拷贝的时候进程是否阻塞!
阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回!
同步和异步,阻塞和非阻塞,之前其实有些混用,其实它们完全不是一回事,而且它们修饰的对象也不相同。阻塞和非阻塞是指当进程访问的数据如果尚未就绪,进程是否需要等待,简单说这相当于函数内部的实现区别,也就是未就绪时是直接返回还是等待就绪 ; 而同步和异步是指访问数据的机制,同步一般指主动请求并等待I/O操作完毕的方式,当数据就绪后在读写的时候必须阻塞(区别就绪与读写二个阶段,同步的读写必须阻塞),异步则指主动请求数据后便可以继续处理其它任务,随后等待I/O,操作完毕的通知,这可以使进程在数据读写时也不阻塞(等待"通知")。 对于异步IO,举个例子假设有一些需要处理的数据可能放在磁盘上。预先知道这些数据的位置,所以预先发起异步IO读请求。等到真正需要用到这些数据的时候,再等待异步IO完成。使用了异步IO,在发起IO请求到实际使用数据这段时间内,程序还可以继续做其他事情。在Linux下有对应的AIO函数,当然还有对应的glibc版本。
[二] 几种服务端的编程模型
对于一个较为实用的服务端业务逻辑实现来说,好的代码架构直接决定了其真实的处理能力,而好的架构必定是最能有效并且充分地利用系统资源,我们可以从下面的几种server模型中体会其不同的处理方式带来的系统性能的差异。
·1· 基本的socket建立TCP链接,从开始的socket( )到最后的accept( )之后哦的send( )/recv( )操作,即实现了最为间的单进程模型,很明显其不具备并发的能力,这种模型下server只能简单的处理一个客户端的请求,所以这种最为简单的模型只是适合一对一下基于严格时序逻辑的网络业务。
·2·在1的基础上我们如果需要支持并发,最为简单的操作便是为每个请求的任务fork( )出一个子进程来处理。这也是最简单的迭代式并发模型,从某种角度来说,进程是可以并发执行的,所以进程的并发促进了server的并发逻辑,并且进程具有很好的隔离性,模个任务出现问题一般不会影响到其他的业务。这种模型看起来简单,但是在对性能要求不是很高并且链接数较低的场景中还是应用蛮多的。因为太简单,所以就不用说为什么简单了。
·3·preforking
Preforking即是预先建立一定数量的进程/线程来提高请求的速度,相对于2中接受一个请求去fork必定是有性能和效率上的提升,但也带来了一个问题,那就是需要对空闲的进程/线程进行管理,即要求有一定的空闲进程/线程来处理业务请求,又不能使空闲的进程/线程过多造成对系统资源的浪费(注意这里需要做的有两点,1 进程空闲检查 2 资源释放)。但是一旦当需要处理的连接较多时,就会有严重的系统性能消耗,比如我们需要预先建立1000个进程,那时操作系统对进程的切换带来的开销都已经够了,还别谈什么业务处理性能。
·4· 当然preforking这种概念即可引申为我们所谓的进程/线程池,但在其具体的实现策略上还是会产生不同的结果。比如在实际应用时,我们做TCP连接处理,我们假设进程池(或线程)内的每一个进程都在做accept( ),当进程池内无业务处理时,所有的业务进程都会转到休眠状态,一旦有任务投放进来,就会产生引发所有空闲进程的争夺,这就是所谓的“惊群”现象,因为到底哪个进程去优先处理不是我们可控的(当然这里所有线程的业务逻辑是一样的),就会产生业务竞争的现象。这样的结果必然引发的是对系统性能的白白损耗。解决的办法是我们需要将原来的竞争策略转为分配,我们可以让父进程统一做accept( )操作,然后将所有的socket描述符作为资源统一分配给空闲子进程,可以的操作方式为IPC管道或是socketpair( )来实现。
这里插一点内容,对于进程间通信,管道的方式是单向的,进程间需要有父子关系才可以,如要双向通信必须开两个描述符,很不方便。大牛陈硕的推荐是只用TCP,因为tcp协议可以跨主机,具有较强的架构伸缩性,我们的任务可以通过这种方式分散在不同的主机上(真实的物理机,不牵扯虚拟的概念),当然IPC有很多方式,但就集群的方式而言,这种业务的伸缩性是必需的。
·5·重点的多线程处理
对于多线程来实现服务端的业务逻辑,我算是较为重视的一个,因为其中牵扯的问题是最多的,在逻辑架构上的设计也是多样性的,陈硕大牛的那本《Linux多线程服务端编程》中较为推崇的一中策略就是“ one loop per thread + thread pool ”,即为每一个IO线程中有一个event loop(无论是周期性还是单次的),它代表了线程的主循环,当前我需要让那个线程来处理业务我就只需要将对应的timer或是tcp连接注册到对应线程的事件轮询中(当然这里我们并不考虑同一个TCP连接的事件并发),关于事件轮询在Linux下我们常用的就是epoll,因为它的确是一种高性能的机制,对因还有FreeBSD的kqueue,但目前主流的服务端都是Linux,所以epoll的学习性价比还是蛮高的。
对于thread pool , 它的作用更多的是计算和实际业务处理,当然这里在管理方面需要对应的条件变量和mutex来管理,对于互斥锁这种东西,很多人都在排斥,如果在服务端代码出现了大量的锁必定是低效的,但我认为引起低效的更多是锁间引发的竞争关系,而不是单纯的互斥。
对于用不用多线程,这个问题并不是一定的,因为对于不同的情况而言,具体的问题要结合业务的复杂性和时序逻辑来分析。但是对于提升响应速度,让IO操作和任务计算处理并行或是从降低延时来说,多线程是不错的选择。
[三]、epoll的部分内容
对于epoll这个东西本来不打算再说,因为之前有过一篇Blog来说它的简单实用,这里再赘述一下吧,因为的确是蛮重要的东西,它是Linux下最经典的异步IO框架(这里真实的IO操作不一定是异步的,用AIO_Function才可以实现真实的IO异步)。
Epoll的函数调用主要有:epoll_create( )、 epoll_wait( )、 epoll_ctl( )、close( ).
根据man手册介绍, epoll_create(int size) 用来创建一个epoll实例,向内核申请支持size个句柄的资源(存储)。Size的大小不代表epoll支持的最大句柄个数,而隐射了内核扩展句柄存储的尺寸,也就是说当后面需要再向epoll中添加句柄遇到存储不够的时候,内核会按照size追加分配。在2.6以后的内核中,该值失去了意义,但必须大于0。epoll_create执行成功,返回一个非负的epoll描述句柄,用来指定该资源,否则返回-1。
对于事件的轮询控制主要通过epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)完成。控制对象是用户申请的句柄,即fd;Epfd指定所控制的epoll资源;op指对fd的动作,包括向epoll中添加一个句柄EPOLL_CTL_ADD,删除一个句柄EPOLL_CTL_DEL,修改epoll对一个存在句柄的监控模式EPOLL_CTL_MOD;event指出需要让epoll对fd的监控模式(收、发、触发方式等)。epoll_ctl执行成功返回0, 否则返回-1。
关于epoll的事件类型定义如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
该结构中我们主要看epoll_event。epoll_event->data涵盖了调用epoll_ctl增加或者修改某指定句柄时写入的信息,epoll_event->event,则包含了返回事件的位域。具体的添加句柄操作,限于篇幅就不再多说,seracher一下一大堆介绍,我的之前一篇Blog也有简单的介绍:
http://blog.csdn.net/sim_szm/article/details/8860803 具体可以参照。
当向epoll中添加若干句柄后,就要进入监控状态,此时通过系统调用epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)完成。epoll_wait在执行的时候,在timeout内,将有动作的句柄的信息填充到event,event和maxevents决定了epoll监控句柄的上限。timeout的单位是微妙级别,当为-1时,除非内部句柄有动作,否则持续等待。epoll_wait执行成功返回有动作的句柄的总数,句柄信息在events中包含;如果在超时timeout内返回零,表示没有io请求的句柄;否则返回-1。
对于epoll的使用有两种重要的事件触发方式,边沿触发(Edge Triggered)和水平触发(Level Triggered),边沿触发,效率较高,只在Socket发送缓冲区由满变成不满和接收缓冲区由空变成非空的瞬间,EPOLL会分别检测到EPOLLOUT和EPOLLIN事件,其它时候,没有任何事件可被检测到,为确保Socket上的收、发正常,应用程序必需确保“发则发到发不出,收必收至收不到”。在边沿触发下,正确的读写操作便是:
· 读:只要可读,就一直读,直到返回0,或者 errno = EAGAIN ·
· 写:只要可写,就一直写,直到数据发送完,或者 errno = EAGAIN ·
至于水平触发方式,只要发送缓冲区不为满,即可检测到EPOLLOUT,只要接收缓冲区不为空,即可检测到EPOLLIN,效率不如前者高,但编程更容易,不容易出错。
给一段epoll的使用代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/time.h>
#include <sys/resource.h>
#define MAXBUF 1024
#define MAXEPOLLSIZE 10000
#define BACKLOG 1
/*
setnonblocking - 设置句柄为非阻塞方式
*/
int setnonblocking(int sockfd)
{
if (fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)|O_NONBLOCK) == -1) {
return -1;
}
return 0;
}
/*
handle_message - 处理每个 socket 上的消息收发
*/
int handle_message(int new_fd)
{
char buf[MAXBUF + 1];
int len;
bzero(buf, MAXBUF + 1);
len = recv(new_fd, buf, MAXBUF, 0);
if (len > 0)
printf
("socket %d recv message :'%s',size as \n",
new_fd, buf, len);
else {
if (len < 0)
printf
("消息接收失败!错误代码是%d,with error code '%s'\n",
errno, strerror(errno));
close(new_fd);
return -1;
}
return len;
}
int main(int argc, char **argv){
int listener, new_fd, kdpfd, nfds, n, ret, curfds,opt;
socklen_t len;
struct sockaddr_in my_addr, their_addr;
struct epoll_event ev;
struct epoll_event events[MAXEPOLLSIZE];
struct rlimit rt;
/* 设置每个进程允许打开的最大文件数 */
rt.rlim_max = rt.rlim_cur = MAXEPOLLSIZE;
if (setrlimit(RLIMIT_NOFILE, &rt) == -1) {
perror("setrlimit");
exit(1);
}
else printf("设置系统资源参数成功!\n");
if ((listener = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket error ");
exit(1);
} else
printf("socket init succeed !\n");
setnonblocking(listener);
opt=1;
setsockopt(listener,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
bzero(&my_addr, sizeof(my_addr));
my_addr.sin_family = AF_INET;
my_addr.sin_port = htons(8080);
my_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listener, (struct sockaddr *) &my_addr, sizeof(struct sockaddr))== -1) {
perror("bind");
exit(1);
}
if (listen(listener,BACKLOG) == -1) {
perror("listen");
exit(1);
} else
printf("our service start !\n");
/* 创建 epoll 句柄,把监听 socket 加入到 epoll 集合里 */
kdpfd = epoll_create(MAXEPOLLSIZE);
len = sizeof(struct sockaddr_in);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listener;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev) < 0) {
fprintf(stderr, "epoll set insertion error: fd=%d\n", listener);
return -1;
} else
printf("监听 socket 加入 epoll 成功!\n");
curfds = 1;
while (1) {
nfds = epoll_wait(kdpfd, events, curfds, -1);
if (nfds == -1) {
perror("epoll_wait");
break;
}
/* 处理所有事件 */
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listener) {
new_fd = accept(listener, (struct sockaddr *) &their_addr,
&len);
if (new_fd < 0) {
perror("accept");
continue;
} else
printf("connect with: %s socket is:%d\n", inet_ntoa(their_addr.sin_addr), new_fd);
setnonblocking(new_fd);
ev.events = EPOLLIN | EPOLLET; //边沿触发
ev.data.fd = new_fd;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, new_fd, &ev) < 0) {
fprintf(stderr, "把 socket '%d' 加入 epoll 失败!%s\n",
new_fd, strerror(errno));
return -1;
}
curfds++;
} else {
ret = handle_message(events[n].data.fd);
if (ret < 1 && errno != 11) {
epoll_ctl(kdpfd, EPOLL_CTL_DEL, events[n].data.fd,
&ev);
curfds--;
}
}
}
}
close(listener);
return 0;
}
关于epoll的缓冲区处理还是一个很复杂的问题,限于篇幅,没办法在多说。今天就先说到这里,很多问题也只是很浅层次的说明,服务端牵扯了太多的机制,没办法一一列举,当然也是限于自己的知识水平,好多问题都还不了解。慢慢学习吧。