0


【网络编程下】五种网络IO模型


前言

本篇文章主要从网络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
  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只需要监听那些已经准备好的队列集合中的文件描述符,效率较高

4.信号驱动式I/O

5. 异步I/O

标签: c语言 网络 IO

本文转载自: https://blog.csdn.net/Atcxr/article/details/138443986
版权归原作者 放牛的守护神_ 所有, 如有侵权,请联系我们删除。

“【网络编程下】五种网络IO模型”的评论:

还没有评论