一、网络IO请求
网络I/O请求是指在计算机网络中,向其他主机或服务器发送请求或接收响应的操作。这些请求可以包括获取网页、下载文件、发送电子邮件等。网络I/O请求需要使用合适的协议和通信方式来进行数据传输,例如HTTP、FTP、SMTP等。
要完成一个完整的 TCP/IP 网络通信过程,需要使用一系列函数来实现。这些函数包括 bind、listen、accept 和 recv/send 等。下面是它们的配合流程:
- 创建套接字(socket):使用 socket 函数创建一个套接字,指定协议族和套接字类型。
- 绑定地址(bind):将本地地址绑定到套接字上,使得客户端可以通过该地址访问服务器。
- 监听连接请求(listen):将套接字设置为监听状态,并指定最大等待连接数(backlog)。
- 接受连接请求(accept):当有客户端发起连接请求时,使用 accept 函数创建新的套接字用于与客户端进行通信。
- 读写数据(recv/send):使用新创建的套接字进行数据传输,包括从客户端读取数据和向客户端发送数据。
- 关闭连接(close):在通信结束后,需要使用 close 函数关闭套接字以释放资源。
对于第4步的请求,如果向下面方式处理,则只能接受一个客户端的请求。注意,如果把accept放在while循环里,也不能解决多客户端请求,反而会发生阻塞。
int clientfd=accept(sockfd,(struct sockaddr*)&clientaddr,&len); //调用 accept() 函数后,它会一直阻塞等待直到有新的客户端连接请求到达为止。
printf("accept\n");
while (1){
char buffer[BUFFER_LEN]={0};
int ret=recv(clientfd,buffer,BUFFER_LEN,0);
printf("ret: %d,buffer:%s\n", ret,buffer);
send(clientfd,buffer,ret,0);
}
因此,若要处理多客户端的情况,可以采用以下方法
- 一请求一线程
- select
- poll
- epoll
二、一请求一线程
如下图所示,一请求一线程的方式,确实可以解决多客户端连接和收发信息的情况。但是,实际业务中,面对数以万计的客户端,如果每个开辟一个线程,将会带来很大的消耗。好比你开一家餐厅,如果来一个顾客就要安排一个服务员,那如果你客流量上千,那不得雇佣一千个服务员!!因此,解决思路就是如果让一个服务员服务多个顾客。
三、IO多路复用——select的通俗理解
I/O多路复用是指一种机制,它允许单个进程可以监视多个文件描述符(通常是套接字),并在这些文件描述符中的任何一个变为可读或可写时立即进行相应的处理。这样就可以避免使用多线程/多进程方式来实现高并发。
1、select函数
select函数是一个I/O多路复用函数,用于同时监听多个文件描述符上的可读、可写、异常等事件。它可以让程序在单线程下同时处理多个I/O操作,提高程序的并发性能。
select函数的原型为:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:需要监听的最大文件描述符值加1。
- readfds:读事件集合。
- writefds:写事件集合。
- exceptfds:异常事件集合。
- timeout:超时时间,当所有文件描述符都没有事件时,select函数会阻塞等待事件到来,如果超过了超时时间还没有事件到来,则返回0。
select函数返回值:
- 大于0表示有文件描述符就绪;
- 等于0表示超时;
- 小于0表示出错。
2、accpet函数
accept()函数用于接受一个已经建立的连接,并返回一个新的套接字描述符,以便与该连接进行通信。该函数的原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数解释如下:
- sockfd:需要等待连接的套接字文件描述符。
- addr:指向存放远程主机地址信息的缓冲区。
- addrlen:远程主机地址信息长度。
返回值是新的套接字文件描述符,如果失败则返回-1。
该函数会一直阻塞,直到有客户端请求连接。一旦有新的连接请求,它将创建一个新的套接字,并使用该套接字来与客户端进行通信,而原始套接字则继续监听其他连接请求。
3、recv函数
recv()函数用于从已连接的套接字中接收数据。该函数的原型如下:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数解释如下:
- sockfd:需要接收数据的套接字文件描述符。
- buf:用于存储接收到数据的缓冲区地址。
- len:缓冲区长度。
- flags:调用方式标志,通常设置为0。
返回值是实际读取到的字节数,如果返回0表示连接被关闭,如果返回-1表示发生错误。
该函数通过网络读取指定长度(len)的数据,并将其存储在指定地址(buf)所指向的缓冲区中。它会一直阻塞等待直到有足够的数据可供读取或者出错。
四、IO多路复用——poll
需要包含头文件#include <poll.h>
poll函数是一个系统调用,用于等待多个文件描述符上的事件。它与select函数类似,但提供了更好的性能和可扩展性。
在使用poll函数时,需要创建一个pollfd结构体数组来指定要监视的文件描述符及其感兴趣的事件类型。每个结构体包含以下字段:
- fd:表示要监视的文件描述符
- events:表示所关注的事件类型(如POLLIN表示可读事件)
- revents:返回时表示发生了哪些事件
poll函数的参数包括一个指向pollfd结构体数组的指针、数组中元素的数量以及超时时间。具体来说,它的定义如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- fds:指向pollfd结构体数组的指针,用于描述需要监视哪些文件描述符和对应的事件类型。
- nfds:表示fds数组中元素的数量。
- timeout:表示超时时间,单位为毫秒。如果timeout值为负数,则表示永远等待;如果timeout值为0,则表示立即返回。
五、IO多路复用——epoll
需要包含头文件#include <sys/epoll.h>
1、epoll_create
epoll_create函数是用于创建一个新的 epoll 实例,它的原型如下:
int epoll_create(int size);
其中,size 参数指定了 epoll 实例中允许监听的最大文件描述符数量。该函数返回一个非负整数作为 epoll 句柄,如果出现错误则返回 -1。
注意:在 Linux 2.6.8 以前版本中,epoll_create 函数只接受一个参数,即 epoll 实例大小将被忽略。而在新版本中,则必须传递一个大于0的值作为实例大小参数。
2、epoll_ctl
epoll_ctl()函数是Linux内核提供的用于控制epoll实例的系统调用函数之一,它可以用来添加、修改或删除需要监听的文件描述符以及相应事件。其原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
- epfd:表示要操作的epoll实例标识符。
- op:表示要进行的操作类型,有以下三种取值:
EPOLL_CTL_ADD:将指定文件描述符加入到监听队列中;
EPOLL_CTL_MOD:修改已经加入监听队列中的文件描述符对应事件信息;
EPOLL_CTL_DEL:将已经加入监听队列中的文件描述符移除。 - fd:表示需要被操作的文件描述符。
- event:指向一个结构体变量,用来设置需要监听的事件类型以及相关属性。
在使用epoll_ctl()函数时,需要先创建一个epoll实例,并使用EPOLL_CTL_ADD操作将待监听的文件描述符添加到该实例中。如果后续需要修改所监听事件类型或者属性,则可以使用EPOLL_CTL_MOD操作。当不再需要继续监听某个文件描述符时,则可以使用EPOLL_CTL_DEL操作将其从监控列表中删除。
3、epoll_wait
epoll_wait函数是Linux内核提供的用于异步IO操作的系统调用函数之一,它可以用于等待一个或多个文件描述符上的事件发生,并在事件发生时通知用户进程。该函数与epoll_create和epoll_ctl一起使用来管理非阻塞I/O文件描述符。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明:
- epfd:表示要监控的epoll实例标识符。
- events:指向存储返回事件的结构体数组。
- maxevents:表示最大监听事件数。
- timeout:超时时间,以毫秒为单位,如果timeout为负数则表示永久等待。
当有事件到达时,epoll_wait会将所有就绪的事件存放在传入的events数组中,并返回就绪事件数量。对于每个就绪的文件描述符,需要通过判断events[i].events字段中位设置情况来确定具体是哪种类型的事件(例如可读、可写等)。同时,在处理完就绪事件后,需要将相应文件描述符重新加入到epoll监听队列中。
4、epoll_event
epoll_event是一个结构体,用于描述一个文件描述符上的事件。其定义如下:struct epoll_event { uint32_t events; // 表示监听的事件类型 epoll_data_t data; // 用户数据,可以是指针或者整数值 };
其中events字段表示需要监听的事件类型,取值如下:
- EPOLLIN:表示该文件描述符上有可读数据。
- EPOLLOUT:表示该文件描述符上可以写数据。
- EPOLLRDHUP:表示对端关闭连接或者半关闭连接,即收到FIN包。
- EPOLLHUP:表示该文件描述符被挂起,可能是对端进程崩溃或者其他错误情况导致的。
- EPOLLERR:表示出错。
5、边缘触发和水平触发)
另外,epoll还提供了ET(边缘触发)和LT(水平触发)两种工作模式。
- 水平触发模式
在水平触发模式下,如果文件描述符上的事件没有被处理完毕,epoll 会持续通知应用程序该文件描述符上仍有事件待处理。在这种情况下,如果应用程序不及时响应并读取数据,则 epoll 会一直通知应用程序该文件描述符上有数据可读取。 - 边沿触发模式
在边沿触发模式下,只要文件描述符上出现新的事件(例如数据可读或连接建立),epoll 就会通知应用程序。但是,在通知之后,如果应用程序没有立即响应并读取所有数据,则 epoll 不会再次通知该文件描述符上有新的数据可读。
总体来说,边沿触发模式相比于水平触发模式更为高效,并且可以避免由于重复监听导致 CPU 占用率过高的问题,一般用于数据量很大,需要分批次接收的时候。但是,在使用边沿触发模式时需要注意及时读取所有数据,并确保每个事件都得到了正确处理。
需要注意的是,EPOLLET模式下,并不会丢失数据。即如果数据未全部接收,此时又发送新的数据,接收的时候将先接收上一次的数据。并且,epoll默认是EPOLLLT。
五、区别对比
1、select和poll
select和poll都是用于多路复用I/O的系统调用函数,可以同时监控多个文件描述符上的事件。它们的主要区别如下:
- 可以处理的文件描述符数量不同
select支持最大1024个文件描述符,而poll没有限制。 - select采用轮询方式,poll采用链表方式
select将所有待检测的文件描述符放在一个fd_set集合中,每次轮询时需要遍历整个集合;而poll将所有待检测的文件描述符放在一个链表中,每次检查时只需要遍历该链表即可。 - select支持几乎所有操作系统,poll仅支持部分操作系统
select是标准POSIX接口,在几乎所有操作系统上都能使用;而poll则不是标准接口,在一些老旧的操作系统上可能无法使用。 - select对于返回状态码不够清晰明了,而poll更加直观
select返回后需要使用FD_ISSET宏来判断哪些文件描述符已经就绪;而poll返回后直接通过revents字段来判断哪些文件描述符已经就绪。 - select效率较低,因为每次都要重新设置fd_set集合;poll效率较高
由于select内核实现有许多缺陷,所以每次使用前都需要重新设置fd_set集合;而poll没有这个问题,所以效率更高。
总体来说,poll比select更加灵活、可靠,而且效率也更高。但由于select是标准接口,在一些特殊的情况下还是有其用武之地。
2、poll和epoll
poll和epoll都是用于I/O多路复用的系统调用,可以同时监视多个文件描述符是否有数据可读或可写。但是它们有以下区别:
- 处理方式不同
poll采用轮询的方式扫描所有的文件描述符,每次扫描时需要遍历整个被监控的文件描述符集合。如果被监控的文件描述符集合很大,那么就会带来较大的开销。 - 而epoll采用事件通知机制,只有在发生事件时才对该事件进行处理。这样可以大幅减少轮询带来的开销,提高效率。
- 监听对象数量不同
poll可以监听的文件描述符数量受限于操作系统中一个进程能打开的最大文件数目。如果要监听更多的文件描述符,则需要增加进程打开文件数目限制,但是这会占用更多系统资源。 - 而epoll没有监听对象数量上限,因为它采用基于事件驱动模式,在处理完一个事件后,并不删除该事件对应的结构体,所以支持万级别甚至百万级别以上并发连接。
- 内核与用户空间交互方式不同
poll每次调用都需要将所有监控的fd集合从用户空间拷贝到内核空间,而epoll只需要一次拷贝,然后在内核中对其进行操作,避免了多次拷贝的开销。
3、select和epoll
select和epoll都是用于I/O多路复用的系统调用,主要用于同时处理多个文件描述符的输入输出事件。但是它们之间存在一些不同点:
- 操作系统支持程度:select是POSIX标准中定义的函数,可以在大多数操作系统上使用,而epoll只能在Linux操作系统上使用。
- 处理方式:select采用轮询方式来检查文件描述符的状态变化,每次调用都需要将所有待监控的文件描述符从用户空间拷贝到内核空间,并且每次返回时需要遍历整个集合。而epoll通过回调机制,在文件描述符就绪时直接通知应用程序。
- 所监视的文件描述符数量:select所能监视的文件描述符数量是有限制的,通常为1024或2048个。而epoll没有此限制,可以监视大量的文件描述符。
- 内存开销:由于select需要将所有待监控的文件描述符从用户空间拷贝到内核空间,并且每次返回时需要遍历整个集合,因此会产生较大的内存开销。而epoll只需将被触发事件的fd放入一个链表中即可,因此内存开销较小。
总体来说,相比select函数,epoll具有更高效、更灵活、更强大等优势,在高并发场景下性能更佳。
六、事件驱动reactor
对于普通函数调用的流程:
程序调用某函数
→
\to
→函数执行
→
\to
→程序等待
→
\to
→函数将结果和控制权返回给程序
→
\to
→程序继续处理。
而reactor是一种事件驱动机制。其和普通函数调用的不同之处在于:应用程序不是主动的调用某个 API 完成处理,而是恰恰相反,应用程序需要提供相应的接口并注册到 reactor上,如果相应的事件发生,reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”。
reactor模式是处理并发I/O比较常见的模式。主要是思想是一下几个:
1、将所有要处理的I/O绑定到一个I/O多路复用器上,并且在此阻塞主线程/进程。
2、一旦有事件触发(可以是文件描述符或者socket的可读可写),多路复用器将事先准备好的相应I/O事件回调函数分发到对应的处理器中。
据此,reactor模型有三个重要的组件:
1、多路复用器:由操作系统提供,在 linux 上一般是 select, poll, epoll 等系统调用。
2、事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中。
3、事件处理器:负责处理特定事件的处理函数。
优点:
将网络 I/O 事件与处理这些事件的业务逻辑分开处理,从而实现高效、可扩展的网络编程。具体来说,当有 I/O 事件发生时,reactor 会立即通知相应的处理程序,并由处理程序进行相关操作。这种方式可以保证在系统中有大量并发连接的情况下,每个连接都能得到及时响应,并且不会阻塞其他连接。并且可以使得代码结构更加清晰、易于维护。
使用epoll 与 reactor 相结合能够提高网络编程应用程序的性能和可扩展性,并且可以更好地满足高并发请求的需求。对于简单的epll和reactor,
驱动的事件有两个:EPOLLIN 和 EPOLLOUT;
回调函数:对于listen fd,将调用accept_cb;而对于client fd将调用recv_cb和send_cb。
数据存储结构图:
网络I/O与开发分离示意图:
事件触发示意图:
七、代码
子不语