I/O基本概念
I/O
I/O即数据的读取(接收)或写入(发送)操作
通常用户进程中的一个完整I/O分为两个阶段
- 用户进程空间<-->内核空间
- 内核空间<-->设备空间(磁盘、网卡等)
I/O分为内存I/O、网络I/O和磁盘I/O三种
同步和异步
- 对于一个线程的请求调用来讲,同步和异步的区别在于是否要等这个请求出最终结果
- 对于多个线程而言,同步或异步就是线程间的步调是否要一致、是否要协调
- 同步也经常用在一个线程内先后两个函数的调用上
- 异步就是一个请求返回时一定不知道结果,还得通过其他机制来获知结果,如:主动轮询或被动通知
阻塞和非阻塞
- 阻塞与非阻塞与等待消息通知时的状态(调用线程)有关
- 阻塞和同步是完全不同的概念。同步是对于消息的通知机制而言,阻塞是针对等待消息通知时的状态来说的
- 进程从创建、运行到结束总是处于下面五个状态之一:新建状态、就绪状态、运行状态、阻塞状态及死亡状态
线程在运行过程中,可能由于以下几种原因进入阻塞状态:
- 线程通过调用sleep方式进休眠状态
- 线程调用一个在I/O上被阻塞的操作,即该操作在输入/输出操作完成前不会返回到它的调用者
- 线程试图得到一个锁,而该锁正被其他线程持有,于是只能进入阻塞状态,等到获取了同步锁,才能恢复执行
- 线程在等待某个触发条件
可能阻塞套接字的Linux Sockets API调用分为以下四种:
输入操作 输出操作 接受连接(accept) 外出连接(connect)
在网络通信中,对数据进行收发在发送端和接收端的相应设备上会进行如下顺序的传递:
发送端应用的发送缓冲区 ----> 发送端操作系统的内核缓冲区 ---> 发送端网卡 ---> 网线 ---->接收端网卡 ----> 接收端操作系统的内核缓冲区 -----> 接收端应用的接收缓冲区
对数据进行写入时,应用程序调用write/sendto等相关系统调用将数据发送到接收端的接收缓冲区;在读取数据时,应用程序调用read/recvfrom等相关系统调用将数据从接收缓冲区搬运到用户区。
以数据的读取为例,在调用read等系统调用时,会经历以下两个阶段:
(1)等待数据到达内核接收缓冲区
(2)将数据从接收缓冲区搬运到用户区
五种I/O模型
阻塞I/O模型
阻塞I/O:当用户调用read后,用户线程会被阻塞,等内核数据准备好 并且 数据从内核缓冲区拷贝到用户态缓冲区后 read才会返回
阻塞I/O是两个阶段都会阻塞 没有数据时也会阻塞
阻塞式IO在进行数据读取时,如果内核中没有数据(发送端可能还没有发送数据或者发送的数据还没有达到),此时内核就开始等待数据,同时用户进程也进入阻塞状态,整个进程就会被挂起等待,不能做其他的事情。当有数据到达内核时,内核等待结束,将数据从内核拷贝到用户区,用户进程结束阻塞,从挂起状态转为运行状态。
所以,阻塞式IO在进行数据读取时,上述两个阶段都会阻塞(内核等待数据,用户进程阻塞)。
在Linux中,默认所有的socket都是阻塞式的。阻塞式接口是指当进行系统调用时,如果数据没有准备好,该应用进程就会被挂起,系统调用不会返回,直到有数据达到或者调用出错时,系统调用才会返回,进程才会结束阻塞状态。
但是在网络编程中,一般服务器需要处理多客户端,如果是像这种单进程服务器。与一个客户端连接建立之后,服务器就会使用read等系统调用对客户端进行数据读取来处理请求。当该客户端没有发送数据或者发送的数据还没有达到时,服务器就会进入阻塞状态,此时整个服务器进程就会挂起。当其他客户端连接请求达到时,服务器由于处于挂起状态,什么也不能做,所以也不能对其他客户端进行处理。因此,这种单进程的阻塞式IO的服务器只能处理一个客户端的情况,这样的服务器没有任何的实用性。
一种解决办法是,利用多进程或多线程来处理多客户端的情形。如果在对一个客户端进行读取时导致一个进程被挂起,可以创建其他的进程来处理其他客户端的请求。但是多进程和多线程的创建需要浪费一定的资源,当有过多的连接没有数据往来的时候,会造成浪费,同时也可能造成频繁的进程上下文切换。所以多线程做法一般适用于中小型应用场景。
非阻塞式I/O模型
非阻塞I/O是调用read后 如果没有数据就立马返回 通过不断轮询的方式去调用read 直到数据被拷贝到用户态的应用程序缓冲区 read请求才获取到结果
非阻塞I/O阻塞的是第二个阶段 第一阶段没有数据时候不会被阻塞 第二阶段等待内核把数据从内核态拷贝到用户态的过程中才会阻塞
非阻塞式IO在使用recvfrom等系统调用进行数据读取时,如果内核中没有数据到达,此时内核会进行等待。但是与阻塞式IO不同的是,此时的用户进程并不会被阻塞,不会被挂起,而是出错返回。
但是之后内核将数据准备好之后,由于该系统调用已经返回,所以进程无法得到数据已经准备好并且无法将数据由内核拷贝到用户区。所以,此时还需要使用系统调用进行数据的拷贝。因为不知道内核什么时候将数据准备好,所以就需要不断的使用系统调用来询问内核有没有将数据准备好,一旦准备好就进行数据的拷贝。
非阻塞式IO中,一般需要循环的对文件描述符进行读写,不断的询问数据有没有准备好。这个过程就称为轮询。因此,在非阻塞IO中,内核在等待数据,用户进程在轮询访问数据有无准备好。
在该模型中,进程大部分的工作都是在轮询访问,如果大多数连接都没有数据到达,那么大多数轮询都是空轮旋,并没有发挥实际有效的作用,所以这样做实际是对CPU资源的一种浪费。一般在特定场景下才会使用该模型。
代码演示:
I/O多路复用模型
信号驱动式I/O模型
异步I/O模型
五种I/O模型比较
阻塞IO和非阻塞IO
共同点
数据从内核拷贝到应用进程其实还是阻塞的状态
不同点
阻塞IO需要一直等待内核数据准备好,进程处于阻塞状态;非阻塞IO一直在询问内核数据是否准备好,进程处于运行态,一直占用着CPU
非阻塞IO和IO多路复用
共同点
数据从内核拷贝到应用进程其实还是阻塞的状态
不同点
当有多个连接的时候,非阻塞IO会不断地询问每个连接的数据是否准备好,如果准备好则调用read系统调用进行读取,但是我们上面说过,如果连接一直没有数据到,将会有许多无用的轮旋;IO多路复用则把这项工作交给操作系统,有操作系统通过与IO设备绑定的回调直接把有数据到来的连接返回给应用进程,应用进程可以直接一个个读取而无需再像非阻塞IO那样去询问没有数据的连接。
在网络刚刚诞生的时代,服务器还没有这么大的负载,用单线程阻塞IO的时候搓搓有余。
随着并发量越来越大,单线程无法支撑,人们便利用多核并发处理能力,每个连接创建一个线程进行处理,也就是多线程阻塞IO。
但是慢慢发现,创建过多线程造成很大地资源浪费,并且频繁地上下文切换也带来不菲地系统开销,因此出现了非阻塞IO(单线程多线程根据具体业务要求),线程每次询问操作系统是否准备好数据时不再向阻塞IO那样,要把自己挂起,而是每次询问,操作系统直接返回结果,有数据就返回数据,没有数据就返回异常,但同时造成大量地空沦陷,不是所有的连接都有数据到来,进程也会一直占用CPU。
因此,人们后来又发明了IO多路复用,将“轮旋”操作交给操作系统完成,操作系统通过与IO设备的回调等优化,直接返回数据准备好的连接给应用进程。典型的实现有windows的select和linux的epoll。window的select虽然也是一个个轮询每一个连接,但是每次轮询都是在内核空间下进行,不存在用户态和内核态之间的切换,相比非阻塞IO每次询问都是一次昂贵的系统调用。而在linux系统上实现的epoll则进一步优化(通过红黑树),使得可以在O(1)的时间复杂度内返回数据准备好的连接,而不需要轮询。
再后来出现的信号驱动IO和异步IO也是为了进一步解决应用场景而出现的技术,本文不再展开,有兴趣的读者可以自行查阅,一般的业务场景最多就用到IO多路复用技术。技术没有好坏,仅仅只是为了适应应用场景而被创造出来,选用什么技术也要看具体的业务场景。
I/O多路复用以及select函数
多路复用的是fd_set函数
既然非阻塞IO中,进程会产生很多空轮询,那能不能直接操作系统告诉我们那个连接的数据准备好了,我们直接去轮询这些连接不就好了?IO多路复用就是做这一件事情
IO多路复用下,进程首先使用select/epoll等系统调用等待多个连接。select等系统调用可以设置阻塞和非阻塞(或者阻塞的时间)。如果是阻塞方式,若等待的所有连接的数据均未达到,此时进程会阻塞在select处。当至少有一个连接就绪条件满足时,该系统调用就会返回,然后应用进程再调用read等分别对就绪的连接进行数据的拷贝。如果是非阻塞方式或者设置了阻塞的时间,当没有调用select时或者在规定时间内没有连接满足就绪条件,此时select会出错返回-1,此时为了对数据进行操作,所以必须循环的调用select来判断有无连接满足就绪条件。
当等待的多个连接中至少有一个满足就绪条件时,select返回数据准备好的连接。此时应用进程调用read(此时的套接字是阻塞的)等对数据进行拷贝。此时,read一定不会阻塞。因为内核中一定有数据到达。
所以,通过上述的说明,对于单进程的服务器可以通过IO多路复用的方式来处理多客户端的情况。此时便可避免多线程阻塞IO带来的多进程或多线程的创建造成的资源方面的问题,同时可以处理多客户端。但是两种方式各有优缺点,比如在连接比较少的时候,内核这种处理显得太笨重。
多路复用的实现方式(三个函数相互协作)
- select函数
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
- poll函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- epoll API
epoll_create
epoll_wait
epoll_ctl
select函数
int select(int nfds, fd_set *readfds, fd_set *writefds,d_set *exceptfds,
struct timeval *timeout);
//nfds: 是三个集合中编号最高的文件描述符,加上 1
//readfds/writefds/exceptfds:
// 可读集合/可写集合/异常集合
//timeout:
// NULL:永久阻塞 0:非阻塞模式
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 */
};
多路复用select实现
示例代码: net.h
#ifndef _NET_H_
#define _NET_H_
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
#include <errno.h>
typedef struct sockaddr Addr;
typedef struct sockaddr_in Addr_in;
#define BACKLOG 5
#define ErrExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while(0)
void Argment(int argc, char *argv[]);
int CreateSocket(char *argv[]);
int DataHandle(int fd);
#endif
server.c
这段代码是一个简单的网络服务器应用,使用了select
方法来处理多个套接字的输入和输出请求。这种模型是基于非阻塞的I/O,可以处理多个客户端连接和数据传输,适用于并发网络服务。下面是对代码的详细解析及注释:
#include "net.h"
#include <sys/select.h>
#define MAX_SOCK_FD 1024 // 最大文件描述符的数量
int main(int argc, char *argv[])
{
int i, ret, fd, newfd; // 变量定义
fd_set set, tmpset; // 文件描述符集
Addr_in clientaddr; // 客户端地址结构
socklen_t clientlen = sizeof(Addr_in); // 客户端地址长度
// 检查命令行参数,如果不符合要求,退出程序
Argument(argc, argv);
// 创建并设置监听模式的套接字
fd = CreateSocket(argv);
FD_ZERO(&set); // 清空文件描述符集
FD_ZERO(&tmpset); // 清空临时文件描述符集
FD_SET(fd, &set); // 将监听套接字加入文件描述符集
while(1){
tmpset = set; // 复制文件描述符集
// 使用select监听文件描述符集中的活动
if( (ret = select(MAX_SOCK_FD, &tmpset, NULL, NULL, NULL)) < 0)
ErrExit("select");
if(FD_ISSET(fd, &tmpset) ){ // 检查监听套接字是否有新的连接请求
// 接受客户端连接,并创建新的套接字描述符
if( (newfd = accept(fd, (Addr *)&clientaddr, &clientlen) ) < 0)
perror("accept");
// 打印客户端地址和端口信息
printf("[%s:%d]已建立连接\n",
inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
FD_SET(newfd, &set); // 将新的套接字描述符加入到文件描述符集
} else {
// 遍历文件描述符集,处理客户端数据
for(i = fd + 1; i < MAX_SOCK_FD; i++){
if(FD_ISSET(i, &tmpset)){
// 处理接收到的数据
if( DataHandle(i) <= 0){
// 获取断开连接的客户端信息
if( getpeername(i, (Addr *)&clientaddr, &clientlen) )
perror("getpeername");
// 打印断开连接的客户端地址和端口信息
printf("[%s:%d]断开连接\n",
inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
FD_CLR(i, &set); // 从文件描述符集中移除
}
}
}
}
}
return 0;
}
代码详解:
- 初始化和参数检查:首先检查命令行参数的合法性,并根据参数创建监听套接字。
- 初始化文件描述符集:使用
FD_ZERO
清空文件描述符集,并使用FD_SET
添加监听套接字。 - 主循环:使用
select
等待文件描述符集中的任何活动。如果监听套接字有活动,表明有新的客户端连接请求,服务器将接受这个连接请求并将新的连接套接字添加到文件描述符集中。如果是其他套接字有活动,则调用DataHandle
处理数据,如果客户端断开连接或数据处理函数返回值为0或负数,将该套接字从文件描述符集中移除,并打印断开连接的信息。
这种基于select
的服务器模型适用于处理较小规模的并发连接,但当连接数非常高时,效率较低,可能需要考虑使用更现代的技术如epoll
。
socket.c:
#include "net.h"
//参数检查函数 Argment
void Argment(int argc, char *argv[]){
if(argc < 3){
fprintf(stderr, "%s<addr><port>\n", argv[0]); // 如果命令行参数少于3个,打印错误信息并退出
exit(0);
}
}
//创建并初始化套接字的函数 CreateSocket
int CreateSocket(char *argv[]){
int fd = socket(AF_INET, SOCK_STREAM, 0); // 创建IPv4流式套接字
if(fd < 0)
ErrExit("socket"); // 如果套接字创建失败,调用错误处理函数并退出
int flag = 1;
if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)))
perror("setsockopt"); // 设置套接字选项,允许地址和端口的快速重用
Addr_in addr;
bzero(&addr, sizeof(addr)); // 初始化地址结构体
addr.sin_family = AF_INET; // 设置协议族为IPv4
addr.sin_port = htons(atoi(argv[2])); // 将字符串形式的端口号转换为整数,并转为网络字节序
if(bind(fd, (Addr *)&addr, sizeof(Addr_in)))
ErrExit("bind"); // 绑定地址结构体到套接字
if(listen(fd, BACKLOG))
ErrExit("listen"); // 将套接字设置为监听模式
return fd; // 返回套接字文件描述符
}
//数据处理函数 DataHandle
int DataHandle(int fd){
char buf[BUFSIZ] = {}; // 数据接收缓冲区
Addr_in peeraddr; // 对端地址结构体
socklen_t peerlen = sizeof(Addr_in); // 对端地址长度
if(getpeername(fd, (Addr *)&peeraddr, &peerlen))
perror("getpeername"); // 获取对端套接字地址
int ret = recv(fd, buf, BUFSIZ, 0); // 接收数据
if(ret < 0)
perror("recv"); // 数据接收错误处理
if(ret > 0){
printf("[%s:%d]data: %s\n",
inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port), buf); // 打印接收到的数据和对端地址信息
}
return ret; // 返回接收到的数据长度
}
1. 参数检查函数 Argment
此函数检查程序启动时是否传递了至少两个参数(地址和端口)。如果参数不足,程序将打印使用方法并退出。
2. 创建并初始化套接字的函数 CreateSocket
此函数创建套接字,并进行初始化设置,包括地址重用、绑定IP地址和端口,并设置为监听状态。
3. 数据处理函数 DataHandle
此函数用于接收和处理来自客户端的数据。它尝试读取数据,如果成功,则打印出接收到的数据和客户端的地址信息。如果接收过程中发生错误,将进行错误处理。
这些函数组合使用,能够建立一个基本的服务器,它接受客户端连接,接收数据,并在控制台输出相关信息。
其中:getpeername(); //获得对端的ip和端口号
#include <sys/socket.c>
int getpeername(int sockfd,struct sockaddr *addr,socklen_t *addrlen);
多路复用poll函数&epoll函数
int poll(struct pollfd *fds,nfds_t nfds,int timeout);
复用的fds,nfds是文件描述符的个数 添加一个就是1、两个就是2...,
timeout :设置阻塞的时间(ms) , 0 为非阻塞 ,负数表示永久阻塞
struct pollfd {
int fd; /*文件描述符 */
short events; /*请求的事件 */
short revents; /*返回的事件 */
}
poll函数 :事件类型
- events:
- POLLIN:有数据可读
- POLLPRI:有紧急数据需要读取
- POLLOUT:文件可写
- ......
poll函数的实现
这段代码是一个使用poll
函数的网络服务器应用示例。poll
是一个高效的网络I/O多路复用函数,用于监视多个文件描述符集的状态变化。这个服务器能够接受新的客户端连接,并处理来自客户端的数据。下面是对代码的逐行解析和详细注释:
#include "net.h"
#include <poll.h>
#define MAX_SOCK_FD 1024 // 定义最大文件描述符数量
int main(int argc, char *argv[])
{
int i, j, fd, newfd; // 定义变量
nfds_t nfds = 1; // 初始化文件描述符数量,开始时只有监听套接字
struct pollfd fds[MAX_SOCK_FD] = {}; // 初始化pollfd结构数组
Addr_in addr; // 定义地址结构体
socklen_t addrlen = sizeof(Addr_in); // 地址长度
// 检查命令行参数数量,如果不足则退出
Argment(argc, argv);
// 创建监听套接字
fd = CreateSocket(argv);
fds[0].fd = fd; // 设置监听套接字的文件描述符
fds[0].events = POLLIN; // 监听读取事件
while(1){
// 调用poll函数等待事件发生
if(poll(fds, nfds, -1) < 0)
ErrExit("poll"); // 如果poll调用出错,则退出
// 遍历文件描述符数组
for(i = 0; i < nfds; i++){
// 如果是监听套接字并且有读事件发生
if(fds[i].fd == fd && fds[i].revents & POLLIN){
// 接受客户端连接
if((newfd = accept(fd, (Addr *)&addr, &addrlen)) < 0)
perror("accept");
fds[nfds].fd = newfd; // 将新的套接字添加到数组
fds[nfds++].events = POLLIN; // 设置监听事件为读取
// 打印新连接的客户端信息
printf("[%s:%d][nfds=%lu] connection successful.\n",
inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), nfds);
}
// 如果是客户端套接字并且有读事件发生
if(i > 0 && fds[i].revents & POLLIN){
// 处理接收到的数据
if(DataHandle(fds[i].fd) <= 0){
// 如果处理数据失败或连接关闭,获取对方地址信息
if(getpeername(fds[i].fd, (Addr *)&addr, &addrlen) < 0)
perror("getpeername");
// 打印断开连接的客户端信息
printf("[%s:%d][fd=%d] exited.\n",
inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), fds[i].fd);
close(fds[i].fd); // 关闭文件描述符
// 移除文件描述符数组中的当前元素
for(j=i; j<nfds-1; j++)
fds[j] = fds[j+1];
nfds--; // 文件描述符数量减一
i--; // 索引回退,因为数组被压缩
}
}
}
}
close(fd); // 关闭监听套接字
return 0;
}
核心逻辑解释:
- 初始化:设置初始的文件描述符数组,包含一个监听套接字。
- Poll监听循环:使用
poll
持续监听所有活动的文件描述符。当有新的连接请求时,在监听套接字上接受并加入到文件描述符数组中。对于已连接的客户端,检查是否有数据可读。 - 连接管理:当有数据传入时,调用
DataHandle
函数处理数据。如果客户端断开或数据处理函数返回值非正,将该客户端的文件描述符从监听列表中移除,并关闭该套接字。
这种poll
的使用方式提高了服务器处理多连接的能力,适合中等规模的并发处理。
socket.c:
#include "net.h"
void Argment(int argc, char *argv[]){
if(argc < 3){
fprintf(stderr, "%s<addr><port>\n", argv[0]);
exit(0);
}
}
int CreateSocket(char *argv[]){
/*创建套接字*/
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0)
ErrExit("socket");// 如果创建失败,调用错误处理函数并退出
/*允许地址快速重用*/
int flag = 1;
if( setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag) ) )
perror("setsockopt"); // 设置套接字选项,允许地址重用,失败时打印错误
/*设置通信结构体*/
Addr_in addr;
bzero(&addr, sizeof(addr) );// 清零地址结构体
addr.sin_family = AF_INET; // 设置地址类型为IPv4
addr.sin_port = htons( atoi(argv[2]) );// 设置端口号,将命令行传入的端口号字符串转换为整数,并转换为网络字节序
/*绑定通信结构体*/
if( bind(fd, (Addr *)&addr, sizeof(Addr_in) ) )
ErrExit("bind"); // 绑定地址到套接字,失败则调用错误处理函数并退出
/*设置套接字为监听模式*/
if( listen(fd, BACKLOG) )
ErrExit("listen");// 设置套接字为监听状态,失败则调用错误处理函数并退出
return fd;
}
int DataHandle(int fd){
char buf[BUFSIZ] = {}; // 数据缓冲区
Addr_in peeraddr; // 客户端地址结构
socklen_t peerlen = sizeof(Addr_in); // 客户端地址结构的长度
if(getpeername(fd, (Addr *)&peeraddr, &peerlen))
perror("getpeername"); // 获取连接对端的地址,失败时打印错误
int ret = recv(fd, buf, BUFSIZ, 0); // 从套接字接收数据
if(ret < 0)
perror("recv"); // 接收失败时打印错误信息
if(ret > 0){
printf("[%s:%d]data: %s\n",
inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port), buf); // 如果接收到数据,打印数据和客户端地址
}
return ret; // 返回接收到的字节数
}
此函数从指定的文件描述符接收数据。它首先获取连接的对端地址,然后尝试接收数据。接收到的数据将被打印出来,包括发送方的IP地址和端口号。
这些函数一起构成了服务器的基本功能,用于接收客户端连接和处理来自客户端的数据请求。
epoll函数族
/*创建epoll句柄*/
int epoll_create(int size); //size参数实际上已经被弃用
/*epoll句柄的控制接口*/
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *event);
/*等待 epoll 文件描述符上的 I/O 事件*/
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
epoll_ctl:
epfd: epoll 专用的文件描述符,epoll_create()的返回值
op: 表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
fd: 需要监听的文件描述符
event: 告诉内核要监听什么事件
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 */
};
- EPOLLIN :表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET :将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- epfd: epoll 专用的文件描述符,epoll_create()的返回值
- events: 分配好的 epoll_event 结构体数组,epoll_wait 将会把发生的事件赋值到events 数组中
- maxevents: events 数组的元素个数
- timeout: 超时时间,单位为毫秒,为 -1 时,函数为阻塞
示例代码:
这段代码是一个利用epoll
实现的网络服务器。epoll
是Linux下一种高效的IO事件通知机制,尤其适用于处理大量并发连接。这个例子展示了如何使用epoll
来监听和处理网络请求。下面是详细的代码解释和注释:(server.c)
#include "net.h"
#include <sys/epoll.h>
#define MAX_SOCK_FD 1024 // 最大监听的文件描述符数量
int main(int argc, char *argv[])
{
int i, nfds, fd, epfd, newfd; // 定义变量
Addr_in addr; // 客户端地址结构
socklen_t addrlen = sizeof(Addr_in); // 地址结构的大小
struct epoll_event tmp, events[MAX_SOCK_FD] = {}; // epoll事件结构体
/* 检查命令行参数,如果不足则退出 */
Argment(argc, argv);
/* 创建监听套接字 */
fd = CreateSocket(argv);
/* 创建epoll实例 */
if((epfd = epoll_create(1)) < 0)
ErrExit("epoll_create");
tmp.events = EPOLLIN; // 监听读取事件
tmp.data.fd = fd; // 将监听套接字设置到epoll事件中
/* 将监听套接字添加到epoll监控中 */
if(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &tmp))
ErrExit("epoll_ctl");
while(1) {
/* 等待事件发生,阻塞调用 */
if((nfds = epoll_wait(epfd, events, MAX_SOCK_FD, -1)) < 0)
ErrExit("epoll_wait");
printf("nfds = %d\n", nfds); // 打印活动的文件描述符数量
for(i = 0; i < nfds; i++) {
if(events[i].data.fd == fd){
/* 接收新的客户端连接 */
if((newfd = accept(fd, (Addr *)&addr, &addrlen)) < 0)
perror("accept");
printf("[%s:%d] connection.\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
tmp.events = EPOLLIN; // 设置新套接字的事件类型为读取
tmp.data.fd = newfd; // 设置新套接字的文件描述符
/* 将新套接字添加到epoll监控中 */
if(epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &tmp))
ErrExit("epoll_ctl");
} else {
/* 处理客户端数据 */
if(DataHandle(events[i].data.fd) <= 0){
/* 数据处理完成或连接断开,从epoll中移除该套接字 */
if(epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL))
ErrExit("epoll_ctl");
if(getpeername(events[i].data.fd, (Addr *)&addr, &addrlen))
perror("getpeername");
printf("[%s:%d] exited.\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
close(events[i].data.fd); // 关闭套接字
}
}
}
}
close(epfd); // 关闭epoll文件描述符
close(fd); // 关闭监听套接字
return 0;
}
核心逻辑解释:
- 初始化和参数检查:首先检查命令行参数并创建一个监听套接字。
- Epoll初始化:创建一个epoll实例,并将监听套接字注册到这个epoll实例中,监听连接请求。
- Epoll事件循环:程序进入无限循环,等待epoll事件的发生。使用
epoll_wait
阻塞直到有事件发生。 - 事件处理:对于每个发生的事件,如果是监听套接字上的事件,则接受新的连接请求,并将新的套接字也注册到epoll实例中。如果是其他套接字的事件,调用
DataHandle
处理数据。如果处理结果表明连接
socket.c:
#include "net.h"
void Argment(int argc, char *argv[]){
if(argc < 3){
fprintf(stderr, "%s<addr><port>\n", argv[0]); // 如果传递的命令行参数少于3个,则打印错误消息并退出
exit(0);
}
}
int CreateSocket(char *argv[]){
int fd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个IPv4的TCP套接字
if(fd < 0)
ErrExit("socket"); // 套接字创建失败时,调用错误处理函数并退出程序
int flag = 1;
if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)))
perror("setsockopt"); // 设置套接字选项以允许地址重用,失败时打印错误
Addr_in addr;
bzero(&addr, sizeof(addr)); // 初始化地址结构体
addr.sin_family = AF_INET; // 设置协议族为IPv4
addr.sin_port = htons(atoi(argv[2])); // 将端口号从字符串转换为整数,并转换为网络字节序
if(bind(fd, (Addr *)&addr, sizeof(Addr_in)))
ErrExit("bind"); // 绑定地址到套接字,失败时调用错误处理函数并退出
if(listen(fd, BACKLOG))
ErrExit("listen"); // 设置套接字为监听模式,失败时调用错误处理函数并退出
return fd; // 返回套接字文件描述符
}
int DataHandle(int fd){
char buf[BUFSIZ] = {}; // 创建一个足够大的缓冲区来存储接收到的数据
Addr_in peeraddr; // 创建一个地址结构体来存储对端(客户端)的地址信息
socklen_t peerlen = sizeof(Addr_in); // 对端地址结构的长度
if(getpeername(fd, (Addr *)&peeraddr, &peerlen))
perror("getpeername"); // 获取连接的对端地址,如果失败则打印错误
int ret = recv(fd, buf, BUFSIZ, 0); // 从套接字接收数据
if(ret < 0)
perror("recv"); // 如果接收失败,打印错误
if(ret > 0){
printf("[%s:%d]data: %s\n",
inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port), buf); // 如果接收成功,打印对端的IP地址、端口和数据内容
}
return ret; // 返回接收到的数据长度,可以用来判断连接是否断开
}
总结:
select
, poll
, 和 epoll
是三种常见的 I/O 多路复用技术,用于同时监视多个文件描述符的状态变化,以提高网络服务器或应用程序处理多个并发客户端的效率。以下是这三种技术的详细解释以及它们各自的特点和优缺点。
1. select
select
是最早的 I/O 多路复用技术之一,用于监视一定数量的文件描述符,等待其中一个或多个变为就绪状态(可读、可写或异常)。
特点:
- 用户需要维护一个文件描述符集合,每次调用
select
前需要重新设置这个集合。 select
会修改传入的文件描述符集,因此每次调用后需要重置。
优点:
- 简单易用,广泛支持于各种平台。
缺点:
- 文件描述符数量受限于
FD_SETSIZE
,通常为 1024,这限制了select
能够监视的文件描述符的最大数量。 - 每次调用
select
都需要在用户态和内核态之间复制大量的数据,效率不高。 - 每次调用都需要重新指定所有的文件描述符和时间限制,增加了额外的开销。
2. poll
poll
与 select
类似,但提供了一些改进,用于解决 select
的一些限制。
特点:
- 使用
pollfd
结构来监视每个文件描述符,其中包括文件描述符、请求的事件和返回的事件。 - 不受
select
的文件描述符数量限制,理论上可以处理更多的连接。
优点:
- 不受
FD_SETSIZE
限制,可以监视任意数量的文件描述符。 - 接口相对简单,易于理解和使用。
缺点:
- 与
select
类似,poll
在处理大量文件描述符时效率并不高,因为它需要遍历整个文件描述符集合来执行操作。 - 仍然需要在每次调用时复制整个文件描述符集合从用户空间到内核空间。
3. epoll
epoll
是 Linux 特有的 I/O 多路复用技术,旨在克服 select
和 poll
的性能限制,特别是在处理大量并发连接时。
特点:
- 提供了两种模式:
EPOLL_LT
(水平触发,默认)和EPOLL_ET
(边缘触发)。 - 使用一个事件列表只返回活跃的连接,而不是遍历整个文件描述符集。
优点:
- 高效:只需要将活跃的文件描述符传递给用户空间,减少了数据复制的开销。
- 可扩展:使用红黑树管理所有的文件描述符,只处理就绪的描述符,适合处理大量并发连接。
- 灵活:支持水平触发和边缘触发,为不同的应用场景提供灵活选择。
缺点:
- 只在 Linux 系统上可用,缺乏跨平台性。
- 使用较为复杂,特别是在边缘触发模式下,需要更仔细地管理 I/O 状态和事件。
# select,poll和epoll各自优缺点
### select与poll
#### select
1. 单个进程能够监视的文件描述符的数量**有最大限制,通常是1024**,虽然可以更改数量,但由于select采用轮询的方式扫描文件描述符,**文件描述符数量越多,性能越差**
2. 内核/用户空间内存拷贝问题,select**需要复制大量的句柄数据结构**,会产生巨大的开销
3. select返回的是含有整个句柄的数组,应用程序**需要遍历整个数组**才能发现哪些句柄发生了事件
4. select的触发方式是**水平触发**,应用程序如果没有完成对一个已经就绪的文件描述符进行I/O操作,那么之后每次select调用还是会将这些文件描述符通知进程
5. 内核中实现select是用**轮询**方法,即每次检测都会遍历所有FD_SET中的句柄
假设服务器需要支持100万的并发连接,在`__FD_SETSIZE`为`1024`的情况下,则我们至少需要开辟1000个进程才能实现100万的并发连接
#### poll
poll**使用链表**保存文件描述符,因此**没有了监视文件数量的限制**,但其他三个缺点依然存在
select与poll目前在小规模服务器上还是有用武之地,并且维护老系统代码的时候,经常会用到这两个函数;
#### epoll
epoll是Linux下多路复用I/O接口select/poll的**增强版本**
epoll**只需要监听那些已经准备好的队列集合中的文件描述符**,效率较高
总结
- 如果应用程序只需要处理少量连接,或者跨平台性很重要,
select
或poll
可能更合适。 - 对于高性能服务器,尤其是那些需要处理大量并发连接的服务器,
epoll
提供了更好的性能和可扩展性。