如果写过最基础的TCP服务,那就应该清楚 accept
和 recv
函数是阻塞式的(默认),也就是说程序就卡在这个地方等待,直到有连接或者数据来到。单线程处理这种事情时,一旦有数据到来就会一直处理这个连接的数据,而没法接收新的连接。这种情况可以用多线程处理,但是服务器并发量大的时候,如果每个请求都新建一个线程的话,会占用很多系统资源。其实操作系统可以在一个线程里分时处理这些事务,也就是常说的I/O多路复用。
select,poll,epoll 都是I/O多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。select
是比较早出现的技术,但是select
同时处理的描述符有个数限制(默认1024)。为了弥补其缺点出现了poll
,虽然poll
没有个数限制,但是其实现机制与select
类似,且随着文件描述符变多系统性能会下降。为了解决这些问题,后来就出现了epoll
复用机制(Linux 2.6+,Richard 老爷子的 UNPv3 书没有提到 epoll),也是现在比较常用的I/O复用机制。
下面会具体描述这三者的使用以及特点。
select
select
的实现机制是先定义一个含有一共1024比特的long型数组的结构fd_set
,用来“存放”监听的文件描述符,首先使用宏FD_ZERO
把这个集合清空。然后使用宏FD_SET
把需要监听的文件描述符放在这个集合中,最后调用select
函数来监听这些文件描述符,可以一直阻塞等待直到有可操作的描述符才返回,也可以设置一个超时时间。
当调用select()
函数的时候,内核会根据I/O状态修改与此描述符匹配的fd_set中的标志位。当select函数返回的时候,返回的是所有句柄列表,并没有告知哪个描述符准备好了。需要手动检查哪个描述符对应的标志位发生了变化,再对相应的描述符进行读写操作。
select API
int select (int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout);
- 参数1:一般使用最大文件描述符+1
- 参数2:关注读状态的描述符集,一般都用的这个
- 参数3:关注写状态的描述符集,不用设置为NULL
- 参数4:异常状态描述符集,没用过,一般设置NULL
- 参数5:设置阻塞超时时间,这个参数有3种可能。1. 设置空指针则一直等待,2. 等待timeval指定的固定时间,3. timeval结构值为0,则每次调用都不等待。
select 示例
先看代码把流程搞懂再来看总结性的话可能更有助于理解,所以我一直喜欢直接贴示例代码。
这是一个完整的TCP Server代码,可以同时处理多个客户端连接。可以直接跳过前面socket直接看后面的select相关代码。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/select.h>
#define BACKLOG 5
#define BUFF_SIZE 200
#define DEFAULT_PORT 6666
typedef struct {
int fd; /* client's connection descriptor */
struct sockaddr_in addr; /* client's address */
} CLIENT;
int main(int argc, char *argv[])
{
int SERVER_PORT = DEFAULT_PORT;
if (argc > 2)
printf("param err:\nUsage:\n\t%s port | %s\n\n", argv[0], argv[0]);
if (argc == 2)
SERVER_PORT = atoi(argv[1]);
int i, maxi, maxfd, nready, nbytes;
int servSocket, cliSocket;
// 定义fd_set集合
fd_set allset, rset;
socklen_t addrLen;
char buffer[BUFF_SIZE];
CLIENT client[FD_SETSIZE]; /* FD_SETSIZE == 1024 */
struct sockaddr_in servAddr, cliAddr;
if ((servSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
exit(1);
}
int optval = 1;
if (setsockopt(servSocket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
perror("setsockopt");
exit(0);
}
bzero(&servAddr, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(SERVER_PORT);
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(servSocket, (struct sockaddr *)&servAddr, sizeof(servAddr)) < 0) {
printf("bind");
exit(1);
}
if (listen(servSocket, BACKLOG) < 0) {
printf("listen err");
exit(1);
}
printf("Listen Port: %d\nListening ...\n", SERVER_PORT);
maxi = -1;
maxfd = servSocket;
// 把自定义的client数组中的fd都初始化为-1
for (i = 0; i < FD_SETSIZE; i++)
client[i].fd = -1; /* -1 indicates available entry */
// 清空allset集合的标志位
FD_ZERO(&allset);
// 把监听socket放入这个集合中
FD_SET(servSocket, &allset);
for (;;) {
rset = allset;
// 定义两秒的超时时间
struct timeval timeout;
timeout.tv_sec = 2;
timeout.tv_usec = 0;
// 这个只关注可读状态的描述符,并设置固定的超时时间
nready = select(maxfd + 1, &rset, NULL, NULL, &timeout);
// 出错返回-1
if (nready < 0) {
perror("select");
break;
}
// 超时时间到了返回0
else if (nready == 0) {
printf("select time out\n");
continue;
}
// 关注的描述符可操作,返回值>0
// select返回的是整个集合,检查监听的socket是否可读
if (FD_ISSET(servSocket, &rset)) {
addrLen = sizeof(cliAddr);
// 监听的socket可读,直接调用accept接收请求
if ((cliSocket = accept(servSocket, (struct sockaddr *)&cliAddr, &addrLen)) < 0) {
perror("accept");
exit(1);
}
printf("\nNew client connections %s:%d\n", inet_ntoa(cliAddr.sin_addr),
ntohs(cliAddr.sin_port));
// 保存客户端连接的socket,放在之前定义的client数组中
for (i = 0; i < FD_SETSIZE; i++) {
if (client[i].fd < 0) {
client[i].fd = cliSocket;
client[i].addr = cliAddr;
break;
}
}
if (i == FD_SETSIZE)
perror("too many clients");
// 把刚刚接收的链接描述符放在关注集合中
FD_SET(cliSocket, &allset);
if (cliSocket > maxfd)
maxfd = cliSocket; /* for select */
if (i > maxi)
maxi = i; /* max index in client[] array */
if (--nready <= 0)
continue; /* no more readable descriptors */
}
// 上一步处理了新连接,这里处理已有连接可读的socket
// 遍历所有的客户连接socket
for (i = 0; i <= maxi; i++)
{
if ((cliSocket = client[i].fd) < 0)
continue;
// 依次检查每一个客户连接是否可读
if (FD_ISSET(cliSocket, &rset)) {
memset(buffer, 0, BUFF_SIZE);
// 当前客户连接可读则直接使用recv接收数据
nbytes = recv(cliSocket, buffer, sizeof(buffer), 0);
if (nbytes < 0) {
perror("recv");
continue;
}
// recv返回0表示客户端断开连接
else if (nbytes == 0) {
printf("\nDisconnect %s:%d\n", inet_ntoa(client[i].addr.sin_addr),
ntohs(client[i].addr.sin_port));
close(cliSocket);
// 把此客户端连接从关注集合中清除
FD_CLR(cliSocket, &allset);
client[i].fd = -1;
} else {
printf("\nFrom %s:%d\n", inet_ntoa(client[i].addr.sin_addr),
ntohs(client[i].addr.sin_port));
printf("Recv: %sLength: %d\n\n", buffer, nbytes);
}
if (--nready <= 0)
break; /* no more readable descriptors */
}
}
}
}
源码下载:
https://github.com/lmshao/snippets/blob/master/c/Select_TcpServer.c
poll
上面说到select最多只能支持1024个描述符,因为它是使用含有1024比特的long型数组的结构fd_set
来“存放”监听的文件描述符,虽然可以在内核中修改此参数但是非常不方便。
为了解决这个个数限制,后来就有了poll这个模型。select的结构fd_set是固定大小的,poll使用pollfd
结构的数组来传递描述符,这个数组长度可以由用户自己定义,其中一个结构标志一个描述符,这下就解决了select的个数限制问题。
poll API
int poll (struct pollfd *__fds, unsigned long __nfds, int __timeout);
- 参数1:指向一个结构数组第一个元素的指针,每个数组元素都是一个
pollfd
结构。 - 参数2:上面数组中元素的个数
- 参数3:这个超时时间和select的不太一样。这里直接使用整数值来表示等待的毫秒数,0表示立即返回不阻塞,UNP书上说INFTIM表示永远等待,但是最新的Ubuntu上面函数定义就是让设置为-1,那就设置-1吧。
- 返回值:>0 就绪的描述符个数,=0 等待超时, <0 出错。
struct pollfd {
int fd; /* File descriptor to poll. */
short int events; /* Types of events poller cares about. */
short int revents; /* Types of events that actually occurred. */
};
上面是这个pollfd的结构,每个描述符使用一个此结构来标志。测试条件由events指定,函数在revents中返回该描述符的状态。即你关注什么状态就把events设置相应的值,返回的时候系统使用revents告诉用户发生了什么事情。这个状态值在系统中有宏定义。常见宏如下所示,正规TCP数据和UDP数据都被认为是普通数据。
#define POLLRDNORM 0x040 /* 普通数据可读 */
#define POLLWRNORM 0x100 /* 可以写数据 */
#define POLLERR 0x008 /* 发生错误 */
poll 示例
还是TCP Server的示例。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <poll.h>
#define BACKLOG 5
#define BUFF_SIZE 200
#define DEFAULT_PORT 6666
#define OPEN_MAX 1024 // 这个值可以更大
int main(int argc, char **argv)
{
int SERV_PORT = DEFAULT_PORT;
if (argc > 2)
printf("param err:\nUsage:\n\t%s port | %s\n\n", argv[0], argv[0]);
if (argc == 2)
SERV_PORT = atoi(argv[1]);
int i, maxi, nready;
int servSocket, cliSocket;
ssize_t nbytes;
char buf[BUFF_SIZE];
socklen_t addrLen;
struct pollfd client[OPEN_MAX]; // 定义一个很大的 pollfd 数组
struct sockaddr_in cliAddr, servAddr;
if ((servSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
exit(1);
}
int optval = 1;
if (setsockopt(servSocket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
perror("setsockopt");
exit(0);
}
bzero(&servAddr, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(SERV_PORT);
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(servSocket, (struct sockaddr *)&servAddr, sizeof(servAddr)) < 0) {
perror("bind");
exit(1);
}
if (listen(servSocket, BACKLOG) < 0) {
perror("listen err");
exit(1);
}
printf("Listen Port: %d\nListening ...\n", SERV_PORT);
// 先把listen的描述符放进数组
client[0].fd = servSocket;
client[0].events = POLLRDNORM; // 关注可读状态
// 初始化此数组
for (i = 1; i < OPEN_MAX; i++)
client[i].fd = -1; /* -1 indicates available entry */
maxi = 0; /* max index into client[] array */
for (;;) {
// 开始监听啦~
nready = poll(client, maxi + 1, -1);
if (nready < 0) { // 报错了
printf("poll err");
exit(1);
}
// servSocket可读,说明有新链接来了
if (client[0].revents & POLLRDNORM)
{
addrLen = sizeof(cliAddr);
if ((cliSocket = accept(servSocket, (struct sockaddr *)&cliAddr, &addrLen)) < 0) {
printf("accept err");
exit(1);
}
for (i = 1; i < OPEN_MAX; i++){
if (client[i].fd < 0) {
client[i].fd = cliSocket; // 保存客户端连接的描述符,按顺序放在数组中
client[i].events = POLLRDNORM; // 还是关注是否可读
break;
}
}
printf("\nNew client connections client[%d] %s:%d\n", i, inet_ntoa(cliAddr.sin_addr),
ntohs(cliAddr.sin_port));
if (i == OPEN_MAX)
printf("too many clients");
if (i > maxi)
maxi = i; /* max index in client[] array */
if (--nready <= 0)
continue; /* no more readable descriptors */
}
// 循环检查所有的客户端连接
for (i = 1; i <= maxi; i++)
{
if ((cliSocket = client[i].fd) < 0)
continue;
if (client[i].revents & (POLLRDNORM | POLLERR)) {
memset(buf, 0, BUFF_SIZE);
nbytes = recv(cliSocket, buf, BUFF_SIZE, 0);
if (nbytes < 0) {
printf("recv err");
continue;
} else if (nbytes == 0) {
printf("client[%d] closed connection\n", i);
close(cliSocket);
client[i].fd = -1; // 客户端断开连接,重置标志位
} else {
printf("\nFrom client[%d]\n", i);
printf("Recv: %sLength: %d\n\n", buf, (int)nbytes);
}
if (--nready <= 0)
break; /* no more readable descriptors */
}
}
}
}
源码下载:
https://github.com/lmshao/snippets/blob/master/c/Poll_TcpServer.c
epoll
虽然poll解决了select的描述符个数限制,但是实现机制都是把用户态的描述符copy到内核态,然后全部吐出来,用户手动去遍历查询。且随着数量增长,其性能也会大幅下降。于是各个平台就搞了新的I/O复用机制,Linux的是epoll,Windows的是IOCP,Unix的是Kqueue。
epoll 模型中一个重要的概念是epoll instance
,epoll实例是一种内核数据结构,从用户空间来看的话,可以理解为两个list。
- interest list (epoll set): 进程注册要监视的一组文件描述符。
- ready list: 是 interest list 中处于准备状态的一组文件描述符,由内核动态地把准备好的文件描述符放倒这个集合中。
这么看的话epoll是用一个描述符来管理多个描述符,先来看看epoll的API。
epoll API
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 */
};
epoll_event 为用户传入的参数结构体,用户标志一个描述符。
events
标志关注的epoll事件,在sys.epoll.h
的enum EPOLL_EVENTS
中有宏定义。常见宏如下
- EPOLLIN :文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:文件描述符可以写;
- EPOLLPRI:文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:文件描述符发生错误;
- EPOLLHUP:文件描述符被挂起;
- EPOLLET: 将EPOLL设为**边缘触发(Edge Triggered)**模式,这是相对于水平触发(Level Triggered)来说的。关于触发模式接下来再细说。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
epoll_create
int epoll_create (int __size);
int epoll_create1 (int __flags);
epoll_create 创建一个新的epoll实例
并返回此实例的描述符,epoll_create1与前一个功能一样,使用 FLAGS 代替未使用的 SIZE。此后的用户关心的描述符结构与此描述符进行绑定或者解绑就可以了。
epoll_ctl
int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);
epoll_ctl 注册感兴趣的文件描述符,把文件描述符添加到epoll实例
的interest list感兴趣列表中。
epoll_wait
int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
epoll_wait 监听 epoll 实例_epfd
中I/O事件,如果当前没有可用事件将阻塞当前线程。函数返回值为可操作的ready
事件个数,第二个参数__events
为ready
事件结构数组指针。第三个参数一般为第二个参数的数组长度。
也就是说epoll只返回可操作性的文件描述符,而不是把所有的描述符都返回来让用户去遍历哪个可操作。
epoll 的触发模式
水平触发(LT,Level Triggered)
epoll_wait() 会通知你某个描述符上有数据可读写,如果你不处理,下次调用的时候还会通知你,直到你处理为止。如果有大量不关心的文件描述符出现可读写状态,就会一直通知你,严重影响你检查关心的文件描述符的效率。
边缘触发(ET, Edge Triggered)
与水平触发模式相反,调用epoll_wait()的时候会通知你哪个文件描述符可读写,如果你不处理或者没处理完下次也不通知你,只通知你这一次,爱咋咋地。直到第二次有数据可读写的时候再次通知你。这种效率比较高,但是不能保证数据的完整性,如果一次处理不完就不告诉你了。
epoll 示例
同上,也是个TCP Server示例。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define BACKLOG 5
#define BUFF_SIZE 200
#define DEFAULT_PORT 6666
#define MAX_EVENTS 10
int main(int argc, char *argv[])
{
int SERVER_PORT = DEFAULT_PORT;
if (argc > 2)
printf("param err:\nUsage:\n\t%s port | %s\n\n", argv[0], argv[0]);
if (argc == 2)
SERVER_PORT = atoi(argv[1]);
int nbytes;
char buffer[BUFF_SIZE];
int servSocket, cliSocket;
socklen_t addrLen;
struct sockaddr_in servAddr, cliAddr;
struct epoll_event ev, readyEvents[MAX_EVENTS];
int nfds, epollfd;
if ((servSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
printf("socket err");
exit(1);
}
int optval = 1;
if (setsockopt(servSocket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
perror("setsockopt");
exit(0);
}
bzero(&servAddr, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(SERVER_PORT);
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(servSocket, (struct sockaddr *)&servAddr, sizeof(servAddr)) < 0) {
perror("bind");
exit(1);
}
if (listen(servSocket, BACKLOG) < 0) {
perror("listen");
exit(1);
}
printf("Listen Port: %d\nListening ...\n", SERVER_PORT);
// 创建一个epoll实例
if ((epollfd = epoll_create1(0)) == -1) {
perror("epoll_create");
exit(1);
}
// ev是一个临时的变量,设置关心的描述符和关心的事件,然后把此结构与epoll实例绑定
ev.events = EPOLLIN;
ev.data.fd = servSocket;
// 给epoll实例感兴趣列表添加一个事件
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, servSocket, &ev) == -1) {
perror("epoll_ctl");
exit(1);
}
for (;;) {
// 等待epollfd表示的epoll实例中的事件变化,返回准备好的事件集合readyEvents
if ((nfds = epoll_wait(epollfd, readyEvents, MAX_EVENTS, -1)) == -1) {
perror("epoll_wait");
exit(1);
}
for (int n = 0; n < nfds; n++) {
// 有新连接到来了
if (readyEvents[n].data.fd == servSocket) {
cliSocket = accept(servSocket, (struct sockaddr *)&cliAddr, &addrLen);
if (cliSocket == -1) {
perror("accept");
exit(1);
}
printf("\nNew client connections client[%d] %s:%d\n", cliSocket,
inet_ntoa(cliAddr.sin_addr), ntohs(cliAddr.sin_port));
ev.events = EPOLLIN | EPOLLET; // 设置关心可读状态和边缘触发模式
ev.data.fd = cliSocket;
// 把心连接描述符加到epoll实例感兴趣列表
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, cliSocket, &ev) == -1) {
perror("epoll_ctl: cliSocket");
exit(1);
}
} else {
// 已有连接发数据过来了,开始接收数据~
cliSocket = readyEvents[n].data.fd;
memset(buffer, 0, BUFF_SIZE);
/* recv data */
nbytes = recv(cliSocket, buffer, sizeof(buffer), 0);
if (nbytes < 0) {
perror("recv");
continue;
} else if (nbytes == 0) {
printf("\nDisconnect fd[%d]\n", cliSocket);
close(cliSocket);
// 关闭文件描述符epoll实例会自动移除此描述符,
// 也可以使用EPOLL_CTL_DEL手动移除
} else {
printf("\nFrom fd[%d]", cliSocket);
printf("\nRecv: %sLength: %d\n\n", buffer, nbytes);
}
}
}
}
// return 0;
}
源码下载:
https://github.com/lmshao/snippets/blob/master/c/Epoll_TcpServer.c
总结
select
优点:
出现的比较早,很多平台都支持,应用广泛。
缺点:
文件描述符有默认1024的个数限制。
每次都把描述符从用户态copy到内核态,发生变化后然后再copy出来,调用者遍历检查所有描述符的可读写状态。
poll
优点:
没有描述符个数限制。但是个数多的时候性能也会下降。
缺点:
select除了个数限制外的缺点他都有。
epoll
优点:
使用一个文件描述符管理多个描述符,没有描述符个数限制。
事件驱动模式,每次调用只返回状态改变的文件描述符。
也算是目前应用最广泛的I/O复用类型,libevent libuv等异步事件库都是使用的epoll。
缺点:
没查到。
因个人理解有限,文中有说得不对的地方请留言。