服务器设计系列:网络模型

    全文针对linux环境。tcp/udp两种server种,tcp相对较复杂也相对比较常用。本文就从tcp server开始讲起。先从基本说起,看一个单线程的网络模型,处理流程如下:
    socket-->bind-->listen-->[accept-->read-->write-->close]-->close
    []中代码循环运行,[]外的是对监听socket的处理,[]内的是对accept返回的客户socket的处理。这些系统调用的参数以及需要的头文件等,只需要在linux下man就好。
    一、注意事项

    (1)包裹宏使用。这些系统调用返回-1表示失败。检测系统调用的返回值是个好习惯,应该说必须检测,如果系统调用总是成功的话,它为何又要有返回值呢?每次检查的话,代码写起来又很是罗唆,并且容易遗漏检测。使用宏包裹系统调用或者使用包裹函数是不错的方案。下面给出几个预定义包裹宏:

#define NOERROR_FUNC(func,opt) if((func)<0) \
 { \
  printf("Line[%d] error[%d:%s]\n",__LINE__,errno,strerror(errno)); \
  opt; \
 }
#define NOERROR_FUNC_1(func) NOERROR_FUNC(func,return -1)
#define NOERROR_FUNC_NULL(func) NOERROR_FUNC(func,return NULL)
    不知道strerror?,刚说了,去linux下:man strerror。以后使用就可以类似于这样:
NOERROR_FUNC_1((fd=socket(AF_INET,SOCKET_STREAM,0)));
NOERROR_FUNC_1(bind(fd,(struct sockaddr *)&serverAddr,sizeof(struct sockaddr_in)));
    (2)不能返回失败的错误。大多数阻塞式系统调用要处理EINTR错误,另accept还要处理ECONNABORTED。与(1)同样道理,预定义宏如下:
#define NOERROR_FUNC_BUT_ERR(func,opt,err,erropt) if((func)<0) \
 { \
  printf("Line[%d] error[%d:%s]\n",__LINE__,errno,strerror(errno)); \
  if(errno==err) { erropt;} \
  else {opt;} \
 }
#define NOERROR_FUNC_BUT_ERR_2(func,opt,err1,err2,erropt) if((func)<0) \
 { \
  printf("Line[%d] error[%d:%s]\n",__LINE__,errno,strerror(errno)); \
  if(errno==err1||errno==err2) { erropt;} \
  else {opt;} \
 }
    调用accept的代码就可以如此写:

while(1)
 {
  client_sockfd=accept(fd,(struct sockaddr *)&clientAddr,&lenAddr);
  NOERROR_FUNC_BUT_ERR_2(client_sockfd,retun -1,EINTR,ECONNABORTED,continue);
...... 
    (3)涉及到系统调用分两类:从用户态到内核态,该类系统调用使用值参数,有bind/setsockopt/connect;从内核态到用户态,该类系统调用使用值-结果参数,有accept/getsockopt。
    看下两者函数原型,从用户态到内核态:
int setsockopt(int s, int level, int optname, const void *optval, socklen_t optlen);
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
int bind(int sockfd,struct sockaddr *Addr,socklen_t addrlen);
    从内核态到用户态:

int getsockopt(int s, int level, int optname, void *optval, socklen_t *optlen);
int accept(int sockfd,struct sockaddr *Addr,socklen_t *addrlen);
    看最后一个参数,从用户态到内核态只要告诉内核参数长度的值就可以了,因此是值方式。从内核态到用户态,要事先准备好变量保存内核态返回的结果长度值,因此是指针方式,称之为值-结果参数。
    二、系统调用
    (1)socket

int fd;
NOERROR_FUNC_1(fd=socket(AF_INET,SOCKET_STREAM,0));
    创建一个ipv4的tcp socket。
    (2)bind
    把socket绑定到一个地址,首先要指明地址,如下:
struct sockaddr_in addr;
addr.sin_family=AF_INET;//协议类型
addr.sin_port=htons(5000);//端口地址
addr.sin_addr.s_addr=htonl(INADDR_ANY);//此处表示任意ip(主机有多个网卡,则将环路地址127.0.0.1以及各网卡ip都指定)
NOERROR_FUNC_1(bind(fd,(struct sockaddr *)addr,sizeof(struct sockaddr_in)));
    创建ipv4协议的地址,使用5000端口,接收任何地址的connect,把该地址和fd绑定。
    注意:
    1)、地址声明的时候使用struct sockaddr_in,使用的时候总是强制转化为struct sockaddr。
    2)、 struct sockaddr_in结构中端口和ip都必须是网络序。htons把主机序的short int转化为网络序,htonl把主机序的long int转化为网络序。
    3)、除任意ip地址为常量外,一般习惯用点分字符串表示ip地址,而addr.sin_addr.s_addr要使用网络序整型。
    因此有两个函数可以在字符串和网络序ip地址之间做转换:
const char *inet_ntop(int af, const void *src,char *dst, socklen_t cnt);
int inet_pton(int af, const char *src, void *dst);

    这里是需要网络序,因此使用ton(to net)那个函数,比如:

NOERROR_FUNC_1(inet_pton(AF_INET,"172.168.0.45", &addr.sin_addr.s_addr));
    (3)setsockopt

long val;
socklen_t len=sizeof(val);
NOERROR_FUNC_1(setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&(val=1),len));
    给socket设置选项,常用的不多,SO_REUSEADDR是一个,服务器一般使用,其它还有SO_RCVBUF,SO_SNDBUF。accept返回的对端socket继承监听socket的发送缓存、接收缓存选项。一般也不需要设置SO_RCVBUF,SO_SNDBUF,默认的足够了,带宽很大的情况下,需要设置,以免其称为瓶颈,貌似默认的是8092字节。哦,还有要在listen前设置。
    (4)listen

NOERROR_FUNC_1(listen(fd,SOMAXCONN));
    把fd从主动端口变为被动端口,等待client connect。第二个参数是表示三次握手中队列以及完成了三次握手等待accept系统函数来取的队列的相加值,有的系统不是简单相加,还有一个系数,也就是如果设置5,系数是2,那么两个队列的和就是10。如果队列满,而accept没来取(很忙的情况下,来不及调用accept),再有连接来就会被拒绝掉,要想系统能处理超大爆发的连接,就加大这个参数值,加快accept的处理。SOMAXCONN表示取系统允许的最大值。
    (5)accept
    前面已经举例了,这里就不再列例子了。
    阻塞式调用,需要处理EINTR(被信号终止),ECONNABORTED(返回前client异常终止),处理的方式就是重新accept。
    (6)read

int read(int fd,char *buf,size_t len);
    这是针对文件描述符的一个系统调用,socket也属于文件描述符。tcp协议中传输的数据都是流字节,没有什么结束符的标志,只能由协议提供结束方式,比如http协议使用"\r\n\r\n"或者"\n\n"标识一条信令结束,这样的话,我们只能一个字节一个字节的读取,然后结合已经读取的字节,判断是否应该结束读。 而网络模型中要提高性能,一个重要方面就是要减少系统调用的次数。因此tcp中都要使用缓存区一次读取尽可能多的数据,然后再从该缓存区一个字节一个字节的读取,缓存区数据被读完而没有到结束位置的时候,再次调用系统调用read。
    返回值为0表示对端正常关闭,大于0表示读取到的字节数。示例见最后例子。
    (7)write
int write(int fd,char *buf,size_t len);
    两个需要注意的地方:
    1)、对EINTR处理。防止被信号中断,没有正确写入需求的字符数。
    2)、signal(SIGPIPE, SIG_IGN);这句代码的意思是忽略SIGPIPE信号。
    write写被重置(对端意外关闭)的套接口,产生SIGPIPE信号,不处理的话程序被终止。忽略的话,继续写会产生EPIPE错误,检查write系统调用的返回结果就好了。示例见最后例子。
    signal的使用,man下就看到了,回调函数的原型等都有,SIG_IGN也会出现,呵呵。
    (8)close就不说了
    (9)fcntl

    要对socket设置为非阻塞方式,setsockopt没有提供相应的选项,只能用fcntl函数设置。
int flags;
NOERROR_FUNC_1(flags=fcntl(client_sockfd,F_GETFL,0));
NOERROR_FUNC_1(fcntl(client_sockfd,F_SETFL,flags|O_NONBLOCK));
    多路复用I/O(select/poll/epoll)通常设置为非阻塞方式。
    设置为阻塞方式(默认方式)代码:
int flags;
NOERROR_FUNC_1(flags=fcntl(client_sockfd,F_GETFL,0));
NOERROR_FUNC_1(fcntl(client_sockfd,F_SETFL,flags&~O_NONBLOCK));
    对于阻塞方式的套接口,如果要避免read write永远阻塞,设置等待时间的方式有3种:信号方式,不推荐,不说了;select方式,每次调用read前调用select监视该套接口是否在指定时间内可写,超时select返回0,这样每次执行read都要调用两个系统调用,不推荐;最后就是设置套接口选项SO_RECVTIMEO和SO_SNDTIMEO,其实这个也不推荐,总之不推荐阻塞式的方式,呵呵。实用的网络模型都是多路复用的。
    非阻塞方式下的connect函数要说下,当然是就客户端而言,connect后如果没有立即返回连接成功的话,把这个socket加入select的 fd_set(poll的pollfd,epoll的EPOLL_CTL_ADD操作),要监视是否可写事件,可写的时候用getsockopt获取SO_ERROR选项,如果非负(其实就是0值)就标示connect成功,否则就是失败。EPOLL中测试结果是connect失败的返回事件是EPOLLERR|EPOLLHUP,并不是加入时的EPOLLOUT,成功的时候是EPOLLOUT。
    三、示例
    最后给个单线程的服务器,虽说没什么实用意义,不过就象“hello world!”,入门第一课。
    这个例子,读取数据,回写response,关闭clientfd。不管read write是否出错,都执行close,因此代码很简单。
//一个单线程服务器

//下面是包含的头文件和宏:
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <errno.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>

#define NOERROR_FUNC(func,opt) if((func)<0) \
    { \
        printf("Line[%d] error[%d:%s]\n",__LINE__,errno,strerror(errno)); \
        opt; \
    }
#define NOERROR_FUNC_BUT_ERR(func,opt,err,erropt) if((func)<0) \
    { \
        printf("Line[%d] error[%d:%s]\n",__LINE__,errno,strerror(errno)); \
        if(errno==err) { erropt;} \
        else {opt;} \
    }
#define NOERROR_FUNC_BUT_ERR_2(func,opt,err1,err2,erropt) if((func)<0) \
    { \
        printf("Line[%d] error[%d:%s]\n",__LINE__,errno,strerror(errno)); \
        if(errno==err1||errno==err2) { erropt;} \
        else {opt;} \
    }

#define NOERROR_FUNC_1(func) NOERROR_FUNC(func,return -1)
#define NOERROR_FUNC_NULL(func) NOERROR_FUNC(func,return NULL)

#define BUF_LEN 1024


//下面是缓存区和读写代码:
class BuffCache
{
public:
    BuffCache():count(0){}
	//从缓冲区中读取一个字节
    int read_socket(int fd,char * pCh)
    {
		//若缓冲区中数据被读完
        if(count<=0)
        {
        again:
			//从流中读取尽可能多的数据到缓冲区:阻塞式调用
            if((count=read(fd,buf,BUF_LEN))<0)
            {
                if(errno==EINTR)
                    goto again;
                *pCh='\0';
                return -1;
            }
            else if(count==0)  //到达数据流尾部,整个数据读完
            {
                *pCh='\0';
                return 0;
            }
            ptrBuf=buf;
        }
        count--;  //读取一个字节
        *pCh=*(ptrBuf++);  //指针向前移一个位置
        return 1;
    }
private:
    char buf[BUF_LEN];  //缓冲区数组
    char * ptrBuf;
    int count;
};

//从缓冲区逐字节的读取
inline int read_double_enter(int fd,char * pCh, int maxsize,BuffCache *cache)
{
    int i=0;
    char *ptr=pCh;
    int res=0;
    int sum=0;	
    for(i=0;i<maxsize;i++)  //一个字节一个字节的读取
    {
        if((res=cache->read_socket(fd,ptr))<0)
            return -1;
        else if(res==0) //到达数据流尾部
        {
            *ptr='\0';
            return sum;
        }
        else  //若读到HTTP数据流结束符"\n\n"或"\r\n\r\n",则读取完毕
        {
            if(*ptr=='\n'&&
                ((ptr-pCh>=1&&*(ptr-1)=='\n')||
                (ptr-pCh>=3&&*(ptr-1)=='\r'&&*(ptr-2)=='\n'&&*(ptr-3)=='\r')))
            {
                *(ptr+1)='\0';
                return ++sum;
            }
        }    
        ptr++;
        sum++;
    }
}

//向客户端写数据
inline int writen(int fd,const char * buf, int len)
{
    int count=0;
    int leftlen=len;
    const char * ptr=buf;
	//将buf数据写到客户端,直到所有数据写完
    while(leftlen>0)
    {
    again:
        NOERROR_FUNC_BUT_ERR((count=write(fd,ptr,leftlen)),return -1,EINTR,goto again);
        leftlen-=count;
        ptr+=count;
    }
}

//下面是main函数:
int main()
{
    int server_sockfd;
    int client_sockfd;
    struct sockaddr_in serverAddr;
    struct sockaddr_in clientAddr;
    size_t lenAddr;
        int val;

	//创建地址-端口结构
    memset(&serverAddr,0,sizeof(serverAddr));
    serverAddr.sin_family=AF_INET;
    serverAddr.sin_port=htons(5000);  //端口
    serverAddr.sin_addr.s_addr=htonl(INADDR_ANY);  //任意本地IP地址

	//创建TCP Socket
    NOERROR_FUNC_1((server_sockfd=socket(AF_INET,SOCK_STREAM,0)));
	//设置Socket属性,服务器端通常设置SO_REUSEADDR
    NOERROR_FUNC_1(setsockopt(server_sockfd,SOL_SOCKET,SO_REUSEADDR,&(val=1),sizeof(val)));
	//绑定服务器地址和端口
    NOERROR_FUNC_1(bind(server_sockfd,(struct sockaddr *)&serverAddr,sizeof(struct sockaddr_in)));
	//监听客户端连接
    NOERROR_FUNC_1(listen(server_sockfd,SOMAXCONN));
  
    const static char * response="HTTP/1.1 200 OK\r\n\r\n";
    char buf[BUF_LEN];
	//忽略SIGPIPE信号
    signal(SIGPIPE, SIG_IGN);
	//不停地接收客户端连接,并读写数据
    while(1)
    {
		//阻塞式调用
        client_sockfd=accept(server_sockfd,(struct sockaddr *)&clientAddr,&lenAddr);
		//阻塞式调用要处理ENTR错误,accept还要处理ECONNABORTED错误
        NOERROR_FUNC_BUT_ERR_2(client_sockfd,return -1,EINTR,ECONNABORTED,continue);
        BuffCache cache;
		//读取数据,并回写response消息
        if(read_double_enter(client_sockfd,buf,BUF_LEN,&cache)>0)
            writen(client_sockfd,response,19);
        close(client_sockfd);
    }
    close(server_sockfd);
    return 0;
}
    随便写的一个程序,凑合着看吧。
    四、其它基础性知识说明
    (1)除read/write外,还有recv/send,recvfrom/sendto,recvmsg/sendmsg不说了。
    (2)信号处理不说了。
    (3)多路复用下面详细叙述。
    (4)信号方式的多路复用不细说了,在tcp中只能accept除使用信号SIGIO,但是该信号为非可靠信号,当大量client连接到来的时候,经常丢失信号,10并发都支持不了,实在没什么实际意义。

    五、网络模型分类

    根据是否多路复用分为三大类:阻塞式模型、多路复用模型、实时信号模型。

    1、阻塞式模型根据是否多线程分四类:
    (1)单线程处理。
实现可以参见上面单线程的示例代码。
    (2)一个请求一个线程
    主线程阻塞在accept处,新连接到来,实时生成线程处理新连接。受限于进程的线程数,以及实时创建线程的开销,过多线程后上下文切换的开销,该模型也就是有学习上价值。
    (3)预派生一定数量线程,并且所有线程阻塞在accept处
    该模型和下面的(4)都类似于线程的领导者/追随者模型。传统的看法认为多进程(linux上线程仍然是进程方式)同时阻塞在accept处,当新连接到来时会有“惊群”现象发生,即所有都被激活,之后有一个获取连接描述符返回,其它再次转为睡眠。linux从2.2.9版本开始就不再存在这个问题,只会有一个被激活,其它平台依旧可能有这个问题,甚至是不支持所有进程直接在accept阻塞。
    (4)预派生一定数量线程,并且所有线程阻塞在accept前的线程锁处
    一次只有一个线程能阻塞在accept处。避免不支持所有线程直接阻塞在accept,并且避免惊群问题。特别是当前linux2.6的线程库下,模型(3)没有存在的价值了。另有文件锁方式,不具有通用性,并且效率也不高,不再单独列举。
    (5)主线程处理accept,预派生多个线程(线程池)处理连接
    类似与线程的半同步/半异步模型。主线程的accept返回后,将clientfd放入预派生线程的线程消息队列,线程池读取线程消息队列处理clientfd。主线程只处理accept,可以快速返回继续调用accept,可以避免连接爆发情况的拒绝连接问题,另加大线程消息队列的长度,可以有效减少线程消息队列处的系统调用次数。

    线程及线程消息队列的介绍要参考《服务器设计系列:线程》部分。
    (6)预派生多线程阻塞在accept处,每个线程又有预派生线程专门处理连接
    这是(3)和(4)/(5)的复合体。经测试,(5)中的accept线程处理能力非常强,远远大于业务线程,并发10000的连接数也毫无影响,因此该模型没有实际意义。
    总结:就前五模型而言,性能最好的是模型(5)。模型(3)/(4)可以一定程度上改善模型(1)的处理性能,处理爆发繁忙的连接,仍然不理想。阻塞式模型因为读的阻塞性,容易受到攻击,一个死连接(建立连接但是不发送数据的连接)就可以导致业务线程死掉。因此内部服务器的交互可以采用这类模型,对外的服务不适合。优先(5),然后是(4),然后是(1),其它不考虑。
    2、多路复用模型根据多路复用点、是否多线程分类:
    以下各个模型依据选用select/poll/epoll又都细分为3类。下面个别术语采用select中的,仅为说明。
    (1)accept函数在多路复用函数之前,主线程在accept处阻塞,多个从线程在多路复用函数处阻塞。主线程和从线程通过管道通讯,主线程通过管道依次将连接的clientfd写入对应从线程管道,从线程把管道的读端pipefd作为fd_set的第一个描述符,如pipefd可读,则读数据,根据预定义格式分解出clientfd放入fd_set,如果clientfd可读,则read之后处理业务。
    此方法可以避免select的fd_set上限限制,具体机器上select可以支持多少个描述符,可以通过打印sizeof(fd_set)查看,我机器上是512字节,则支持512×8=4096个。为了支持多余4096的连接数,此模型下就可以创建多个从线程分别多路复用,主线程accept后平均放入(顺序循环)各个线程的管道中。创建5个从线程以其对应管道,就可以支持2w的连接,足够了。另一方面相对与单线程的select,单一连接可读的时候,还可以减少循环扫描fd_set的次数。单线程下要扫描所有fd_set(如果再最后),该模型下,只需要扫描所在线程的fd_set就可。
    (2)accept函数在多路复用函数之前,与(1)的差别在于,主线程不直接与从线程通过管道通讯,而是将获取的fd放入另一缓存线程的线程消息队列,缓存线程读消息队列,然后通过管道与从线程通讯。
    目的是在主线程中减少系统调用,加快accept的处理,避免连接爆发情况下的拒绝连接。
    (3)多路复用函数在accept之前。多路复用函数返回,如果可读的是serverfd,则accept,其它则read,后处理业务,这是多路复用通用的模型,也是经典的reactor模型。
    (4)连接在单独线程中处理。
    以上(1)(2)(3)都可以在检测到cliendfd可读的时候,把描述符写入另一线程(也可以是线程池)的线程消息队列,另一线程(或线程池)负责read,后处理业务。
    (5)业务线程独立,下面的网络层读取结束后通知业务线程。
    以上(1)(2)(3)(4)中都可以将业务线程(可以是线程池)独立,事先告之(1)、(2)、(3)、(4)中read所在线程(上面1、2、4都可以是线程池),需要读取的字符串结束标志或者需要读取的字符串个数,读取结束,则将clientfd/buffer指针放入业务线程的线程消息队列,业务线程读取消息队列处理业务。这也就是经典的proactor模拟。
    总结:模型(1)是拓展select处理能力不错选择;模型(2)是模型(1)在爆发连接下的调整版本;模型(3)是经典的reactor,epoll在该模型下性能就已经很好,而select/poll仍然存在爆发连接的拒绝连接情况;模型(4)(5)则是方便业务处理,对模型(3)进行多线程调整的版本。带有复杂业务处理的情况下推荐模型(5)。根据测试显示,使用epoll的时候,模型(1)(2)相对(3)没有明显的性能优势,(1)由于主线程两次的系统调用,反而性能下降。
    3、实时信号模型:
    使用fcntl的F_SETSIG操作,把描述符可读的信号由不可靠的SIGIO(SYSTEM V)或者SIGPOLL(BSD)换成可靠信号。即可成为替代多路复用的方式。优于select/poll,特别是在大量死连接存在的情况下,但不及epoll。
    六、多进程的参与的方式
    (1)fork模型。fork后所有进程直接在accept阻塞。
以上主线程在accept阻塞的都可以在accept前fork为多进程。同样面临惊群问题。
    (2)fork模型。fork后所有进程阻塞在accept前的线程锁处。同线程中一样避免不支持所有进程直接阻塞在accept或者惊群问题,所有进程阻塞在共享内存上实现的线程互斥锁。
    (3)业务和网络层分离为不同进程模型。这个模型可能是受unix简单哲学的影响,一个进程完成一件事情,复杂的事情通过多个进程结合管道完成。我见过进程方式的商业协议栈实现。自己暂时还没有写该模型的示例程序测试对比性能。
    (4)均衡负载模型。起多个进程绑定到不同的服务端口,前端部署lvs等均衡负载系统,暴露一个网络地址,后端映射到不同的进程,实现可扩展的多进程方案。
    总结:个人认为(1)(2)没什么意义。(3)暂不评价。(4)则是均衡负载方案,和以上所有方案不冲突。

    七、多路复用模型

    1、select模型
    select的原型如下:

#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    其中参数n表示监控的所有fd中最大值+1。
    和select模型紧密结合的四个宏,含义不解释了:
FD_CLR(int fd, fd_set *set);
FD_ISSET(int fd, fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ZERO(fd_set *set);

    调用select()将阻塞,直到指定的文件描述符准备好执行I/O,或者可选参数timeout指定的时间已经过去。
    监视的文件描述符分为三类set,每一种对应等待不同的事件。readfds中列出的文件描述符被监视是否有数据可供读取(如果读取操作完成则不会阻塞)。writefds中列出的文件描述符则被监视是否写入操作完成而不阻塞。最后,exceptfds中列出的文件描述符则被监视是否发生异常,或者无法控制的数据是否可用(这些状态仅仅应用于套接字)。这三类set可以是NULL,这种情况下select()不监视这一类事件。
    select()成功返回时,每组set都被修改以使它只包含准备好I/O的文件描述符。例如,假设有两个文件描述符,值分别是7和9,被放在readfds中。当select()返回时,如果7仍然在set中,则这个文件描述符已经准备好被读取而不会阻塞。如果9已经不在set中,则读取它将可能会阻塞(我说可能是因为数据可能正好在select返回后就可用,这种情况下,下一次调用select()将返回文件描述符准备好读取)。
    第一个参数n,等于所有set中最大的那个文件描述符的值加1。因此,select()的调用者负责检查哪个文件描述符拥有最大值,并且把这个值加1再传递给第一个参数。
    timeout参数是一个指向timeval结构体的指针,timeval定义如下:

#include <sys/time.h>
struct timeval {
    long tv_sec; /* seconds */
    long tv_usec; /* 10E-6 second */
}; 
    如果这个参数不是NULL,则即使没有文件描述符准备好I/O,select()也会在经过tv_sec秒和tv_usec微秒后返回。当select()返回时,timeout参数的状态在不同的系统中是未定义的,因此每次调用select()之前必须重新初始化timeout和文件描述符set。实际上,当前版本的Linux会自动修改timeout参数,设置它的值为剩余时间。因此,如果timeout被设置为5秒,然后在文件描述符准备好之前经过了3秒,则这一次调用select()返回时tv_sec将变为2。
    如果timeout中的两个值都设置为0,则调用select()将立即返回,报告调用时所有未决的事件,但不等待任何随后的事件。
    文件描述符set不会直接操作,一般使用几个助手宏来管理。这允许Unix系统以自己喜欢的方式来实现文件描述符set。但大多数系统都简单地实现set为位数组。FD_ZERO移除指定set中的所有文件描述符。每一次调用select()之前都应该先调用它。
fd_set writefds;
FD_ZERO(&writefds);

    设计良好的代码应该永远不使用FD_CLR,而且实际情况中它也确实很少被使用。
    FD_ISSET测试一个文件描述符是否指定set的一部分。如果文件描述符在set中则返回一个非0整数,不在则返回0。FD_ISSET在调用select()返回之后使用,测试指定的文件描述符是否准备好相关动作:
if (FD_ISSET(fd, &readfds))
/* 'fd' is readable without blocking! */
    因为文件描述符set是静态创建的,它们对文件描述符的最大数目强加了一个限制,能够放进set中的最大文件描述符的值由FD_SETSIZE指定。在Linux中,这个值在编译内核时指定,如1024,4096。本章后面我们还将看到这个限制的衍生物。
    返回值和错误代码
    select()成功时返回准备好I/O的文件描述符数目,包括所有三个set。如果提供了timeout,返回值可能是0;错误时返回-1,并且设置errno为下面几个值之一:
    EBADF:给某个set提供了无效文件描述符。
    EINTR:等待时捕获到信号,可以重新发起调用。
    EINVAL:参数n为负数,或者指定的timeout非法。
    ENOMEM:不够可用内存来完成请求。

    理解select模型的关键在于理解fd_set。为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
    执行fd_set set; FD_ZERO(&set); 则set用位表示是0000,0000;
    若fd=5,执行FD_SET(fd,&set); 后set变为0001,0000(第5位置为1);
    若再加入fd=2,fd=1,则set变为0001,0011;
    执行select(6,&set,0,0,0)阻塞等待;
    若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意没有事件发生的fd=5被清空。
    基于上面的讨论,可以轻松得出select模型的特点:
    (1)可监控的文件描述符个数取决与sizeof(fd_set)的值。我这边服务器上sizeof(fd_set)=512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096。据说可调,另有说虽然可调,但调整上限受于编译内核时的变量值。这里对调整fd_set的大小不太感兴趣,上面网络模型分类中的模型2(1)可以有效突破select可监控的文件描述符上限。
    (2)将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,一是用于在select返回后,array作为源数据和fd_set进行FD_ISSET判断。二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
    (3)可见select模型必须在select前循环array(加fd,取maxfd),select返回后循环array(FD_ISSET判断是否有时间发生)。
    下面给一个伪码说明基本select模型的服务器模型:

array[slect_len];
nSock=0;
array[nSock++]=listen_fd;  //之前监听端口已绑定并listen
maxfd=listen_fd;
while{
   FD_ZERO(&set);   //对描述符集清零
   foreach (fd in array)  //select之前的循环
   {
       fd大于maxfd,则maxfd=fd   //记录最大的描述符
       FD_SET(fd,&set)   //从array取得fd逐一加入到set中
   }
   res=select(maxfd+1,&set,0,0,0);
   if(FD_ISSET(listen_fd,&set))
   {
       newfd=accept(listen_fd);  //接收客户端连接
       array[nsock++]=newfd;
            if(--res<=0) continue
   }
   foreach 下标1开始 (fd in array)     //select之后的循环
   {
       if(FD_ISSET(fd,&set))
          执行读等相关操作
          如果错误或者关闭,则要删除该fd,将array中相应位置和最后一个元素互换就好,nsock减一
             if(--res<=0) continue

   }
}
    2、poll模型

    poll()系统调用是System V的多元I/O解决方案。它解决了select()的几个不足,尽管select()仍然经常使用(多数还是出于习惯,或者打着可移植的名义):
#include <sys/poll.h>
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
    和select()不一样,poll()没有使用低效的三个基于位的文件描述符set,而是采用了一个单独的结构体pollfd数组,由fds指针指向这个组。pollfd结构体定义如下:

#include <sys/poll.h>
struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
}; 
    每一个pollfd结构体指定了一个被监视的文件描述符,可以以数组形式传递多个结构体,指示poll()监视多个文件描述符,nfds表示结构体数组元素个数。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码。内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。合法的事件如下:
    POLLIN:有数据可读。
    POLLRDNORM:有普通数据可读。
    POLLRDBAND:有优先数据可读。
    POLLPRI:有紧迫数据可读。
    POLLOUT:写数据不会导致阻塞。
    POLLWRNORM:写普通数据不会导致阻塞。
    POLLWRBAND:写优先数据不会导致阻塞。
    POLLMSG:SIGPOLL消息可用。
    此外,revents域中还可能返回下列事件:
    POLLER:指定的文件描述符发生错误。
    POLLHUP:指定的文件描述符挂起事件。
    POLLNVAL:指定的文件描述符非法。
    这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。使用poll()和select()不一样,你不需要显式地请求异常情况报告。
    POLLIN | POLLPRI等价于select()的读事件,POLLOUT | POLLWRBAND等价于select()的写事件。POLLIN等价于POLLRDNORM | POLLRDBAND,而POLLOUT则等价于POLLWRNORM。
    例如,要同时监视一个文件描述符是否可读和可写,我们可以设置events为POLLIN | POLLOUT。在poll返回时,我们可以检查revents中的标志,对应于文件描述符请求的events结构体。如果POLLIN事件被设置,则文件描述符可以被读取而不阻塞。如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。
    timeout参数指定等待的毫秒数,无论I/O是否准备好,poll都会返回。timeout指定为负数值表示无限超时;timeout为0指示poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其它的事件。这种情况下,poll()就像它的名字那样,一旦选举出来,立即返回。
    返回值和错误代码
    成功时,poll()返回结构体中revents域不为0的文件描述符个数;如果在超时前没有任何事件发生,poll()返回0;失败时,poll()返回-1,并设置errno为下列值之一:
    EBADF:一个或多个结构体中指定的文件描述符无效。
    EFAULT:fds指针指向的地址超出进程的地址空间。
    EINTR:请求的事件之前产生一个信号,调用可以重新发起。
    EINVAL:nfds参数超出PLIMIT_NOFILE值。
    ENOMEM:可用内存不足,无法完成请求。

    和select相比,poll模型有两大改进:
    (1)不再有fd个数的上限限制,可以将参数fds想象成栈低指针,nfds是栈中元素个数,该栈可以无限制增长。
    (2)引入pollfd结构,将fd信息、需要监控的事件、返回的事件分开保存,则poll返回后不会丢失fd信息和需要监控的事件信息,也就省略了select模型中前面的循环操作,返回后的循环仍然不可避免。另每次poll阻塞操作都会自动把上次的revents清空。
    poll的服务器模型伪码:

struct pollfd fds[POLL_LEN];
unsigned int nfds=0;
fds[0].fd=server_sockfd;
fds[0].events=POLLIN|POLLPRI;
nfds++;
while{
  res=poll(fds,nfds,-1);
  if(fds[0].revents&(POLLIN|POLLPRI))
  {
	执行accept并加入fds中,
	if(--res<=0)  continue
  }
  循环之后的fds;
  if(fds[i].revents&(POLLIN|POLLERR ))
  {
	  操作略
	  if(--res<=0)  continue
  }
}
    注意select和poll中res的检测,可有效减少循环的次数,这也是大量死连接存在时,select和poll性能下降厉害的原因。
    3、epoll模型

    epoll与select/poll不同的一点是,它是由一组系统调用组成。
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    epoll相关系统调用是在Linux 2.5.44开始引入的。该系统调用针对传统的select/poll系统调用的不足,设计上作了很大的改动。select/poll的缺点在于:
    1)每次调用时要重复地从用户态读入参数。
    2)每次调用时要重复地扫描文件描述符。
    3)每次在调用开始时,要把当前进程放入各个文件描述符的等待队列。在调用结束后,又把进程从各个等待队列中删除。
    在实际应用中,select/poll监视的文件描述符可能会非常多,如果每次只是返回一小部分,那么,这种情况下select/poll显得不够高效。epoll的设计思路,是把单个select/poll操作拆分为1个epoll_create + 多个epoll_ctr l+ 一个wait。此外,内核针对epoll操作添加了一个文件系统”eventpollfs”,每一个或者多个要监视的文件描述符都有一个对应的eventpollfs文件系统的inode节点,主要信息保存在eventpoll结构体中。而被监视的文件的重要信息则保存在epitem结构体中。所以他们是一对多的关系。

    由于在执行epoll_create和epoll_ctrl时,已经把用户态的信息保存到内核态了,所以之后即使反复地调用epoll_wait,也不会重复地拷贝参数,扫描文件描述符,反复地把当前进程放入/放出等待队列。这样就避免了以上的三个缺点。
    epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著减少程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它不会复用文件描述符集合来传递结果,而这样做会迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready就绪队列的描述符集合就行了。epoll除了提供select/poll 那种IO事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
    与以上模型相比,epoll的优点:
    (1)它保留了poll的两个相对与select的优点。
    (2)epoll_wait的参数events作为出参,直接返回了有事件发生的fd,epoll_wait的返回值既是发生事件的个数,省略了poll中返回之后的循环操作。
    (3)不再象select、poll一样将标识符局限于fd,epoll中可以将标识符扩大为指针,大大增加了epoll模型下的灵活性。

    epoll本身的优点:
    (1)支持一个进程打开大数目的socket描述符。

     select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。   

    (2)IO效率不随FD数目增加而线性下降。
    传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的---比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。

    (3)使用mmap加速内核与用户空间的消息传递。
    这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。

    (4)内核微调。

     这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小--- 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网卡驱动架构。
    epoll工作模式:epoll有2种工作方式,即LT和ET。
    (1)LT(level-triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
    (2)ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。

    epoll的接口非常简单,共有三个函数:
    (1)int epoll_create(int size);
    创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll 后,必须调用close()关闭,否则可能导致fd被耗尽。
    (2)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
    EPOLL_CTL_ADD:注册新的fd到epfd中;
    EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
    EPOLL_CTL_DEL:从epfd中删除一个fd。
    第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事件,struct epoll_event结构如下:

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 */
 };
    events可以是以下几个宏的集合:
    EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    EPOLLOUT:表示对应的文件描述符可以写;
    EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    EPOLLERR:表示对应的文件描述符发生错误;
    EPOLLHUP:表示对应的文件描述符被挂断;
    EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
    EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
    (3) int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
    epoll使用框架
    通过在包含一个头文件#include <sys/epoll.h>以及几个简单的API将可以大大的提高你的网络服务器的支持人数。
    首先,通过epoll_create(int maxfds)来创建一个epoll的句柄,其中maxfds为你epoll所支持的最大句柄数。这个函数会返回一个新的epoll句柄,之后的所有操作将通过这个句柄来进行操作。在用完之后,记得用close()来关闭这个创建出来的epoll句柄。
    然后,在你的网络主循环里面,每一帧的调用epoll_wait(int epfd, epoll_event* events, int max events, int timeout)来查询所有的网络接口,看哪一个可以读,哪一个可以写了。基本的语法为:nfds = epoll_wait(kdpfd, events, maxevents, -1);
    其中kdpfd为用epoll_create创建之后的句柄,events是一个epoll_event*的指针,当epoll_wait这个函数操作成功之后, events里面将储存所有的读写事件。max_events是当前需要监听的所有socket句柄数。最后一个timeout是 epoll_wait的超时,为0的时候表示马上返回,为-1的时候表示一直等下去,直到有事件范围,为任意正整数的时候表示等这么长的时间,如果一直没有事件,则返回。一般如果网络主循环是单独的线程的话,可以用-1来等,这样可以保证一些效率,如果是和主逻辑在同一个线程的话,则可以用0来保证主循环的效率。
    接下来,epoll_wait范围之后应该是一个循环,遍历所有的事件。
    几乎所有的epoll程序都使用下面的框架:

#include <sys/epoll.h>
//...

#define EPOLL_LEN 1000
#define EPOLL_MAX_EVENT 1000

//...
struct epoll_event ev;
int epfd=epoll_create(EPOLL_LEN);
epoll_ctl(epfd,EPOLL_CTL_ADD,server_sockfd,&ev)
struct epoll_event events[EPOLL_MAX_EVENT];
while(1)
{
    nfds = epoll_wait(epfd,events,EPOLL_MAX_EVENT,-1);
    for(i=0;i<nfds;++i)   //循环nfds,是server_sockfd则accept,否则执行响应操作
    {
        if(events[i].data.fd==server_sockfd) //如果是主socket的事件,则表示有新的连接
        {
            connfd = accept(server_sockfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
            ev.data.fd=connfd;
            ev.events=EPOLLIN|EPOLLET;
            epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
        }
        else if( events[i].events&EPOLLIN ) //接收到数据,读socket
        {
            if ( (sockfd = events[i].data.fd) < 0) continue;
                 n = read(sockfd, line, MAXLINE)) < 0    //读
                 ev.data.ptr = md;     //md为自定义类型,添加数据
                 ev.events=EPOLLOUT|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
        }
        else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
        {
            struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取数据
            sockfd = md->fd;
            send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据
            ev.data.fd=sockfd;
            ev.events=EPOLLIN|EPOLLET;
            epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
        }
        else
        {
            //其他情况的处理
        }
    }
}
close(epfd);
    epoll使用中的问题:
    (1)epoll_ctl的EPOLL_CTL_DEL操作中,最后一个参数是无意义的,但是在小版本号过低的2.6内核下要求最后一个参数一定非NULL,否则返回失败,并且返回的errno在man epoll_ctl中不存在,因此安全期间,保证epoll_ctl的最后一个参数总非NULLL。
    (2)如果一个fd(比如管道)的事件导致了另一个fd2的删除,则必须扫描返回结果集中是否有fd2,有则在结果集中删除,避免冲突。
    (3)有文章说epoll在G网环境下性能会低于poll/select,看有些测试,给出的拐点在2w/s并发之后,我本人的工作范围不可能达到这么高的并发,个人在测试性能的时候最大也是取的1w/s的并发,一个是因为系统单进程允许打开的文件描述符最大值,4w的数字太高了,另一个就是我这边服务器的性能达不到那么高的性能,极限1.7w/s的响应,那测试的数据竟然在2w并发的时候还有2w的响应,不知道是什么硬件配置。或许等有了G网的环境,会关注epoll高并发下的性能下降。
    (4)有人测试指出,epoll的LT和ET性能的差异还是蛮大的,LT单台能撑到10wcpu已经是极限,但ET撑到15w没啥问题。不过如果应用不要求撑到这么高的在线,那确实LT就可以满足了。如果没有这么高的在线连接量需求,可以使用LT模式,编程简单、安全。
    4、port模型
    port则和epoll非常接近,不需要前后的两次扫描,直接返回有事件的结果,可以象epoll一样绑定指针,不同点是:
    (1)epoll可以返回多个事件,而port一次只返回一个(port_getn可以返回多个,但是在不到指定的n值时,等待直到达到n个)
    (2)port返回的结果会自动port_dissociate,如果要再次监控,需要重新port_associate
    这个就不多说了。
    从以上的介绍中,我们可以看出 select-->poll-->epoll/port的演化路线:
    (1)从readset、writeset等分离到将读写事件集中到统一的结构。
    (2)从阻塞操作前后的两次循环到之后的一次循环,到精确返回有事件发生的fd。
    (3)从只能绑定fd信息,到可以绑定指针结构信息。

    八、抽象接口
    综合以上多路复用函数的特点,可以进行统一的封装,这里给出我封装的接口,也算是给一个思路:

 virtual int init()=0;
 virtual int wait()=0;
 virtual void * next_result()=0;
 virtual void delete_from_results(void * data)=0;
 virtual void * get_data(void * event)=0;
 virtual int get_event(void * event)=0;
 virtual int add_data(int fd,XPollData * data)=0;
 virtual int delete_data(int fd,XPollData *data)=0;
 virtual int change_data(int fd,XPollData *data)=0;
 virtual int reset_data(int fd,XPollData *data)=0;

    使用的时候就是先init,再wait,再循环执行next_result直到空,每个result,使用get_data和get_event挨个处理,如果某个fd引起另一个fd关闭,调delete_from_results(除epoll,其它都直接return),处理完reset_data(select和port用,poll/epoll直接return)。


参考文献:
技术系列之网络模型:http://www.cppblog.com/CppExplore/MyPosts.html

epoll基本原理及使用框架:http://www.cnblogs.com/panfeng412/articles/2229095.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值