要想用单线程实现并发服务器,也是可行,这时就需要依靠epoll了。epoll是IO多路复用的其中一种方法,其他的还有select,poll。
这里主要讲解epoll。
多路复用的“多路”的是多个网络连接,“复用”指的是复用同一线程。
一般情况,epoll的性能是优于select,poll很多的,epoll是只能使用在linux环境的。
首先回顾下多线程并发的处理流程:
主线程:
调用accept()去检测客户端是否有连接请
1)若是有新的客户端连接请求,就建立连接
2)若是无新客户端的连接请求,就阻塞
子线程:
调用read()或write()函数和客户端进行收发数据。
epoll处理流程:
使用epoll_wait()函数去委托内核检测服务器端所有的文件描述符(主要是监听和通信两类),这个检测过程会导致线程阻塞,若检测到有已就绪的文件描述符,那阻塞就解除,并将这些已就绪的文件描述符通过一个结构体传出来。
接着对传出的文件描述符进行判断:
1)若是监听的文件描述符,就调用accept()建立连接,此时不会阻塞,因为这文件描述符是已就绪的。
2)如是通信的文件描述符,就调用通信函数(read(),write())和已建立连接的客户端进行通信。
这样就可以在单线程的场景下实现服务器并发了。
epoll与多线程相比,其系统开销小,不必创建/销毁线程,也不用管理维护线程。
// 创建epoll实例,并且通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
//创建一个红黑树模型的实例,用于管理待检测的文件描述符的集合。
int epoll_create(int size);
// 在 Linux 内核 2.6.8 版本以后,这个参数是被忽略的,只需要指定一个大于 0 的数值就可以了。
//返回值:失败返回 - 1;成功返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的epoll 实例。
//使用
int epfd=epoll_create(1);
// 联合体, 多个变量共用同一块内存
typedef union epoll_data {
void *ptr;
int fd; // 一般情况下使用这个成员, 和epoll_ctl的第三个参数相同
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//管理红黑树实例上的节点,可以进行添加、修改、删除操作。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//参数1是epoll_ctl()函数的返回值
//参数2是个枚举值,控制该函数执行操作
//参数3是我们要进行操作的文件描述符
//参数4,event是一个epoll_event结构体,其中的events表示事件,如EPOLLIN等
//函数返回值:失败返回-1,成功返回 0.
//使用例子
int ret=epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); //添加事件到epoll
int ret=epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev); //修改epoll红黑树上的事件
int ret=epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL); //删除事件
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
//参数1是epoll_create () 函数的返回值,通过这个参数找到 epoll 实例
//参数2是传出参数,这是一个结构体数组的地址,里边存储了已就绪的文件描述符的信息
//参数3是修饰第二个参数,结构体数组的容量(元素个数)
//参数4是表示最大的等待时间,0:函数不阻;-1:函数一直阻塞
//返回值:成功返回检测到的已就绪的文件描述符的总个数,若是0表示没有检测到满足条件的文件描述符;
// 失败返回-1.
按照上面的讲解,用法如下
//伪代码
int lfd=sokcet();
int ret=bind();
ret=listen();
int epfd=epoll_create(1); //创建epoll实例
struct epoll_event ev;
ev.data.fd=lfd; //设置为服务器的监听文件描述符
ev.data.events=EPOLLIN; //设置要检测的事件
int ret=epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev);//添加到epoll上
struct epoll_event events[1024];
while(1){
int nums=epoll_wait(epfd,events,1024,-1);//进行检测
for(int i=0;i
这是epoll的水平工作模式。epoll是默认采用LT触发模式,即水平触发,只要fd上有事件,就会一直通知内核。这样可以保证所有事件都得到处理、不容易丢失,但可能发生的大量重复通知也会影响epoll的性能。
而其还有另一种工作模式:边沿模式,简称为 ET 模式。
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当文件描述符从未就绪变为就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知(only once)。ET模式在很大程度上减少了epoll事件被重复触发的次数,其效率要比LT模式高,但编程相比水平模式相对复杂。
使用了IO多路复用,把socket设置为非阻塞最好,Linux 手册关于 select 的内容中有如下说明:
Under Linux, select() may report a socket file descriptor as "ready for reading",
while nevertheless a subsequent read blocks.
This could for example happen when data has arrived but upon examination has wrong checksum and is discarded.
There may be other circumstances in which a file descriptor is spuriously reported as ready.
Thus it may be safer to use O_NONBLOCK on sockets that should not block.
百度翻译结果:
在Linux下,select()可能会将套接字文件描述符报告为“ready for reading”(准备读取),但会报告后续的读取块。这可以用于当数据已经到达,但在检查时校验和错误并被丢弃时,就会发生这种情况。在其他情况下文件描述符被错误地报告为就绪。因此,在不应该阻塞的套接字上使用O_NONBLOCK可能更安全。
通俗点理解,就是epoll返回的事件不一定是可读写,若是使用了阻塞I/O,那在调用read()/write()时候则有可能发生阻塞,所以最好在使用I/O多路复用的时候搭配非阻塞socket。
按照上一节的用法,使用make命令编译后,打开多个终端,一个先输入./Server运行服务端,另几个终端再分别输入./client运行客户端。
完整源代码:https://github.com/liwook/CPPServer/tree/main/code/server_v3