一切都从最基本开始。
网络编程中客户端连服务端的经典代码:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in svraddr;
svraddr.sin_family = AF_INET;
svraddr.sin_port = htons(iPort);
svraddr.sin_addr.s_addr = inet_addr(Ip);
connect(sockfd, (struct sockaddr*)&svraddr, sizeof(svraddr);
char req[1024]="test";
sendto(acceptfd, req, strlen(req)+1, 0, (struct sockaddr*)&svraddr, sizeof(svraddr));
网络编程中服务端接收客户端的经典代码:
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in svraddr;
svraddr.sin_family = AF_INET;
svraddr.sin_port = htons(iPort);
svraddr.sin_addr.s_addr = INADDR_ANY;
bind(listenfd, (struct sockaddr*)&svraddr, sizeof(svraddr)
listen(listenfd, SOMAXCONN);
struct sockaddr_in cliaddr;
socklen_t clilen = 0;
while(1)
{
int acceptfd = accept(listenfd, (struct sockaddr*)&cliaddr, & clilen);
char rec[1024];
memset(sRec, 0, 1024);
int byte_read = recv(acceptfd, rec, 1024, 0);
if (byte_read > 0)
{
sendto(acceptfd, rec, byte_read, 0, (struct sockaddr*)&cliaddr, sizeof(cliaddr));
}
close(acceptfd);
}
在单进程单请求的处理中,这几个函数完美的完成了一次客户端请求服务端发包的过程。Server端就是一个处理机,当来一个请求时,server捕获到这个请求,然后处理。
当服务端accept时,会阻塞当前进程,直到客户端发送请求过来。
由于服务端是处理单请求,当多个请求同时到来时,后面的请求会被阻塞住。比如:
服务端S先执行,阻塞于accept。
客户端c1连接S,但先不发送数据,则S会阻塞在recv这个io上面
此时,客户端c2连接S,即使发送数据过去,S仍然无法响应
当c1发送数据给S后,S处理完毕,会accept到c2,处理c2的请求
这种模式的缺点太明显了,无法做到处理多个fd。那么有没有这么一种设计,当io请求到来的时候,告诉服务端有请求去处理呢?
这就是i/o多路复用了
Select下的多路复用,仍然会阻塞,但是不会像Accept那样阻塞在i/o上面,而是会阻塞到select这个系统调用上面。
如此这般,当有请求(新客户端连接请求,已经连接的套接口发送数据请求等)到来时,select会捕捉到这些请求。
Select的基本流程是这样的:
阻塞于包含一堆fd的集合之上,这些fds可能是套接口,文件等
Fd中有可写,可读,或异常,或超时,select返回。
进入逻辑处理阶段
Select继续阻塞,循环处理。
经过改造之后的server端代码
char line[MAXLINE];
socklen_t clilen;
//声明epoll_event结构体的变量,ev用于注册事件,数组用于回传要处理的事件
struct epoll_event ev,events[20];
//生成用于处理accept的epoll专用的文件描述符
epfd=epoll_create(256);
struct sockaddr_in clientaddr;
struct sockaddr_in serveraddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
//把socket设置为非阻塞方式
//setnonblocking(listenfd);
//设置与要处理的事件相关的文件描述符
ev.data.fd=listenfd;
//设置要处理的事件类型
ev.events=EPOLLIN|EPOLLET;
//ev.events=EPOLLIN;
//注册epoll事件
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
bzero(&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(Ip);
serveraddr.sin_port=htons(SERV_PORT);
bind(listenfd,(sockaddr *)&serveraddr, sizeof(serveraddr));
listen(listenfd, LISTENQ);
maxi = 0;
for ( ; ; ) {
//等待epoll事件的发生
nfds=epoll_wait(epfd,events,20,500);
//处理所发生的所有事件
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd)
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen);
//setnonblocking(connfd);
//设置用于读操作的文件描述符
ev.data.fd=connfd;
//设置用于注测的读操作事件
ev.events=EPOLLIN|EPOLLET;
//ev.events=EPOLLIN;
//注册ev
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
}
else if(events[i].events&EPOLLIN)
{
if ( (sockfd = events[i].data.fd) < 0)
continue;
if ( (n = read(sockfd, line, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
close(sockfd);
events[i].data.fd = -1;
}
} else if (n == 0) {
close(sockfd);
events[i].data.fd = -1;
}
line[n] = '/0';
cout << "read " << line << endl;
//设置用于写操作的文件描述符
ev.data.fd=sockfd;
//设置用于注测的写操作事件
ev.events=EPOLLOUT|EPOLLET;
//修改sockfd上要处理的事件为EPOLLOUT
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
else if(events[i].events&EPOLLOUT)
{
sockfd = events[i].data.fd;
write(sockfd, line, n);
//设置用于读操作的文件描述符
ev.data.fd=sockfd;
//设置用于注测的读操作事件
ev.events=EPOLLIN|EPOLLET;
//修改sockfd上要处理的事件为EPOLIN
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
}
}
经过select改造后的server可以同时接受10个请求而不会阻塞,效率提升了很多。
Select返回可能会小于0,这就是异常,其中包括被信号打断,此时error= EINTR,我们需要特殊处理这种情况。
Select超时情况下会返回为0.
正常情况下select会返回准备就绪的fd。如果是listen的套接字返回,则程序需要去accept请求,如果是accept的套接字返回,则程序需要去读或者写该套接字。
Select有个缺点,就是他仅能知道集合中有某个fd触发,却无法知道是哪一个,只能挨个去找。
这样就诞生了epoll。Epoll会记录下某个fd的信息,这样当fd触发的时候,epoll机制可以很快的找到相应的fd,进行处理。
Epoll技术是在linux2.6新加的,在头文件sys/epoll.h里面定义了其中的全部数据结构和操作。
主要使用三个接口:
int epoll_create(int size);
创建一个epoll句柄
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
将某个fd操作(op)到该句柄上
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
等待该epoll句柄。
Epoll设计下server代码就变成了下面这个样子了。
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int epollfd = epoll_create(20);
struct epoll_event ev, events[20];
//setnonblocking(listenfd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listenfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev);
struct sockaddr_in svraddr;
svraddr.sin_family = AF_INET;
svraddr.sin_port = htons(atoi(argv[1]));
svraddr.sin_addr.s_addr = INADDR_ANY;
bind(listenfd, (struct sockaddr *)&svraddr, sizeof(svraddr));
listen(listenfd, SOMAXCONN);
int clientfd[10];
memset(clientfd, -1, sizeof(clientfd));
struct sockaddr_in cliaddr;
socklen_t clilen;
char recv[1024];
while(1)
{
int nfds = epoll_wait(epollfd, events, 20, 500);
for(int n = 0; n < nfds; ++n)
{
if(events[n].data.fd == listenfd)
{
int iclifd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen);
for(int i=0; i<10; i++)
{
if (clientfd[i] == -1)
{
clientfd[i] = iclifd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, iclifd, &ev);
break;
}
}
}
for(int i=0; i<=10; i++)
{
if(clientfd[i]>=0 && events[n].data.fd==clientfd[i])
{
int n = read(clientfd[i], recv, 1024);
if (n<=0)
{
close(clientfd[i]);
epoll_ctl(epollfd, EPOLL_CTL_DEL, iclifd, &ev);
clientfd[i] = -1;
}
else
{
write(clientfd[i], recv, n);
}
}
}
}
}
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_data 联合体用来保存触发事件的某个文件描述符相关的数据。
例如一个client连接到服务器,服务器通过调用accept函数可以得到于这个client对应的socket文件描述符,可以把这文件描述符赋给epoll_data的fd字段以便后面的读写操作在这个文件描述符上进行。这个可以通过epoll_ctrl的EPOLL_CTL_ADD 选项来实现,然后wait此epoll句柄,当触发特定的事件时进行特殊的处理。
epoll_event 结构体的events字段是表示感兴趣的事件和被触发的事件。可能的取值为:
EPOLLIN :表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:表示对应的文件描述符有事件发生;
ET和LT模式的区别:
LT(水平触发)模式是缺省的工作模式,同时支持block和noblock方式,这种模式下面编程会容易一些,因为只要没有处理完毕,内核不不断的通知。LT模式下的epoll和poll/select差不多,只是理论速度会快一点而已。
ET(边沿触发)模式是epoll的高速模式,只支持noblock方式。这种情况下,描述符由未就绪到就绪的变化内核会通知,直到程序做了某些操作使得该描述符不再为就绪状态。
比如从一个描述符读取数据,一次没有读完,LT模式下面内核会通知继续去读,但在ET模式下需要程序自己去控制,直到读完位置。一次读取的内容如果与自己想读取的数量一致,这个时候缓冲区很有可能有未读的数据存在。
同样,往fd写数据的时候,可能发送端的速度高于接收端的速度,这样就很容易把缓冲区填满(返回EAGAIN错误),这个时候就需要等一下子,慢慢再传才可以了。
ET模式下面客户端关闭某个fd的时候,fd状态改变,内核会告诉服务端该fd有数据可读,但这个时候的读显然已经违背了服务端的本意,时间上也差远了。
多路复用的目的,就是为了提高服务端处理高并发请求的能力,采用懒人办法,就是多进程处理,父进程负责接收信号,然后fork子进程处理逻辑,这样在逻辑梳理上就清晰多了。
每种方法都有自己的利弊,就看我们更看重哪一点了。