目录
[5. 异步I/O模型](#5. 异步I/O模型)
[1. 阻塞IO](#1. 阻塞IO)
[5. 异步I/O](#5. 异步I/O)
前言
本篇文章主要从网络IO角度讲解IO模型,着重讲解多路复用在网络编程上的的应用
一.I/O基本概念
IO 是 Input/Output 的缩写,指的是输入和输出。在计算机当中,IO 操作通常指将数据从一个设备或文件中读取到计算机内存中,或将内存中的数据写入设备或文件中。这些设备可以包括硬盘驱动器、网卡、键盘、屏幕等。
通常用户进程中的一个完整I/O分为两个阶段: 用户进程空间→内核空间 内核空间→设备空间 I/O分为内存I/O、网络I/O和磁盘I/O三种
Linux中进程无法直接操作I/O设备,其必须通过系统调用请求内核来协助完成I/O操作。 内核会为每个I/O设备维护一个缓冲区。 对于一个输入操作来说,进程I/O系统调用后,内核会先看缓冲区中有没有相应的缓存数据,没有的话再到设备(比如网卡设备)中读取(因为设备I/O一般速度较慢,需要等待); 内核缓冲区有数据则直接复制到用户进程空间。 所以,对于一个网络输入操作通常包括两个不同阶段:
(1)等待网络数据到达网卡,把数据从网卡读取到内核缓冲区,准备好数据。
(2)从内核缓冲区复制数据到用户进程空间。
网络I/O的本质是对socket的读取,socket在Linux系统中被抽象为流,I/O可以理解为对流的操作。 网络I/O的模型可分为两种:
-
异步I/O(asynchronous I/O)
-
同步I/O(synchronous I/O)
同步I/O又包括
-
阻塞I/O(blocking I/O)
-
非阻塞I/O(non-blocking I/O)
-
多路复用I/O(multiplexing I/O)
-
信号驱动I/O(signal-driven I/O)
强调一下:信号驱动I/O属于同步I/O。 信号驱动I/O和异步I/O只作概念性的讲解,不作为学习重点。
1.同步和异步
(1)对于一个线程的请求调用来讲,同步和异步的区别在于是否要等这个请求出最终结果
(2)对于多个线程而言,同步或异步就是线程间的步调是否要一致、是否要协调
(3)同步也经常用在一个线程内先后两个函数的调用上
(4)异步就是一个请求返回时一定不知道结果,还得通过其他机制来获知结果,如:主动轮询或被动通知
2.阻塞和非阻塞
阻塞与非阻塞与等待消息通知时的状态(调用线程)有关
阻塞和同步是完全不同的概念。同步是对于消息的通知机制而言,阻塞是针对等待消息通知时的状态来说的
进程从创建、运行到结束总是处于下面五个状态之一:新建状态、就绪状态、运行状态、阻塞状态及死亡状态
二.五种网络I/O模型
1.阻塞I/O模型
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达,当所有等待分组到达时,它被复制到内核中的某个缓冲区。第二步是把数据从内核缓冲区复制到应用程序缓冲区。 同步阻塞I/O模型是最常用、最简单的模型。在Linux中,默认情况下,所有套接字都是阻塞的。下面我们以阻塞套接字的recvfrom的调用图来说明阻塞,如图所示
2.非阻塞式I/O模型
非阻塞的recvform系统调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error
(EAGAIN
或EWOULDBLOCK
)。 进程在返回之后,可以先处理其他的业务逻辑,稍后再发起recvform系统调用。 采用轮询的方式检查内核数据,直到数据准备好。再拷贝数据到进程,进行数据处理。 在Linux下,可以通过设置套接字选项使其变为非阻塞。非阻塞的套接字的recvfrom操作如图所示
可以看到前三次调用recvfrom请求时,并没有数据返回,内核返回errno
(EWOULDBLOCK
),并不会阻塞进程。 当第四次调用recvfrom时,数据已经准备好了,于是将它从内核空间拷贝到程序空间,处理数据。但是将数据从内核拷贝到用户空间,这个阶段阻塞。
3.多路复用
I/O多路复用的好处在于单个进程就可以同时处理多个网络连接的I/O。它的基本原理是不再由应用程序自己监视连接,而由内核替应用程序监视文件描述符。通过 select、poll、epoll 等机制,允许一个进程同时监视多个文件描述符,当某个文件描述符就绪时再进行 IO 操作。这种模型下,程序可以同时处理多个连接,提高了并发处理能力。 以select函数为例,当用户进程调用了select,那么整个进程会被阻塞,而同时,kernel会"监视"所有select负责的socket,当任何一个socket中的数据准备好,select就会返回。 这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程,如下图所示。
4.信号驱动式I/O模型
该模型允许socket进行信号驱动I/O,并注册一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据,如图所示
注意:虽然信号驱动IO在注册完信号处理函数以后,就可以做其他事情了。但是第二阶段拷贝数据的过程当中进程依然是被阻塞的,而后要介绍的异步IO是完全不会阻塞进程的,所以信号驱动虽然具有异步的特点,但依然属于同步IO 。
5. 异步I/O模型
相对于同步I/O,异步I/O不是按顺序执行。用户进程进行aio_read
系统调用之后,就可以去处理其他逻辑了,无论内核数据是否准备好,都会直接返回给用户进程,不会对进程造成阻塞。这是因为aio_read
只向内核递交申请,并不关心有没有数据。 等到数据准备好了,内核直接复制数据到进程空间,然后内核向进程发送通知,此时数据已经在用户空间了,可以对数据进行处理。
三.五种I/O模型比较
前四种I/O模型都是同步I/O操作,它们的区别在于第一阶段,而第二阶段是一样的:在数据从内核复制到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用。 相反,异步I/O模型在等待数据和接收数据的这两个阶段都是非阻塞的,可以处理其他的逻辑,用户进程将整个I/O操作交由内核完成,内核完成后会发送通知。在此期间,用户进程不需要检查I/O操作的状态,也不需要主动拷贝数据。
六.I/O代码示例
1. 阻塞IO
**通常情况下在 linux 中,Socket 在创建时会默认采用阻塞式 IO。这意味着当调用 Socket 的接收数据或发送数据函数时,如果没有数据可用或者无法立即发送数据,程序会被阻塞,直到数据准备好或者可以发送数据为止。**阻塞IO一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。
server.c 服务器端代码
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 5001
#define BACKLOG 5
int main(int argc, char *argv[])
{
int fd, newfd;
char buf[BUFSIZ] = {}; //BUFSIZ 8142
struct sockaddr_in addr;//配置ipv4的地址族结构体
/*创建套接字*/
fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0)
{
perror("socket");
exit(0);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);//主机转网络
addr.sin_addr.s_addr = 0;//自己的ip地址
/*绑定通信结构体*/
if(bind(fd, (struct sockaddr *)&addr, sizeof(addr) ) == -1)
{
perror("bind");
exit(0);
}
/*设置套接字为监听模式*/
if(listen(fd, BACKLOG) == -1)
{
perror("listen");
exit(0);
}
/*接受客户端的连接请求,生成新的用于和客户端通信的套接字*/
newfd = accept(fd, NULL, NULL);
if(newfd < 0)
{
perror("accept");
exit(0);
}
printf("BUFSIZ = %d\n", BUFSIZ);
read(newfd, buf, BUFSIZ);
printf("buf = %s\n", buf);
close(fd);
return 0;
}
client.c 客户端代码
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 5001
#define BACKLOG 5
#define STR "Hello World!"
int main(int argc, char *argv[])
{
int fd;
struct sockaddr_in addr;
/*创建套接字*/
fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0)
{
perror("socket");
exit(0);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");//要访问的ip,这个ip是本机
/*向服务端发起连接请求*/
if(connect(fd, (struct sockaddr *)&addr, sizeof(addr) ) == -1)
{
perror("connect");
exit(0);
}
write(fd, STR, sizeof(STR) );
printf("STR = %s\n", STR);
close(fd);
return 0;
}
2.非阻塞I/O
服务器端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main()
{
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
const char *response = "Hello from server";
// 创建 TCP 套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址和端口
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 将套接字设置为非阻塞模式
if (fcntl(server_fd, F_SETFL, O_NONBLOCK) < 0)
{
perror("fcntl failed");
exit(EXIT_FAILURE);
}
// 将套接字绑定到服务器地址和端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0)
{
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, 3) < 0)
{
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("Server started. Waiting for connections...\n");
while (1)
{
// 非阻塞地等待并接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) >= 0)
{
// 设置新连接的套接字为非阻塞模式
if (fcntl(new_socket, F_SETFL, O_NONBLOCK) < 0)
{
perror("fcntl failed");
exit(EXIT_FAILURE);
}
printf("New client connected\n");
}
// 从客户端接收数据
int bytes_received;
while ((bytes_received = recv(new_socket, buffer, BUFFER_SIZE, 0)) > 0)
{
printf("Received: %s\n", buffer);
// 发送响应给客户端
send(new_socket, response, strlen(response), 0);
// 清空缓冲区
memset(buffer, 0, BUFFER_SIZE);
}
if (bytes_received == 0)
{
printf("Client disconnected\n");
close(new_socket);
}
}
close(server_fd);
return 0;
}
客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERVER_IP "127.0.0.1"
#define PORT 8080
#define BUFFER_SIZE 1024
int main()
{
int sock = 0;
struct sockaddr_in serv_addr;
char buffer[BUFFER_SIZE] = {0};
const char *message = "Hello from client";
// 创建 TCP 套接字
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址和端口
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 将服务器 IP 地址转换为网络字节序并设置到 sockaddr_in 结构体中
if(inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0)
{
perror("invalid address");
exit(EXIT_FAILURE);
}
// 连接到服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
{
perror("connection failed");
exit(EXIT_FAILURE);
}
printf("Connected to server\n");
// 发送数据给服务器
send(sock, message, strlen(message), 0);
printf("Message sent to server: %s\n", message);
// 接收服务器的响应
int bytes_received = recv(sock, buffer, BUFFER_SIZE, 0);
if (bytes_received > 0)
{
printf("Response from server: %s\n", buffer);
}
else if (bytes_received == 0)
{
printf("Server disconnected\n");
}
else
{
perror("recv failed");
}
close(sock);
return 0;
}
3.多路复用
(1)select
select:select 使用了一个 fd_set 集合来保存需要监控的文件描述符,并提供了 select() 函数来检查这些文件描述符的状态。当调用 select() 函数时,内核会遍历这个 fd_set 集合,检查每个文件描述符的状态是否就绪。如果某个文件描述符就绪,select() 函数就会返回,否则会阻塞程序直到有文件描述符就绪或超时。
客户端操作服务器时就会产生这三种文件描述符(简称fd):writefds(写)、readfds(读)、和 exceptfds(异常)。select 会阻塞住监视 3 类文件描述符,等有数据、可读、可写、出异常或超时就会返回;返回后通过遍历 fdset 整个数组来找到就绪的描述符 fd,然后进行对应的 IO 操作。
优点:能够在多个平台上使用,是标准的 POSIX 调用。
适用于小规模连接,文件描述符数量不大的情况。
缺点:监视的文件描述符数量有最大限制,通常为1024,增加数量会降低性能。
需要复制大量的句柄数据结构,可能产生大量开销。
返回的数组中包含所有就绪的句柄,需要遍历整个数组才能找到发生事件的句柄。
触发方式是水平触发,如果未完成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:
NULL:永久阻塞
0:非阻塞模式
fd_set结构体
编程流程
Ⅰ.准备文件描述符集合
在使用 select() 函数之前,需要准备三个文件描述符集合,分别是读文件描述符集合、写文件描述符集合和异常文件描述符集合。可以使用 fd_set 类型的变量来表示这些集合,并使用 FD_ZERO() 宏将其初始化为空集。
Ⅱ.设置需要监视的文件描述符
将需要监视的文件描述符添加到相应的文件描述符集合中,可以使用 FD_SET() 宏将文件描述符添加到集合中。
Ⅲ.调用 select() 函数调用 select() 函数来监视文件描述符的状态变化,函数返回时,返回值表示就绪文件描述符的数量,如果出现错误则返回 -1。
Ⅳ.检查就绪文件描述符在 select() 函数返回后,可以使用 FD_ISSET() 宏来检查具体哪些文件描述符已经就绪。
Ⅴ.处理就绪文件描述符根据 FD_ISSET() 的返回值来判断哪些文件描述符已经就绪,然后进行相应的操作,比如读取数据、发送数据等。
Ⅵ.重复以上步骤可以循环调用 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
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;
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;
}
server.c
#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);
/*检查参数,小于3个 直接退出进程*/
Argment(argc, argv);
/*创建已设置监听模式的套接字*/
fd = CreateSocket(argv);
FD_ZERO(&set);
FD_ZERO(&tmpset);
FD_SET(fd, &set);
while(1){
tmpset = set;
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;
}
(2)poll
基本原理与 select 一致,也是轮询+遍历。唯一的区别就是 poll 没有最大文件描述符限制(使用链表的方式存储 fd)。
优点:相对于 select,没有监视文件数量限制,使用链表保存文件描述符。
缺点:仍然需要复制大量的数据结构。
需要遍历整个链表来找到就绪的文件描述符。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 请求的事件 */
short revents; /* 返回的事件 */
};
timeout:
设置阻塞的时间(毫秒)
0为非阻塞
负数表示永久阻塞
使用流程:
Ⅰ准备 pollfd 数组在使用 poll() 函数之前,需要准备一个 struct pollfd 类型的数组,数组中的每个元素代表一个待监视的文件描述符。这个结构体的定义如下:
Ⅱ设置需要监视的文件描述符对于每个待监视的文件描述符,将其添加到 pollfd 数组中,并设置所关心的事件类型。
Ⅲ调用 poll() 函数调用 poll() 函数来监视文件描述符的状态变化,函数返回时,返回值表示就绪文件描述符的数量,如果出现错误则返回 -1。
Ⅳ.检查就绪文件描述符在 poll() 函数返回后,遍历 pollfd 数组,检查每个文件描述符的 revents 成员,以确定哪些文件描述符已经就绪。
Ⅴ.处理就绪文件描述符根据 revents 成员的值来判断哪些文件描述符已经就绪,然后进行相应的操作,比如读取数据、发送数据等。
Ⅵ.重复以上步骤可以循环调用 poll() 函数来重复监视文件描述符的状态变化,实现长时间的事件驱动循环。
代码:
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
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;
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;
}
server.c
#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] = {};
Addr_in addr;
socklen_t addrlen = sizeof(Addr_in);
/*检查参数,小于3个 直接退出进程*/
Argment(argc, argv);
/*创建已设置监听模式的套接字*/
fd = CreateSocket(argv);
fds[0].fd = fd;
fds[0].events = POLLIN;
while(1){
if( poll(fds, nfds, -1) < 0)
ErrExit("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;
}
(3)epoll
epoll可以理解为event poll,它是一种事件驱动的I/O模型,可以用来替代传统的select和poll模型。epoll的优势在于它可以同时处理大量的文件描述符,而且不会随着文件描述符数量的增加而降低效率。
epoll的实现机制是通过内核与用户空间共享一个事件表,这个事件表中存放着所有需要监控的文件描述符以及它们的状态,当文件描述符的状态发生变化时,内核会将这个事件通知给用户空间,用户空间再根据事件类型进行相应的处理。
epoll 使用了一个事件表(event table)来保存需要监控的文件描述符和相应的事件类型,并提供了 epoll_ctl() 函数来向事件表中添加、修改或删除文件描述符。与 select 和 poll 不同的是,epoll 的设计更加高效,它使用了内核中的事件通知机制,可以避免遍历文件描述符集合,当文件描述符的状态发生变化时,内核会立即通知应用程序。这样可以避免遍历文件描述符集合,减少了不必要的 CPU 消耗,从而提高了效率。当调用 epoll_wait() 函数时,内核会立即返回已就绪的文件描述符列表,无需遍历整个事件表。
没有 fd 个数限制,用户态拷贝到内核态只需要一次,使用时间通知机制来触发。通过 epoll_ctl 注册 fd,一旦 fd 就绪就会通过 callback 回调机制来激活对应 fd,进行相关的 io 操作。epoll 之所以高性能是得益于它的三个函数:
epoll_create() 系统启动时,在 Linux 内核里面申请一个B+树结构文件系统,返回 epoll 对象,也是一个 fd。
epoll_ctl() 每新建一个连接,都通过该函数操作 epoll 对象,在这个对象里面修改添加删除对应的链接 fd,绑定一个 callback 函数
epoll_wait() 轮训所有的 callback 集合,并完成对应的 IO 操作
优点:适用于大规模连接,仅监听已准备好的文件描述符,效率较高。
使用边缘触发(只通知状态变化),提高了效率。
在 Linux 上有较好的性能,采用更先进的事件通知机制。
无文件描述符数量限制。
缺点:只能在Linux操作系统上可用。
使用流程:
Ⅰ.创建 epoll 实例首先,需要调用 epoll_create() 函数创建一个 epoll 实例,并获取一个文件描述符用于操作 epoll 实例。函数返回一个文件描述符,用于引用新创建的 epoll 实例。
Ⅱ.添加文件描述符到 epoll 实例使用 epoll_ctl() 函数向 epoll 实例中添加或删除文件描述符,或者修改文件描述符上关注的事件。在添加文件描述符时,需要先设置 event 结构体的 events 成员,指定文件描述符关注的事件类型,然后调用 epoll_ctl() 函数进行添加操作。
Ⅲ.等待就绪事件使用 epoll_wait() 函数等待就绪事件的发生,该函数会阻塞程序直到有事件发生或超时。函数返回就绪事件的数量,如果超时则返回 0,如果出现错误则返回 -1。
Ⅳ.处理就绪事件在 epoll_wait() 函数返回后,遍历 events 数组,处理每个就绪事件。可以通过 events[i].data.fd 获取就绪事件对应的文件描述符,并通过 events[i].events 获取该文件描述符上发生的事件类型。
Ⅴ.重复以上步骤可以循环调用 epoll_wait() 函数来重复等待就绪事件的发生,从而实现长时间的事件驱动循环。
代码:
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
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;
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;
}
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] = {};
/*检查参数,小于3个 直接退出进程*/
Argment(argc, argv);
/*创建已设置监听模式的套接字*/
fd = CreateSocket(argv);
if( (epfd = epoll_create(1)) < 0)
ErrExit("epoll_create");
tmp.events = EPOLLIN;
tmp.data.fd = fd;
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;
if( epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &tmp) )
ErrExit("epoll_ctl");
}else{/*处理客户端数据*/
if(DataHandle(events[i].data.fd) <= 0){
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);
close(fd);
return 0;
}
(4)select,poll和epoll各自优缺点
select
-
单个进程能够监视的文件描述符的数量有最大限制,通常是1024 ,虽然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差
-
内核/用户空间内存拷贝问题,select需要复制大量的句柄数据结构,会产生巨大的开销
-
select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件
-
select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行I/O操作,那么之后每次select调用还是会将这些文件描述符通知进程
-
内核中实现select是用轮询方法,即每次检测都会遍历所有FD_SET中的句柄
假设服务器需要支持100万的并发连接,在__FD_SETSIZE
为1024
的情况下,则我们至少需要开辟1000个进程才能实现100万的并发连接
poll
poll使用链表 保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在 select与poll目前在小规模服务器上还是有用武之地,并且维护老系统代码的时候,经常会用到这两个函数;
epoll
epoll是Linux下多路复用I/O接口select/poll的增强版本 epoll只需要监听那些已经准备好的队列集合中的文件描述符,效率较高