网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
if (connfd > maxfd) {
maxfd = connfd; //主要给select第一个参数用的
}
if (i > maxi) {
maxi = i; //保证maxi存的总是client数组的最后一个下标元素
}
//如果nready = 0, 说明新连接都已经处理完了,且没有已建立好的连接触发读事件
if (--nready == 0) {
continue;
}
}
for (i = 0; i <= maxi; i++) {
if ((sockfd = client[i]) < 0) {
continue;
}
//sockfd 是存在client里的fd,该函数触发,说明有数据过来了
if (FD\_ISSET(sockfd, &rset)) {
if ((n = read(sockfd, buf, sizeof(buf))) == 0) {
printf("socket[%d] closed\n", sockfd);
close(sockfd);
FD\_CLR(sockfd, &allset);
client[i] = -1;
} else if (n > 0) {
//正常接收到了数据
printf("accept data: %s\n", buf);
}
if (--nready == 0) {
break;
}
}
}
}
close(listenfd);
return 0;
}
#### 缺点
`select`作为`IO`多路复用的初始版本,只能说是能用而已,性能并不能高到哪儿去,使用的局限性也比较大。主要体现在以下几个方面:
>
> * 文件描述符上限:`1024`,同时监听的最大文件描述符也为`1024`个
> * `select`需要遍历所有的文件描述符(`1024`个),所以通常需要自定义数据结构(数组),单独存文件描述符,减少遍历
> * 监听集合和满足条件的集合是同一个集合,导致判断和下次监听时需要对集合读写,也就是说,下次监听时需要清零,那么当前的集合结果就需要单独保存。
>
>
>
#### 优点
但`select`也并不是一无是处,我个人是十分喜欢`select`这个函数的,主要得益于以下几个方面:
>
> * 它至少提供了单线程同时处理多个`IO`的一种解决方案,在一些简单的场景(比如并发小于 `1024`)的时候 ,还是很有用处的
> * `select`的实现比起`poll`和`epoll`,要简单明了许多,这也是我为什么推荐在一些简单场景优先使用`select`的原因
> * `select`是跨平台的,相比于`poll`和`epoll`是`Unix`独有,`select`明显有更加广阔的施展空间
> * 利用`select`的跨平台特性,可以实现很多有趣的功能。比如实现一个跨平台的`sleep`函数。
> + 我们知道,`Linux`下的原生`sleep`函数是依赖于`sys/time.h`的,这也就意味着它无法被`Windows`平台调用。
> + 因为`select`函数本身跨平台,而第五个参数恰好是一个超时时间,即:我们可以传入一个超时时间,此时程序就会阻塞在`select`这里,直到超时时间触发,这也就间接地实现了`sleep`功能。
> + 代码实现如下
> ```
> //传入一个微秒时间
> void general\_sleep(int t){
> struct timeval tv;
> tv.tv_usec = t % 10e6;
> tv.tv_sec = t / 10e6;
> select(0, NULL, NULL, NULL, &tv);
> }
>
> ```
> + `select`实现的`sleep`函数至少有两个好处:
> - 可以跨平台调用
> - 精度可以精确到微秒级,比起`Linux`原生的`sleep`函数,精度要高得多。
>
>
>
### poll
#### 原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数说明:
>
> `fds`: 数组的首地址
> `nfds`: 最大监听的文件描述符个数
> `timeout`: 超时时间
>
>
>
鉴于`select`函数的一些 缺点和局限性,`poll`的实现就做了一些升级。首先,它突破了`1024`文件描述符的限制,其次,它将事件封装了一下 ,构成了`pollfd`的结构体,并将这个 结构体中注册的事件直接与`fd`进行了绑定,这样 就无需每次有事件触发,就遍历所有的`fd`了,我们只需要遍历这个 结构体数组中的`fd`即可。
那么 ,`poll`函数可以注册哪些事件类型呢?
POLLIN 读事件
POLLPRI 触发异常条件
POLLOUT 写事件
POLLRDHUP 关闭连接
POLLERR 发生了错误
POLLHUP 挂断
POLLNVAL 无效请求,fd未打开
POLLRDNORM 等同于POLLIN
POLLRDBAND 可以读取优先带数据(在 Linux 上通常不使用)。
POLLWRNORM 等同于POLLOUT
POLLWRBAND 可以写入优先级数据。
事件虽然比较多,但我们主要关心`POLLIN`和`POLLOUT`就行了。
#### 实现
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#include <errno.h>
#include <ctype.h>
#define OPEN_MAX 1024
int main(int argc, char *argv[]){
int i, maxi, listenfd, connfd, sockfd;
int nready; // 接受poll返回值,记录满足监听事件的fd个数
ssize_t n;
char buf[BUFSIZ], str[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN = 16
struct pollfd client[OPEN_MAX]; //用来存放监听文件描述符和事件的集合
struct sockaddr_in cliaddr, servaddr;
socklen_t clilen;
listenfd = socket(AF_INET, SOCK_STREAM, 0); //创建服务端fd
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //设置端口复用
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8888);
if (bind(listenfd, (struct sockaddr \*)&servaddr, sizeof(servaddr)) < 0) {
perror("bind");
}
listen(listenfd, 128);
//设置第一个要监听的文件描述符,即服务端的文件描述符
client[0].fd = listenfd;
client[0].events = POLLIN; //监听读事件
for(i = 1; i < OPEN_MAX; i++) { //注意从1开始,因为0已经被listenfd用了
client[i].fd = -1;
}
maxi = 0; //因为已经加进去一个了,所以从0开始就行
//----------------------------------------------------------
//至此,初始化全部完成, 开始监听
for(;;) {
nready = poll(client, maxi+1, -1); // 阻塞监听是否有客户端读事件请求
if (client[0].revents & POLLIN) { // listenfd触发了读事件
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr\*)&cliaddr, &clilen); //接受新客户端的连接请求
printf("recieved from %s at port %d\n", inet\_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port));
for (i = 0; i < OPEN_MAX; i++) {
if (client[i].fd < 0) {
client[i].fd = connfd; // 将新连接的文件描述符加到client数组中
break;
}
}
if (i == OPEN_MAX) {
perror("too many open connections");
}
client[i].events = POLLIN;
if(i > maxi) {
maxi = i;
}
if (--nready == 0) {
continue;
}
}
//前面的if没满足,说明有数据发送过来,开始遍历client数组
for (i = 1; i <= maxi; i++) {
if ((sockfd = client[i].fd) < 0) {
continue;
}
//读事件满足,用read去接受数据
if (client[i].revents & POLLIN) {
if ((n = read(sockfd, buf, sizeof(buf))) < 0) {
if (errno = ECONNRESET) { // 收到RST标志
printf("client[%d] aborted conection\n", client[i].fd);
close(sockfd);
client[i].fd = -1; //poll中不监控该文件描述符,直接置-1即可,无需像select中那样移除
} else {
perror("read error");
}
} else if (n == 0) { //客户端关闭连接
printf("client[%d] closed connection\n", client[i].fd);
close(sockfd);
client[i].fd = -1;
} else {
printf("recieved data: %s\n", buf);
}
}
if (--nready <= 0) {
break;
}
}
}
close(listenfd);
return 0;
}
#### 优点
`poll`函数相比于`select`函数来说,最大的优点就是突破了`1024`个文件描述符的限制,这使得百万并发变得可能。
而且不同于`select`,`poll`函数的监听和返回是分开的,因此不用在每次操作之前都单独备份一份了,简化了代码实现。因此,可以理解为`select`的升级增强版。
#### 缺点
虽然`poll`不需要遍历所有的文件描述符了,只需要遍历加入数组中的描述符,范围缩小了很多,但缺点仍然是需要遍历。假设真有百万并发的场景,当仅有两三个事件触发的时候,仍然要遍历上百万个文件描述符,只为了找到那触发事件的两三个`fd`,这样看来 ,就有些得不偿失了。而这个缺点,将在`epoll`中得以彻底解决。
`poll`作为 一个过度版本的实现 ,说实话地位有些尴尬:它既不具备`select`函数跨平台的优势,又不具备`epoll`的高性能。因此使用面以及普及程度相对来说,反而是三者之中最差劲的一个。
若说它的唯一使用场景,大概也就是开发者既想突破`1024`文件描述符的限制,又不想把代码写得像`epoll`那样复杂了。
### epoll
#### 原型
`epoll`可谓是当前`IO`多路复用的最终形态,它是`poll`的 增强版本。我们说`poll`函数,虽然突破了`select`函数`1024`文件描述符的限制,且把监听事件和返回事件分开了,但是说到底还是要遍历所有文件描述符,才能知道到底是哪个文件描述符触发了事件,或者需要单独定义一个数组。
而`epoll`则可以返回一个触发了事件的所有描述符的数组集合,在这个数组集合里,所有的文件描述符都是需要处理的,就不需要我们再单独定义数组了。
虽然`epoll`功能强大了,但是使用起来却麻烦得多。不同于`select`和`poll`使用一个函数监听即可,`epoll`提供了三个函数。
##### epoll\_create
首先,需要使用`epoll_create`创建一个句柄:
#include <sys/epoll.h>
int epoll_create(int size);
该函数返回一个文件描述符,这个文件描述符并不是 一个常规意义的文件描述符,而是一个平衡二叉树(准确来说是红黑树)的根节点。`size`则是树的大小,它代表你将监听多少个文件描述符。`epoll_create`将按照传入的大小,构造出一棵大小为`size`的红黑树。
注意:这个`size`只是建议值,实际内核并不一定局限于`size`的大小,可以监听比`size`更多的文件描述符。但是由于平衡二叉树增加节点时可能需要自旋,如果`size`与实际监听的文件描述符差别过大,则会增加内核开销。
##### epoll\_ctl
第二个函数是`epoll_ctl`, 这个函数主要用来操作`epoll`句柄,可以使用该函数往红黑树里增加文件描述符,修改文件描述符,和删除文件描述符。
可以看到,**`select`和`poll`使用的都是`bitmap`位图,而`epoll`使用的是红黑树。**
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
`epoll_ctl`有四个参数,参数1就是`epoll_create`创建出来的句柄。
第二个参数`op`是操作标志位,有三个值,分别如下:
>
> * EPOLL\_CTL\_ADD 向树增加文件描述符
> * EPOLL\_CTL\_MOD 修改树中的文件描述符
> * EPOLL\_CTL\_DEL 删除树中的文件描述符
>
>
>
第三个参数就是需要操作的文件描述符,这个没啥说的。
重点看第四个参数,它是一个结构体。这个结构体原型如下:
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 \*/
};
第一个元素为`uint32_t`类型的`events`,这个和`poll`类似,是一个`bit mask`,主要使用到的标志位有:
>
> * EPOLLIN 读事件
> * EPOLLOUT 写事件
> * EPOLLERR 异常事件
>
>
>
这个结构体还有第二个元素,是一个`epoll_data_t`类型的联合体。我们先重点关注里面的`fd`,它代表一个文件描述符,初始化的时候传入需要监听的文件描述符,当监听返回时,此处会传出一个有事件发生的文件描述符,因此,无需我们遍历得到结果了。
##### epoll\_wait
`epoll_wait`才是真正的监听函数,它的原型如下:
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
第一个参数不用说了, 注意第二个参数,它虽然也是`struct epoll_event *`类型,但是和`epoll_ctl`中的含义不同,`epoll_ctl`代表传入进去的是一个地址,`epoll_wait`则代表传出的是一个数组。这个数组就是返回的所有触发了事件的文件描述符集合。
第三个参数`maxevents`代表这个数组的大小。
`timeout`不用说了,它代表的是超时时间。不过要注意的是,`0`代表立即返回,`-1`代表永久阻塞,如果大于`0`,则代表毫秒数(注意`select`的`timeout`是微秒)。
这个函数的返回值也是有意义的,它代表有多少个事件触发,也就可以简单理解为传出参数`events`的大小。
##### 监听流程
大致梳理一下`epoll`的监听流程:
>
> * 首先,要有一个服务端的`listenfd`
> * 然后,使用`epoll_create`创建一个句柄
> * 使用`epoll_ctl`将`listenfd`加入到树中,监听`EPOLLIN`事件
> * 使用`epoll_wait`监听
> * 如果`EPOLLIN`事件触发,说明有客户端连接上来,将新客户端加入到`events`中,重新监听
> * 如果再有`EPOLLIN`事件触发:
> * 遍历`events`,如果`fd`是`listenfd`,则说明又有新客户端连接上来,重复上面的步骤,将新客户端加入到`events`中
> * 如果`fd`不为`listenfd`,这说明客户端有数据发过来,直接调用`read`函数读取内容即可。
>
>
>
#### 触发
`epoll`有两种触发方式,分别为**水平触发**和**边沿触发**。
* 水平触发
所谓的水平触发,就是只要仍有数据处于就绪状态,那么可读事件就会一直触发。
举个例子,假设客户端一次性发来了`4K`数据 ,但是服务器`recv`函数定义的`buffer`大小仅为`1024`字节,那么一次肯定是不能将所有数据都读取完的,这时候就会继续触发可读事件,直到所有数据都处理完成。
`epoll`默认的触发方式就是水平触发。
* 边沿触发
边沿触发恰好相反,边沿触发是只有数据发送过来的时候会触发一次,即使数据没有读取完,也不会继续触发。必须`client`再次调用`send`函数触发了可读事件,才会继续读取。
假设客户端 一次性发来`4K`数据,服务器`recv`的`buffer`大小为`1024`字节,那么服务器在第一次收到`1024`字节之后就不会继续,也不会有新的可读事件触发。只有当客户端再次发送数据的时候,服务器可读事件触发 ,才会继续读取第二个`1024`字节数据。
注意:第二次可读事件触发时,它读取的仍然是上次未读完的数据 ,而不是客户端第二次发过来的新数据。也就是说:\*\*数据没读完虽然不会继续触发`EPOLLIN`,但不会丢失数据。 \*\*
* 触发方式的设置:
水平触发和边沿触发在内核里 使用两个`bit mask`区分,分别为:
>
> EPOLLLT 水平 触发
> EPOLLET 边沿触发
>
>
>
我们只需要在注册事件的时候将其与需要注册的事件做一个位或运算即可:
ev.events = EPOLLIN; //LT
ev.events = EPOLLIN | EPOLLET; //ET
#### 实现
![img](https://img-blog.csdnimg.cn/img_convert/66e46dd80db13c399d7770497d544c1d.png)
![img](https://img-blog.csdnimg.cn/img_convert/9f683ebbde8cbe347b72c34b661d7c1a.png)
**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**
**[需要这份系统化的资料的朋友,可以添加戳这里获取](https://bbs.csdn.net/topics/618668825)**
**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**
>
> EPOLLLT 水平 触发
> EPOLLET 边沿触发
>
>
>
我们只需要在注册事件的时候将其与需要注册的事件做一个位或运算即可:
ev.events = EPOLLIN; //LT
ev.events = EPOLLIN | EPOLLET; //ET
#### 实现
[外链图片转存中...(img-faQjquHj-1715794729878)]
[外链图片转存中...(img-a1DaNrc7-1715794729878)]
**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**
**[需要这份系统化的资料的朋友,可以添加戳这里获取](https://bbs.csdn.net/topics/618668825)**
**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**