Epoll并发聊天服务器的实现
一、相关知识
1.实现并发通信的三种方式
实现并发通信主要有三种方式:多进程服务器、多路复用服务器(I/O复用)、多线程服务器
- 多进程服务器
多进程服务器指的是利用不同进程处理来自不同客户端发来的连接请求,进程之间以轮转的方式运行,由于各个进程之间轮转运行的时间间隔很小,故在用户看来其实现了并行处理所有的客户请求。
多进程服务器主要使用fork()函数进行创建子进程,将主进程和子进程隔离开来对各个客户端的请求进行分别响应,fork()函数的原型为:
#include<unisted.h>
pid_t fork(void);
//成功时返回进程ID,失败时返回-1
fork()函数将创建调用的进程副本,也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用fork函数的进程
在子进程完成其任务之后,若程序员没有对其进行销毁,那么该子进程会变成僵尸进程(Zombie),若父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。我们可以使用wait()函数销毁僵尸进程:
#include<sys/wait.h>
pid_t wait(int * statloc);
//成功时返回终止的子进程ID,失败时返回-1
调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指内存空间。
但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离:
- WIFEXITED子进程正常终止时返回“真”
- WEXITSTATUS返回子进程的返回值
int status;
pid_t pid=fork();
.....
if(pid == 0)return 3;
wait(&status);
if(WIFEXITED(status))
//输出3
printf("Child send: %d \n",WEXITSTATUS(status));
调用wait函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止,因此需谨慎调用该函数!
相较于wait()销毁僵尸进程,waitpid()是更好的函数,并且使用相关信号(signal()函数)可以在子进程调用完成后自动销毁,但是多进程服务器并不是本文的重点,故不再赘述。
- 多路复用服务器
下面用两张图简单介绍多进程服务器与多路复用服务器的区别


IO多路复用相对于阻塞式和非阻塞式的好处就是它可以监听多个 socket ,并且不会消耗过多资源。select函数是实现I/O多路复用的最简单也是最重要的函数。
当用户进程调用 select 时,它会监听其中所有 socket() 直到有一个或多个 socket 数据已经准备好,否则就一直处于阻塞状态。select()的缺点在于单个进程能够监视的文件描述符的数量存在最大限制,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的的开销也线性增长。同时,由于网络响应时间的延迟使得大量的tcp链接处于非常活跃状态,但调用select()会对所有的socket进行一次线性扫描,所以这也浪费了一定的开销。
select()函数的参数有些复杂,下面使用注释对select()的各个参数进行详细的解释
#include<sys/select.h>
#include<sys/time.h>
int select(
int maxfd, fd_set * readset, fd_set * writeset, fd_set * exceptset, const struct timeval * timeout);
//maxfd:监视对象文件描述符数量
//readset:将所有关注”是否存在待读取数据“的文件描述符注册到fd_set型变量,并传递其地址值
//writeset:将所有关注”是否可传输无阻塞数据“的文件描述符注册到fd_set型变量,并传递其地址值
//exceptset:将所有关注”是否发生异常“的文件描述符注册到fd_set型变量,并传递其地址值
//timeout:调用select函数后,为防止陷入无限循环的阻塞,传递超时信息
//返回值:发生错误时返回-1,超时返回时返回0,因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数
select函数使用具体流程:
- 定义select需要的各种参数,例如fd_max、timeout、所需监听的套接字列表等。
- 使用select函数,并对其返回值进行判断。
- 若返回值为0,那么继续循环使用select监视套接字列表;若大于0,使用FD_ISSET确认是哪一个列表发生了变化,之后对select函数的返回值(即发生变化的套接字)进行操作。
select不合理的两个缺点:
- 调用后常见的针对所有文件描述符的循环语句。
- 每次调用函数时都需要向该函数传递监视对象信息。
调用select函数后,不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的fd_set变量的变化,找出发生变化的文件描述符,因此无法避免针对所有监视对象的循环语句。
向系统传递监视对象信息是select函数的瓶颈,因为这种应用程序与系统层面的交互将对程序造成很大负担。
由于select函数是针对套接字的处理,而套接字是由操作系统管理的,故无法绕开应用程序与操作系统之间的交互,那么可行的优化方法是,仅向操作系统传递1次监视对象,监视范围或内容发生变化时只通知发生变化的事项。
epoll()函数解决了select()函数在处理上的瓶颈,其优点为:
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
- 调用对应于select函数的epoll_wait函数时无需每次传递监视对象信息。
epoll实现需要的三个函数
- epoll_create:创建保存epoll文件描述符的空间,取代了select中自己创建fd_set的操作,由操作系统负责保存监视对象文件描述符
- epoll_ctl:向空间注册并注销文件描述符
- epoll_wait:与select函数类似,等待文件描述符发生变化
epoll方式通过epoll_event结构体将发生变化的文件描述符单独集中到一起
struct epoll_event
{
__uint32_t events;
epoll_data_t data;
}
typedef union epoll_data
{
void * ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
}epoll_data_t;
#include<sys/epoll.h>
int epoll_create(int size);
//成功时返回epoll文件描述符,失败时返回-1
//调用epoll_create时创建的文件描述符保存空间称为”epoll例程”,通过参数size传递的值决定epoll例程的大小,但该值仅供操作系统参考
#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event);
//成功时返回0,失败时返回-1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
//成功时返回发生事件的文件描述符数,失败时返回-1
//该函数的调用方式如下:
int event_cnt;
struct epoll_event * ep_events;
.....
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
.....
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
//调用函数后,返回发生事件的文件描述符数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合,因此无需像select那样插入针对所有文件描述符的循环
epoll()有两种不同的触发方式,分别是条件触发和边缘触发
- 条件触发:只要输入缓冲有数据就会一直通知该事件,即多次注册
- 边缘触发:输入缓冲收到数据时仅注册1次该事件,即使输入缓冲中还留有数据,也不会再进行注册
epoll默认使用条件触发,即在有限的输入缓冲下,多个客户端对其进行连接那么会触发多次wait函数。
将注册客户端套接字中的
event.events = EPOLLIN
改为
event.events = EPOLLIN | EPOLLET
就可以实现epoll的边缘触发,即从客户端接收数据时,仅注册1次事件,但是需要进行额外的处理,例如将其变为非阻塞模式。
- 多线程服务器
多进程服务器的瓶颈在于在切换进程时涉及到内核态和用户态的切换以及上下文的保存,这种操作在大量的切换过程中十分占用资源,而利用线程可以很好地避开进程切换的瓶颈,多线程的优点为:
- 线程的创建和上下文切换比进程的创建和上下文切换更快。
- 线程间交换数据时无需特殊技术。
多线程服务器的编写涉及到线程库(pthread.h)以及信号量的互斥同步等操作,在实现起来相对复杂,这里不在赘述。
2 Epoll的两种触发方式
epoll()函数具有两种触发模式:边缘触发(ET, Edge Triggered)和条件触发(LT, Level Triggered),这两种模式定义了epoll()函数如何响应文件描述符的就绪事件。
- 条件触发(Level Triggered, LT):
- 在此模式下,只要文件描述符处于就绪状态(例如,有数据可读、可写),
epoll_wait就会通知这个事件。 - 它更容易理解和使用,因为只要条件满足,事件就会一直被报告。
- 但是,这可能导致效率问题,尤其是在高负载时。如果应用程序没有读取所有可用数据,下次调用
epoll_wait时,它仍然会报告相同的文件描述符,可能导致多余的处理。
- 在此模式下,只要文件描述符处于就绪状态(例如,有数据可读、可写),
- 边缘触发(Edge Triggered, ET):
- 在边缘触发模式下,只有文件描述符状态发生变化时(例如,从非就绪变为就绪),
epoll_wait才会通知事件。 - 这种模式对于提高效率非常有用,因为它减少了事件的重复报告。
- 但是,它也更难正确地使用。应用程序必须确保每次都处理所有的可用数据,因为新的数据到来不会再次触发事件,除非文件描述符的状态再次改变。
- 在边缘触发模式下,只有文件描述符状态发生变化时(例如,从非就绪变为就绪),
上面的描述可能比较晦涩,用一个简单的例子来讲解两种触发模式的特点:
儿子:“妈妈,我收到了500元压岁钱。”
妈妈:“嗯,真棒!“
儿子:”我给隔壁小王买了烤鸭,花了200元。”
妈妈:“嗯,做的好!”
儿子:“妈妈,我还买了玩具,剩下50元。”

本文围绕Epoll并发聊天服务器展开,先介绍实现并发通信的多进程、多路复用、多线程三种方式,阐述Epoll的条件触发和边缘触发两种模式,还提及线程使用及同步问题。接着给出代码实现,最后展示运行结果,客户接入和发消息时服务器有相应显示。

1431

被折叠的 条评论
为什么被折叠?



