网络I/O的日常工作开发过程中的网络模型

作为一名程序员,在日常工作中,或多或少接触过网络I/O的概念,接触过网络编程,听说过sockets等,但为了更深入的了解,还是有点欠缺。通过这篇文章,可以了解网络中最重要的模块I/O,以及几种网络模型的介绍。在我们日常的工作开发过程中,可以根据具体需求选择具体的网络模型,达到事半功倍的效果。

0 什么是 I/O

通常指内部存储器与外部存储器或其他外设之间的数据、输入和输出。

是信息处理系统(如计算器)与外界(可能是人或其他信息处理系统)之间的通信。输入是系统接收到的信号或数据,输出是系统发出的信号或数据。该术语也可以用作动作的一部分;“运行 I/O”是运行输入或输出的操作。

在Unix系统下,无论是标准输入还是通过socket接收网络输入,都有两个步骤:

输入/输出设备是人类(或其他系统)用来与计算器通信的硬件的一部分。例如,键盘或鼠标是计算器的输入设备,而显示器和打印机是输出设备。计算器之间的通信设备,例如电信调制解调器和网卡,通常执行输入和输出操作。简单地说,用户进程与内核交互计算机中io设备是什么,内核与硬件交互。

1 阻塞 I/O 模型

应用程序发起一个I/O系统调用,应用程序进程会阻塞,直到得到结果(数据返回或操作超时)。

默认情况下,Unix 系统上的所有文件描述符都以“阻塞模式”开始。这意味着读取、写入或连接等 I/O 系统调用在默认情况下是阻塞的。

要理解这一点,请考虑一个在终端上等待标准输入 (stdin) 的程序。如果这是通过调用 read 函数来完成的,程序将阻塞直到实际数据可用(例如,当用户在键盘上键入字符时)。具体来说,内核会将进程置于“睡眠”状态,直到标准输入上的数据可用。其他类型的文件描述符也是如此。例如,如果您尝试从 TCP 套接字读取数据,读取调用将阻塞,直到连接的另一端实际发送数据。

int main(int argc, char *argv[]) {
    char buf[ MAX_BUFFER_LENGTH ];
    int length = 0;
    if( (length = read( 0, buf,  MAX_BUFFER_LENGTH )) < 0 ) {
        return -1;
    }
    buf[length] = '\0';
    printf("input: \n%s\n", buf);
    return 0;
}

当我们执行上面的代码时,在执行代码的过程中,会调用read函数,最终进入内核态。这时候进入内核态的第一步就是I/O等待数据状态。

从用户进程的角度来看,它会被阻塞。直到超时或键盘输入数据,数据才从内核态复制到用户态的内存中。此时用户进程被阻塞,程序开始执行以下其他步骤。

特征:

用户进程会阻塞等待内核,直到内核返回数据

2 非阻塞I/O模型

通常通过将套接字描述符设置为 O_NONBLOCK 模式。

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

如果套接字描述符设置为非阻塞,则在数据准备好之前调用 read 函数将返回 -1 并且 errno 将设置为 EWOULDBLOCK。

从上图可以看出,用户进程调用read系统调用后,如果内核态没有数据,read调用会立即返回,不会阻塞用户进程。当用户进程判断结果是错误时,就知道数据没有准备好,所以用户可以在这个时间到下一次发出读查询的时间间隔内做其他事情,或者直接发送再次读取操作。一旦内核中的数据准备好,再次接收到用户进程的系统调用,它立即将数据复制到用户内存中(这个阶段仍然是阻塞的)并返回。

整个过程可以概括为:用户进程不断调用read系统调用,询问内核数据是否准备好。因此,非阻塞I/O模式可以理解为不断循环询问内核的模式。

struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};
ssize_t nbytes;
for (;;) {
    /* try fd1 */
    if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {
        if (errno != EWOULDBLOCK) {
            perror("read/fd1");
        }
    } else {
        handle_data(buf, nbytes);
    }
    /* try fd2 */
    if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {
        if (errno != EWOULDBLOCK) {
            perror("read/fd2");
        }
    } else {
        handle_data(buf, nbytes);
    }
    /* 处理其他事情 */
    // do other
}

与阻塞 I/O 相比,非阻塞 I/O 性能提升不少,但仍然存在很多问题,例如:

1、当数据输入很慢时,程序会频繁不必要的唤醒,浪费CPU资源。

2、当数据进来时,如果程序在休眠或者处理其他逻辑,可能不会立即读取数据,所以程序的延迟会很差。

3、在这种模式下管理大量文件描述符会变得很麻烦。

特征:

1、用户进程会不断询问内核数据是否准备好

2、抽象来说,非阻塞 I/O 类似于异步 I/O。不同的是内核是不断轮询的,另一个是被动通知。2、抽象来说,非阻塞 I/O 类似于异步 I/O。不同的是内核是不断轮询的,另一个是被动通知。

3 信号驱动 I/O 模型

当进程发起IO操作时,向内核注册一个信号处理函数,进程不阻塞返回;当内核数据准备好时,向进程发送信号,进程在信号处理函数中调用IO来读取数据。

信号驱动的 I/O 在 TCP 中不太有用,因为信号在 TCP 套接字中生成得太频繁了。

以下情况可能会导致在 TCP 套接字上生成 SIGIO 信号:

当然,我们可以在 TCP 监听套接字上使用 SIGIO,这样我们就可以在信号处理程序中处理新的连接。

对于 UDP,只有以下两种情况会产生 SIGIO 信号:

因此,对于UDP socket产生的SIGIO信号,我们只需要调用recvfrom来读取到达的数据,或者获取发生的异步错误。

void io_handler(int signal) {
  int       numbytes;  /* Number of bytes recieved from client */
  int       addr_len;  /* Address size of the sender    */
  struct sockaddr_in   their_addr;  /* connector's address information  */
 
  if ((numbytes=recvfrom(sock, buf, MAXBUFLEN, 0, \
                    (struct sockaddr *)&their_addr, &addr_len)) == -1) {
                perror("recvfrom");
                exit(1);
   }
 
  buf[numbytes]='\0';
  printf("got from %s --->%s \n  ",inet_ntoa(their_addr.sin_addr),buf);
  return;
}
int main() {
  int length;
  struct sockaddr_in server;
  
  sock = socket(AF_INET, SOCK_DGRAM, 0);
  if (sock < 0) {
    perror("opening datagram socket");
    exit(1);
  }
  server.sin_family = AF_INET;
  server.sin_addr.s_addr = INADDR_ANY;
  server.sin_port = htons(MYPORT);
  if (bind(sock, (struct sockaddr *)&server, sizeof server) <0 ){
    perror("binding datagram socket");
    exit(1);
  }
  length = sizeof(server);
  if (getsockname(sock, (struct sockaddr *)&server, &length) < 0){
    perror("getting socket name");
    exit(1);
  }
  printf("Socket port #%d\n", ntohs(server.sin_port));
  // 第一步,注册事件函数
  signal(SIGIO,io_handler);
  // 第二步 设置要接收的进程id或进程组id,通知其自己的进程id或进程的挂起输入组id
  if (fcntl(sock,F_SETOWN, getpid()) < 0){
    perror("fcntl F_SETOWN");
    exit(1);
  }
  // 第三步,允许接收异步I/O信号
  if (fcntl(sock,F_SETFL,FASYNC) <0 ){
    perror("fcntl F_SETFL, FASYNC");
    exit(1);
  }
  for(;;)
  ;
  // .......
  }

> 这种模式比较复杂,实际使用的并不多。它仅在内核 2.6 中引入。

## 4 异步 I/O 模型

同步 I/O 意味着当您想要读取或写入某些内容时,您可能需要调用名为 read() 或 write() 的函数,它们会阻塞,阻止执行进一步移动,直到读取或写入完成。这就是正常文件读取和写入的典型工作方式。打开一个文件,然后调用 read(),它会用所需的数据填充缓冲区,并在所有完成后返回,以便可以用所需的数据填充缓冲区。

异步 I/O 正好相反。与等待请求操作完成后再返回的读写函数不同,异步 I/O 操作立即返回程序,而读写操作在后台继续进行。

这有什么好处?这意味着您的程序或游戏可以继续在屏幕上显示内容、更新输入、滚动进度条等等,而所有硬盘数据都按照您的要求进行处理。您还可以向系统发送多个 IO 请求,以便操作系统可以找到最有效的方式来访问它需要的所有数据。

用户进程发起读操作后,可以立即开始做其他事情。另一方面,从内核的角度来看,当它接收到一个异步读时,它会立即返回,所以它不会为用户进程生成任何块。然后,内核将等待数据准备完成,然后将数据复制到用户内存中。当这一切完成后,内核会向用户进程发送一个信号,告诉它读操作已经完成。

在异步IO中,以下几个概念非常重要:

struct aiocb {
  int             aio_fildes;     /* 文件描述符 */
  off_t           aio_offset;     /* 文件便宜 */
  volatile void  *aio_buf;        /* buffer位置 */
  size_t          aio_nbytes;     /* 传输数据大小 */
  int             aio_reqprio;    /* 请求优先级 */
  struct sigevent aio_sigevent;   /* 通知的方式 */
  int             aio_lio_opcode; 
};

aio_read()

该函数告诉系统要读取哪个文件、开始读取的偏移量、要读取的字节数以及要读取的字节的位置。

aio_error()

检查 IO 请求的当前状态。使用此功能可以查看请求是否成功。您所要做的就是给它一个地址,与您给 aio_read() 的地址相同。如果请求成功完成,该函数返回 0,如果请求仍在工作,则返回 EINPROGRESS,如果发生错误,则返回其他错误代码。

aio_return()

一旦您看到请求已完成,请检查 IO 请求的结果。如果请求成功,此函数返回读取的字节数。失败时,函数返回 -1。

以下是异步 I/O 模型的一个简单示例。通过这个例子,你可以很容易的理解模型的大致流程。

int main(){
  int file = open("blah.txt", O_RDONLY, 0);
 
  if (file == -1)
  {
    cout << "Unable to open file!" << endl;
    return 1;
  }
  
  char* buffer = new char[SIZE_TO_READ];
 
  // 定义控制块变量
  aiocb cb;
 
  memset(&cb, 0, sizeof(aiocb));
  cb.aio_nbytes = SIZE_TO_READ;
  cb.aio_fildes = file;
  cb.aio_offset = 0;
  cb.aio_buf = buffer;
 
  // 读取数据
  if (aio_read(&cb) == -1)
  {
    cout << "Unable to create request!" << endl;
    close(file);
  }
 
  cout << "Request enqueued!" << endl;
 
  // 等待,知道请求处理完成
  while(aio_error(&cb) == EINPROGRESS)
  {
    cout << "Working..." << endl;
  }
 
  // 判断读取的字节数
  int numBytes = aio_return(&cb);
 
  if (numBytes != -1)
    cout << "Success!" << endl;
  else
    cout << "Error!" << endl;
 
  // 释放资源
  delete[] buffer;
  close(file);
 
  return 0;
}

特征:

1、用户程序告诉内核它要执行一个操作,不等内核回复就立即返回

2、内核完成整个操作,包括将获取的数据复制到用户的缓冲区,然后通知用户。

5 I/O 多路复用

I/O 多路复用是一种能力告诉内核,如果一个或多个 I/O 条件已经准备好,例如一个输入准备好被读取,或者一个描述符可用于更多的输出,我们需要得到通知。

I/O 多路复用模型使用 select、poll 和 epoll 函数。这些函数也会阻塞进程,但与阻塞 I/O 不同的是,这两个函数可以同时阻塞多个 I/O 操作。对于多次读操作和多次写操作,可以同时检测到I/O函数,直到数据可读或可写时才真正调用I/O操作函数。

当用户进程调用select时,整个进程会被阻塞,同时内核会“监控”所有select负责的socket。当任何套接字中的数据准备好时,select 将返回。这时,用户进程再次调用读操作,将数据从内核拷贝到用户进程。

int         maxfdp1, stdineof;
fd_set      rset;
char        buf[MAXLINE];
int     n;
stdineof = 0;
FD_ZERO(&rset);
for ( ; ; ) {
    if (stdineof == 0)
        FD_SET(fileno(fp), &rset);
    FD_SET(sockfd, &rset);
    maxfdp1 = max(fileno(fp), sockfd) + 1;
    select(maxfdp1, &rset, NULL, NULL, NULL);
    if (FD_ISSET(sockfd, &rset)) {  /* socket is readable */
        if ( (n = read(sockfd, buf, MAXLINE)) == 0) {
            if (stdineof == 1)
                return;     /* normal termination */
            else
                err_quit("str_cli: server terminated prematurely");
        }
        write(fileno(stdout), buf, n);
    }
    if (FD_ISSET(fileno(fp), &rset)) {  /* input is readable */
        if ( (n = read(fileno(fp), buf, MAXLINE)) == 0) {
            stdineof = 1;
            shutdown(sockfd, SHUT_WR);  /* send FIN */
            FD_CLR(fileno(fp), &rset);
            continue;
            }
        writen(sockfd, buf, n);
    }
}

IO复用适用于以下场合:

(1)当客户端处理多个描述符时(通常是交互式输入和网络套接字)

(2)当客户端同时处理多个套接字时,这是可能的计算机中io设备是什么,但很少见。

(3)如果一个TCP服务器需要同时处理监听socket和连接socket,一般使用I/O多路复用。

(4)如果服务器同时处理 TCP 和 UDP

(5)如果服务器处理多个服务或多个协议

1、复用方式包括select、poll、epoll函数。每个功能的性能特点和开发难度都不同。您需要根据实际需要选择最好的一种。

2、现在基本上所有商业或大型程序都使用多路复用和非阻塞模式的组合

参考

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论