美文网首页
I/O多路复用与协程

I/O多路复用与协程

作者: GGBond_8488 | 来源:发表于2023-01-18 16:44 被阅读0次

协程

我们都知道线程是进程中的执行体,拥有一个执行入口,以及从进程虚拟地址空间中分配的栈(用户栈和内核栈),操作系统会记录线程控制信息,而线程获得CPU时间片以后才可以执行,切换时这里栈指针,指令指针,栈基等寄存器都要切换到对应的线程。

程序栈
如果线程自己又创建了几个执行体(协程),给它们各自指定执行入口,申请一些内存分给它们用作执行栈,那么线程就可以按需调度这几个执行体。
协程
为了实现这些执行体的切换,线程也需要记录它们的控制信息。包括ID,执行栈的位置,执行入口地址,执行现场等等。线程可以选择一个执行体来执行,此时CPU中指令指针就会指向这个执行体的执行入口,栈基和栈指针寄存器也会指向线程给它分配的执行栈。
协程切换
通过同样的方式,可以恢复到之前的执行体, 这样就可以从上次中断的地方继续执行。这些由线程创建的执行体就是所谓的协程(纤程等)
因为用户程序不能操作内核空间,所以只能给协程分配用户栈,而操作系统对协程一无所知,所以协程又被称为“用户态线程”。
所以协程思想的关键在于,控制流的“主动让出”和“恢复”,每个协程都拥有自己的执行栈,可以保存自己的执行现场。
协程调度
所以可以由用户程序,按需创建协程,协程“主动让出”执行权时,会保存执行现场,然后切换到其它协程,协程恢复执行时,会根据之前保存的执行现场,恢复到中断前的状态继续执行,这样就通过协程,实现了既轻量又灵活的,由用户态进行调度的,多任务模型。可以参考goroutine的调度方式理解协程。

I/O多路复用

前言

我们知道通过操作系统记录的进程控制信息,可以找到打开文件描述符表,进程打开的文件,创建的socket等等,都会记录到这张表里。


文件描述符表

socket的所有操作都由操作系统来提供,也就是要通过系统调用来完成,每创建一个socket,就会在打开文件描述符表中,对应增加一条记录,而返回给应用程序的只有一个socket描述符,用于识别不同的socket。


socket速写

而且每个TCP socket在创建时,操作系统都会为它分配一个读缓冲区和一个写缓冲区,要获得响应数据,就要从读缓冲区拷贝过来,同样的要通过socket发送数据,也要先把数据拷贝到写缓冲区才行。
所以,问题出现了,用户程序想要读数据的时候,读缓冲区里未必有数据,想发送数据的时候,写缓冲区里也未必有空间。
那怎么办?第一种办法,乖乖的让出CPU,进到等待队列里,等socket就绪后,再次获得时间片就可以继续执行了。这就是阻塞式IO。
使用阻塞式IO,要处理一个socket就要占用一个线程。等这个socket处理完才能接手下一个,这在高并发场景下会加剧调度开销
第二中办法是非阻塞式IO,也就是不让出CPU,但是需要频繁的检查socket是否就绪了。这是一种“忙等待”的方式,很难把握轮询的间隔时间,容易造成空耗CPU,加剧相应延迟。
第三种办法就是“IO多路复用”,由操作系统提供支持,把需要等待的socket加入到监听集合,这样就可以通过一次系统调用,同时监听多个socket。
有socket就绪了,就可以逐个处理了,既不用为了等待某个socket而阻塞,也不会陷入“忙等待”之中

select

第一种select,我们可以设置要监听的描述符,也可以设置等待超时时间,如果有准备好的fd,或达到指定超时时间,select函数就会返回。从函数签名来看,它支持监听可读,可写,异常三类事件。因为这个fd_set是个unsigned long型的数组,共16个元素,每一位对应一个fd,16*64=1024,最多可以监听1024个fd。这就有点少了,而且每次调用select都要传递所有监听集合,这就需要频繁的从用户态到内核态拷贝数据。除此之外,即便有fd就绪了,也需要遍历整个监听集合,来判断哪个fd是可操作的,这些都会影响性能。

poll

第二种IO多路复用实现方式:poll。在select的基础上使用链表来记录fd,虽然支持的fd数目等于最多可打开的文件描述符的个数,但是另外两个问题依然存在。

epoll

epoll就没有这些问题了,它提供三个接口,epoll_create1用于创建一个epoll,并获取一个句柄,epoll_ctl用于添加,修改或删除fd与对应的事件信息,除了指定fd和要监听的事件类型,还可以传入一个event data,通常会按需定义一个数据结构,用于处理对应的fd。可以看到每次都只需传入要操作的一个fd,无需传入所有监听集合,而且只需要注册这一次,通过epoll_wait得到的fd集合都是已经就绪的,逐个处理即可,无需遍历所有监听集合。

int epfd = epoll_create1(0);
struct epoll_event events[MAX_EVENTS], ev;
---
ev.data.fd = sockfd;
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for(int i = 0; i < nfds; ++i){
            if(events[i].data.fd == sockfd){        //新客户端连接
                struct sockaddr_in clnt_addr;
                bzero(&clnt_addr, sizeof(clnt_addr));
                socklen_t clnt_addr_len = sizeof(clnt_addr);

                int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
                bzero(&ev, sizeof(ev));
                ev.data.fd = clnt_sockfd;
                ev.events = EPOLLIN | EPOLLET;
                setnonblocking(clnt_sockfd);
                epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sockfd, &ev);
            } else if(events[i].events & EPOLLIN){      //可读事件
                char buf[READ_BUFFER];
                while(true){    //由于使用非阻塞IO,读取客户端buffer,一次读取buf大小数据,直到全部读取完毕
                    bzero(&buf, sizeof(buf));
                    ssize_t bytes_read = read(events[i].data.fd, buf, sizeof(buf));
                    if(bytes_read > 0){
                        write(events[i].data.fd, buf, sizeof(buf));
                    } else if(bytes_read == -1 && errno == EINTR){  //客户端正常中断、继续读取
                        printf("continue reading");
                        continue;
                    } else if(bytes_read == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))){//非阻塞IO,这个条件表示数据全部读取完毕
                        printf("finish reading once, errno: %d\n", errno);
                        break;
                    } else if(bytes_read == 0){  //EOF,客户端断开连接
                        printf("EOF, client fd %d disconnected\n", events[i].data.fd);
                        close(events[i].data.fd);   //关闭socket会自动将文件描述符从epoll树上移除
                        break;
                    }
                }
            } else{         //其他事件
                printf("something else happened\n");
            }

通过IO多路复用,线程再也不用为了等待某一个socket,而阻塞或空耗CPU。并发处理能力因而大幅提升,但是也并非没有问题,例如一个socket可读了,但是这回只读到了半条请求,也就是说需要再次等待这个socket可读,在继续处理下一个socket之前,需要记录下这个socket的处理状态,下一个这个socket可读时,也需要恢复上次保存的现场,才好继续处理。

也就是说,在IO多路复用中实现业务逻辑时,我们需要随着事件的等待和就绪,而频繁的保存和恢复现场,这并不符合常规开发习惯,如果业务逻辑比较简单还好,若是较为复杂的业务场景,就是悲剧了。


I/O多路复用调度

既然业务处理过程中,要等待事件时,需要保存现场并切换到下一个就绪的fd,而事件就绪时又需要恢复现场继续处理,那岂不是很适合协程?
在IO多路复用这里,事件循环依然存在,依然要在循环中逐个处理就绪的fd,但处理过程却不是围绕具体业务,而是面向协程调度。如果是用于监听端口的fd就绪了,就建立连接创建一个新的fd,交给一个协程来负责,协程执行入口就指向业务处理函数入口,业务处理过程中,需要等待时就注册IO事件,然后让出。

这样执行权就会回到切换到该协程的地方继续执行。如果是其他等待IO事件的fd就绪了,只需要恢复关联的协程即可。

协程拥有自己的栈,要保存和恢复现场都很容易实现。这样IO多路复用这一层的事件循环,就和具体业务逻辑解耦了。可以把read,write,connect等可以回发生等待的函数包装一下,在其中实现IO事件注册与主动让出,这样在业务逻辑层面就可以使用这些包装函数,按照常规的顺序编程方式,来实现业务逻辑了。
这些包装函数在需要等待时,就会注册IO事件,然后让出协程。这样我们在实现业务逻辑时就完全不用关心保存和恢复现场的问题了。协程和IO多路复用之间的合作,不仅保留了IO多路复用的高并发性能,还解放了业务逻辑的实现。

参考:https://www.cnblogs.com/Me1onRind/p/10671741.html
https://zhuanlan.zhihu.com/p/400371315

相关文章

网友评论

      本文标题:I/O多路复用与协程

      本文链接:https://www.haomeiwen.com/subject/vmaxhdtx.html