IO多路复用简述

发布于:2022-11-28 ⋅ 阅读:(365) ⋅ 点赞:(0)

跨主机间通信

在这里插入图片描述

  1. 要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。
  2. 服务端使用socket()函数创建网络协议为IPv4,以及传输协议为TCP的Socket;
  3. bind()函数,给这个Socket绑定一个IP地址和端口;
  4. 绑定完IP地址和端口后,就可以调用listen()函数进行监听,此时对应TCP状态图中的listen;
  5. 服务端进入了监听状态后,通过调用accept()函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来;
  6. 客户端调用connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的TCP三次握手就开始了。
  7. 当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的TCP 全连接队列里拿出一个已经完成连接的Socket返回应用程序,后续数据传输都用这个 Socket。
  • 注意,监听的 Socket 和真正用来传数据的 Socket 是两个:
  • 一个叫作监听 Socket;
  • 一个叫作已连接 Socket;

跨主机发送数据
跨主机发送数据

跨主机接收数据
跨主机接收数据

Linux的socket通信

  1. 基于 Linux 一切皆文件的理念,在内核中Socket也是以「文件」的形式存在的,也是有对应的文件描述符。在Linux下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是1024,不过我们可以通过ulimit增大文件描述符的数目;
  2. 文件描述符的作用是什么?每一个进程都有一个数据结构task_struct,该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。
  3. 每个文件都有一个inode,Socket文件的inode指向了内核中的Socket结构,在这个结构体里有两个队列,分别是发送队列和接收队列,这个两个队列里面保存的是一个个 struct sk_buff,用链表的组织形式串起来。
  4. sk_buff可以表示各个层的数据包,在应用层数据包叫data,在TCP 层我们称为segment,在IP层我们叫packet,在数据链路层称为 frame。由于各个协议层的数据包共用一个结构体,于是当网络数据包经过协议栈时只需要前后移动指针剥离或者添加上各层协议的包头即可,不需要重复多次拷贝数据包。

如何服务更多的用户

前面提到的 TCP Socket调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的。

多进程模型

在这里插入图片描述

  1. 服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept()函数就会返回一个「已连接Socket」,这时就通过fork()函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
  2. 这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是0,则是子进程;如果返回值是其他的整数,就是父进程。正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,
  3. 可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。
  4. 用多个进程来应付多个客户端的方式,在应对100个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。
  5. 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

多线程模型

在这里插入图片描述

  1. 线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
  2. 当服务器与客户端TCP完成连接后,通过pthread_create()函数创建线程,然后将「已连接Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
  3. 如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上下文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
  4. 我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的Socket放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接Socket」进行处理。
  5. 需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个socket队列前要加锁。
  6. 基于进程或者线程的IO网路模型,每个TCP连接都需要分配一个进程或者一个线程进行维护,那么连接数大时也是抗不住的。

阻塞与非阻塞、同步与异步

  1. 阻塞是指当调用函数的结果返回之前,当前调用线程会被挂起,并且让出CPU资源;在这里插入图片描述
  2. 非阻塞指在调用线程不能立刻得到结果之前,该函数不会阻塞当前线程,⽽会立刻返回,并设置相应的错误代码(有可能是因为拿不到任何有意义的数据执⾏失败了)。非阻塞是⼀种将调用线程直接切回到当前执⾏线程的操作。虽然表面上看非阻塞的⽅式可以明显的提⾼CPU的利用率,但是也带了另外⼀种后果就是系统的线程切换增加。增加的CPU执⾏时间能不能补偿系统的切换成本需要好好评估。
    在这里插入图片描述
  3. 同步IO:⽆论read和send是阻塞 I/O,还是非阻塞I/O都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不⾼,read调用就会在这个同步过程中等待比较长的时间。
  4. 异步IO:真正的异步 I/O是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用
    等待。当我们发起aio_read(异步I/O)之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不⼀样,应用程序并不需要主动发起拷贝动作。在这里插入图片描述
  5. 非阻塞不⼀定是异步的,为了得到明确的结果,非阻塞函数常用的⽅式就是不停轮询;异步也不⼀定是非阻塞的,异步有可能阻塞到了其他线程上,只不过是当前线程持续进⾏⼯作⽽已。
  6. 阻塞和非阻塞是指等待函数调用结果的⽅式;⽽同步异步是指被调用的函数如何通知到调用者即返回的⽅式。
  7. 非阻塞是不阻塞到recv等读取/写⼊数据等函数上,但会阻塞到其他函数上。非阻塞调用之后可能会返回错误,需要主动轮询或者阻塞到其他线程函数(如select、epoll)。非阻塞某些函数支持回调。
  8. 异步是指是否由操作系统内核完成了数据从内核态到用户态的复制操作,如果真到是由操作系统完成了内核到用户的状态转换,然后再反过来调用用户态的函数,那么这就是真正的“异步”。否则任何其他形式的所谓异步,即便是由操作系统通知用户程序去拷贝数据,都不是真正的异步。异步IO需要操作系统在硬件层面的接⼝予以支持,特别是⽹卡驱动。
  9. 在绝⼤多数Linux系统中,异步IO仅限于⽂件读写;在WindowsNT和AIX、Solaris系统中,存在⽹络IO和⽂件IO的异步操作。

IO/多路复用

本质意义

  1. IO 多路复用(IO Multiplexing)就是为了解决NIO带来的监控⽂件描述符的问题的,它的好处就在于单个进程就可以同时处理多个⽹络连接的IO
  2. 基本原理就是不再由应用程序自⼰监视连接(轮询),取⽽代之由内核替应用程序监视⽂件描述符。
  3. 我们熟悉的select/poll/epoll函数是内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。这些API的调用总是Block的;
  4. 在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
  5. IO多路复用是要和NIO⼀起使用才有实际意义的(尽管他们可以不配合一起使用)。在使用IO多路复用之前,请务必先把fd设为O_NONBLOCK。
  6. IO多路复用和NIO⼀起仅仅是解决了调度的问题,避免CPU在这个过程中的浪费,使系统的瓶颈更容易触达到⽹络带宽,⽽非CPU或者内存。

select/poll(效率低下)

select的⽅法签名如下所示:

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

它接受3个⽂件描述符的数组,分别监听读取(readfds),写⼊(writefds)和异常(expectfds)事件。
那么⼀个 IO多路复用的代码⼤概是这样:

struct timeval tv = {.tv_sec = 1, .tv_usec = 0};

ssize_t nbytes;
while(1) {
    FD_ZERO(&read_fds);

    setnonblocking(fd1);

    setnonblocking(fd2);

    FD_SET(fd1, &read_fds);

    FD_SET(fd2, &read_fds);

    // 把要监听的fd拼到⼀个数组⾥,⽽且每次循环都得重来⼀次...
    if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到达
        perror("select出错了");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < FD_SETSIZE; i++) {
        if (FD_ISSET(i, &read_fds)) {
            /* 检测到第[i]个读取fd已经收到了,这⾥假设buf总是⼤于到达的数据,所以可以⼀次read完 */
            if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
                process_data(nbytes, buf);
            } else {
                perror("读取出错了");
                exit(EXIT_FAILURE);
            }
        }
    }
}
  1. 用select监听了read_fds中的多个socket的读取事件。调用select后,程序会Block住,直到⼀个事件发⽣了,或者等到最⼤1秒钟(tv定义了这个时间长度)就返回。之 后,需要遍历所有注册的fd,挨个检查哪个fd有事件到达(FD_ISSET返回true)。如果是,就说明数据已经到达了,可以读取fd了。读取后就可以进⾏数据的处理。
  2. select能够支持的最⼤的fd数组的长度是1024。这对要处理⾼并发的web服务器是不可接受的。
  3. fd数组按照监听的事件分为了3个数组,为了这3个数组要分配3段内存去构造,⽽且每次调用select前都要重设它们(因为select会改这3个数组);调用select后,这3数组要从用户态复制⼀份到内核态;事件到达后,要再次遍历这3数组。代码不优雅,执⾏效率低下。
  4. select返回后要挨个遍历fd,找到被“SET”的那些进⾏处理。
  5. select是⽆状态的,即每次调用select,内核都要重新检查所有被注册的fd的状态。select返回后,这些状态就被返回了,内核不会记住它们;到了下⼀次调用,内核依然要重新 检查⼀遍。查询的效率也很低。
    在这里插入图片描述
  6. 从上图可以看出,多路复用需要使用两个系统调用(select和recvfrom),⽽BIO只调用了⼀个recvfrom。所以,如果处理的连接数不是很⾼的话,使用多路复用的服务器并不⼀定比使用多线程+阻塞IO的性能更好,可能延迟还更⼤。IO复用的优势并不是对于单个连接能处理得更快,⽽是单个进程(线程)就可以同时处理多个⽹络连接的IO。
  7. 实际使用时,对于每⼀个socket,都可以设置为非阻塞。但是,如上图所示,整个用户的进程其实是⼀直被阻塞的。只不过进程是被select这个函数阻塞,⽽不是被IO操作给阻塞。所以IO多路复用是阻塞在select,epoll这样的系统调度函数的调用之上,⽽没有阻塞在真正的I/O系统调用(recvfrom)。
  8. select和epoll本质上都是轮询。poll优化了select的⼀些问题。比如不再有3个数组,⽽是1个polldfd结构的数组了,并且也不需要每次重设了。数组的个数也没有了1024的限制。但其他的问题依旧:
    8.1 依然是⽆状态的,性能的问题与select差不多⼀样;
    8.2 应用程序仍然⽆法很⽅便的拿到那些“有事件发⽣的fd“,还是需要遍历所有注册的fd。

epoll

  1. epoll不是⼀个函数,是⼀系列的函数,它们是Linux下多路复用的实现;
  2. 与select和poll不同,要使用epoll是需要先创建的。
int epfd = epoll_create(10);
  1. epoll_create在内核层创建了⼀个数据表,接⼝会返回⼀个“epoll的⽂件描述符”指向这个表。接⼝参数是⼀个表达要监听事件列表的长度的数值,epoll内部随后会根据事件注册和事件注 销动态调整epoll中表格的⼤小。
  2. 返回⽂件描述符epfd有两个原因:
    4.1 epoll是有状态的,不像select和poll那样每次都要重新传⼊所有要监听的fd,这避免了很多⽆谓的数据复制。epoll的数据是用接⼝epoll_ctl来管理的(增、删、改)。
    4.2 epoll⽂件描述符在进程被fork时,⼦进程是可以继承的。这可以给对多进程共享⼀份epoll数据,实现并⾏监听⽹络请求带来便利。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  1. epoll创建后,第⼆步是使用epoll_ctl接⼝来注册要监听的事件。
    5.1 第⼀个参数就是上面创建的epfd。
    5.2 第⼆个参数op表示如何对⽂件名进⾏操作,共有3种。
  • EPOLL_CTL_ADD - 注册⼀个事件
  • EPOLL_CTL_DEL - 取消⼀个事件的注册
  • EPOLL_CTL_MOD - 修改⼀个事件的注册
    5.3 第三个参数是要操作的fd,这里必须是支持NIO的fd(比如socket)。
    5.4 第四个参数是⼀个epoll_event的类型的数据,表达了注册的事件的具体信息(读事件还是连接事件、采用边缘触发还是水平触发等等)。
  1. 通过epoll_ctl就可以灵活的注册/取消注册/修改注册某个fd的某些事件。
  2. 使用epoll_wait来等待事件的发⽣。
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
  1. 这⼀步是"block"的。只有当注册的事件⾄少有⼀个发⽣,或者timeout达到时,该调用才会返回。这与select和poll⼏乎⼀致。但不⼀样的地⽅是evlist,它是epoll_wait的传出参数数组,里面只包含那些被触发的事件对应的fd,⽽不是像select和poll那样返回所有注册的fd。
  2. 综合起来,⼀段比较完整的epoll代码⼤概是这样的
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int nfds, epfd, fd1, fd2;

// 假设这⾥有两个socket,fd1和fd2,被初始化好。
// 设置为non blocking
setnonblocking(fd1);
setnonblocking(fd2);
// 创建epoll
epfd = epoll_create(MAX_EVENTS);
if (epollfd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
}
//注册事件 关注⼀个fd1的读取事件事件,并采用边缘触发
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = fd1;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) {
    perror("epoll_ctl: error register fd1");
    exit(EXIT_FAILURE);
}
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) {
    perror("epoll_ctl: error register fd2");
    exit(EXIT_FAILURE);
}
// 监听事件
for (;;) {
    nfds = epoll_wait(epdf, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_wait");
        exit(EXIT_FAILURE);
    }
    for (n = 0; n < nfds; ++n) { // 处理所有发⽣IO事件的fd
        process_event(events[n].data.fd);
        // 如果有必要,可以利⽤epoll_ctl继续对本fd注册下⼀次监听,然后重新epoll_wait
    }
}

  1. epoll在内核的数据被建立好了之后,每次某个被监听的fd⼀旦有事件发⽣,内核就直接标记之。epoll_wait调用时,会尝试直接读取到当时已经标记好的fd列表,如果没有就会进⼊等待状态。
  2. epoll_wait直接只返回了被触发的fd列表,这样上层应用写起来也轻松愉快,再也不用从⼤量注册的fd中筛选出有事件的fd了。
  3. 默认情况下,epoll使用⽔平触发,这与select和poll的⾏为完全⼀致。在⽔平触发下,epoll顶多算是⼀个“跑得更快的poll”。
    在这里插入图片描述

水平触发

⽔平触发只关⼼⽂件描述符中是否还有没完成处理的数据,如果有,不管怎样epoll_wait,总是会被返回。简单说——⽔平触发代表了⼀种“状态”。如上图的fd1;

边缘触发

  1. 边缘触发只关⼼⽂件描述符是否有新的事件产⽣,如果有,则返回;如果返回过⼀次,不管程序是否处理了,只要没有新的事件产⽣,epoll_wait不会再认为这个fd被“触发”了。简单说——边缘触发代表了⼀个“事件”。如上图的fd2;
  2. 那么边缘触发怎么才能迫使新事件产⽣呢?⼀般需要反复调用read/write这样的IO接⼝,直到得到了EAGAIN或EWOULDBLOCK错误码,再去尝试epoll_wait才有可能得到下次事件。
  3. 边缘触发把如何处理数据的控制权完全交给了开发者,提供了巨⼤的灵活性。比如,读取⼀个http的请求,开发者可以决定只读取http中的headers数据就停下来,然后根据业务逻辑判断是否要继续读(比如需要调用另外⼀个服务来决定是否继续读),⽽不是次次被socket尚有数据的状态烦扰;写⼊数据时也是如此。比如希望将⼀个资源A写⼊到socket。当socket的buffer充⾜时,epoll_wait会返回这个fd是准备好的。但是资源A此时不⼀定准备好。如果使用⽔平触发,每次经过epoll_wait也总会被打扰。在边缘触发下,开发者有机会更精细的定制这里的控制逻辑。
  4. 不好的⼀面是,边缘触发也⼤⼤的提⾼了编程的难度。⼀不留神,可能就会miss掉处理部分socket数据的机会。如果没有很好的根据EAGAIN来“重置”⼀个fd,就会造成此fd永远没有新事件产⽣,进⽽导致饿死相关的处理代码。
本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到